Initial commit
45
.gitignore
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
/.pnpm-store
|
||||
|
||||
# Build outputs
|
||||
/dist
|
||||
/dist-ssr
|
||||
|
||||
# Logging
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
!.env.example
|
||||
|
||||
# Cypress
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Vue
|
||||
*.tsbuildinfo
|
||||
*.vue.js
|
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 ZTMYO
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
193
README.md
Normal file
@@ -0,0 +1,193 @@
|
||||
|
||||
|
||||
<p align="center">
|
||||
<img alt="logo" src="./doc/imgs/小石榴.png" width="100"/>
|
||||
</p>
|
||||
<h1 align="center" style="margin: 20px 0 30px; font-weight: bold;">XiaoShiLiu</h1>
|
||||
<p align="center">
|
||||
<b>基于 Express + Vue 前后端分离仿小红书项目</b>
|
||||
</p>
|
||||
<p align="center">
|
||||
<i>一个高仿小红书的图文社区项目,支持图文发布、社交互动等核心功能,旨在提供从前端到后端的完整实践范本</i>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/ZTMYO/XiaoShiLiu/stargazers">
|
||||
<img src="https://img.shields.io/github/stars/ZTMYO/XiaoShiLiu?style=flat&logo=github&color=brightgreen&label=Stars">
|
||||
</a>
|
||||
<a href="https://github.com/ZTMYO/XiaoShiLiu">
|
||||
<img src="https://img.shields.io/badge/XiaoShiLiu-v1.0-brightgreen.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAYAAAAeP4ixAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxMAAAsTAQCanBgAAAa9SURBVGhD7Zp9bJXVHcc/v9OXOYrSF9pShsoixVIcLVtacRX7uoIUqAsvMaPIMpiZmWTq3v5ZMhIyZzI3plET1G0iEOzElCFOeQmUymaHm7IsdJV2g85S7At9k77Q3vv89se59z73PhRhyRLvNf02J885v/M9z7nfc37nd855UmECODmllaiuB1kikO6t/zSg0A16EMzvzQdHX/fWS3jBmXN3OiahVoTScHu0QVXfw9Fq03q8PWgLCXGyK+ZhfPWCZIRaRDEU7UJMjWk+epigEJ1bPF1FTseKiCAU7cIxXzYtR88bABWpjTURAIJkILoPQJzsssVitMFLiiUoutpgnE3eitiD1Jhu//hSrznWMOj4Sk1GXGLMrQ0vppn4acZrjFVMCok2TAqJNkwKiTZ8ZoSI3l6qXmMECvPQ7NmgIPsOwfCIl/H/w8L5aP48exzcvQ/Gxq095za0qgzZ9htInWZtCgwNw+hluB4h+vMfwX1LAEUq18OHF7wUi+JF6H1fsx1IoKPwgrcXVRgeQZ7fA+32nfr9B2HTWgCkaDUMj6DbH0cL8hABtj4DP3nYfW9vP7LkARgavg7XErEvibxMXons2bC0BFlWgtwbeC4rdZ9VnrS8DNZUQUYaJCbAwvnwhUxEBBFB83MhLQUK8pD+Afj176DlHIz77G/x+SEtBb1pKvwva+QaMuBiP7ScQ9vO21lzHLfuQjd65qxNbefRtg70g7Nw5qx1jxnp6O6nkHtL3DbPboXcuTb/ygFITQFj4KW90NUDe/ZbL7GjTLzb0kKf3gIdncib9fD3f7p2CYiZOQN9eD3S/G94+TW3Yd1bSN1bNp+WjNbXIsago2PImoeQnj6X68XNWdbVTPhwKTh+m/1cIuTlwJQbIHM6nO+0a0MBv+VEzkhKMpQXwYZV6JdyIqqCXWj5V+HrS9Fv3w9JUyI4QegPHkQSAmO0qw4+SQRA+0dI5Xo0bP3JN74Hp5rstWnDKjRvHlQWQcECGBiERQstMbDYI4Xk59jpA+Rv/4ioCq5VqTtoG09PQVdNcJWpXAzVlW553IeuWeamtVXomiq07C6Ii7McVTvas2aEmun9K+x6UEX8fuTVN6BvEBISkLffhQW3Q0cnDHwMV7hWfi4ioEOj0PyviKrQpF8agoaTSOVidPUy2FlnfwjAHXPRn/0QCXMReWhdKB9EkK6/fAF58RVbqCoL+TsAKyvQE+8im7fA6TPQ2QNTk9DiO+FQA5KYCEf/HKJHzIgWL7KZU6fd3ibC8UarLHs2zM+2tsI89IUnkBuTXJ7fAZ/PRphg8juIYCPhbbdaXnw8urbKbRfE3YXokntwXnoSfXMHuvc52LwB3f8iurICLVgQorpCZmbC3C/a/OETIfNEkD/9FfX5bZhcXg6ArqyE5MBmFYA+sR0tXQelNWjZOrS8Bn3gsUBEE3eW83MhJRl1glMFPLcL2b4bVlQgs2dBeircMhNungkZ0yF3DvrIt0J9hYRoeRGIoJfHkIPX+KjS2QNNLTZfYmdRnv4ttLWjxxpd3uDH0NMLF3uR7l6kqwfp6A7bLAMIBAY9+6F9CnZnH7T+L5eGYMUmG9bb2mHdI+A41r0CCAmRIyeg9g2oPQB9AyHCVVHfiA6NQMNJ62ZdF5HlG5E/HnM5Qfd0FPvn2ZDC8z19SOP71hzUGDwYTE2CPzwPs7Lg1lmw81eICQSKAFzXutCFbNmGPP6sLScmQGIipCa77LB1I7WvIys2Wn7Q7PPZdkF6yo12s8tKh6xMyMpEsyb41vHOe8jKjXajwyMQG/mo/wuMjNpg8877AZXu75l4Z0+dhja8ijbWIfcUAqA+P/QPupyL/Tb8XYGwiPXj78DhnXD4ZTi0w6YdT0KcsbzweNI3YCOAN8Yo6Ng4euSEPbBeGrZe4JneiYX0DkBzK3z+Btd2sMG+5FoIG00xBomPuyJZKHou9DHdwsRZF/SKSZqCPPVTSE+zQWnrY6iIXX8BXPX0q8WLYHGB6/87Xgvtop+IO3LQ6orI8C2RaxsEOj5C9uyHy2OuuegraMldlvGL7TYsb/6m3aSDA3R5zJbHfciBI9DaZvlXExJrmNi1YhCTQqINk0KiDZNCog2fJSF6HUfd6IaCGkXCzt2xCj1mQPd6zbEH2S2am5uovowWEbnFWx0LUNX/SHxXtpGmpjFRrVYY9ZKiHQqjqFMjTU1jBkBajp8CvhtLYuxncXnUtDS8jfdS6WSX3Ilhf/T/X4oOqFBtmuuPBy3e2zHOnMKbMFMeRaRGYI63/tOEQiuqu3CGt5nWk2H3bvgvVR2LuswEsWUAAAAASUVORK5CYII=">
|
||||
</a>
|
||||
<a href="https://github.com/ZTMYO/XiaoShiLiu/blob/master/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/mashape/apistatus.svg">
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/static/v1?message=Vue&color=4f4f4f&logo=Vue.js&logoColor=4FC08D&label=">
|
||||
<img src="https://img.shields.io/static/v1?&message=JavaScript&color=4f4f4f&logo=JavaScript&logoColor=F7DF1E&label=">
|
||||
</a>
|
||||
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
|
||||
> **声明**
|
||||
> 本项目基于 MIT 协议,免费开源,仅供学习交流,禁止转卖,谨防受骗。如需商用请保留版权信息,确保合法合规使用,运营风险自负,与作者无关。
|
||||
|
||||
---
|
||||
|
||||
> 📁 **项目结构说明**:本项目包含完整的前后端代码,前端位于 `vue3-project/` 目录,后端位于 `express-project/` 目录。详细结构请查看 [项目结构文档](./doc/PROJECT_STRUCTURE.md)。
|
||||
## 演示站点
|
||||
|
||||
🌐 **在线体验**: [www.shiliu.space](https://www.shiliu.space)
|
||||
|
||||
🔗 **视频演示**: [www.bilibili.com](https://www.bilibili.com/video/BV1J4agztEBX/?spm_id_from=333.1387.homepage.video_card.click)
|
||||
|
||||
## 项目展示
|
||||
|
||||
### PC端界面
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="./doc/imgs/1.png" alt="PC端界面1" width="300"/></td>
|
||||
<td><img src="./doc/imgs/2.png" alt="PC端界面2" width="300"/></td>
|
||||
<td><img src="./doc/imgs/3.png" alt="PC端界面3" width="300"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="./doc/imgs/4.png" alt="PC端界面4" width="300"/></td>
|
||||
<td><img src="./doc/imgs/5.png" alt="PC端界面5" width="300"/></td>
|
||||
<td><img src="./doc/imgs/6.png" alt="PC端界面6" width="300"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="./doc/imgs/7.png" alt="PC端界面7" width="300"/></td>
|
||||
<td><img src="./doc/imgs/8.png" alt="PC端界面8" width="300"/></td>
|
||||
<td><img src="./doc/imgs/9.png" alt="PC端界面9" width="300"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="./doc/imgs/10.png" alt="PC端界面7" width="300"/></td>
|
||||
<td><img src="./doc/imgs/11.png" alt="PC端界面8" width="300"/></td>
|
||||
<td><img src="./doc/imgs/12.png" alt="PC端界面9" width="300"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
### 移动端界面
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="./doc/imgs/m1.png" alt="移动端界面1" width="200"/></td>
|
||||
<td><img src="./doc/imgs/m2.png" alt="移动端界面2" width="200"/></td>
|
||||
<td><img src="./doc/imgs/m3.png" alt="移动端界面3" width="200"/></td>
|
||||
<td><img src="./doc/imgs/m4.png" alt="移动端界面4" width="200"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="./doc/imgs/m5.png" alt="移动端界面5" width="200"/></td>
|
||||
<td><img src="./doc/imgs/m6.png" alt="移动端界面6" width="200"/></td>
|
||||
<td><img src="./doc/imgs/m7.png" alt="移动端界面7" width="200"/></td>
|
||||
<td><img src="./doc/imgs/m8.png" alt="移动端界面8" width="200"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
## 项目文档
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| [部署指南](./doc/DEPLOYMENT.md) | 部署配置和环境搭建说明 |
|
||||
| [项目结构](./doc/PROJECT_STRUCTURE.md) | 项目目录结构架构说明 |
|
||||
| [数据库设计](./doc/DATABASE_DESIGN.md) | 数据库表结构设计文档 |
|
||||
| [API接口文档](./doc/API_DOCS.md) | 后端API接口说明和示例 |
|
||||
|
||||
## 项目亮点
|
||||
|
||||
- **工程化:** 环境配置、代码规范、构建与产物优化的完整流程
|
||||
- **业务能力:** 鉴权流程、路由守卫、状态管理与接口封装
|
||||
- **体验优化:** 骨架屏、懒加载、预加载、无障碍与响应式适配
|
||||
- **组件与分层:** 可复用组件拆分、按领域分组与别名引入
|
||||
- **后台管理:** 基础CRUD、数据管理与配置面板,支持后续扩展权限与统计
|
||||
|
||||
## 技术栈
|
||||
|
||||
> 💡点击可展开查看详细内容
|
||||
<details>
|
||||
<summary><b>前端技术</b></summary>
|
||||
|
||||
- **Vue.js 3** - 前端框架(Composition API)
|
||||
- **Vue Router 4** - 路由管理
|
||||
- **Pinia** - 状态管理
|
||||
- **Vite** - 构建工具和开发服务器
|
||||
- **Axios** - HTTP客户端
|
||||
- **VueUse** - Vue组合式工具库
|
||||
- **CropperJS** - 图片裁剪
|
||||
- **Vue3 Emoji Picker** - 表情选择器
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>后端技术</b></summary>
|
||||
|
||||
- **Node.js** - 运行环境
|
||||
- **Express.js** - Web框架
|
||||
- **MySQL** - 数据库
|
||||
- **JWT** - 身份认证
|
||||
- **Multer** - 文件上传
|
||||
- **bcrypt** - 密码加密
|
||||
- **CORS** - 跨域资源共享
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
|
||||
#### 第三方API
|
||||
- **图片存储:** 灌装的示例图片来自 [栗次元图床](https://t.alcy.cc/),提供稳定的图片存储服务
|
||||
- **图片上传:** 用户上传图片使用了 [夏柔API](https://api.aa1.cn/doc/xinyew_jdtc.html),确保图片上传的稳定性和速度
|
||||
- **属地查询:** IP属地查询服务使用 [保罗API](https://api.pearktrue.cn/dashboard/detail/290),实现精准的IP属地定位功能
|
||||
|
||||
|
||||
### 环境要求
|
||||
|
||||
| 组件 | 版本要求 |
|
||||
|------|----------|
|
||||
| Node.js | >= 16.0.0 |
|
||||
| MySQL | >= 5.7 |
|
||||
| MariaDB | >= 10.3 |
|
||||
| npm | >= 8.0.0 |
|
||||
| yarn | >= 1.22.0 |
|
||||
| 浏览器 | 支持ES6+ |
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
# 使用 cnpm或npm
|
||||
cnpm install
|
||||
# 或使用 yarn
|
||||
yarn install
|
||||
```
|
||||
|
||||
### 2. 启动开发服务器
|
||||
|
||||
```bash
|
||||
# 启动开发服务器
|
||||
npm run dev
|
||||
|
||||
# 或使用 yarn
|
||||
yarn dev
|
||||
```
|
||||
|
||||
开发服务器将在 `http://localhost:5173` 启动
|
||||
|
||||
### 3. 构建生产版本
|
||||
|
||||
```bash
|
||||
# 构建生产版本
|
||||
npm run build
|
||||
|
||||
# 预览生产版本
|
||||
npm run preview
|
||||
```
|
||||
|
||||
> ⚠️ **重要提醒**:前端项目需要配合后端服务使用,详细配置请查看 [部署指南](./doc/DEPLOYMENT.md)
|
||||
|
||||
## 作者
|
||||
|
||||
**@ZTMYO**
|
||||
- GitHub: [https://github.com/ZTMYO](https://github.com/ZTMYO)
|
||||
|
2659
doc/API_DOCS.md
Normal file
160
doc/DATABASE_DESIGN.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# 图文社区项目数据库设计
|
||||
|
||||
## 概述
|
||||
|
||||
基于小石榴风格的图文社区项目,简化版数据库结构设计,包含用户管理、内容发布、社交互动等核心功能。
|
||||
|
||||
### 字符集和排序规则
|
||||
|
||||
- 数据库字符集:`utf8mb4`
|
||||
- 排序规则:`utf8mb4_unicode_ci`
|
||||
- 存储引擎:`InnoDB`
|
||||
|
||||
## 核心数据表结构
|
||||
|
||||
### 1. 用户表 (users)
|
||||
|
||||
| 字段名 | 类型 | 说明 | 备注 |
|
||||
|--------|------|------|------|
|
||||
| id | BIGINT | 用户ID | 主键,自增 |
|
||||
| password | VARCHAR(255) | 密码 | 可为空 |
|
||||
| user_id | VARCHAR(50) | 小石榴号 | 唯一标识 |
|
||||
| nickname | VARCHAR(100) | 昵称 | 显示名称 |
|
||||
| avatar | VARCHAR(500) | 头像URL | 用户头像 |
|
||||
| bio | TEXT | 个人简介 | 用户介绍 |
|
||||
| location | VARCHAR(100) | IP属地 | 地理位置 |
|
||||
| follow_count | INT | 关注数 | 统计字段,默认0 |
|
||||
| fans_count | INT | 粉丝数 | 统计字段,默认0 |
|
||||
| like_count | INT | 获赞数 | 统计字段,默认0 |
|
||||
| is_active | TINYINT(1) | 是否激活 | 默认1 |
|
||||
| last_login_at | TIMESTAMP | 最后登录时间 | 可为空 |
|
||||
| created_at | TIMESTAMP | 创建时间 | 注册时间 |
|
||||
| updated_at | TIMESTAMP | 更新时间 | 自动更新 |
|
||||
| gender | VARCHAR(10) | 性别 | 可为空 |
|
||||
| zodiac_sign | VARCHAR(20) | 星座 | 可为空 |
|
||||
| mbti | VARCHAR(4) | MBTI人格类型 | 可为空 |
|
||||
| education | VARCHAR(50) | 学历 | 可为空 |
|
||||
| major | VARCHAR(100) | 专业 | 可为空 |
|
||||
| interests | JSON | 兴趣爱好 | JSON数组,可为空 |
|
||||
|
||||
### 2. 笔记表 (posts)
|
||||
|
||||
| 字段名 | 类型 | 说明 | 备注 |
|
||||
|--------|------|------|------|
|
||||
| id | BIGINT | 笔记ID | 主键,自增 |
|
||||
| user_id | BIGINT | 发布用户ID | 外键关联users |
|
||||
| title | VARCHAR(200) | 标题 | 笔记标题 |
|
||||
| content | TEXT | 内容 | 笔记描述 |
|
||||
| category | VARCHAR(50) | 分类 | 如:穿搭、美食等,可为空 |
|
||||
| is_draft | TINYINT(1) | 是否为草稿 | 1-草稿,0-已发布,默认1 |
|
||||
| view_count | BIGINT | 浏览量 | 统计字段,默认0 |
|
||||
| like_count | INT | 点赞数 | 统计字段,默认0 |
|
||||
| collect_count | INT | 收藏数 | 统计字段,默认0 |
|
||||
| comment_count | INT | 评论数 | 统计字段,默认0 |
|
||||
| created_at | TIMESTAMP | 发布时间 | 创建时间 |
|
||||
|
||||
### 3. 笔记图片表 (post_images)
|
||||
|
||||
| 字段名 | 类型 | 说明 | 备注 |
|
||||
|--------|------|------|------|
|
||||
| id | BIGINT | 图片ID | 主键,自增 |
|
||||
| post_id | BIGINT | 笔记ID | 外键关联posts |
|
||||
| image_url | VARCHAR(500) | 图片URL | 原图地址 |
|
||||
|
||||
### 4. 标签表 (tags)
|
||||
|
||||
| 字段名 | 类型 | 说明 | 备注 |
|
||||
|--------|------|------|------|
|
||||
| id | INT | 标签ID | 主键,自增 |
|
||||
| name | VARCHAR(50) | 标签名 | 标签内容,唯一 |
|
||||
| use_count | INT | 使用次数 | 热度统计,默认0 |
|
||||
| created_at | TIMESTAMP | 创建时间 | 首次使用时间 |
|
||||
|
||||
### 5. 笔记标签关联表 (post_tags)
|
||||
|
||||
| 字段名 | 类型 | 说明 | 备注 |
|
||||
|--------|------|------|------|
|
||||
| id | BIGINT | 关联ID | 主键,自增 |
|
||||
| post_id | BIGINT | 笔记ID | 外键关联posts |
|
||||
| tag_id | INT | 标签ID | 外键关联tags |
|
||||
| created_at | TIMESTAMP | 创建时间 | 关联时间 |
|
||||
|
||||
### 6. 关注关系表 (follows)
|
||||
|
||||
| 字段名 | 类型 | 说明 | 备注 |
|
||||
|--------|------|------|------|
|
||||
| id | BIGINT | 关注ID | 主键,自增 |
|
||||
| follower_id | BIGINT | 关注者ID | 外键关联users |
|
||||
| following_id | BIGINT | 被关注者ID | 外键关联users |
|
||||
| created_at | TIMESTAMP | 关注时间 | 创建时间 |
|
||||
|
||||
### 7. 点赞表 (likes)
|
||||
|
||||
| 字段名 | 类型 | 说明 | 备注 |
|
||||
|--------|------|------|------|
|
||||
| id | BIGINT | 点赞ID | 主键,自增 |
|
||||
| user_id | BIGINT | 用户ID | 外键关联users |
|
||||
| target_type | TINYINT | 目标类型 | 1-笔记, 2-评论 |
|
||||
| target_id | BIGINT | 目标ID | 笔记或评论ID |
|
||||
| created_at | TIMESTAMP | 点赞时间 | 创建时间 |
|
||||
|
||||
### 8. 收藏表 (collections)
|
||||
|
||||
| 字段名 | 类型 | 说明 | 备注 |
|
||||
|--------|------|------|------|
|
||||
| id | BIGINT | 收藏ID | 主键,自增 |
|
||||
| user_id | BIGINT | 用户ID | 外键关联users |
|
||||
| post_id | BIGINT | 笔记ID | 外键关联posts |
|
||||
| created_at | TIMESTAMP | 收藏时间 | 创建时间 |
|
||||
|
||||
### 9. 评论表 (comments)
|
||||
|
||||
| 字段名 | 类型 | 说明 | 备注 |
|
||||
|--------|------|------|------|
|
||||
| id | BIGINT | 评论ID | 主键,自增 |
|
||||
| post_id | BIGINT | 笔记ID | 外键关联posts |
|
||||
| user_id | BIGINT | 评论用户ID | 外键关联users |
|
||||
| parent_id | BIGINT | 父评论ID | 回复评论时使用,可为空 |
|
||||
| content | TEXT | 评论内容 | 评论文本 |
|
||||
| like_count | INT | 点赞数 | 统计字段,默认0 |
|
||||
| created_at | TIMESTAMP | 评论时间 | 创建时间 |
|
||||
|
||||
### 10. 通知表 (notifications)
|
||||
|
||||
| 字段名 | 类型 | 说明 | 备注 |
|
||||
|--------|------|------|------|
|
||||
| id | BIGINT | 通知ID | 主键,自增 |
|
||||
| user_id | BIGINT | 接收用户ID | 外键关联users |
|
||||
| sender_id | BIGINT | 发送用户ID | 外键关联users |
|
||||
| type | TINYINT | 通知类型 | 1-点赞, 2-评论, 3-关注 |
|
||||
| title | VARCHAR(200) | 通知标题 | 通知内容 |
|
||||
| target_id | BIGINT | 关联目标ID | 笔记或评论ID,可为空 |
|
||||
| comment_id | BIGINT | 关联评论ID | 用于评论和回复通知,可为空 |
|
||||
| is_read | TINYINT(1) | 是否已读 | 默认0 |
|
||||
| created_at | TIMESTAMP | 通知时间 | 创建时间 |
|
||||
|
||||
|
||||
|
||||
### 11. 用户会话表 (user_sessions)
|
||||
|
||||
| 字段名 | 类型 | 说明 | 备注 |
|
||||
|--------|------|------|------|
|
||||
| id | BIGINT | 会话ID | 主键,自增 |
|
||||
| user_id | BIGINT | 用户ID | 外键关联users |
|
||||
| token | VARCHAR(255) | 访问令牌 | 唯一 |
|
||||
| refresh_token | VARCHAR(255) | 刷新令牌 | 可为空 |
|
||||
| expires_at | TIMESTAMP | 过期时间 | 令牌过期时间 |
|
||||
| user_agent | TEXT | 用户代理 | 浏览器信息,可为空 |
|
||||
| is_active | TINYINT(1) | 是否激活 | 默认1 |
|
||||
| created_at | TIMESTAMP | 创建时间 | 会话创建时间 |
|
||||
| updated_at | TIMESTAMP | 更新时间 | 自动更新 |
|
||||
|
||||
### 12. 管理员表 (admin)
|
||||
|
||||
| 字段名 | 类型 | 说明 | 备注 |
|
||||
|--------|------|------|------|
|
||||
| id | BIGINT | 管理员ID | 主键,自增 |
|
||||
| username | VARCHAR(50) | 管理员用户名 | 唯一 |
|
||||
| password | VARCHAR(255) | 管理员密码 | 加密存储 |
|
||||
| created_at | TIMESTAMP | 创建时间 | 账号创建时间 |
|
||||
|
289
doc/DEPLOYMENT.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# 部署指南
|
||||
|
||||
本文档详细介绍了小石榴图文社区项目的部署流程和配置说明。
|
||||
|
||||
## 环境要求
|
||||
|
||||
| 组件 | 版本要求 | 说明 |
|
||||
|------|----------|------|
|
||||
| Node.js | >= 16.0.0 | 运行环境 |
|
||||
| MySQL | >= 5.7 | 数据库 |
|
||||
| MariaDB | >= 10.3 | 数据库(可选) |
|
||||
| npm | >= 8.0.0 | 包管理器 |
|
||||
| yarn | >= 1.22.0 | 包管理器(可选) |
|
||||
| 浏览器 | 支持ES6+ | 现代浏览器 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
# 使用 cnpm
|
||||
cnpm install
|
||||
# 或使用 yarn
|
||||
yarn install
|
||||
```
|
||||
|
||||
### 2. 配置后端API地址
|
||||
|
||||
创建环境配置文件(可选):
|
||||
|
||||
```bash
|
||||
# 复制环境配置模板
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
编辑 `.env` 文件,配置后端API地址:
|
||||
|
||||
```env
|
||||
# 后端API地址
|
||||
VITE_API_BASE_URL=http://localhost:3001
|
||||
|
||||
# 其他配置...
|
||||
```
|
||||
|
||||
### 3. 启动开发服务器
|
||||
|
||||
```bash
|
||||
# 启动开发服务器
|
||||
npm run dev
|
||||
|
||||
# 或使用 yarn
|
||||
yarn dev
|
||||
```
|
||||
|
||||
开发服务器将在 `http://localhost:5173` 启动
|
||||
|
||||
### 4. 构建生产版本
|
||||
|
||||
```bash
|
||||
# 构建生产版本
|
||||
npm run build
|
||||
|
||||
# 预览生产版本
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 后端服务配置
|
||||
|
||||
⚠️ **重要提醒**:前端项目需要配合后端服务使用
|
||||
|
||||
1. **启动后端服务**:
|
||||
```bash
|
||||
# 进入后端项目目录
|
||||
cd ../express-project
|
||||
|
||||
# 安装后端依赖
|
||||
npm install
|
||||
|
||||
# 启动后端服务
|
||||
npm start
|
||||
```
|
||||
|
||||
2. **后端服务地址**:`http://localhost:3001`
|
||||
|
||||
3. **API文档**:查看后端项目的 `API_DOCS.md` 文件
|
||||
|
||||
## 开发环境配置
|
||||
|
||||
### 环境检查
|
||||
|
||||
```bash
|
||||
# 检查Node.js版本
|
||||
node --version
|
||||
|
||||
# 检查npm版本
|
||||
npm --version
|
||||
```
|
||||
|
||||
### 开发服务器
|
||||
|
||||
```bash
|
||||
# 启动开发服务器(热重载)
|
||||
npm run dev
|
||||
|
||||
# 访问地址:http://localhost:5173
|
||||
```
|
||||
|
||||
### 代码规范
|
||||
|
||||
- 使用 Vue 3 Composition API
|
||||
- 遵循 Vue.js 官方风格指南
|
||||
- 组件命名采用 PascalCase
|
||||
- 文件命名采用 kebab-case
|
||||
|
||||
## 配置文件说明
|
||||
|
||||
### 前端配置文件(vue3-project目录)
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `.env` | 环境变量配置文件 |
|
||||
| `vite.config.js` | Vite构建工具配置 |
|
||||
| `package.json` | 项目依赖和脚本配置 |
|
||||
| `jsconfig.json` | JavaScript项目配置 |
|
||||
|
||||
### 后端配置文件(express-project目录)
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `config/config.js` | 主配置文件 |
|
||||
| `config/database.js` | 数据库配置 |
|
||||
| `.env` | 环境变量配置文件 |
|
||||
| `database_design.md` | 数据库设计文档 |
|
||||
| `scripts/init-database.js` | 数据库初始化脚本 |
|
||||
| `generate-data.js` | 测试数据生成脚本 |
|
||||
|
||||
## npm脚本命令
|
||||
|
||||
### 前端脚本(在vue3-project目录下执行)
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `npm run dev` | 启动开发服务器 |
|
||||
| `npm run build` | 构建生产版本 |
|
||||
| `npm run preview` | 预览生产版本 |
|
||||
|
||||
### 后端脚本(在express-project目录下执行)
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `npm start` | 启动服务器 |
|
||||
| `npm run dev` | 启动开发服务器(热重载) |
|
||||
| `npm run init-db` | 初始化数据库 |
|
||||
| `npm run generate-data` | 生成测试数据 |
|
||||
|
||||
## 环境变量配置
|
||||
|
||||
### 前端环境变量(vue3-project/.env)
|
||||
|
||||
```env
|
||||
# API服务器地址
|
||||
VITE_API_BASE_URL=http://localhost:3001/api
|
||||
|
||||
# 其他前端配置
|
||||
VITE_APP_TITLE=小石榴图文社区
|
||||
VITE_USE_REAL_API=true
|
||||
```
|
||||
|
||||
### 后端环境变量(express-project/.env)
|
||||
|
||||
```env
|
||||
# 服务器配置
|
||||
NODE_ENV=development
|
||||
PORT=3001
|
||||
|
||||
# JWT配置
|
||||
JWT_SECRET=xiaoshiliu_secret_key_2025
|
||||
JWT_EXPIRES_IN=7d
|
||||
REFRESH_TOKEN_EXPIRES_IN=30d
|
||||
|
||||
# 数据库配置
|
||||
DB_HOST=localhost
|
||||
DB_USER=root
|
||||
DB_PASSWORD=123456
|
||||
DB_NAME=xiaoshiliu
|
||||
DB_PORT=3306
|
||||
|
||||
# API配置
|
||||
API_BASE_URL=http://localhost:3001
|
||||
|
||||
# 上传配置
|
||||
UPLOAD_MAX_SIZE=50mb
|
||||
```
|
||||
|
||||
## 数据库脚本说明
|
||||
|
||||
项目的数据库相关脚本都统一放在 `express-project/scripts/` 目录下,方便管理和使用:
|
||||
|
||||
### 脚本文件介绍
|
||||
|
||||
#### 1. 数据库初始化脚本
|
||||
- **文件位置**:`scripts/init-database.js`
|
||||
- **功能**:创建数据库和所有数据表结构
|
||||
- **使用方法**:
|
||||
```bash
|
||||
cd express-project
|
||||
node scripts/init-database.js
|
||||
```
|
||||
- **说明**:首次部署时必须运行,会自动创建 `xiaoshiliu` 数据库和12个数据表
|
||||
|
||||
#### 2. 测试数据生成脚本
|
||||
- **文件位置**:`scripts/generate-data.js`
|
||||
- **功能**:生成模拟的用户、帖子、评论等测试数据
|
||||
- **使用方法**:
|
||||
```bash
|
||||
cd express-project
|
||||
node scripts/generate-data.js
|
||||
```
|
||||
- **说明**:可选运行,用于快速填充测试数据,包含50个用户、200个帖子、800条评论等
|
||||
|
||||
#### 3. SQL初始化文件
|
||||
- **文件位置**:`scripts/init-database.sql`
|
||||
- **功能**:纯SQL版本的数据库初始化脚本
|
||||
- **使用方法**:可直接在MySQL客户端中执行
|
||||
- **说明**:与 `init-database.js` 功能相同,提供SQL版本供参考
|
||||
|
||||
#### 4. 示例图片更新脚本
|
||||
- **文件位置**:`scripts/update-sample-images.js`
|
||||
- **功能**:自动获取最新图片链接并更新数据库中的示例图片
|
||||
- **使用方法**:
|
||||
```bash
|
||||
cd express-project
|
||||
node scripts/update-sample-images.js
|
||||
```
|
||||
- **说明**:
|
||||
- 自动从栗次元API获取最新的图片链接
|
||||
- 更新 `imgLinks/avatar_link.txt`(50个头像链接)
|
||||
- 更新 `imgLinks/post_img_link.txt`(300个帖子图片链接)
|
||||
- 批量更新数据库中的用户头像和帖子图片
|
||||
- 支持统计显示更新前后的图片数量
|
||||
|
||||
## 开发环境启动流程
|
||||
|
||||
### 1. 启动后端服务
|
||||
|
||||
```bash
|
||||
# 打开第一个终端,进入后端目录
|
||||
cd express-project
|
||||
|
||||
# 安装后端依赖(首次运行)
|
||||
npm install
|
||||
|
||||
# 配置数据库(首次运行)
|
||||
# 编辑 config/config.js 或 .env 文件
|
||||
|
||||
# 初始化数据库(首次运行)
|
||||
node scripts/init-database.js
|
||||
|
||||
# 生成测试数据(可选)
|
||||
node scripts/generate-data.js
|
||||
|
||||
# 启动后端服务
|
||||
npm start
|
||||
# 后端服务运行在 http://localhost:3001
|
||||
```
|
||||
|
||||
### 2. 启动前端服务
|
||||
|
||||
```bash
|
||||
# 打开第二个终端,进入前端目录
|
||||
cd vue3-project
|
||||
|
||||
# 安装前端依赖(首次运行)
|
||||
npm install
|
||||
|
||||
# 配置API地址(可选)
|
||||
# 编辑 .env 文件,设置 VITE_API_BASE_URL
|
||||
|
||||
# 启动前端开发服务器
|
||||
npm run dev
|
||||
# 前端服务运行在 http://localhost:5173
|
||||
```
|
||||
|
||||
### 3. 访问应用
|
||||
|
||||
| 服务 | 地址 |
|
||||
|------|------|
|
||||
| 前端界面 | http://localhost:5173 |
|
||||
| 后端API | http://localhost:3001 |
|
163
doc/PROJECT_STRUCTURE.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# 项目结构
|
||||
|
||||
本文档详细介绍了小石榴图文社区项目的目录结构和文件组织。
|
||||
|
||||
## 总体结构
|
||||
|
||||
```
|
||||
小石榴图文社区/
|
||||
├── vue3-project/ # 前端项目
|
||||
├── express-project/ # 后端项目
|
||||
├── README.md # 项目主文档
|
||||
├── DEPLOYMENT.md # 部署指南
|
||||
└── PROJECT_STRUCTURE.md # 项目结构说明(本文档)
|
||||
```
|
||||
|
||||
## 前端项目结构(vue3-project/)
|
||||
|
||||
```
|
||||
vue3-project/
|
||||
├── public/ # 静态资源目录
|
||||
│ └── logo.ico # 网站图标
|
||||
├── src/ # 源代码目录
|
||||
│ ├── api/ # API接口封装
|
||||
│ ├── assets/ # 静态资源(图片、样式等)
|
||||
│ ├── components/ # 公共组件
|
||||
│ ├── composables/ # 组合式函数
|
||||
│ ├── config/ # 配置文件
|
||||
│ ├── directives/ # 自定义指令
|
||||
│ ├── router/ # 路由配置
|
||||
│ ├── stores/ # Pinia状态管理
|
||||
│ ├── utils/ # 工具函数
|
||||
│ ├── views/ # 页面组件
|
||||
│ ├── App.vue # 根组件
|
||||
│ └── main.js # 入口文件
|
||||
├── .env.example # 环境变量模板
|
||||
├── index.html # HTML模板
|
||||
├── package.json # 项目配置
|
||||
├── vite.config.js # Vite配置
|
||||
└── README.md # 前端项目说明
|
||||
```
|
||||
|
||||
### 前端目录详细说明
|
||||
|
||||
| 目录/文件 | 说明 | 主要内容 |
|
||||
|-----------|------|----------|
|
||||
| `public/` | 静态资源目录 | 网站图标、不需要编译的静态文件 |
|
||||
| `src/api/` | API接口封装 | HTTP请求封装、接口定义 |
|
||||
| `src/assets/` | 静态资源 | 图片、字体、样式文件等 |
|
||||
| `src/components/` | 公共组件 | 可复用的Vue组件 |
|
||||
| `src/composables/` | 组合式函数 | Vue 3 Composition API逻辑复用 |
|
||||
| `src/config/` | 配置文件 | 应用配置、常量定义 |
|
||||
| `src/directives/` | 自定义指令 | Vue自定义指令 |
|
||||
| `src/router/` | 路由配置 | Vue Router路由定义 |
|
||||
| `src/stores/` | 状态管理 | Pinia状态管理模块 |
|
||||
| `src/utils/` | 工具函数 | 通用工具函数、辅助方法 |
|
||||
| `src/views/` | 页面组件 | 页面级Vue组件 |
|
||||
| `App.vue` | 根组件 | 应用程序根组件 |
|
||||
| `main.js` | 入口文件 | 应用程序入口点 |
|
||||
| `vite.config.js` | Vite配置 | 构建工具配置 |
|
||||
|
||||
## 后端项目结构(express-project/)
|
||||
|
||||
```
|
||||
express-project/
|
||||
├── config/ # 配置文件目录
|
||||
│ ├── config.js # 主配置文件
|
||||
│ └── database.js # 数据库配置
|
||||
├── routes/ # 路由文件目录
|
||||
│ ├── auth.js # 认证路由
|
||||
│ ├── users.js # 用户路由
|
||||
│ ├── posts.js # 帖子路由
|
||||
│ ├── comments.js # 评论路由
|
||||
│ └── ... # 其他路由文件
|
||||
├── middleware/ # 中间件目录
|
||||
│ ├── auth.js # 认证中间件
|
||||
│ └── crudFactory.js # CRUD工厂
|
||||
├── utils/ # 工具函数目录
|
||||
├── scripts/ # 脚本文件目录
|
||||
│ ├── init-database.js # 数据库初始化脚本
|
||||
│ ├── init-database.sql # SQL初始化脚本
|
||||
│ ├── generate-data.js # 测试数据生成脚本
|
||||
│ └── update-sample-images.js # 示例图片更新脚本
|
||||
├── app.js # 应用入口文件
|
||||
├── package.json # 项目配置
|
||||
└── .env.example # 环境变量模板
|
||||
```
|
||||
|
||||
### 后端目录详细说明
|
||||
|
||||
| 目录/文件 | 说明 | 主要内容 |
|
||||
|-----------|------|----------|
|
||||
| `config/` | 配置文件目录 | 应用配置、数据库配置 |
|
||||
| `routes/` | 路由文件目录 | Express路由定义、API端点 |
|
||||
| `middleware/` | 中间件目录 | Express中间件、认证逻辑 |
|
||||
| `utils/` | 工具函数目录 | 通用工具函数、辅助方法 |
|
||||
| `scripts/` | 脚本文件目录 | 数据库初始化、数据生成脚本 |
|
||||
| `app.js` | 应用入口文件 | Express应用程序入口 |
|
||||
|
||||
### 路由文件说明
|
||||
|
||||
| 路由文件 | 功能 | 主要端点 |
|
||||
|----------|------|----------|
|
||||
| `auth.js` | 用户认证 | 登录、注册、token验证 |
|
||||
| `users.js` | 用户管理 | 用户信息CRUD、关注关系 |
|
||||
| `posts.js` | 帖子管理 | 帖子发布、编辑、删除、查询 |
|
||||
| `comments.js` | 评论管理 | 评论发布、删除、查询 |
|
||||
|
||||
### 脚本文件说明
|
||||
|
||||
| 脚本文件 | 功能 | 使用场景 |
|
||||
|----------|------|----------|
|
||||
| `init-database.js` | 数据库初始化 | 首次部署时创建数据库结构 |
|
||||
| `init-database.sql` | SQL初始化脚本 | 直接在MySQL客户端执行 |
|
||||
| `generate-data.js` | 测试数据生成 | 开发环境填充测试数据 |
|
||||
| `update-sample-images.js` | 图片链接更新 | 更新示例图片资源 |
|
||||
|
||||
## 技术架构
|
||||
|
||||
### 前端架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Vue 3 App │
|
||||
├─────────────────────────────────────┤
|
||||
│ Views (页面) │ Components (组件) │
|
||||
├─────────────────────────────────────┤
|
||||
│ Router (路由) │ Stores (状态管理) │
|
||||
├─────────────────────────────────────┤
|
||||
│ API (接口) │ Utils (工具) │
|
||||
├─────────────────────────────────────┤
|
||||
│ Vite (构建工具) │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 后端架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Express Server │
|
||||
├─────────────────────────────────────┤
|
||||
│ Routes (路由) │ Middleware (中间件) │
|
||||
├─────────────────────────────────────┤
|
||||
│ Config (配置) │ Utils (工具) │
|
||||
├─────────────────────────────────────┤
|
||||
│ MySQL Database │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 数据流向
|
||||
|
||||
```
|
||||
前端 Vue App
|
||||
↓ HTTP请求
|
||||
Express 路由
|
||||
↓ 数据处理
|
||||
中间件验证
|
||||
↓ 数据库操作
|
||||
MySQL 数据库
|
||||
↓ 返回数据
|
||||
前端状态更新
|
||||
↓ 视图渲染
|
||||
用户界面展示
|
||||
```
|
BIN
doc/imgs/1.png
Normal file
After Width: | Height: | Size: 3.0 MiB |
BIN
doc/imgs/10.png
Normal file
After Width: | Height: | Size: 494 KiB |
BIN
doc/imgs/11.png
Normal file
After Width: | Height: | Size: 1.0 MiB |
BIN
doc/imgs/12.png
Normal file
After Width: | Height: | Size: 1.1 MiB |
BIN
doc/imgs/2.png
Normal file
After Width: | Height: | Size: 2.7 MiB |
BIN
doc/imgs/3.png
Normal file
After Width: | Height: | Size: 205 KiB |
BIN
doc/imgs/4.png
Normal file
After Width: | Height: | Size: 286 KiB |
BIN
doc/imgs/5.png
Normal file
After Width: | Height: | Size: 481 KiB |
BIN
doc/imgs/6.png
Normal file
After Width: | Height: | Size: 440 KiB |
BIN
doc/imgs/7.png
Normal file
After Width: | Height: | Size: 2.2 MiB |
BIN
doc/imgs/8.png
Normal file
After Width: | Height: | Size: 697 KiB |
BIN
doc/imgs/9.png
Normal file
After Width: | Height: | Size: 223 KiB |
BIN
doc/imgs/m1.png
Normal file
After Width: | Height: | Size: 1.2 MiB |
BIN
doc/imgs/m2.png
Normal file
After Width: | Height: | Size: 580 KiB |
BIN
doc/imgs/m3.png
Normal file
After Width: | Height: | Size: 135 KiB |
BIN
doc/imgs/m4.png
Normal file
After Width: | Height: | Size: 350 KiB |
BIN
doc/imgs/m5.png
Normal file
After Width: | Height: | Size: 920 KiB |
BIN
doc/imgs/m6.png
Normal file
After Width: | Height: | Size: 539 KiB |
BIN
doc/imgs/m7.png
Normal file
After Width: | Height: | Size: 806 KiB |
BIN
doc/imgs/m8.png
Normal file
After Width: | Height: | Size: 153 KiB |
BIN
doc/imgs/小石榴.png
Normal file
After Width: | Height: | Size: 595 KiB |
20
express-project/.env.example
Normal file
@@ -0,0 +1,20 @@
|
||||
# 服务器配置
|
||||
PORT=3001
|
||||
|
||||
# JWT配置
|
||||
JWT_SECRET=xiaoshiliu_secret_key_2025
|
||||
JWT_EXPIRES_IN=7d
|
||||
REFRESH_TOKEN_EXPIRES_IN=30d
|
||||
|
||||
# 数据库配置
|
||||
DB_HOST=localhost
|
||||
DB_USER=root
|
||||
DB_PASSWORD=123456
|
||||
DB_NAME=xiaoshiliu
|
||||
DB_PORT=3306
|
||||
|
||||
# 上传配置
|
||||
UPLOAD_MAX_SIZE=50mb
|
||||
|
||||
# 其他配置
|
||||
NODE_ENV=development
|
79
express-project/app.js
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 小石榴校园图文社区 - Express后端服务
|
||||
*
|
||||
* @author ZTMYO
|
||||
* @github https://github.com/ZTMYO
|
||||
* @description 基于Express框架的图文社区后端API服务
|
||||
* @version 1.0.0
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const cors = require('cors');
|
||||
const config = require('./config/config');
|
||||
|
||||
// 导入路由模块
|
||||
const authRoutes = require('./routes/auth');
|
||||
const usersRoutes = require('./routes/users');
|
||||
const postsRoutes = require('./routes/posts');
|
||||
const commentsRoutes = require('./routes/comments');
|
||||
const likesRoutes = require('./routes/likes');
|
||||
const tagsRoutes = require('./routes/tags');
|
||||
const searchRoutes = require('./routes/search');
|
||||
const notificationsRoutes = require('./routes/notifications');
|
||||
const uploadRoutes = require('./routes/upload');
|
||||
const statsRoutes = require('./routes/stats');
|
||||
const adminRoutes = require('./routes/admin');
|
||||
|
||||
const app = express();
|
||||
|
||||
// 中间件配置
|
||||
// CORS配置
|
||||
const corsOptions = {
|
||||
origin: [
|
||||
'http://localhost:5173',
|
||||
'http://localhost:3001'
|
||||
],
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization']
|
||||
};
|
||||
|
||||
app.use(cors(corsOptions));
|
||||
app.options('*', cors(corsOptions)); // 显式处理OPTIONS请求
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
||||
|
||||
// 路由配置
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/users', usersRoutes);
|
||||
app.use('/api/posts', postsRoutes);
|
||||
app.use('/api/comments', commentsRoutes);
|
||||
app.use('/api/likes', likesRoutes);
|
||||
app.use('/api/tags', tagsRoutes);
|
||||
app.use('/api/search', searchRoutes);
|
||||
app.use('/api/notifications', notificationsRoutes);
|
||||
app.use('/api/upload', uploadRoutes);
|
||||
app.use('/api/stats', statsRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
|
||||
// 错误处理中间件
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('服务器错误:', err);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
});
|
||||
|
||||
// 404 处理
|
||||
app.use('*', (req, res) => {
|
||||
res.status(404).json({ code: 404, message: '接口不存在' });
|
||||
});
|
||||
|
||||
// 启动服务器
|
||||
const PORT = config.server.port;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`● 服务器运行在端口 ${PORT}`);
|
||||
console.log(`● 环境: ${config.server.env}`);
|
||||
});
|
||||
|
||||
module.exports = app;
|
57
express-project/config/config.js
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 小石榴校园图文社区 - 应用配置文件
|
||||
* 集中管理所有配置项
|
||||
*
|
||||
* @author ZTMYO
|
||||
* @github https://github.com/ZTMYO
|
||||
* @description Express应用的核心配置管理
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
// 服务器配置
|
||||
server: {
|
||||
port: process.env.PORT || 3001,
|
||||
env: process.env.NODE_ENV || 'development'
|
||||
},
|
||||
|
||||
// JWT配置
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET || 'xiaoshiliu_secret_key_2025',
|
||||
expiresIn: process.env.JWT_EXPIRES_IN || '7d',
|
||||
refreshExpiresIn: process.env.REFRESH_TOKEN_EXPIRES_IN || '30d'
|
||||
},
|
||||
|
||||
// 数据库配置
|
||||
database: {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '123456',
|
||||
database: process.env.DB_NAME || 'xiaoshiliu',
|
||||
port: process.env.DB_PORT || 3306,
|
||||
charset: 'utf8mb4'
|
||||
},
|
||||
|
||||
// 上传配置
|
||||
upload: {
|
||||
maxSize: process.env.UPLOAD_MAX_SIZE || '50mb',
|
||||
allowedTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||
},
|
||||
|
||||
// API配置
|
||||
api: {
|
||||
baseUrl: process.env.API_BASE_URL || 'http://localhost:3001',
|
||||
timeout: 30000
|
||||
},
|
||||
|
||||
// 分页配置
|
||||
pagination: {
|
||||
defaultLimit: 20,
|
||||
maxLimit: 100
|
||||
},
|
||||
|
||||
// 缓存配置
|
||||
cache: {
|
||||
ttl: 300 // 5分钟
|
||||
}
|
||||
};
|
21
express-project/config/database.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// 数据库配置
|
||||
const mysql = require('mysql2/promise');
|
||||
const config = require('./config');
|
||||
|
||||
// 数据库连接配置
|
||||
const dbConfig = {
|
||||
...config.database,
|
||||
timezone: '+08:00'
|
||||
};
|
||||
|
||||
// 创建连接池
|
||||
const pool = mysql.createPool({
|
||||
...dbConfig,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
pool
|
||||
};
|
147
express-project/constants/index.js
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 应用常量定义
|
||||
*/
|
||||
|
||||
// HTTP状态码
|
||||
const HTTP_STATUS = {
|
||||
OK: 200,
|
||||
CREATED: 201,
|
||||
BAD_REQUEST: 400,
|
||||
UNAUTHORIZED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
CONFLICT: 409,
|
||||
INTERNAL_SERVER_ERROR: 500
|
||||
};
|
||||
|
||||
// 响应码
|
||||
const RESPONSE_CODES = {
|
||||
SUCCESS: 200,
|
||||
ERROR: 500,
|
||||
VALIDATION_ERROR: 400,
|
||||
UNAUTHORIZED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
CONFLICT: 409
|
||||
};
|
||||
|
||||
// 用户类型
|
||||
const USER_TYPES = {
|
||||
ADMIN: 'admin',
|
||||
USER: 'user'
|
||||
};
|
||||
|
||||
// 性别
|
||||
const GENDERS = {
|
||||
MALE: 'male',
|
||||
FEMALE: 'female',
|
||||
OTHER: 'other'
|
||||
};
|
||||
|
||||
// 笔记分类
|
||||
const POST_CATEGORIES = {
|
||||
STUDY: 'study',
|
||||
CAMPUS: 'campus',
|
||||
EMOTION: 'emotion',
|
||||
INTEREST: 'interest',
|
||||
LIFE: 'life',
|
||||
SOCIAL: 'social',
|
||||
HELP: 'help',
|
||||
OPINION: 'opinion',
|
||||
GRADUATION: 'graduation',
|
||||
CAREER: 'career'
|
||||
};
|
||||
|
||||
// 通知类型
|
||||
const NOTIFICATION_TYPES = {
|
||||
LIKE_POST: 1, // 点赞笔记
|
||||
LIKE_COMMENT: 2, // 点赞评论
|
||||
COMMENT: 3, // 评论
|
||||
FOLLOW: 4, // 关注
|
||||
COLLECT: 5, // 收藏
|
||||
MENTION: 7 // @提及
|
||||
};
|
||||
|
||||
// 点赞目标类型
|
||||
const LIKE_TARGET_TYPES = {
|
||||
POST: 1, // 笔记
|
||||
COMMENT: 2 // 评论
|
||||
};
|
||||
|
||||
// 排序字段
|
||||
const SORT_FIELDS = {
|
||||
CREATED_AT: 'created_at',
|
||||
VIEW_COUNT: 'view_count',
|
||||
LIKE_COUNT: 'like_count',
|
||||
COLLECT_COUNT: 'collect_count',
|
||||
COMMENT_COUNT: 'comment_count'
|
||||
};
|
||||
|
||||
// 排序方向
|
||||
const SORT_ORDERS = {
|
||||
ASC: 'ASC',
|
||||
DESC: 'DESC'
|
||||
};
|
||||
|
||||
// 文件类型
|
||||
const FILE_TYPES = {
|
||||
IMAGE: 'image',
|
||||
VIDEO: 'video',
|
||||
DOCUMENT: 'document'
|
||||
};
|
||||
|
||||
// 支持的图片格式
|
||||
const SUPPORTED_IMAGE_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp'
|
||||
];
|
||||
|
||||
// 错误消息
|
||||
const ERROR_MESSAGES = {
|
||||
VALIDATION_FAILED: '数据验证失败',
|
||||
UNAUTHORIZED: '未授权访问',
|
||||
FORBIDDEN: '权限不足',
|
||||
NOT_FOUND: '资源不存在',
|
||||
DUPLICATE_ENTRY: '数据已存在',
|
||||
DATABASE_ERROR: '数据库操作失败',
|
||||
UPLOAD_FAILED: '文件上传失败',
|
||||
INVALID_TOKEN: '无效的令牌',
|
||||
TOKEN_EXPIRED: '令牌已过期'
|
||||
};
|
||||
|
||||
// 成功消息
|
||||
const SUCCESS_MESSAGES = {
|
||||
OPERATION_SUCCESS: '操作成功',
|
||||
CREATE_SUCCESS: '创建成功',
|
||||
UPDATE_SUCCESS: '更新成功',
|
||||
DELETE_SUCCESS: '删除成功',
|
||||
LOGIN_SUCCESS: '登录成功',
|
||||
LOGOUT_SUCCESS: '退出成功',
|
||||
UPLOAD_SUCCESS: '上传成功'
|
||||
};
|
||||
|
||||
// 正则表达式
|
||||
const REGEX_PATTERNS = {
|
||||
EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||
PHONE: /^1[3-9]\d{9}$/,
|
||||
PASSWORD: /^(?=.*[a-zA-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{6,}$/,
|
||||
USER_ID: /^[a-zA-Z0-9_]{3,20}$/
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
HTTP_STATUS,
|
||||
RESPONSE_CODES,
|
||||
USER_TYPES,
|
||||
GENDERS,
|
||||
POST_CATEGORIES,
|
||||
NOTIFICATION_TYPES,
|
||||
LIKE_TARGET_TYPES,
|
||||
SORT_FIELDS,
|
||||
SORT_ORDERS,
|
||||
FILE_TYPES,
|
||||
SUPPORTED_IMAGE_TYPES,
|
||||
ERROR_MESSAGES,
|
||||
SUCCESS_MESSAGES,
|
||||
REGEX_PATTERNS
|
||||
};
|
50
express-project/imgLinks/avatar_link.txt
Normal file
@@ -0,0 +1,50 @@
|
||||
https://tc.alcy.cc/q/20250901/20559b1fb7aff05fa128868c69ede84b.webp
|
||||
https://tc.alcy.cc/q/20250901/89a1235c0be938ef5c0418fd4e0ad735.webp
|
||||
https://tc.alcy.cc/q/20250901/fb9dfd1815a6d2e4fa03564de676354b.webp
|
||||
https://tc.alcy.cc/q/20250901/035fdf2d83b65bf64dd1b20b06e59d6d.webp
|
||||
https://tc.alcy.cc/q/20250901/d1fcf59ae7f6bf60ede642613697d2f5.webp
|
||||
https://tc.alcy.cc/q/20250901/1a19e084ac2873deb43b10863fed15e5.webp
|
||||
https://tc.alcy.cc/q/20250901/69f7dbe35a16e4debc89dd39ae692c87.webp
|
||||
https://tc.alcy.cc/q/20250901/b47b89f898830b34591be9c0cfa66f98.webp
|
||||
https://tc.alcy.cc/q/20250901/f566b631b95bc936b4d0518834e64a34.webp
|
||||
https://tc.alcy.cc/q/20250901/e393ce6e5c4d7e37ea0fa0ebfb837d14.webp
|
||||
https://tc.alcy.cc/q/20250901/179e49c574e307ad56280b2dddc4ee23.webp
|
||||
https://tc.alcy.cc/q/20250901/8b1e08c6d515530c8a9f57b8825286b5.webp
|
||||
https://tc.alcy.cc/q/20250901/0078bde454dc149090ff9d4f3885ad9f.webp
|
||||
https://tc.alcy.cc/q/20250901/0fda015c370738031da0f9dc15b4d75b.webp
|
||||
https://tc.alcy.cc/q/20250901/32c0a15d62a4d515822015acd073913f.webp
|
||||
https://tc.alcy.cc/q/20250901/3a4a07ff18d88180087b76fae56c2d86.webp
|
||||
https://tc.alcy.cc/q/20250901/78e7dff543dd4756991953254ee1fbd8.webp
|
||||
https://tc.alcy.cc/q/20250901/84348b372edf4f5c10803cf621747d20.webp
|
||||
https://tc.alcy.cc/q/20250901/70667acc927e35466424ef287b343003.webp
|
||||
https://tc.alcy.cc/q/20250901/9511cdf62901eb6aa68b572a4892b7d9.webp
|
||||
https://tc.alcy.cc/q/20250901/bb94c19ddea5804835e8533d1d334c8d.webp
|
||||
https://tc.alcy.cc/q/20250901/f89560500531ddffe592458f1c7f7319.webp
|
||||
https://tc.alcy.cc/q/20250901/24dfeeff3ce6ae6d3f88882d9bf01f65.webp
|
||||
https://tc.alcy.cc/q/20250901/71f5f2f32dc67fbae085642665bf5213.webp
|
||||
https://tc.alcy.cc/q/20250901/6a519ab392cb07c1609dbb5469070989.webp
|
||||
https://tc.alcy.cc/q/20250901/0e478c22edbdb8746a87a2d774d7fcca.webp
|
||||
https://tc.alcy.cc/q/20250901/c44fcb7455343a0da02884300a9f8db7.webp
|
||||
https://tc.alcy.cc/q/20250901/ad6f7756d78583aa6fea1b8cbeeac555.webp
|
||||
https://tc.alcy.cc/q/20250901/daf6f39a53ad17fd7a945f7b6fec6f97.webp
|
||||
https://tc.alcy.cc/q/20250901/3f69321289326ca71be4dd03fdd9bcaa.webp
|
||||
https://tc.alcy.cc/q/20250901/c6ce9362d9094287a07ff3e01b13551a.webp
|
||||
https://tc.alcy.cc/q/20250901/45b24658244d41708d59019789fd9d57.webp
|
||||
https://tc.alcy.cc/q/20250901/de6b8b61537bd1ee719333a1f8492b7b.webp
|
||||
https://tc.alcy.cc/q/20250901/fee337bd6fc41176de6cb3f420984271.webp
|
||||
https://tc.alcy.cc/q/20250901/3943aafab071ce87aeb708c7ca64b1e8.webp
|
||||
https://tc.alcy.cc/q/20250901/7d16995cb228a7a221ea522cb2d87d28.webp
|
||||
https://tc.alcy.cc/q/20250901/13aa186b09a12ffb0ac02725d788a0cd.webp
|
||||
https://tc.alcy.cc/q/20250901/808f5d89b1f0ca3e6bda7801c896d3a9.webp
|
||||
https://tc.alcy.cc/q/20250901/3ceb17983f9cc5cdbdcc6248ad4fed8a.webp
|
||||
https://tc.alcy.cc/q/20250901/ea5740fec878bc072cc2626810173718.webp
|
||||
https://tc.alcy.cc/q/20250901/8d26e52c45448f3d6a867c54a0c3515d.webp
|
||||
https://tc.alcy.cc/q/20250901/f3fc49c52b70d6f64d7cc515726a520b.webp
|
||||
https://tc.alcy.cc/q/20250901/81c4b87c29b0a0a7b9542fd432045275.webp
|
||||
https://tc.alcy.cc/q/20250901/91187e0f78d1f3605bbc3f9f70ab6199.webp
|
||||
https://tc.alcy.cc/q/20250901/8f7d902b8ae4e09845ab26aab331c7e5.webp
|
||||
https://tc.alcy.cc/q/20250901/036f870168a22936de15c75f5834427b.webp
|
||||
https://tc.alcy.cc/q/20250901/f17cd2ac8fb30615219470007b2ed810.webp
|
||||
https://tc.alcy.cc/q/20250901/b9b05619ae8bf4270a238386894cd380.webp
|
||||
https://tc.alcy.cc/q/20250901/84bfea21487012dde7f215122aa7643d.webp
|
||||
https://tc.alcy.cc/q/20250901/97004962f4f7cd81a270a6786ae45134.webp
|
234
express-project/imgLinks/post_img_link.txt
Normal file
@@ -0,0 +1,234 @@
|
||||
https://tc.alcy.cc/q/20250901/d1e47ad8ae4c29dac7095e34b89a0c31.webp
|
||||
https://tc.alcy.cc/q/20250901/b91a62aa942a4c820f9eefb61addf1d7.webp
|
||||
https://tc.alcy.cc/q/20250901/ab71f12df1af7f8f93b2a92254377876.webp
|
||||
https://tc.alcy.cc/q/20250901/3b145596ab0ad19d72367a580e46680e.webp
|
||||
https://tc.alcy.cc/q/20250901/883622942b492016d23d34c81550fdca.webp
|
||||
https://tc.alcy.cc/q/20250901/8b0427576e3acd55de9ea64233dbdfec.webp
|
||||
https://tc.alcy.cc/q/20250901/8f32b6f95dd0e384661d9ada78b726fb.webp
|
||||
https://tc.alcy.cc/q/20250901/ccedc98c0fe0f2d9256afe435a02c57a.webp
|
||||
https://tc.alcy.cc/q/20250901/18c60e148d78438adb19c74b4f839070.webp
|
||||
https://tc.alcy.cc/q/20250901/22254480e3cb723562cd82dce29ca677.webp
|
||||
https://tc.alcy.cc/q/20250901/3ed7258fb1a2f7bd11ec9108134df01e.webp
|
||||
https://tc.alcy.cc/q/20250901/09c0e3f3d5879a5bdcbe850bc800e50c.webp
|
||||
https://tc.alcy.cc/q/20250901/b6f63b8cabca3ee8eb2ca5e81c61cafa.webp
|
||||
https://tc.alcy.cc/q/20250901/61909a1f9ba7242760201e2a59d8d9b6.webp
|
||||
https://tc.alcy.cc/q/20250901/d8d5dcbcb1d71b0504be8f19573a5e51.webp
|
||||
https://tc.alcy.cc/q/20250901/fe6d35467bd972deac0c2abc8f546b93.webp
|
||||
https://tc.alcy.cc/q/20250901/d6d68da45d19f2556c6bb5c29836ace1.webp
|
||||
https://tc.alcy.cc/q/20250901/fe73728bbbb383171b9a1f0a51e35366.webp
|
||||
https://tc.alcy.cc/q/20250901/db008c5dfeb51034812814c3494e26be.webp
|
||||
https://tc.alcy.cc/q/20250901/0337a6521a76323580fb92610e6840c2.webp
|
||||
https://tc.alcy.cc/q/20250901/7dd411f872387bec1e26d9cf746f5b57.webp
|
||||
https://tc.alcy.cc/q/20250901/3d860344c63f87dd021b45a5ee22f9b6.webp
|
||||
https://tc.alcy.cc/q/20250901/787e2fabd006ef5b11f528e34ef7411e.webp
|
||||
https://tc.alcy.cc/q/20250901/1985e44e2fd4719268def433cac0ce8f.webp
|
||||
https://tc.alcy.cc/q/20250901/e13a55ea48c9b949d171b57fa51fe278.webp
|
||||
https://tc.alcy.cc/q/20250901/e799605e3ae4ff8131da2f4f3f55f84c.webp
|
||||
https://tc.alcy.cc/q/20250901/55017a0062b7ce2445b7a2bc1b94f6bf.webp
|
||||
https://tc.alcy.cc/q/20250901/70cab9575df0667a3094bf0f73faa706.webp
|
||||
https://tc.alcy.cc/q/20250901/7f19b138afa365e075614daa19418cd7.webp
|
||||
https://tc.alcy.cc/q/20250901/6872b52a533718fd9a1da3a435ebac87.webp
|
||||
https://tc.alcy.cc/q/20250901/8ba1b773c51aabba2dd0b756e313a565.webp
|
||||
https://tc.alcy.cc/q/20250901/6cc0f8598e625909c829d06486612b3f.webp
|
||||
https://tc.alcy.cc/q/20250901/a46dfd720332ce4bf884e1708218bea0.webp
|
||||
https://tc.alcy.cc/q/20250901/9bda0009f84247b506cd0ee1b3b9e14f.webp
|
||||
https://tc.alcy.cc/q/20250901/d3482a58b3ef4c20d71c4425f0281bdf.webp
|
||||
https://tc.alcy.cc/q/20250901/659c185115b5e675c7b5305b3e49814c.webp
|
||||
https://tc.alcy.cc/q/20250901/d8b96669f74a2ee70b749efcc25aec34.webp
|
||||
https://tc.alcy.cc/q/20250901/0db02cbd1d8ef7de82e8487a865b3d24.webp
|
||||
https://tc.alcy.cc/q/20250901/8649beef001da4050833617c8b015493.webp
|
||||
https://tc.alcy.cc/q/20250901/10f4a037f740a8eee5069ccc65588413.webp
|
||||
https://tc.alcy.cc/q/20250901/6dc060b0fe537deb949a360dd65b4312.webp
|
||||
https://tc.alcy.cc/q/20250901/77c5b92b15dbb798a44891fb0a87d2fe.webp
|
||||
https://tc.alcy.cc/q/20250901/7c55fdb9be0d27589b610575e7338d96.webp
|
||||
https://tc.alcy.cc/q/20250901/d664af8e12f2affd7e5738bd724f7a6d.webp
|
||||
https://tc.alcy.cc/q/20250901/5f445deecf1c3e10b04c98de0f1758d8.webp
|
||||
https://tc.alcy.cc/q/20250901/43ef5e0adcca48936677cdbe98affc5f.webp
|
||||
https://tc.alcy.cc/q/20250901/d74ace44db5d7ea93902b350a6205bc1.webp
|
||||
https://tc.alcy.cc/q/20250901/cfc3f6ec556af9d7181719504e8d1b2e.webp
|
||||
https://tc.alcy.cc/q/20250901/011b5c5fd43295aa9a659ca5513067e6.webp
|
||||
https://tc.alcy.cc/q/20250901/6d252618b589ab4f436a6889199175a8.webp
|
||||
https://tc.alcy.cc/q/20250901/9c5072377f663420ae4d5b796c5dff2d.webp
|
||||
https://tc.alcy.cc/q/20250901/9a42703233b183f11142657b3e50759b.webp
|
||||
https://tc.alcy.cc/q/20250901/a3c3ec2dde71deabb9390bede1031099.webp
|
||||
https://tc.alcy.cc/q/20250901/526e1ab244e2d25290ac81129eedf65b.webp
|
||||
https://tc.alcy.cc/q/20250901/57d47d96a6f064e8d5533f7ed4db41d9.webp
|
||||
https://tc.alcy.cc/q/20250901/d7b7d3a57d9a64d37aefcb6507c99109.webp
|
||||
https://tc.alcy.cc/q/20250901/de48cba3ceac77e5a45eb1d3a28c945e.webp
|
||||
https://tc.alcy.cc/q/20250901/3d2e5fbf9b9463e3a9ecc7f3b1e19c26.webp
|
||||
https://tc.alcy.cc/q/20250901/d51f4131b855090c3e9638f596abd193.webp
|
||||
https://tc.alcy.cc/q/20250901/44b922db63e5870f31ce453200d4f2bf.webp
|
||||
https://tc.alcy.cc/q/20250901/6f4e655adbbef747f13f3551aeba4c6e.webp
|
||||
https://tc.alcy.cc/q/20250901/adf79ca7632140eb272ed16f5b1b51c5.webp
|
||||
https://tc.alcy.cc/q/20250901/291bc27a29d6f08dfa31ca9c1a22f971.webp
|
||||
https://tc.alcy.cc/q/20250901/8c9dc3a3187a1899c79384ff314bef61.webp
|
||||
https://tc.alcy.cc/q/20250901/944c9e78116a0dd3f648c09e50ef946c.webp
|
||||
https://tc.alcy.cc/q/20250901/f3044841c6bb7b34be9cdb9dced2a2d1.webp
|
||||
https://tc.alcy.cc/q/20250901/269c8bc753d51c37889c6a12c98ea3c3.webp
|
||||
https://tc.alcy.cc/q/20250901/1b414f82b7dbd7b046542a102d71070c.webp
|
||||
https://tc.alcy.cc/q/20250901/f9dce030fdc621a5f62651ed7db12929.webp
|
||||
https://tc.alcy.cc/q/20250901/28f968a1f5a0b690ed614ce2ac51dde8.webp
|
||||
https://tc.alcy.cc/q/20250901/c851b4f987ce8ef4e6e538af5f33e1fe.webp
|
||||
https://tc.alcy.cc/q/20250901/0af4b0145876d613af43fc13cfd8476d.webp
|
||||
https://tc.alcy.cc/q/20250901/8a6fa9718db258b5d7394cbece41cfbd.webp
|
||||
https://tc.alcy.cc/q/20250901/6e3badc2342def6e4cae36dcd2ce018f.webp
|
||||
https://tc.alcy.cc/q/20250901/09f3a7d5b7b3eb74b2991dfcd4ca3d9e.webp
|
||||
https://tc.alcy.cc/q/20250901/c10535495ecbf2c2cd2b7ad8e11e0d8a.webp
|
||||
https://tc.alcy.cc/q/20250901/22057c16ddfee23dfec29b575a6ca997.webp
|
||||
https://tc.alcy.cc/q/20250901/7d544d5c7f4b7e4a79b3518acd77f513.webp
|
||||
https://tc.alcy.cc/q/20250901/e786471c1ce49c06158bf823710ab37b.webp
|
||||
https://tc.alcy.cc/q/20250901/b1ecea2be4409dd13307613609430c66.webp
|
||||
https://tc.alcy.cc/q/20250901/1f12bc9a2a7cee1235ea1ee2bf37dd14.webp
|
||||
https://tc.alcy.cc/q/20250901/a70200a162500b3b01c41a2f5904d8b4.webp
|
||||
https://tc.alcy.cc/q/20250901/22a72e39ff0d3b2a9fe5039f67601d96.webp
|
||||
https://tc.alcy.cc/q/20250901/18ae3cddf2e067f9f8fc8e87817f6a4c.webp
|
||||
https://tc.alcy.cc/q/20250901/5b5500d45df367a5e39230bde079ffc4.webp
|
||||
https://tc.alcy.cc/q/20250901/2853a9dadee447bb3babd487620f7334.webp
|
||||
https://tc.alcy.cc/q/20250901/341b7cc93f0abe0eb9f564b7fa39a5c1.webp
|
||||
https://tc.alcy.cc/q/20250901/ddc37d1c5540fd96d769e752365633ce.webp
|
||||
https://tc.alcy.cc/q/20250901/2d1eae3a5ae01d8d3d5db4afc2022c97.webp
|
||||
https://tc.alcy.cc/q/20250901/f3133a40aeaf484ecb521e34099b458c.webp
|
||||
https://tc.alcy.cc/q/20250901/35eca220ce61069ed1f03704758f40bd.webp
|
||||
https://tc.alcy.cc/q/20250901/967e1d03ed6fc2d15a7921246cdade95.webp
|
||||
https://tc.alcy.cc/q/20250901/a53ed2dc971902f71c2a892a15f076ff.webp
|
||||
https://tc.alcy.cc/q/20250901/7279cc35afe4e954653a3d2f99b5d01f.webp
|
||||
https://tc.alcy.cc/q/20250901/deeb4bac8b9bf81b262f0adf7fe81138.webp
|
||||
https://tc.alcy.cc/q/20250901/43af3faa8e2606c958eee54f56b6da6a.webp
|
||||
https://tc.alcy.cc/q/20250901/1a74e2dd71db9911d3c8d671ed291707.webp
|
||||
https://tc.alcy.cc/q/20250901/6a4cfad14a7c671d9c6c69a85839083b.webp
|
||||
https://tc.alcy.cc/q/20250901/2777a242e078e3f51e23b4bbe5d69fb5.webp
|
||||
https://tc.alcy.cc/q/20250901/a2b2a23bdf55556cb94b2b9c42e2a819.webp
|
||||
https://tc.alcy.cc/q/20250901/44c326548b9bf841f50545030a5166d7.webp
|
||||
https://tc.alcy.cc/q/20250901/2b2c8a90dff6dc53dc7eb5e034b22c12.webp
|
||||
https://tc.alcy.cc/q/20250901/13c3f86f9ecb25bf4eac5723fa3a5f8d.webp
|
||||
https://tc.alcy.cc/q/20250901/cf82d027c724f2b666afb88ddc6661e3.webp
|
||||
https://tc.alcy.cc/q/20250901/3cc7ebcbf2127e7ee40df0bff8610872.webp
|
||||
https://tc.alcy.cc/q/20250901/90a95ffd0b13f871f299b56dc15f3e9f.webp
|
||||
https://tc.alcy.cc/q/20250901/682e584509838d416f703d9a1f866d01.webp
|
||||
https://tc.alcy.cc/q/20250901/25ca4decb6399a7ed20088872d1329ce.webp
|
||||
https://tc.alcy.cc/q/20250901/d1461dcdbe43fc511132eb0264d2ec26.webp
|
||||
https://tc.alcy.cc/q/20250901/a017001c06cfed5e33e16916cd4f31ea.webp
|
||||
https://tc.alcy.cc/q/20250901/0dc5029a767ab8a1eba609e6354f7506.webp
|
||||
https://tc.alcy.cc/q/20250901/cf39bae887abd97d72d2503748824d33.webp
|
||||
https://tc.alcy.cc/q/20250901/2139b75276b5afbe0e18df05ec594810.webp
|
||||
https://tc.alcy.cc/q/20250901/568b29db4b7eeb201a3314deb2a019f5.webp
|
||||
https://tc.alcy.cc/q/20250901/f4996fad1ba4320155f8505a6a1d3b52.webp
|
||||
https://tc.alcy.cc/q/20250901/9a91bbacc37669e50b6f3b5c45a36594.webp
|
||||
https://tc.alcy.cc/q/20250901/216156ea12c2862bcc9452cb78f20d93.webp
|
||||
https://tc.alcy.cc/q/20250901/2cc0ee318cac153bcbc6665664aae122.webp
|
||||
https://tc.alcy.cc/q/20250901/538c71f0524e2de6a51b14e9c6ff7b40.webp
|
||||
https://tc.alcy.cc/q/20250901/31c446c7084ba3c5148d94f9ca03e22a.webp
|
||||
https://tc.alcy.cc/q/20250901/c74e3901690f2a66d1c11c6a72130821.webp
|
||||
https://tc.alcy.cc/q/20250901/d2aa3b4ae49f5c79348d1c4df6062ece.webp
|
||||
https://tc.alcy.cc/q/20250901/489454452bea7b6c7e997e121289e49b.webp
|
||||
https://tc.alcy.cc/q/20250901/9394ae674b8177fcae720cf99d336e46.webp
|
||||
https://tc.alcy.cc/q/20250901/0b434dffb056aad443fa365aed0555d0.webp
|
||||
https://tc.alcy.cc/q/20250901/14c09f49c7224f57fd26521f53c5da35.webp
|
||||
https://tc.alcy.cc/q/20250901/61cf841b566f27d7198b13c1e1733677.webp
|
||||
https://tc.alcy.cc/q/20250901/5e3c5166f3d3bc79ec3bc4da86bbc331.webp
|
||||
https://tc.alcy.cc/q/20250901/2c4be9f2de64643837e3aa4b3ff0d163.webp
|
||||
https://tc.alcy.cc/q/20250901/89b8aa31126ff284fde20361f6b4a0d1.webp
|
||||
https://tc.alcy.cc/q/20250901/dde5105fa7b29415d0fe36337fa6dc8d.webp
|
||||
https://tc.alcy.cc/q/20250901/312aeac564bcc62fa6081a76468a95b5.webp
|
||||
https://tc.alcy.cc/q/20250901/8b73ebffbac06ba235d58370853377e0.webp
|
||||
https://tc.alcy.cc/q/20250901/351e19f4f4c19bc1d1b955068a7294f3.webp
|
||||
https://tc.alcy.cc/q/20250901/822b421d1bdde1ebe1f70eb99a67086e.webp
|
||||
https://tc.alcy.cc/q/20250901/db008c5dfeb51034812814c3494e26be.webp
|
||||
https://tc.alcy.cc/q/20250901/3ed34f1b9592654e3e1088626275b810.webp
|
||||
https://tc.alcy.cc/q/20250901/6cf8d3f689be9183f7b5bafe21beef2c.webp
|
||||
https://tc.alcy.cc/q/20250901/66a78e972d04094f51899d81c278c423.webp
|
||||
https://tc.alcy.cc/q/20250901/f4ead0aa4a95f59e16c18e747286e97c.webp
|
||||
https://tc.alcy.cc/q/20250901/5bd648127188385928a43bd001f774d1.webp
|
||||
https://tc.alcy.cc/q/20250901/81dafcb5936577055c21e57d7cc25e3a.webp
|
||||
https://tc.alcy.cc/q/20250901/58e6971fe567e1ce8766c4c150bc3079.webp
|
||||
https://tc.alcy.cc/q/20250901/2a02a4ed81e3b6f881c8bfc22cc4064e.webp
|
||||
https://tc.alcy.cc/q/20250901/ed7c697416f80c2cb04ee02d80067f8a.webp
|
||||
https://tc.alcy.cc/q/20250901/942c1da38a706bd18c43b95eea9cf316.webp
|
||||
https://tc.alcy.cc/q/20250901/e215aa21fa8322e477db6dd2aa18b5a0.webp
|
||||
https://tc.alcy.cc/q/20250901/e70d91f2b51dadcb0f2481ef319c80d7.webp
|
||||
https://tc.alcy.cc/q/20250901/a8174a16e6918fdbdede41ccb7ab0e95.webp
|
||||
https://tc.alcy.cc/q/20250901/12f463808276c37f3cada8b91f3410ad.webp
|
||||
https://tc.alcy.cc/q/20250901/686016f725dbaff3c1efcc4714b5f2cf.webp
|
||||
https://tc.alcy.cc/q/20250901/1fd7089c4b6e8bddb7d773815ad4f72a.webp
|
||||
https://tc.alcy.cc/q/20250901/749990a598727b25bf7e42f7a0dd8b52.webp
|
||||
https://tc.alcy.cc/q/20250901/44d40e72b1bda76e791a73b408ed85be.webp
|
||||
https://tc.alcy.cc/q/20250901/e6427ed74b2258d27f947c797b63f8fc.webp
|
||||
https://tc.alcy.cc/q/20250901/7df992e65674930c00b8ed912107c81b.webp
|
||||
https://tc.alcy.cc/q/20250901/83d24995e0f8f1600c02e4cd12c2d812.webp
|
||||
https://tc.alcy.cc/q/20250901/d76733918147de701a68ffb234495335.webp
|
||||
https://tc.alcy.cc/q/20250901/8f20f61e631e21e1f2955666ba9f82e8.webp
|
||||
https://tc.alcy.cc/q/20250901/efc42ada7d02f12ed9e73a82eef6f1f2.webp
|
||||
https://tc.alcy.cc/q/20250901/707009806ba14e951068855a4247a8a0.webp
|
||||
https://tc.alcy.cc/q/20250901/423e549b9fa61cbf36c4a52ee9abfe63.webp
|
||||
https://tc.alcy.cc/q/20250901/88c453f125b765595a37f85ef8ce67d8.webp
|
||||
https://tc.alcy.cc/q/20250901/60509bf08c81137e4ffb26e7dda63e39.webp
|
||||
https://tc.alcy.cc/q/20250901/dcb413fe7cba291070a21b71eeb9d8d7.webp
|
||||
https://tc.alcy.cc/q/20250901/605e1a464ad1a42bb38a7cfcf6d3d00c.webp
|
||||
https://tc.alcy.cc/q/20250901/ebb5009ff103f1c84888f8fdf74c209c.webp
|
||||
https://tc.alcy.cc/q/20250901/613460d152c73ef91412df410e53f57b.webp
|
||||
https://tc.alcy.cc/q/20250901/27649c87a8b81f597e583ccc244fcd6c.webp
|
||||
https://tc.alcy.cc/q/20250901/ac75ebc321be94f2a95014e3790adc7a.webp
|
||||
https://tc.alcy.cc/q/20250901/32d2dbfa95ce5f0678ede7d393375e40.webp
|
||||
https://tc.alcy.cc/q/20250901/10e7479b08c26c56a795541aefe839c5.webp
|
||||
https://tc.alcy.cc/q/20250901/fc05ed1a3ebef447dd86c0bd1fdb2c57.webp
|
||||
https://tc.alcy.cc/q/20250901/af3e222c3de6b223b747b24eff95ed8c.webp
|
||||
https://tc.alcy.cc/q/20250901/e6ce814ce87b8e06482f8eca0ff22fcd.webp
|
||||
https://tc.alcy.cc/q/20250901/cb69150b4a658f0340013420b83386ff.webp
|
||||
https://tc.alcy.cc/q/20250901/615dedda743acc3a1b39bb8d65328e55.webp
|
||||
https://tc.alcy.cc/q/20250901/025a4c262686e3f1f7eb990ca995713a.webp
|
||||
https://tc.alcy.cc/q/20250901/a8952d07799757a0e79a131bc60ae869.webp
|
||||
https://tc.alcy.cc/q/20250901/73c39b1725d9f64a9242404fad448a9d.webp
|
||||
https://tc.alcy.cc/q/20250901/7b3b0c4ccc9a122f65338237f8e8e23d.webp
|
||||
https://tc.alcy.cc/q/20250901/dd198a19b6bf3ae506ae4a0e1ab18072.webp
|
||||
https://tc.alcy.cc/q/20250901/032a1c428b6e98534d0300a4f644fe44.webp
|
||||
https://tc.alcy.cc/q/20250901/d8981c73a997acbde30b6a85bf725913.webp
|
||||
https://tc.alcy.cc/q/20250901/a2621d151da8437858b4e2c6ab43a4c3.webp
|
||||
https://tc.alcy.cc/q/20250901/e7a81c9d0e9b91797d87dc9ee8571973.webp
|
||||
https://tc.alcy.cc/q/20250901/77d589339aa199880acc62bf247fe792.webp
|
||||
https://tc.alcy.cc/q/20250901/ee159631f52d5bfeb3acc799af5c80b7.webp
|
||||
https://tc.alcy.cc/q/20250901/c0013a85d9df7f68d37bd16108eef934.webp
|
||||
https://tc.alcy.cc/q/20250901/ab8f1194dbfcd80ef3e1721a0f4301aa.webp
|
||||
https://tc.alcy.cc/q/20250901/c656a24e507fd271ca6294943e461b98.webp
|
||||
https://tc.alcy.cc/q/20250901/0c3a802131b6bb4e7aef3ee65b3ca977.webp
|
||||
https://tc.alcy.cc/q/20250901/041bb4cc436c1791b3070686e984f319.webp
|
||||
https://tc.alcy.cc/q/20250901/166efe86e8c7148ef368d665306a0527.webp
|
||||
https://tc.alcy.cc/q/20250901/5e16f39ba55e0767a8000fcbb97e0421.webp
|
||||
https://tc.alcy.cc/q/20250901/acf0b40750ba93ee836d0644602f9467.webp
|
||||
https://tc.alcy.cc/q/20250901/f1ab2c2393dd5724644fd8e23070f731.webp
|
||||
https://tc.alcy.cc/q/20250901/bd153b5d4e585cf77c58057b19ca849c.webp
|
||||
https://tc.alcy.cc/q/20250901/411d2f954e31499ab410f030b5bb5898.webp
|
||||
https://tc.alcy.cc/q/20250901/5bca003af7d7211612196d455f150be3.webp
|
||||
https://tc.alcy.cc/q/20250901/bd4592c74310db395ee3f7aecadd5233.webp
|
||||
https://tc.alcy.cc/q/20250901/ebe0200d6abf433eb182518cdb4898dd.webp
|
||||
https://tc.alcy.cc/q/20250901/ae049d00c578d0ec1c04e817b93bdedb.webp
|
||||
https://tc.alcy.cc/q/20250901/5d49e7cfb0fa0c01dbefe5845b166055.webp
|
||||
https://tc.alcy.cc/q/20250901/afc912c408cb99706117eeb5e455c4d4.webp
|
||||
https://tc.alcy.cc/q/20250901/95db644009f9beee2c6324b1e38904a7.webp
|
||||
https://tc.alcy.cc/q/20250901/dffad71d5a156df6b4a74f6911eeff73.webp
|
||||
https://tc.alcy.cc/q/20250901/15732140fded3521e64616687072a613.webp
|
||||
https://tc.alcy.cc/q/20250901/54167824296ab4383882cd9eed690013.webp
|
||||
https://tc.alcy.cc/q/20250901/7b35b5168dd6f51d1fc15abf5dddd28e.webp
|
||||
https://tc.alcy.cc/q/20250901/0d4aa250975682b862ede91c6bc8e991.webp
|
||||
https://tc.alcy.cc/q/20250901/b1dd5fe59ec9a18b9b23ea097f952431.webp
|
||||
https://tc.alcy.cc/q/20250901/1ad97514082eb56eba51f0d84453ecea.webp
|
||||
https://tc.alcy.cc/q/20250901/78fea2f0ce0942e05c44eb8a898a0d49.webp
|
||||
https://tc.alcy.cc/q/20250901/91333ed31fa8ab9690ef86d82dd5be90.webp
|
||||
https://tc.alcy.cc/q/20250901/97aa93edab7eba8a3dd8775b34f3aeec.webp
|
||||
https://tc.alcy.cc/q/20250901/fc58631650137aabb6f5baf606b953ab.webp
|
||||
https://tc.alcy.cc/q/20250901/0494159c5ebb85a35d1184257e03fcd0.webp
|
||||
https://tc.alcy.cc/q/20250901/506e695da0bf1908fc5ab25847b1f54b.webp
|
||||
https://tc.alcy.cc/q/20250901/3a8d10bd1d15986f40cd663eae0473b3.webp
|
||||
https://tc.alcy.cc/q/20250901/034b33b528f8862d5e52394d4663680a.webp
|
||||
https://tc.alcy.cc/q/20250901/10e567a063422ca25af0ee6949a16a03.webp
|
||||
https://tc.alcy.cc/q/20250901/03b443b4d1ddbafac52d042c098fa2a3.webp
|
||||
https://tc.alcy.cc/q/20250901/d519860c149d54dadb5e6d683e006bb1.webp
|
||||
https://tc.alcy.cc/q/20250901/18bd92cadb8ba41ffe64b2dc9c3fb6e7.webp
|
||||
https://tc.alcy.cc/q/20250901/7f5df35215f40e22747b5ae186394cbd.webp
|
||||
https://tc.alcy.cc/q/20250901/7929b6a10bfcbe53e26a285aa73d013d.webp
|
||||
https://tc.alcy.cc/q/20250901/ca4cb3d05f7b5358136780375d005cb1.webp
|
||||
https://tc.alcy.cc/q/20250901/12cbf4f6d61c66330a29bbb98849e5e3.webp
|
||||
https://tc.alcy.cc/q/20250901/f161c4bf6e87fdc477b9aecb418057bd.webp
|
||||
https://tc.alcy.cc/q/20250901/fad03511e7b72c67649df87d0f753b7b.webp
|
||||
https://tc.alcy.cc/q/20250901/c398644ef33460ccc3bc099cedeb8afc.webp
|
||||
https://tc.alcy.cc/q/20250901/e7147ac7233019c9d1bcf51c26f514da.webp
|
||||
https://tc.alcy.cc/q/20250901/763e1ab061f7319f9dae9dcefeeffa55.webp
|
144
express-project/middleware/auth.js
Normal file
@@ -0,0 +1,144 @@
|
||||
const { verifyToken, extractTokenFromHeader } = require('../utils/jwt');
|
||||
const { pool } = require('../config/database');
|
||||
|
||||
/**
|
||||
* 认证中间件 - 验证JWT token
|
||||
*/
|
||||
async function authenticateToken(req, res, next) {
|
||||
try {
|
||||
const token = extractTokenFromHeader(req);
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
code: 401,
|
||||
message: '访问令牌缺失'
|
||||
});
|
||||
}
|
||||
|
||||
// 验证token
|
||||
const decoded = verifyToken(token);
|
||||
|
||||
// 检查是否为管理员token
|
||||
if (decoded.type === 'admin') {
|
||||
// 管理员token验证
|
||||
const [adminRows] = await pool.execute(
|
||||
'SELECT id, username FROM admin WHERE id = ?',
|
||||
[decoded.adminId]
|
||||
);
|
||||
|
||||
if (adminRows.length === 0) {
|
||||
return res.status(401).json({
|
||||
code: 401,
|
||||
message: '管理员不存在'
|
||||
});
|
||||
}
|
||||
|
||||
// 将管理员信息添加到请求对象
|
||||
req.user = {
|
||||
...adminRows[0],
|
||||
type: 'admin',
|
||||
adminId: decoded.adminId
|
||||
};
|
||||
req.token = token;
|
||||
|
||||
return next();
|
||||
} else {
|
||||
// 普通用户token验证
|
||||
if (!decoded.userId) {
|
||||
return res.status(401).json({
|
||||
code: 401,
|
||||
message: '无效的访问令牌'
|
||||
});
|
||||
}
|
||||
|
||||
// 检查用户是否存在且活跃
|
||||
const [userRows] = await pool.execute(
|
||||
'SELECT id, user_id, nickname, avatar, is_active FROM users WHERE id = ? AND is_active = 1',
|
||||
[decoded.userId]
|
||||
);
|
||||
|
||||
if (userRows.length === 0) {
|
||||
return res.status(401).json({
|
||||
code: 401,
|
||||
message: '用户不存在或已被禁用'
|
||||
});
|
||||
}
|
||||
|
||||
// 检查会话是否有效
|
||||
const [sessionRows] = await pool.execute(
|
||||
'SELECT id FROM user_sessions WHERE user_id = ? AND token = ? AND is_active = 1 AND expires_at > NOW()',
|
||||
[decoded.userId, token]
|
||||
);
|
||||
|
||||
if (sessionRows.length === 0) {
|
||||
return res.status(401).json({
|
||||
code: 401,
|
||||
message: '会话已过期,请重新登录'
|
||||
});
|
||||
}
|
||||
|
||||
// 将用户信息添加到请求对象
|
||||
req.user = userRows[0];
|
||||
req.token = token;
|
||||
|
||||
return next();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Token验证失败:', error);
|
||||
return res.status(401).json({
|
||||
code: 401,
|
||||
message: '无效的访问令牌'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 可选认证中间件 - 如果有token则验证,没有则跳过
|
||||
*/
|
||||
async function optionalAuth(req, res, next) {
|
||||
try {
|
||||
const token = extractTokenFromHeader(req);
|
||||
|
||||
if (!token) {
|
||||
req.user = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
// 验证token
|
||||
const decoded = verifyToken(token);
|
||||
|
||||
// 检查用户是否存在且活跃
|
||||
const [userRows] = await pool.execute(
|
||||
'SELECT id, user_id, nickname, avatar, is_active FROM users WHERE id = ? AND is_active = 1',
|
||||
[decoded.userId]
|
||||
);
|
||||
|
||||
if (userRows.length > 0) {
|
||||
// 检查会话是否有效
|
||||
const [sessionRows] = await pool.execute(
|
||||
'SELECT id FROM user_sessions WHERE user_id = ? AND token = ? AND is_active = 1 AND expires_at > NOW()',
|
||||
[decoded.userId, token]
|
||||
);
|
||||
|
||||
if (sessionRows.length > 0) {
|
||||
req.user = userRows[0];
|
||||
req.token = token;
|
||||
} else {
|
||||
req.user = null;
|
||||
}
|
||||
} else {
|
||||
req.user = null;
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
// 如果token无效,设置user为null继续执行
|
||||
req.user = null;
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
authenticateToken,
|
||||
optionalAuth
|
||||
};
|
303
express-project/middleware/crudFactory.js
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* 通用CRUD操作工厂
|
||||
*/
|
||||
const { success, error, handleError, validateRequired, validateIds } = require('../utils/responseHelper')
|
||||
const {
|
||||
recordExists,
|
||||
recordsExist,
|
||||
isUnique,
|
||||
createRecord,
|
||||
updateRecord,
|
||||
deleteRecord,
|
||||
deleteRecords,
|
||||
getRecord,
|
||||
getRecords,
|
||||
cascadeDelete
|
||||
} = require('../utils/dbHelper')
|
||||
|
||||
/**
|
||||
* 创建CRUD配置
|
||||
* @param {Object} config - CRUD配置
|
||||
* @param {string} config.table - 表名
|
||||
* @param {string} config.name - 资源名称(用于错误消息)
|
||||
* @param {Array} config.requiredFields - 创建时的必填字段
|
||||
* @param {Array} config.updateFields - 更新时允许的字段
|
||||
* @param {Array} config.uniqueFields - 唯一性约束字段
|
||||
* @param {Array} config.cascadeRules - 级联删除规则
|
||||
* @param {Function} config.beforeCreate - 创建前的自定义验证
|
||||
* @param {Function} config.beforeUpdate - 更新前的自定义验证
|
||||
* @param {Function} config.beforeDelete - 删除前的自定义验证
|
||||
* @param {Object} config.searchFields - 搜索字段配置
|
||||
* @param {string} config.defaultOrderBy - 默认排序
|
||||
*/
|
||||
function createCrudHandlers(config) {
|
||||
const {
|
||||
table,
|
||||
name,
|
||||
requiredFields = [],
|
||||
updateFields = [],
|
||||
uniqueFields = [],
|
||||
cascadeRules = [],
|
||||
beforeCreate,
|
||||
afterCreate,
|
||||
beforeUpdate,
|
||||
afterUpdate,
|
||||
beforeDelete,
|
||||
searchFields = {},
|
||||
defaultOrderBy = 'created_at DESC'
|
||||
} = config
|
||||
|
||||
/**
|
||||
* 创建记录
|
||||
*/
|
||||
const create = async (req, res) => {
|
||||
try {
|
||||
const data = req.body
|
||||
|
||||
// 验证必填字段
|
||||
const validation = validateRequired(data, requiredFields)
|
||||
if (!validation.isValid) {
|
||||
return error(res, validation.message, 400, 400)
|
||||
}
|
||||
|
||||
// 验证唯一性约束
|
||||
for (const field of uniqueFields) {
|
||||
if (data[field] && !(await isUnique(table, field, data[field]))) {
|
||||
return error(res, `${field}已存在`, 409, 409)
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义验证
|
||||
if (beforeCreate) {
|
||||
const customValidation = await beforeCreate(data, req)
|
||||
if (!customValidation.isValid) {
|
||||
return error(res, customValidation.message, customValidation.code || 400, customValidation.code || 400)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建记录
|
||||
const id = await createRecord(table, data)
|
||||
|
||||
// 后置处理
|
||||
if (afterCreate) {
|
||||
await afterCreate(id, data, req)
|
||||
}
|
||||
|
||||
success(res, { id }, `${name}创建成功`)
|
||||
} catch (err) {
|
||||
handleError(err, res, `创建${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新记录
|
||||
*/
|
||||
const update = async (req, res) => {
|
||||
try {
|
||||
const id = req.params.id
|
||||
const data = req.body
|
||||
|
||||
// 检查记录是否存在
|
||||
if (!(await recordExists(table, 'id', id))) {
|
||||
return error(res, `${name}不存在`, 404, 404)
|
||||
}
|
||||
|
||||
// 过滤允许更新的字段
|
||||
const updateData = {}
|
||||
for (const field of updateFields) {
|
||||
if (data[field] !== undefined) {
|
||||
updateData[field] = data[field]
|
||||
}
|
||||
}
|
||||
|
||||
// 验证唯一性约束
|
||||
for (const field of uniqueFields) {
|
||||
if (updateData[field] && !(await isUnique(table, field, updateData[field], id))) {
|
||||
return error(res, `${field}已存在`, 409, 409)
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义验证
|
||||
if (beforeUpdate) {
|
||||
const customValidation = await beforeUpdate(updateData, id, req)
|
||||
if (!customValidation.isValid) {
|
||||
return error(res, customValidation.message, customValidation.code || 400, customValidation.code || 400)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新记录
|
||||
await updateRecord(table, id, updateData)
|
||||
|
||||
// 后置处理
|
||||
if (afterUpdate) {
|
||||
await afterUpdate(id, data, req)
|
||||
}
|
||||
|
||||
success(res, null, `${name}更新成功`)
|
||||
} catch (err) {
|
||||
handleError(err, res, `更新${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单个记录
|
||||
*/
|
||||
const deleteOne = async (req, res) => {
|
||||
try {
|
||||
const id = req.params.id
|
||||
|
||||
// 检查记录是否存在
|
||||
if (!(await recordExists(table, 'id', id))) {
|
||||
return error(res, `${name}不存在`, 404, 404)
|
||||
}
|
||||
|
||||
// 自定义验证
|
||||
if (beforeDelete) {
|
||||
const customValidation = await beforeDelete(id, req)
|
||||
if (!customValidation.isValid) {
|
||||
return error(res, customValidation.message, customValidation.code || 400, customValidation.code || 400)
|
||||
}
|
||||
}
|
||||
|
||||
// 级联删除
|
||||
if (cascadeRules.length > 0) {
|
||||
await cascadeDelete(cascadeRules, id)
|
||||
}
|
||||
|
||||
// 删除记录
|
||||
await deleteRecord(table, id)
|
||||
|
||||
success(res, null, `${name}删除成功`)
|
||||
} catch (err) {
|
||||
handleError(err, res, `删除${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除记录
|
||||
*/
|
||||
const deleteMany = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body
|
||||
|
||||
// 验证ID数组
|
||||
const validation = validateIds(ids, `${name}ID`)
|
||||
if (!validation.isValid) {
|
||||
return error(res, validation.message, 400, 400)
|
||||
}
|
||||
|
||||
// 检查记录是否存在
|
||||
const { existingCount, missingValues } = await recordsExist(table, 'id', ids)
|
||||
if (missingValues.length > 0) {
|
||||
return error(res, `部分${name}不存在: ${missingValues.join(', ')}`, 404, 404)
|
||||
}
|
||||
|
||||
// 自定义验证
|
||||
if (beforeDelete) {
|
||||
for (const id of ids) {
|
||||
const customValidation = await beforeDelete(id, req)
|
||||
if (!customValidation.isValid) {
|
||||
return error(res, customValidation.message, customValidation.code || 400, customValidation.code || 400)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 级联删除
|
||||
if (cascadeRules.length > 0) {
|
||||
await cascadeDelete(cascadeRules, ids)
|
||||
}
|
||||
|
||||
// 批量删除记录
|
||||
const deletedCount = await deleteRecords(table, ids)
|
||||
|
||||
success(res, { deletedCount }, `成功删除${deletedCount}个${name}`)
|
||||
} catch (err) {
|
||||
handleError(err, res, `批量删除${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个记录
|
||||
*/
|
||||
const getOne = async (req, res) => {
|
||||
try {
|
||||
const id = req.params.id
|
||||
|
||||
const record = await getRecord(table, id)
|
||||
if (!record) {
|
||||
return error(res, `${name}不存在`, 404, 404)
|
||||
}
|
||||
|
||||
success(res, record)
|
||||
} catch (err) {
|
||||
handleError(err, res, `获取${name}详情`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取记录列表
|
||||
*/
|
||||
const getList = async (req, res) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page) || 1
|
||||
const limit = parseInt(req.query.limit) || 20
|
||||
|
||||
// 构建搜索条件
|
||||
let whereClause = ''
|
||||
const params = []
|
||||
|
||||
for (const [field, config] of Object.entries(searchFields)) {
|
||||
const value = req.query[field]
|
||||
if (value) {
|
||||
const operator = config.operator || '='
|
||||
const condition = config.condition || `${field} ${operator} ?`
|
||||
|
||||
whereClause += whereClause ? ` AND ${condition}` : condition
|
||||
|
||||
if (operator === 'LIKE') {
|
||||
params.push(`%${value}%`)
|
||||
} else {
|
||||
params.push(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 排序处理
|
||||
let orderBy = defaultOrderBy
|
||||
if (req.query.sortBy && req.query.sortOrder) {
|
||||
const allowedSortFields = config.allowedSortFields || ['id', 'created_at']
|
||||
const sortField = req.query.sortBy
|
||||
const sortOrder = req.query.sortOrder.toUpperCase() === 'ASC' ? 'ASC' : 'DESC'
|
||||
|
||||
if (allowedSortFields.includes(sortField)) {
|
||||
orderBy = `${sortField} ${sortOrder}`
|
||||
}
|
||||
}
|
||||
|
||||
const result = await getRecords(table, {
|
||||
page,
|
||||
limit,
|
||||
where: whereClause,
|
||||
params,
|
||||
orderBy
|
||||
})
|
||||
|
||||
success(res, result)
|
||||
} catch (err) {
|
||||
handleError(err, res, `获取${name}列表`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
create,
|
||||
update,
|
||||
deleteOne,
|
||||
deleteMany,
|
||||
getOne,
|
||||
getList
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createCrudHandlers
|
||||
}
|
1710
express-project/package-lock.json
generated
Normal file
28
express-project/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "express-project",
|
||||
"version": "1.0.0",
|
||||
"description": "小石榴校园图文社区Express后端项目",
|
||||
"author": "@ZTMYO",
|
||||
"main": "app.js",
|
||||
"scripts": {
|
||||
"start": "node app.js",
|
||||
"dev": "nodemon app.js",
|
||||
"generate-data": "node generate-data.js",
|
||||
"deploy": "node scripts/deploy.js",
|
||||
"init-db": "node scripts/init-database.js",
|
||||
"analyze-db": "node scripts/analyze-database.js",
|
||||
"setup": "npm install && npm run deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.11.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"mysql2": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
}
|
||||
}
|
1566
express-project/routes/admin.js
Normal file
675
express-project/routes/auth.js
Normal file
@@ -0,0 +1,675 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { pool } = require('../config/database');
|
||||
const { generateAccessToken, generateRefreshToken, verifyToken } = require('../utils/jwt');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const { getIPLocation, getRealIP } = require('../utils/ipLocation');
|
||||
|
||||
// 用户注册
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { user_id, nickname, password } = req.body;
|
||||
|
||||
if (!user_id || !nickname || !password) {
|
||||
return res.status(400).json({ code: 400, message: '缺少必要参数' });
|
||||
}
|
||||
|
||||
if (user_id.length < 3 || user_id.length > 15) {
|
||||
return res.status(400).json({ code: 400, message: '小石榴号长度必须在3-15位之间' });
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(user_id)) {
|
||||
return res.status(400).json({ code: 400, message: '小石榴号只能包含字母、数字和下划线' });
|
||||
}
|
||||
|
||||
if (nickname.length > 10) {
|
||||
return res.status(400).json({ code: 400, message: '昵称长度必须少于10位' });
|
||||
}
|
||||
|
||||
if (password.length < 6 || password.length > 20) {
|
||||
return res.status(400).json({ code: 400, message: '密码长度必须在6-20位之间' });
|
||||
}
|
||||
|
||||
// 检查用户ID是否已存在
|
||||
const [existingUser] = await pool.execute(
|
||||
'SELECT id FROM users WHERE user_id = ?',
|
||||
[user_id]
|
||||
);
|
||||
|
||||
if (existingUser.length > 0) {
|
||||
return res.status(400).json({ code: 400, message: '用户ID已存在' });
|
||||
}
|
||||
|
||||
// 获取用户IP属地
|
||||
|
||||
const userIP = getRealIP(req);
|
||||
let ipLocation;
|
||||
try {
|
||||
ipLocation = await getIPLocation(userIP);
|
||||
} catch (error) {
|
||||
ipLocation = '未知';
|
||||
}
|
||||
// 获取用户User-Agent
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
const defaultAvatar = 'https://img20.360buyimg.com/openfeedback/jfs/t1/331422/15/7925/27988/68b67434Fa5a85fc3/bfe751b0ffb4fdc3.png';
|
||||
|
||||
// 插入新用户(密码使用SHA2哈希加密)
|
||||
const [result] = await pool.execute(
|
||||
'INSERT INTO users (user_id, nickname, password, avatar, bio, location) VALUES (?, ?, SHA2(?, 256), ?, ?, ?)',
|
||||
[user_id, nickname, password, defaultAvatar, '', ipLocation]
|
||||
);
|
||||
|
||||
const userId = result.insertId;
|
||||
|
||||
// 生成JWT令牌
|
||||
const accessToken = generateAccessToken({ userId, user_id });
|
||||
const refreshToken = generateRefreshToken({ userId, user_id });
|
||||
|
||||
// 保存会话
|
||||
await pool.execute(
|
||||
'INSERT INTO user_sessions (user_id, token, refresh_token, expires_at, user_agent, is_active) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 7 DAY), ?, 1)',
|
||||
[userId, accessToken, refreshToken, userAgent]
|
||||
);
|
||||
|
||||
// 获取完整用户信息
|
||||
const [userRows] = await pool.execute(
|
||||
'SELECT id, user_id, nickname, avatar, bio, location, follow_count, fans_count, like_count FROM users WHERE id = ?',
|
||||
[userId]
|
||||
);
|
||||
|
||||
console.log(`用户注册成功 - 用户ID: ${userId}, 小石榴号: ${userRows[0].user_id}`);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '注册成功',
|
||||
data: {
|
||||
user: userRows[0],
|
||||
tokens: {
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
expires_in: 3600
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('用户注册失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 用户登录
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { user_id, password } = req.body;
|
||||
if (!user_id || !password) {
|
||||
return res.status(400).json({ code: 400, message: '缺少必要参数' });
|
||||
}
|
||||
|
||||
// 查找用户
|
||||
const [userRows] = await pool.execute(
|
||||
'SELECT id, user_id, nickname, password, avatar, bio, location, follow_count, fans_count, like_count, is_active, gender, zodiac_sign, mbti, education, major, interests FROM users WHERE user_id = ?',
|
||||
[user_id]
|
||||
);
|
||||
|
||||
if (userRows.length === 0) {
|
||||
return res.status(400).json({ code: 400, message: '用户不存在' });
|
||||
}
|
||||
|
||||
const user = userRows[0];
|
||||
|
||||
if (!user.is_active) {
|
||||
return res.status(400).json({ code: 400, message: '账户已被禁用' });
|
||||
}
|
||||
|
||||
// 验证密码(哈希比较)
|
||||
const [passwordCheck] = await pool.execute(
|
||||
'SELECT 1 FROM users WHERE id = ? AND password = SHA2(?, 256)',
|
||||
[user.id, password]
|
||||
);
|
||||
|
||||
if (passwordCheck.length === 0) {
|
||||
return res.status(400).json({ code: 400, message: '密码错误' });
|
||||
}
|
||||
|
||||
// 生成JWT令牌
|
||||
const accessToken = generateAccessToken({ userId: user.id, user_id: user.user_id });
|
||||
const refreshToken = generateRefreshToken({ userId: user.id, user_id: user.user_id });
|
||||
|
||||
// 获取用户IP和User-Agent
|
||||
const userIP = getRealIP(req);
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
// 获取IP地理位置并更新用户location
|
||||
const ipLocation = await getIPLocation(userIP);
|
||||
await pool.execute(
|
||||
'UPDATE users SET location = ? WHERE id = ?',
|
||||
[ipLocation, user.id]
|
||||
);
|
||||
|
||||
// 清除旧会话并保存新会话
|
||||
await pool.execute('UPDATE user_sessions SET is_active = 0 WHERE user_id = ?', [user.id]);
|
||||
await pool.execute(
|
||||
'INSERT INTO user_sessions (user_id, token, refresh_token, expires_at, user_agent, is_active) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 7 DAY), ?, 1)',
|
||||
[user.id, accessToken, refreshToken, userAgent]
|
||||
);
|
||||
|
||||
// 更新用户对象中的location字段
|
||||
user.location = ipLocation;
|
||||
|
||||
// 移除密码字段
|
||||
delete user.password;
|
||||
|
||||
// 处理interests字段(如果是JSON字符串则解析)
|
||||
if (user.interests) {
|
||||
try {
|
||||
user.interests = typeof user.interests === 'string'
|
||||
? JSON.parse(user.interests)
|
||||
: user.interests;
|
||||
} catch (e) {
|
||||
user.interests = null;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`用户登录成功 - 用户ID: ${user.id}, 小石榴号: ${user.user_id}`);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '登录成功',
|
||||
data: {
|
||||
user,
|
||||
tokens: {
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
expires_in: 3600
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('用户登录失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 刷新令牌
|
||||
router.post('/refresh', async (req, res) => {
|
||||
try {
|
||||
const { refresh_token } = req.body;
|
||||
|
||||
if (!refresh_token) {
|
||||
return res.status(400).json({ code: 400, message: '缺少刷新令牌' });
|
||||
}
|
||||
|
||||
// 验证刷新令牌
|
||||
const decoded = verifyToken(refresh_token);
|
||||
|
||||
// 检查会话是否有效
|
||||
const [sessionRows] = await pool.execute(
|
||||
'SELECT id FROM user_sessions WHERE user_id = ? AND refresh_token = ? AND is_active = 1 AND expires_at > NOW()',
|
||||
[decoded.userId, refresh_token]
|
||||
);
|
||||
|
||||
if (sessionRows.length === 0) {
|
||||
return res.status(401).json({ code: 401, message: '刷新令牌无效或已过期' });
|
||||
}
|
||||
|
||||
// 生成新的令牌
|
||||
const newAccessToken = generateAccessToken({ userId: decoded.userId, user_id: decoded.user_id });
|
||||
const newRefreshToken = generateRefreshToken({ userId: decoded.userId, user_id: decoded.user_id });
|
||||
|
||||
// 获取用户IP和User-Agent
|
||||
const userIP = getRealIP(req);
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
// 获取IP地理位置并更新用户location
|
||||
const ipLocation = await getIPLocation(userIP);
|
||||
await pool.execute(
|
||||
'UPDATE users SET location = ? WHERE id = ?',
|
||||
[ipLocation, decoded.userId]
|
||||
);
|
||||
|
||||
// 更新会话
|
||||
await pool.execute(
|
||||
'UPDATE user_sessions SET token = ?, refresh_token = ?, expires_at = DATE_ADD(NOW(), INTERVAL 7 DAY), user_agent = ? WHERE id = ?',
|
||||
[newAccessToken, newRefreshToken, userAgent, sessionRows[0].id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '令牌刷新成功',
|
||||
data: {
|
||||
access_token: newAccessToken,
|
||||
refresh_token: newRefreshToken,
|
||||
expires_in: 3600
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('刷新令牌失败:', error);
|
||||
res.status(401).json({ code: 401, message: '刷新令牌无效' });
|
||||
}
|
||||
});
|
||||
|
||||
// 退出登录
|
||||
router.post('/logout', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const token = req.token;
|
||||
|
||||
// 将当前会话设为无效
|
||||
await pool.execute(
|
||||
'UPDATE user_sessions SET is_active = 0 WHERE user_id = ? AND token = ?',
|
||||
[userId, token]
|
||||
);
|
||||
|
||||
console.log(`用户退出成功 - 用户ID: ${userId}`);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '退出成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('退出登录失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取当前用户信息
|
||||
router.get('/me', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
|
||||
const [userRows] = await pool.execute(
|
||||
'SELECT id, user_id, nickname, avatar, bio, location, follow_count, fans_count, like_count, is_active, created_at, gender, zodiac_sign, mbti, education, major, interests FROM users WHERE id = ?',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userRows.length === 0) {
|
||||
return res.status(404).json({ code: 404, message: '用户不存在' });
|
||||
}
|
||||
|
||||
const user = userRows[0];
|
||||
|
||||
// 处理interests字段(如果是JSON字符串则解析)
|
||||
if (user.interests) {
|
||||
try {
|
||||
user.interests = typeof user.interests === 'string'
|
||||
? JSON.parse(user.interests)
|
||||
: user.interests;
|
||||
} catch (e) {
|
||||
user.interests = null;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: user
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 管理员登录
|
||||
router.post('/admin/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ code: 400, message: '缺少必要参数' });
|
||||
}
|
||||
|
||||
// 查找管理员
|
||||
const [adminRows] = await pool.execute(
|
||||
'SELECT id, username, password FROM admin WHERE username = ?',
|
||||
[username]
|
||||
);
|
||||
|
||||
if (adminRows.length === 0) {
|
||||
return res.status(400).json({ code: 400, message: '管理员账号不存在' });
|
||||
}
|
||||
|
||||
const admin = adminRows[0];
|
||||
|
||||
// 验证密码(哈希比较)
|
||||
const [passwordCheck] = await pool.execute(
|
||||
'SELECT 1 FROM admin WHERE id = ? AND password = SHA2(?, 256)',
|
||||
[admin.id, password]
|
||||
);
|
||||
|
||||
if (passwordCheck.length === 0) {
|
||||
return res.status(400).json({ code: 400, message: '密码错误' });
|
||||
}
|
||||
|
||||
// 生成JWT令牌
|
||||
const accessToken = generateAccessToken({
|
||||
adminId: admin.id,
|
||||
username: admin.username,
|
||||
type: 'admin'
|
||||
});
|
||||
const refreshToken = generateRefreshToken({
|
||||
adminId: admin.id,
|
||||
username: admin.username,
|
||||
type: 'admin'
|
||||
});
|
||||
|
||||
// 移除密码字段
|
||||
delete admin.password;
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '登录成功',
|
||||
data: {
|
||||
admin,
|
||||
tokens: {
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
expires_in: 3600
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('管理员登录失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取当前管理员信息
|
||||
router.get('/admin/me', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
// 检查是否为管理员token
|
||||
if (!req.user.type || req.user.type !== 'admin') {
|
||||
return res.status(403).json({ code: 403, message: '权限不足' });
|
||||
}
|
||||
|
||||
const adminId = req.user.adminId;
|
||||
|
||||
const [adminRows] = await pool.execute(
|
||||
'SELECT id, username FROM admin WHERE id = ?',
|
||||
[adminId]
|
||||
);
|
||||
|
||||
if (adminRows.length === 0) {
|
||||
return res.status(404).json({ code: 404, message: '管理员不存在' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: adminRows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取管理员信息失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取管理员列表
|
||||
router.get('/admin/admins', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
// 检查是否为管理员token
|
||||
if (!req.user.type || req.user.type !== 'admin') {
|
||||
return res.status(403).json({ code: 403, message: '权限不足' });
|
||||
}
|
||||
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// 搜索条件
|
||||
let whereClause = '';
|
||||
const params = [];
|
||||
|
||||
if (req.query.username) {
|
||||
whereClause += ' WHERE username LIKE ?';
|
||||
params.push(`%${req.query.username}%`);
|
||||
}
|
||||
|
||||
// 验证排序字段
|
||||
const allowedSortFields = ['username', 'created_at'];
|
||||
const sortField = allowedSortFields.includes(req.query.sortBy) ? req.query.sortBy : 'created_at';
|
||||
const sortOrder = req.query.sortOrder && req.query.sortOrder.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
|
||||
|
||||
// 获取总数
|
||||
const countQuery = `SELECT COUNT(*) as total FROM admin ${whereClause}`;
|
||||
const [countRows] = await pool.execute(countQuery, params);
|
||||
const total = countRows[0].total;
|
||||
|
||||
// 查询管理员列表
|
||||
const dataQuery = `
|
||||
SELECT username, password, created_at
|
||||
FROM admin
|
||||
${whereClause}
|
||||
ORDER BY ${sortField} ${sortOrder}
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
const [adminRows] = await pool.execute(dataQuery, [...params, limit, offset]);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: {
|
||||
data: adminRows,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
pages: Math.ceil(total / limit)
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取管理员列表失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 创建管理员
|
||||
router.post('/admin/admins', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
// 检查是否为管理员token
|
||||
if (!req.user.type || req.user.type !== 'admin') {
|
||||
return res.status(403).json({ code: 403, message: '权限不足' });
|
||||
}
|
||||
|
||||
const { username, password } = req.body;
|
||||
|
||||
// 验证必填字段
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ code: 400, message: '账号和密码不能为空' });
|
||||
}
|
||||
|
||||
// 检查用户名是否已存在
|
||||
const [existingRows] = await pool.execute(
|
||||
'SELECT id FROM admin WHERE username = ?',
|
||||
[username]
|
||||
);
|
||||
|
||||
if (existingRows.length > 0) {
|
||||
return res.status(400).json({ code: 400, message: '账号已存在' });
|
||||
}
|
||||
|
||||
// 创建管理员(密码使用SHA2哈希加密)
|
||||
const [result] = await pool.execute(
|
||||
'INSERT INTO admin (username, password, created_at) VALUES (?, SHA2(?, 256), NOW())',
|
||||
[username, password]
|
||||
);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '创建管理员成功',
|
||||
data: {
|
||||
id: result.insertId
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('创建管理员失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新管理员信息
|
||||
router.put('/admin/admins/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
// 检查是否为管理员token
|
||||
if (!req.user.type || req.user.type !== 'admin') {
|
||||
return res.status(403).json({ code: 403, message: '权限不足' });
|
||||
}
|
||||
|
||||
const adminId = req.params.id;
|
||||
const { password } = req.body;
|
||||
|
||||
// 验证必填字段
|
||||
if (!password) {
|
||||
return res.status(400).json({ code: 400, message: '密码不能为空' });
|
||||
}
|
||||
|
||||
// 检查管理员是否存在
|
||||
const [adminRows] = await pool.execute(
|
||||
'SELECT username FROM admin WHERE username = ?',
|
||||
[adminId]
|
||||
);
|
||||
|
||||
if (adminRows.length === 0) {
|
||||
return res.status(404).json({ code: 404, message: '管理员不存在' });
|
||||
}
|
||||
|
||||
// 更新管理员密码(使用SHA2哈希加密)
|
||||
await pool.execute(
|
||||
'UPDATE admin SET password = SHA2(?, 256) WHERE username = ?',
|
||||
[password, adminId]
|
||||
);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '更新管理员信息成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('更新管理员信息失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除管理员
|
||||
router.delete('/admin/admins/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
// 检查是否为管理员token
|
||||
if (!req.user.type || req.user.type !== 'admin') {
|
||||
return res.status(403).json({ code: 403, message: '权限不足' });
|
||||
}
|
||||
|
||||
const adminId = req.params.id;
|
||||
|
||||
// 检查管理员是否存在
|
||||
const [adminRows] = await pool.execute(
|
||||
'SELECT username FROM admin WHERE username = ?',
|
||||
[adminId]
|
||||
);
|
||||
|
||||
if (adminRows.length === 0) {
|
||||
return res.status(404).json({ code: 404, message: '管理员不存在' });
|
||||
}
|
||||
|
||||
// 删除管理员
|
||||
await pool.execute('DELETE FROM admin WHERE username = ?', [adminId]);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '删除管理员成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('删除管理员失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 重置管理员密码
|
||||
router.put('/admin/admins/:id/password', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
// 检查是否为管理员token
|
||||
if (!req.user.type || req.user.type !== 'admin') {
|
||||
return res.status(403).json({ code: 403, message: '权限不足' });
|
||||
}
|
||||
|
||||
const adminId = req.params.id;
|
||||
const { password } = req.body;
|
||||
|
||||
// 验证密码
|
||||
if (!password || password.length < 6) {
|
||||
return res.status(400).json({ code: 400, message: '密码不能为空且长度不能少于6位' });
|
||||
}
|
||||
|
||||
// 检查管理员是否存在
|
||||
const [adminRows] = await pool.execute(
|
||||
'SELECT id FROM admin WHERE id = ?',
|
||||
[adminId]
|
||||
);
|
||||
|
||||
if (adminRows.length === 0) {
|
||||
return res.status(404).json({ code: 404, message: '管理员不存在' });
|
||||
}
|
||||
|
||||
// 更新密码(使用SHA2哈希加密)
|
||||
await pool.execute(
|
||||
'UPDATE admin SET password = SHA2(?, 256) WHERE id = ?',
|
||||
[password, adminId]
|
||||
);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '重置密码成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('重置密码失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 启用/禁用管理员
|
||||
router.put('/admin/admins/:id/status', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
// 检查是否为管理员token
|
||||
if (!req.user.type || req.user.type !== 'admin') {
|
||||
return res.status(403).json({ code: 403, message: '权限不足' });
|
||||
}
|
||||
|
||||
const adminId = req.params.id;
|
||||
const { status } = req.body;
|
||||
|
||||
// 验证状态
|
||||
if (![0, 1].includes(status)) {
|
||||
return res.status(400).json({ code: 400, message: '无效的状态' });
|
||||
}
|
||||
|
||||
// 检查管理员是否存在
|
||||
const [adminRows] = await pool.execute(
|
||||
'SELECT id FROM admin WHERE id = ?',
|
||||
[adminId]
|
||||
);
|
||||
|
||||
if (adminRows.length === 0) {
|
||||
return res.status(404).json({ code: 404, message: '管理员不存在' });
|
||||
}
|
||||
|
||||
// 不能禁用自己
|
||||
if (parseInt(adminId) === req.user.adminId && status === 0) {
|
||||
return res.status(400).json({ code: 400, message: '不能禁用自己' });
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
await pool.execute(
|
||||
'UPDATE admin SET status = ? WHERE id = ?',
|
||||
[status, adminId]
|
||||
);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: `${status === 1 ? '启用' : '禁用'}管理员成功`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('更新管理员状态失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
316
express-project/routes/comments.js
Normal file
@@ -0,0 +1,316 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { pool } = require('../config/database');
|
||||
const { authenticateToken, optionalAuth } = require('../middleware/auth');
|
||||
const NotificationHelper = require('../utils/notificationHelper');
|
||||
const { extractMentionedUsers, hasMentions } = require('../utils/mentionParser');
|
||||
|
||||
// 递归删除评论及其子评论,返回删除的评论总数
|
||||
async function deleteCommentRecursive(commentId) {
|
||||
let deletedCount = 0;
|
||||
|
||||
// 获取所有子评论
|
||||
const [children] = await pool.execute('SELECT id FROM comments WHERE parent_id = ?', [commentId]);
|
||||
|
||||
// 递归删除子评论
|
||||
for (const child of children) {
|
||||
deletedCount += await deleteCommentRecursive(child.id);
|
||||
}
|
||||
|
||||
// 删除当前评论的点赞记录
|
||||
await pool.execute('DELETE FROM likes WHERE target_type = 2 AND target_id = ?', [commentId]);
|
||||
|
||||
// 删除当前评论
|
||||
await pool.execute('DELETE FROM comments WHERE id = ?', [commentId]);
|
||||
|
||||
// 当前评论也算一个
|
||||
deletedCount += 1;
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
// 获取评论列表
|
||||
router.get('/', optionalAuth, async (req, res) => {
|
||||
try {
|
||||
const postId = req.query.post_id;
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
const currentUserId = req.user ? req.user.id : null;
|
||||
|
||||
if (!postId) {
|
||||
return res.status(400).json({ code: 400, message: '缺少笔记ID' });
|
||||
}
|
||||
|
||||
// 获取顶级评论(parent_id为NULL)
|
||||
const [rows] = await pool.execute(
|
||||
`SELECT c.*, u.nickname, u.avatar as user_avatar, u.id as user_auto_id, u.user_id as user_display_id, u.location as user_location
|
||||
FROM comments c
|
||||
LEFT JOIN users u ON c.user_id = u.id
|
||||
WHERE c.post_id = ? AND c.parent_id IS NULL
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[postId, limit, offset]
|
||||
);
|
||||
|
||||
// 为每个评论检查点赞状态
|
||||
for (let comment of rows) {
|
||||
if (currentUserId) {
|
||||
const [likeResult] = await pool.execute(
|
||||
'SELECT id FROM likes WHERE user_id = ? AND target_type = 2 AND target_id = ?',
|
||||
[currentUserId, comment.id]
|
||||
);
|
||||
comment.liked = likeResult.length > 0;
|
||||
} else {
|
||||
comment.liked = false;
|
||||
}
|
||||
|
||||
// 获取子评论数量
|
||||
const [childCount] = await pool.execute(
|
||||
'SELECT COUNT(*) as count FROM comments WHERE parent_id = ?',
|
||||
[comment.id]
|
||||
);
|
||||
comment.reply_count = childCount[0].count;
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
const [countResult] = await pool.execute(
|
||||
'SELECT COUNT(*) as total FROM comments WHERE post_id = ? AND parent_id IS NULL',
|
||||
[postId]
|
||||
);
|
||||
const total = countResult[0].total;
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: {
|
||||
comments: rows,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
pages: Math.ceil(total / limit)
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取评论列表失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 创建评论
|
||||
router.post('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { post_id, content, parent_id } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// 验证必填字段
|
||||
if (!post_id || !content) {
|
||||
return res.status(400).json({ code: 400, message: '笔记ID和评论内容不能为空' });
|
||||
}
|
||||
|
||||
// 验证笔记是否存在
|
||||
const [postRows] = await pool.execute('SELECT id FROM posts WHERE id = ?', [post_id]);
|
||||
if (postRows.length === 0) {
|
||||
return res.status(404).json({ code: 404, message: '笔记不存在' });
|
||||
}
|
||||
|
||||
// 如果是回复评论,验证父评论是否存在
|
||||
if (parent_id) {
|
||||
const [parentRows] = await pool.execute('SELECT id FROM comments WHERE id = ?', [parent_id]);
|
||||
if (parentRows.length === 0) {
|
||||
return res.status(404).json({ code: 404, message: '父评论不存在' });
|
||||
}
|
||||
}
|
||||
|
||||
// 插入评论
|
||||
const [result] = await pool.execute(
|
||||
'INSERT INTO comments (post_id, user_id, content, parent_id) VALUES (?, ?, ?, ?)',
|
||||
[post_id, userId, content, parent_id || null]
|
||||
);
|
||||
|
||||
const commentId = result.insertId;
|
||||
|
||||
// 更新笔记评论数
|
||||
await pool.execute('UPDATE posts SET comment_count = comment_count + 1 WHERE id = ?', [post_id]);
|
||||
|
||||
// 创建通知
|
||||
if (parent_id) {
|
||||
// 回复评论,给被回复的评论作者发通知
|
||||
const [parentCommentResult] = await pool.execute('SELECT user_id FROM comments WHERE id = ?', [parent_id]);
|
||||
if (parentCommentResult.length > 0) {
|
||||
const parentUserId = parentCommentResult[0].user_id;
|
||||
// 不给自己发通知
|
||||
if (parentUserId !== userId) {
|
||||
const notificationData = NotificationHelper.createReplyCommentNotification(parentUserId, userId, post_id, commentId);
|
||||
await NotificationHelper.insertNotification(pool, notificationData);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 评论笔记,给笔记作者发通知
|
||||
const [postResult] = await pool.execute('SELECT user_id FROM posts WHERE id = ?', [post_id]);
|
||||
if (postResult.length > 0) {
|
||||
const postUserId = postResult[0].user_id;
|
||||
// 不给自己发通知
|
||||
if (postUserId !== userId) {
|
||||
const notificationData = NotificationHelper.createCommentPostNotification(postUserId, userId, post_id, commentId);
|
||||
await NotificationHelper.insertNotification(pool, notificationData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理@用户通知
|
||||
if (hasMentions(content)) {
|
||||
const mentionedUsers = extractMentionedUsers(content);
|
||||
|
||||
for (const mentionedUser of mentionedUsers) {
|
||||
try {
|
||||
// 根据小石榴号查找用户的自增ID
|
||||
const [userRows] = await pool.execute('SELECT id FROM users WHERE user_id = ?', [mentionedUser.userId]);
|
||||
|
||||
if (userRows.length > 0) {
|
||||
const mentionedUserId = userRows[0].id;
|
||||
|
||||
// 不给自己发通知
|
||||
if (mentionedUserId !== userId) {
|
||||
// 创建@用户通知
|
||||
const mentionNotificationData = NotificationHelper.createNotificationData({
|
||||
userId: mentionedUserId,
|
||||
senderId: userId,
|
||||
type: NotificationHelper.TYPES.MENTION,
|
||||
targetId: post_id,
|
||||
commentId: commentId
|
||||
});
|
||||
|
||||
await NotificationHelper.insertNotification(pool, mentionNotificationData);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`处理@用户通知失败 - 用户: ${mentionedUser.userId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`创建评论成功 - 用户ID: ${userId}, 评论ID: ${commentId}`);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '评论成功',
|
||||
data: { id: commentId }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('创建评论失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取子评论列表
|
||||
router.get('/:id/replies', optionalAuth, async (req, res) => {
|
||||
try {
|
||||
const parentId = req.params.id;
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
const currentUserId = req.user ? req.user.id : null;
|
||||
|
||||
|
||||
// 获取子评论
|
||||
const [rows] = await pool.execute(
|
||||
`SELECT c.*, u.nickname, u.avatar as user_avatar, u.id as user_auto_id, u.user_id as user_display_id, u.location as user_location
|
||||
FROM comments c
|
||||
LEFT JOIN users u ON c.user_id = u.id
|
||||
WHERE c.parent_id = ?
|
||||
ORDER BY c.created_at ASC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[parentId, limit, offset]
|
||||
);
|
||||
|
||||
// 为每个评论检查点赞状态
|
||||
for (let comment of rows) {
|
||||
if (currentUserId) {
|
||||
const [likeResult] = await pool.execute(
|
||||
'SELECT id FROM likes WHERE user_id = ? AND target_type = 2 AND target_id = ?',
|
||||
[currentUserId, comment.id]
|
||||
);
|
||||
comment.liked = likeResult.length > 0;
|
||||
} else {
|
||||
comment.liked = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
const [countResult] = await pool.execute(
|
||||
'SELECT COUNT(*) as total FROM comments WHERE parent_id = ?',
|
||||
[parentId]
|
||||
);
|
||||
const total = countResult[0].total;
|
||||
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: {
|
||||
comments: rows,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
pages: Math.ceil(total / limit)
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取子评论列表失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// 删除评论
|
||||
router.delete('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const commentId = req.params.id;
|
||||
const userId = req.user.id;
|
||||
|
||||
// 验证评论是否存在并且是当前用户发布的
|
||||
const [commentRows] = await pool.execute(
|
||||
'SELECT id, post_id, user_id, parent_id FROM comments WHERE id = ?',
|
||||
[commentId]
|
||||
);
|
||||
|
||||
if (commentRows.length === 0) {
|
||||
return res.status(404).json({ code: 404, message: '评论不存在' });
|
||||
}
|
||||
|
||||
const comment = commentRows[0];
|
||||
|
||||
// 检查是否是评论作者
|
||||
if (comment.user_id !== userId) {
|
||||
return res.status(403).json({ code: 403, message: '只能删除自己发布的评论' });
|
||||
}
|
||||
|
||||
// 使用递归删除函数删除评论及其所有子评论,获取删除的评论总数
|
||||
const deletedCount = await deleteCommentRecursive(commentId);
|
||||
|
||||
// 根据实际删除的评论数量更新笔记的评论计数
|
||||
await pool.execute('UPDATE posts SET comment_count = comment_count - ? WHERE id = ?', [deletedCount, comment.post_id]);
|
||||
|
||||
console.log(`删除评论成功 - 用户ID: ${userId}, 评论ID: ${commentId}`);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '删除成功',
|
||||
data: {
|
||||
id: commentId,
|
||||
deletedCount: deletedCount
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('删除评论失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
164
express-project/routes/likes.js
Normal file
@@ -0,0 +1,164 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { pool } = require('../config/database');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const NotificationHelper = require('../utils/notificationHelper');
|
||||
|
||||
// 点赞/取消点赞
|
||||
router.post('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { target_type, target_id } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// 验证参数
|
||||
if (!target_type || !target_id) {
|
||||
return res.status(400).json({ code: 400, message: '缺少必要参数' });
|
||||
}
|
||||
|
||||
// target_type: 1=笔记, 2=评论
|
||||
if (![1, 2].includes(parseInt(target_type))) {
|
||||
return res.status(400).json({ code: 400, message: '无效的目标类型' });
|
||||
}
|
||||
|
||||
// 检查是否已经点赞
|
||||
const [existingLike] = await pool.execute(
|
||||
'SELECT id FROM likes WHERE user_id = ? AND target_type = ? AND target_id = ?',
|
||||
[userId, target_type, target_id]
|
||||
);
|
||||
|
||||
if (existingLike.length > 0) {
|
||||
// 已点赞,执行取消点赞
|
||||
await pool.execute(
|
||||
'DELETE FROM likes WHERE user_id = ? AND target_type = ? AND target_id = ?',
|
||||
[userId, target_type, target_id]
|
||||
);
|
||||
|
||||
// 更新对应表的点赞数
|
||||
if (target_type == 1) {
|
||||
// 笔记
|
||||
await pool.execute('UPDATE posts SET like_count = like_count - 1 WHERE id = ?', [target_id]);
|
||||
|
||||
// 更新笔记作者的获赞数
|
||||
await pool.execute(
|
||||
'UPDATE users SET like_count = like_count - 1 WHERE id = (SELECT user_id FROM posts WHERE id = ?)',
|
||||
[target_id]
|
||||
);
|
||||
} else if (target_type == 2) {
|
||||
// 评论
|
||||
await pool.execute('UPDATE comments SET like_count = like_count - 1 WHERE id = ?', [target_id]);
|
||||
}
|
||||
|
||||
console.log(`取消点赞成功 - 用户ID: ${userId}`);
|
||||
res.json({ code: 200, message: '取消点赞成功', data: { liked: false } });
|
||||
} else {
|
||||
// 未点赞,执行点赞
|
||||
await pool.execute(
|
||||
'INSERT INTO likes (user_id, target_type, target_id) VALUES (?, ?, ?)',
|
||||
[userId, target_type, target_id]
|
||||
);
|
||||
|
||||
// 更新对应表的点赞数
|
||||
let targetUserId = null;
|
||||
let notificationTargetId = target_id; // 默认使用原始target_id
|
||||
|
||||
if (target_type == 1) {
|
||||
// 笔记
|
||||
await pool.execute('UPDATE posts SET like_count = like_count + 1 WHERE id = ?', [target_id]);
|
||||
|
||||
// 更新笔记作者的获赞数
|
||||
await pool.execute(
|
||||
'UPDATE users SET like_count = like_count + 1 WHERE id = (SELECT user_id FROM posts WHERE id = ?)',
|
||||
[target_id]
|
||||
);
|
||||
|
||||
// 获取笔记作者ID,用于创建通知
|
||||
const [postResult] = await pool.execute('SELECT user_id FROM posts WHERE id = ?', [target_id]);
|
||||
if (postResult.length > 0) {
|
||||
targetUserId = postResult[0].user_id;
|
||||
}
|
||||
// 点赞笔记时,target_id就是笔记ID
|
||||
notificationTargetId = target_id;
|
||||
} else if (target_type == 2) {
|
||||
// 评论
|
||||
await pool.execute('UPDATE comments SET like_count = like_count + 1 WHERE id = ?', [target_id]);
|
||||
|
||||
// 获取评论作者ID和所属笔记ID,用于创建通知
|
||||
const [commentResult] = await pool.execute('SELECT user_id, post_id FROM comments WHERE id = ?', [target_id]);
|
||||
if (commentResult.length > 0) {
|
||||
targetUserId = commentResult[0].user_id;
|
||||
// 点赞评论时,通知的target_id应该是评论所属的笔记ID,这样点击通知可以跳转到笔记页面
|
||||
notificationTargetId = commentResult[0].post_id;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建通知(不给自己发通知)
|
||||
if (targetUserId && targetUserId !== userId) {
|
||||
|
||||
let notificationData;
|
||||
if (target_type == 1) {
|
||||
// 点赞笔记
|
||||
notificationData = NotificationHelper.createLikePostNotification(targetUserId, userId, notificationTargetId);
|
||||
} else if (target_type == 2) {
|
||||
// 点赞评论
|
||||
notificationData = NotificationHelper.createLikeCommentNotification(targetUserId, userId, notificationTargetId, target_id);
|
||||
}
|
||||
|
||||
// 插入通知到数据库
|
||||
if (notificationData) {
|
||||
await NotificationHelper.insertNotification(pool, notificationData);
|
||||
}
|
||||
}
|
||||
console.log(`点赞成功 - 用户ID: ${userId}`);
|
||||
res.json({ code: 200, message: '点赞成功', data: { liked: true } });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('点赞操作失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 取消点赞(兼容旧接口)
|
||||
router.delete('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { target_type, target_id } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// 验证参数
|
||||
if (!target_type || !target_id) {
|
||||
return res.status(400).json({ code: 400, message: '缺少必要参数' });
|
||||
}
|
||||
|
||||
// 删除点赞记录
|
||||
const [result] = await pool.execute(
|
||||
'DELETE FROM likes WHERE user_id = ? AND target_type = ? AND target_id = ?',
|
||||
[userId, target_type, target_id]
|
||||
);
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({ code: 404, message: '点赞记录不存在' });
|
||||
}
|
||||
|
||||
// 更新对应表的点赞数
|
||||
if (target_type == 1) {
|
||||
// 笔记
|
||||
await pool.execute('UPDATE posts SET like_count = like_count - 1 WHERE id = ?', [target_id]);
|
||||
|
||||
// 更新笔记作者的获赞数
|
||||
await pool.execute(
|
||||
'UPDATE users SET like_count = like_count - 1 WHERE id = (SELECT user_id FROM posts WHERE id = ?)',
|
||||
[target_id]
|
||||
);
|
||||
} else if (target_type == 2) {
|
||||
// 评论
|
||||
await pool.execute('UPDATE comments SET like_count = like_count - 1 WHERE id = ?', [target_id]);
|
||||
}
|
||||
|
||||
console.log(`取消点赞成功 - 用户ID: ${userId}`);
|
||||
res.json({ code: 200, message: '取消点赞成功' });
|
||||
} catch (error) {
|
||||
console.error('取消点赞失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
419
express-project/routes/notifications.js
Normal file
@@ -0,0 +1,419 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { pool } = require('../config/database');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
|
||||
// 获取评论通知
|
||||
router.get('/comments', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const query = `
|
||||
SELECT n.*,
|
||||
u.id as from_user_auto_id,
|
||||
u.nickname as from_nickname,
|
||||
u.avatar as from_avatar,
|
||||
u.user_id as from_user_id,
|
||||
p.title as post_title,
|
||||
(SELECT pi.image_url FROM post_images pi WHERE pi.post_id = p.id ORDER BY pi.id LIMIT 1) as post_image,
|
||||
c.content as comment_content,
|
||||
c.created_at as comment_created_at,
|
||||
c.like_count as comment_like_count,
|
||||
CASE
|
||||
WHEN n.comment_id IS NOT NULL THEN
|
||||
CASE WHEN EXISTS(SELECT 1 FROM likes WHERE user_id = ? AND target_type = 2 AND target_id = n.comment_id)
|
||||
THEN 1 ELSE 0 END
|
||||
ELSE 0
|
||||
END as comment_is_liked,
|
||||
CASE
|
||||
WHEN n.type = 5 AND c.parent_id IS NOT NULL THEN
|
||||
(SELECT content FROM comments WHERE id = c.parent_id)
|
||||
ELSE NULL
|
||||
END as parent_comment_content
|
||||
FROM notifications n
|
||||
LEFT JOIN users u ON n.sender_id = u.id
|
||||
LEFT JOIN posts p ON n.target_id = p.id
|
||||
LEFT JOIN comments c ON n.comment_id = c.id
|
||||
WHERE n.user_id = ? AND n.type IN (4, 5, 7)
|
||||
ORDER BY n.created_at DESC LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
const [rows] = await pool.execute(query, [userId, userId, limit, offset]);
|
||||
|
||||
// 获取总数
|
||||
const [countResult] = await pool.execute(
|
||||
'SELECT COUNT(*) as total FROM notifications WHERE user_id = ? AND type IN (4, 5, 7)',
|
||||
[userId]
|
||||
);
|
||||
const total = countResult[0].total;
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: {
|
||||
notifications: rows,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
pages: Math.ceil(total / limit)
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取评论通知失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取点赞通知
|
||||
router.get('/likes', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const query = `
|
||||
SELECT n.*,
|
||||
u.id as from_user_auto_id,
|
||||
u.nickname as from_nickname,
|
||||
u.avatar as from_avatar,
|
||||
u.user_id as from_user_id,
|
||||
p.title as post_title,
|
||||
(SELECT pi.image_url FROM post_images pi WHERE pi.post_id = p.id ORDER BY pi.id LIMIT 1) as post_image
|
||||
FROM notifications n
|
||||
LEFT JOIN users u ON n.sender_id = u.id
|
||||
LEFT JOIN posts p ON n.target_id = p.id
|
||||
WHERE n.user_id = ? AND n.type IN (1, 2)
|
||||
ORDER BY n.created_at DESC LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
const [rows] = await pool.execute(query, [userId, limit, offset]);
|
||||
|
||||
// 获取总数
|
||||
const [countResult] = await pool.execute(
|
||||
'SELECT COUNT(*) as total FROM notifications WHERE user_id = ? AND type IN (1, 2)',
|
||||
[userId]
|
||||
);
|
||||
const total = countResult[0].total;
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: {
|
||||
notifications: rows,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
pages: Math.ceil(total / limit)
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取点赞通知失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取关注通知
|
||||
router.get('/follows', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const query = `
|
||||
SELECT n.*,
|
||||
u.id as from_user_auto_id,
|
||||
u.nickname as from_nickname,
|
||||
u.avatar as from_avatar,
|
||||
u.user_id as from_user_id
|
||||
FROM notifications n
|
||||
LEFT JOIN users u ON n.sender_id = u.id
|
||||
WHERE n.user_id = ? AND n.type = 6
|
||||
ORDER BY n.created_at DESC LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
const [rows] = await pool.execute(query, [userId, limit, offset]);
|
||||
|
||||
// 获取总数
|
||||
const [countResult] = await pool.execute(
|
||||
'SELECT COUNT(*) as total FROM notifications WHERE user_id = ? AND type = ?',
|
||||
[userId, 6]
|
||||
);
|
||||
const total = countResult[0].total;
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: {
|
||||
notifications: rows,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
pages: Math.ceil(total / limit)
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取关注通知失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取收藏通知
|
||||
router.get('/collections', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const query = `
|
||||
SELECT n.*,
|
||||
u.id as from_user_auto_id,
|
||||
u.nickname as from_nickname,
|
||||
u.avatar as from_avatar,
|
||||
u.user_id as from_user_id,
|
||||
p.title as post_title,
|
||||
(SELECT pi.image_url FROM post_images pi WHERE pi.post_id = p.id ORDER BY pi.id LIMIT 1) as post_image
|
||||
FROM notifications n
|
||||
LEFT JOIN users u ON n.sender_id = u.id
|
||||
LEFT JOIN posts p ON n.target_id = p.id
|
||||
WHERE n.user_id = ? AND n.type = 3
|
||||
ORDER BY n.created_at DESC LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
const [rows] = await pool.execute(query, [userId, limit, offset]);
|
||||
|
||||
// 获取总数
|
||||
const [countResult] = await pool.execute(
|
||||
'SELECT COUNT(*) as total FROM notifications WHERE user_id = ? AND type = ?',
|
||||
[userId, 3]
|
||||
);
|
||||
const total = countResult[0].total;
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: {
|
||||
notifications: rows,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
pages: Math.ceil(total / limit)
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取收藏通知失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取通知列表(通用接口)
|
||||
router.get('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const type = req.query.type; // comment, like, follow
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
let query = `
|
||||
SELECT n.*,
|
||||
u.id as from_user_auto_id,
|
||||
u.nickname as from_nickname,
|
||||
u.avatar as from_avatar,
|
||||
u.user_id as from_user_id
|
||||
FROM notifications n
|
||||
LEFT JOIN users u ON n.sender_id = u.id
|
||||
WHERE n.user_id = ?
|
||||
`;
|
||||
let queryParams = [userId];
|
||||
|
||||
if (type) {
|
||||
query += ` AND n.type = ?`;
|
||||
queryParams.push(type);
|
||||
}
|
||||
|
||||
query += ` ORDER BY n.created_at DESC LIMIT ? OFFSET ?`;
|
||||
queryParams.push(limit, offset);
|
||||
|
||||
const [rows] = await pool.execute(query, queryParams);
|
||||
|
||||
// 获取总数
|
||||
let countQuery = 'SELECT COUNT(*) as total FROM notifications WHERE user_id = ?';
|
||||
let countParams = [userId];
|
||||
if (type) {
|
||||
countQuery += ' AND type = ?';
|
||||
countParams.push(type);
|
||||
}
|
||||
|
||||
const [countResult] = await pool.execute(countQuery, countParams);
|
||||
const total = countResult[0].total;
|
||||
|
||||
// 获取未读数量
|
||||
const [unreadResult] = await pool.execute(
|
||||
'SELECT COUNT(*) as unread FROM notifications WHERE user_id = ? AND is_read = 0',
|
||||
[userId]
|
||||
);
|
||||
const unread = unreadResult[0].unread;
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: {
|
||||
notifications: rows,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
pages: Math.ceil(total / limit)
|
||||
},
|
||||
unread
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取通知列表失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 标记通知为已读
|
||||
router.put('/:id/read', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const notificationId = req.params.id;
|
||||
const userId = req.user.id;
|
||||
|
||||
// 验证通知是否属于当前用户
|
||||
const [notificationRows] = await pool.execute(
|
||||
'SELECT id FROM notifications WHERE id = ? AND user_id = ?',
|
||||
[notificationId, userId]
|
||||
);
|
||||
|
||||
if (notificationRows.length === 0) {
|
||||
return res.status(404).json({ code: 404, message: '通知不存在' });
|
||||
}
|
||||
|
||||
// 标记为已读
|
||||
await pool.execute(
|
||||
'UPDATE notifications SET is_read = 1 WHERE id = ?',
|
||||
[notificationId]
|
||||
);
|
||||
|
||||
res.json({ code: 200, message: '标记成功' });
|
||||
} catch (error) {
|
||||
console.error('标记通知已读失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 标记所有通知为已读
|
||||
router.put('/read-all', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
|
||||
// 标记所有通知为已读
|
||||
await pool.execute(
|
||||
'UPDATE notifications SET is_read = 1 WHERE user_id = ? AND is_read = 0',
|
||||
[userId]
|
||||
);
|
||||
|
||||
|
||||
res.json({ code: 200, message: '全部标记成功' });
|
||||
} catch (error) {
|
||||
console.error('标记所有通知已读失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除通知
|
||||
router.delete('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const notificationId = req.params.id;
|
||||
const userId = req.user.id;
|
||||
|
||||
// 验证通知是否属于当前用户
|
||||
const [result] = await pool.execute(
|
||||
'DELETE FROM notifications WHERE id = ? AND user_id = ?',
|
||||
[notificationId, userId]
|
||||
);
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({ code: 404, message: '通知不存在' });
|
||||
}
|
||||
|
||||
res.json({ code: 200, message: '删除成功' });
|
||||
} catch (error) {
|
||||
console.error('删除通知失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取按类型分组的未读通知数量
|
||||
router.get('/unread-count-by-type', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
|
||||
// 按类型统计未读通知数量
|
||||
const [result] = await pool.execute(
|
||||
`SELECT
|
||||
SUM(CASE WHEN type IN (4, 5, 7) THEN 1 ELSE 0 END) as comments,
|
||||
SUM(CASE WHEN type IN (1, 2) THEN 1 ELSE 0 END) as likes,
|
||||
SUM(CASE WHEN type = 3 THEN 1 ELSE 0 END) as collections,
|
||||
SUM(CASE WHEN type = 6 THEN 1 ELSE 0 END) as follows,
|
||||
COUNT(*) as total
|
||||
FROM notifications
|
||||
WHERE user_id = ? AND is_read = 0`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
const counts = result[0];
|
||||
res.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: {
|
||||
comments: parseInt(counts.comments || 0),
|
||||
likes: parseInt(counts.likes || 0),
|
||||
collections: parseInt(counts.collections || 0),
|
||||
follows: parseInt(counts.follows || 0),
|
||||
total: parseInt(counts.total || 0)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取按类型分组的未读通知数量失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取未读通知数量
|
||||
router.get('/unread-count', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
|
||||
const [result] = await pool.execute(
|
||||
'SELECT COUNT(*) as count FROM notifications WHERE user_id = ? AND is_read = 0',
|
||||
[userId]
|
||||
);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: { count: result[0].count }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取未读通知数量失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
721
express-project/routes/posts.js
Normal file
@@ -0,0 +1,721 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { pool } = require('../config/database');
|
||||
const { optionalAuth, authenticateToken } = require('../middleware/auth');
|
||||
const { uploadBase64ToImageHost } = require('../utils/uploadHelper');
|
||||
const NotificationHelper = require('../utils/notificationHelper');
|
||||
|
||||
// 获取笔记列表
|
||||
router.get('/', optionalAuth, async (req, res) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
const category = req.query.category;
|
||||
const isDraft = req.query.is_draft !== undefined ? parseInt(req.query.is_draft) : 0;
|
||||
const userId = req.query.user_id ? parseInt(req.query.user_id) : null;
|
||||
const currentUserId = req.user ? req.user.id : null;
|
||||
|
||||
let query = `
|
||||
SELECT p.*, u.nickname, u.avatar as user_avatar, u.user_id as author_account, u.id as author_auto_id, u.location
|
||||
FROM posts p
|
||||
LEFT JOIN users u ON p.user_id = u.id
|
||||
WHERE p.is_draft = ?
|
||||
`;
|
||||
let queryParams = [isDraft];
|
||||
|
||||
// 特殊处理推荐频道:显示浏览量前20%的笔记,但支持分页
|
||||
if (category === 'recommend') {
|
||||
// 先获取总笔记数(只计算指定状态的笔记)
|
||||
const [totalCountResult] = await pool.execute('SELECT COUNT(*) as total FROM posts WHERE is_draft = ?', [isDraft]);
|
||||
const totalPosts = totalCountResult[0].total;
|
||||
const topPostsCount = Math.ceil(totalPosts * 0.2); // 前20%的笔记数量
|
||||
|
||||
// 直接获取前20%浏览量的笔记,然后进行分页(只包含指定状态的笔记)
|
||||
query = `
|
||||
SELECT p.*, u.nickname, u.avatar as user_avatar, u.user_id as author_account, u.id as author_auto_id, u.location
|
||||
FROM (
|
||||
SELECT * FROM posts WHERE is_draft = ? ORDER BY view_count DESC LIMIT ?
|
||||
) p
|
||||
LEFT JOIN users u ON p.user_id = u.id
|
||||
ORDER BY p.view_count DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
queryParams = [isDraft, topPostsCount, limit, offset];
|
||||
} else {
|
||||
let whereConditions = [];
|
||||
|
||||
if (category) {
|
||||
whereConditions.push('p.category = ?');
|
||||
queryParams.push(category);
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
whereConditions.push('p.user_id = ?');
|
||||
queryParams.push(userId);
|
||||
}
|
||||
|
||||
if (whereConditions.length > 0) {
|
||||
query += ` AND ${whereConditions.join(' AND ')}`;
|
||||
}
|
||||
|
||||
query += ` ORDER BY p.created_at DESC LIMIT ? OFFSET ?`;
|
||||
queryParams.push(limit, offset);
|
||||
}
|
||||
|
||||
const [rows] = await pool.execute(query, queryParams);
|
||||
|
||||
|
||||
// 获取每个笔记的图片、标签和用户点赞收藏状态
|
||||
for (let post of rows) {
|
||||
// 获取笔记图片
|
||||
const [images] = await pool.execute('SELECT image_url FROM post_images WHERE post_id = ?', [post.id]);
|
||||
post.images = images.map(img => img.image_url);
|
||||
|
||||
// 获取笔记标签
|
||||
const [tags] = await pool.execute(
|
||||
'SELECT t.id, t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = ?',
|
||||
[post.id]
|
||||
);
|
||||
post.tags = tags;
|
||||
|
||||
// 检查当前用户是否已点赞(仅在用户已登录时检查)
|
||||
if (currentUserId) {
|
||||
const [likeResult] = await pool.execute(
|
||||
'SELECT id FROM likes WHERE user_id = ? AND target_type = 1 AND target_id = ?',
|
||||
[currentUserId, post.id]
|
||||
);
|
||||
post.liked = likeResult.length > 0;
|
||||
|
||||
// 检查当前用户是否已收藏
|
||||
const [collectResult] = await pool.execute(
|
||||
'SELECT id FROM collections WHERE user_id = ? AND post_id = ?',
|
||||
[currentUserId, post.id]
|
||||
);
|
||||
post.collected = collectResult.length > 0;
|
||||
} else {
|
||||
post.liked = false;
|
||||
post.collected = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
let total;
|
||||
if (category === 'recommend') {
|
||||
// 推荐频道的总数就是前20%的笔记数量
|
||||
const [totalCountResult] = await pool.execute('SELECT COUNT(*) as total FROM posts WHERE is_draft = ?', [isDraft]);
|
||||
const totalPosts = totalCountResult[0].total;
|
||||
total = Math.ceil(totalPosts * 0.2);
|
||||
} else {
|
||||
let countQuery = 'SELECT COUNT(*) as total FROM posts WHERE is_draft = ?';
|
||||
let countParams = [isDraft];
|
||||
let countWhereConditions = [];
|
||||
|
||||
if (category) {
|
||||
countWhereConditions.push('category = ?');
|
||||
countParams.push(category);
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
countWhereConditions.push('user_id = ?');
|
||||
countParams.push(userId);
|
||||
}
|
||||
|
||||
if (countWhereConditions.length > 0) {
|
||||
countQuery += ` AND ${countWhereConditions.join(' AND ')}`;
|
||||
}
|
||||
|
||||
const [countResult] = await pool.execute(countQuery, countParams);
|
||||
total = countResult[0].total;
|
||||
}
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: {
|
||||
posts: rows,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
pages: Math.ceil(total / limit)
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取笔记列表失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取笔记详情
|
||||
router.get('/:id', optionalAuth, async (req, res) => {
|
||||
try {
|
||||
const postId = req.params.id;
|
||||
const currentUserId = req.user ? req.user.id : null;
|
||||
|
||||
// 获取笔记基本信息
|
||||
const [rows] = await pool.execute(
|
||||
`SELECT p.*, u.nickname, u.avatar as user_avatar, u.user_id as author_account, u.id as author_auto_id, u.location
|
||||
FROM posts p
|
||||
LEFT JOIN users u ON p.user_id = u.id
|
||||
WHERE p.id = ?`,
|
||||
[postId]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ code: 404, message: '笔记不存在' });
|
||||
}
|
||||
|
||||
const post = rows[0];
|
||||
|
||||
// 获取笔记图片
|
||||
const [images] = await pool.execute('SELECT image_url FROM post_images WHERE post_id = ?', [postId]);
|
||||
post.images = images.map(img => img.image_url);
|
||||
|
||||
// 获取笔记标签
|
||||
const [tags] = await pool.execute(
|
||||
'SELECT t.id, t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = ?',
|
||||
[postId]
|
||||
);
|
||||
post.tags = tags;
|
||||
|
||||
// 检查当前用户是否已点赞和收藏(仅在用户已登录时检查)
|
||||
if (currentUserId) {
|
||||
const [likeResult] = await pool.execute(
|
||||
'SELECT id FROM likes WHERE user_id = ? AND target_type = 1 AND target_id = ?',
|
||||
[currentUserId, postId]
|
||||
);
|
||||
post.liked = likeResult.length > 0;
|
||||
|
||||
const [collectResult] = await pool.execute(
|
||||
'SELECT id FROM collections WHERE user_id = ? AND post_id = ?',
|
||||
[currentUserId, postId]
|
||||
);
|
||||
post.collected = collectResult.length > 0;
|
||||
} else {
|
||||
post.liked = false;
|
||||
post.collected = false;
|
||||
}
|
||||
|
||||
// 增加浏览量
|
||||
await pool.execute('UPDATE posts SET view_count = view_count + 1 WHERE id = ?', [postId]);
|
||||
post.view_count = post.view_count + 1;
|
||||
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: post
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取笔记详情失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 创建笔记
|
||||
router.post('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { title, content, category, images, tags, is_draft } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// 验证必填字段:发布时要求标题和内容,草稿时不强制要求
|
||||
if (!is_draft && (!title || !content)) {
|
||||
return res.status(400).json({ code: 400, message: '发布时标题和内容不能为空' });
|
||||
}
|
||||
|
||||
// 插入笔记
|
||||
const [result] = await pool.execute(
|
||||
'INSERT INTO posts (user_id, title, content, category, is_draft) VALUES (?, ?, ?, ?, ?)',
|
||||
[userId, title || '', content || '', category || null, is_draft ? 1 : 0]
|
||||
);
|
||||
|
||||
const postId = result.insertId;
|
||||
|
||||
// 处理图片
|
||||
if (images && images.length > 0) {
|
||||
const validUrls = []
|
||||
const base64Images = []
|
||||
|
||||
// 分离有效URL和base64数据
|
||||
for (const imageUrl of images) {
|
||||
if (imageUrl && typeof imageUrl === 'string') {
|
||||
if (!imageUrl.startsWith('data:image/')) {
|
||||
validUrls.push(imageUrl)
|
||||
} else {
|
||||
base64Images.push(imageUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 上传base64图片到图床
|
||||
if (base64Images.length > 0) {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '')
|
||||
const uploadedUrls = await uploadBase64Images(base64Images, token)
|
||||
if (uploadedUrls.length > 0) {
|
||||
validUrls.push(...uploadedUrls)
|
||||
}
|
||||
}
|
||||
|
||||
// 插入所有有效的图片URL
|
||||
for (const imageUrl of validUrls) {
|
||||
await pool.execute(
|
||||
'INSERT INTO post_images (post_id, image_url) VALUES (?, ?)',
|
||||
[postId, imageUrl]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理标签
|
||||
if (tags && tags.length > 0) {
|
||||
for (const tagName of tags) {
|
||||
// 检查标签是否存在,不存在则创建
|
||||
let [tagRows] = await pool.execute('SELECT id FROM tags WHERE name = ?', [tagName]);
|
||||
let tagId;
|
||||
|
||||
if (tagRows.length === 0) {
|
||||
const [tagResult] = await pool.execute('INSERT INTO tags (name) VALUES (?)', [tagName]);
|
||||
tagId = tagResult.insertId;
|
||||
} else {
|
||||
tagId = tagRows[0].id;
|
||||
}
|
||||
|
||||
// 关联笔记和标签
|
||||
await pool.execute('INSERT INTO post_tags (post_id, tag_id) VALUES (?, ?)', [postId, tagId]);
|
||||
|
||||
// 更新标签使用次数
|
||||
await pool.execute('UPDATE tags SET use_count = use_count + 1 WHERE id = ?', [tagId]);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`创建笔记成功 - 用户ID: ${userId}, 笔记ID: ${postId}`);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '发布成功',
|
||||
data: { id: postId }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('创建笔记失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 搜索笔记
|
||||
router.get('/search', optionalAuth, async (req, res) => {
|
||||
try {
|
||||
const keyword = req.query.keyword;
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
const currentUserId = req.user ? req.user.id : null;
|
||||
|
||||
if (!keyword) {
|
||||
return res.status(400).json({ code: 400, message: '请输入搜索关键词' });
|
||||
}
|
||||
|
||||
console.log(`🔍 搜索笔记 - 关键词: ${keyword}, 页码: ${page}, 每页: ${limit}, 当前用户ID: ${currentUserId}`);
|
||||
|
||||
// 搜索笔记:支持标题和内容搜索(只搜索已激活的笔记)
|
||||
const [rows] = await pool.execute(
|
||||
`SELECT p.*, u.nickname, u.avatar as user_avatar, u.user_id as author_account, u.id as author_auto_id, u.location
|
||||
FROM posts p
|
||||
LEFT JOIN users u ON p.user_id = u.id
|
||||
WHERE p.is_draft = 0 AND (p.title LIKE ? OR p.content LIKE ?)
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[`%${keyword}%`, `%${keyword}%`, limit, offset]
|
||||
);
|
||||
|
||||
// 获取每个笔记的图片、标签和用户点赞收藏状态
|
||||
for (let post of rows) {
|
||||
// 获取笔记图片
|
||||
const [images] = await pool.execute('SELECT image_url FROM post_images WHERE post_id = ?', [post.id]);
|
||||
post.images = images.map(img => img.image_url);
|
||||
|
||||
// 获取笔记标签
|
||||
const [tags] = await pool.execute(
|
||||
'SELECT t.id, t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = ?',
|
||||
[post.id]
|
||||
);
|
||||
post.tags = tags;
|
||||
|
||||
// 检查当前用户是否已点赞和收藏(仅在用户已登录时检查)
|
||||
if (currentUserId) {
|
||||
const [likeResult] = await pool.execute(
|
||||
'SELECT id FROM likes WHERE user_id = ? AND target_type = 1 AND target_id = ?',
|
||||
[currentUserId, post.id]
|
||||
);
|
||||
post.liked = likeResult.length > 0;
|
||||
|
||||
const [collectResult] = await pool.execute(
|
||||
'SELECT id FROM collections WHERE user_id = ? AND post_id = ?',
|
||||
[currentUserId, post.id]
|
||||
);
|
||||
post.collected = collectResult.length > 0;
|
||||
} else {
|
||||
post.liked = false;
|
||||
post.collected = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取总数(只统计已激活的笔记)
|
||||
const [countResult] = await pool.execute(
|
||||
`SELECT COUNT(*) as total FROM posts
|
||||
WHERE is_draft = 0 AND (title LIKE ? OR content LIKE ?)`,
|
||||
[`%${keyword}%`, `%${keyword}%`]
|
||||
);
|
||||
const total = countResult[0].total;
|
||||
|
||||
console.log(` 搜索笔记结果 - 找到 ${total} 个笔记,当前页 ${rows.length} 个`);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: {
|
||||
posts: rows,
|
||||
keyword,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
pages: Math.ceil(total / limit)
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('搜索笔记失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取笔记评论列表
|
||||
router.get('/:id/comments', optionalAuth, async (req, res) => {
|
||||
try {
|
||||
const postId = req.params.id;
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
const currentUserId = req.user ? req.user.id : null;
|
||||
|
||||
console.log(`获取笔记评论列表 - 笔记ID: ${postId}, 页码: ${page}, 每页: ${limit}, 当前用户ID: ${currentUserId}`);
|
||||
|
||||
// 验证笔记是否存在
|
||||
const [postRows] = await pool.execute('SELECT id FROM posts WHERE id = ?', [postId]);
|
||||
if (postRows.length === 0) {
|
||||
return res.status(404).json({ code: 404, message: '笔记不存在' });
|
||||
}
|
||||
|
||||
// 获取顶级评论(parent_id为NULL)
|
||||
const [rows] = await pool.execute(
|
||||
`SELECT c.*, u.nickname, u.avatar as user_avatar, u.id as user_auto_id, u.user_id as user_display_id, u.location as user_location
|
||||
FROM comments c
|
||||
LEFT JOIN users u ON c.user_id = u.id
|
||||
WHERE c.post_id = ? AND c.parent_id IS NULL
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[postId, limit, offset]
|
||||
);
|
||||
|
||||
// 为每个评论检查点赞状态
|
||||
for (let comment of rows) {
|
||||
if (currentUserId) {
|
||||
const [likeResult] = await pool.execute(
|
||||
'SELECT id FROM likes WHERE user_id = ? AND target_type = 2 AND target_id = ?',
|
||||
[currentUserId, comment.id]
|
||||
);
|
||||
comment.liked = likeResult.length > 0;
|
||||
} else {
|
||||
comment.liked = false;
|
||||
}
|
||||
|
||||
// 获取子评论数量
|
||||
const [childCount] = await pool.execute(
|
||||
'SELECT COUNT(*) as count FROM comments WHERE parent_id = ?',
|
||||
[comment.id]
|
||||
);
|
||||
comment.reply_count = childCount[0].count;
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
const [countResult] = await pool.execute(
|
||||
'SELECT COUNT(*) as total FROM comments WHERE post_id = ? AND parent_id IS NULL',
|
||||
[postId]
|
||||
);
|
||||
const total = countResult[0].total;
|
||||
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: {
|
||||
comments: rows,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
pages: Math.ceil(total / limit)
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取笔记评论列表失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// 收藏/取消收藏笔记
|
||||
router.post('/:id/collect', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const postId = req.params.id;
|
||||
const userId = req.user.id;
|
||||
|
||||
// 验证笔记是否存在
|
||||
const [postRows] = await pool.execute('SELECT id FROM posts WHERE id = ?', [postId]);
|
||||
if (postRows.length === 0) {
|
||||
return res.status(404).json({ code: 404, message: '笔记不存在' });
|
||||
}
|
||||
|
||||
// 检查是否已经收藏
|
||||
const [existingCollection] = await pool.execute(
|
||||
'SELECT id FROM collections WHERE user_id = ? AND post_id = ?',
|
||||
[userId, postId]
|
||||
);
|
||||
|
||||
if (existingCollection.length > 0) {
|
||||
// 已收藏,执行取消收藏
|
||||
await pool.execute(
|
||||
'DELETE FROM collections WHERE user_id = ? AND post_id = ?',
|
||||
[userId, postId]
|
||||
);
|
||||
|
||||
// 更新笔记收藏数
|
||||
await pool.execute('UPDATE posts SET collect_count = collect_count - 1 WHERE id = ?', [postId]);
|
||||
|
||||
console.log(`取消收藏成功 - 用户ID: ${userId}, 笔记ID: ${postId}`);
|
||||
res.json({ code: 200, message: '取消收藏成功', data: { collected: false } });
|
||||
} else {
|
||||
// 未收藏,执行收藏
|
||||
await pool.execute(
|
||||
'INSERT INTO collections (user_id, post_id) VALUES (?, ?)',
|
||||
[userId, postId]
|
||||
);
|
||||
|
||||
// 更新笔记收藏数
|
||||
await pool.execute('UPDATE posts SET collect_count = collect_count + 1 WHERE id = ?', [postId]);
|
||||
|
||||
// 获取笔记作者ID,用于创建通知
|
||||
const [postResult] = await pool.execute('SELECT user_id FROM posts WHERE id = ?', [postId]);
|
||||
if (postResult.length > 0) {
|
||||
const targetUserId = postResult[0].user_id;
|
||||
|
||||
// 创建通知(不给自己发通知)
|
||||
if (targetUserId && targetUserId !== userId) {
|
||||
const notificationData = NotificationHelper.createCollectPostNotification(targetUserId, userId, postId);
|
||||
const notificationResult = await NotificationHelper.insertNotification(pool, notificationData);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`收藏成功 - 用户ID: ${userId}, 笔记ID: ${postId}`);
|
||||
res.json({ code: 200, message: '收藏成功', data: { collected: true } });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('笔记收藏操作失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新笔记
|
||||
router.put('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const postId = req.params.id;
|
||||
const { title, content, category, images, tags, is_draft } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// 验证必填字段:如果不是草稿(is_draft=0),则要求标题、内容和分类不能为空
|
||||
if (!is_draft && (!title || !content || !category || category === '未知分类')) {
|
||||
return res.status(400).json({ code: 400, message: '发布时标题、内容和分类不能为空' });
|
||||
}
|
||||
|
||||
// 检查笔记是否存在且属于当前用户
|
||||
const [postRows] = await pool.execute(
|
||||
'SELECT user_id FROM posts WHERE id = ?',
|
||||
[postId]
|
||||
);
|
||||
|
||||
if (postRows.length === 0) {
|
||||
return res.status(404).json({ code: 404, message: '笔记不存在' });
|
||||
}
|
||||
|
||||
if (postRows[0].user_id !== userId) {
|
||||
return res.status(403).json({ code: 403, message: '无权限修改此笔记' });
|
||||
}
|
||||
|
||||
// 更新笔记基本信息
|
||||
await pool.execute(
|
||||
'UPDATE posts SET title = ?, content = ?, category = ?, is_draft = ? WHERE id = ?',
|
||||
[title || '', content || '', category || null, is_draft ? 1 : 0, postId]
|
||||
);
|
||||
|
||||
// 删除原有图片
|
||||
await pool.execute('DELETE FROM post_images WHERE post_id = ?', [postId]);
|
||||
|
||||
// 处理新图片
|
||||
if (images && images.length > 0) {
|
||||
const validUrls = []
|
||||
const base64Images = []
|
||||
|
||||
// 分离有效URL和base64数据
|
||||
for (const imageUrl of images) {
|
||||
if (imageUrl && typeof imageUrl === 'string') {
|
||||
if (!imageUrl.startsWith('data:image/')) {
|
||||
validUrls.push(imageUrl)
|
||||
} else {
|
||||
base64Images.push(imageUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 上传base64图片到图床
|
||||
if (base64Images.length > 0) {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '')
|
||||
const uploadedUrls = await uploadBase64Images(base64Images, token)
|
||||
if (uploadedUrls.length > 0) {
|
||||
validUrls.push(...uploadedUrls)
|
||||
}
|
||||
}
|
||||
|
||||
// 插入所有有效的图片URL
|
||||
for (const imageUrl of validUrls) {
|
||||
await pool.execute(
|
||||
'INSERT INTO post_images (post_id, image_url) VALUES (?, ?)',
|
||||
[postId, imageUrl]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 删除原有标签关联
|
||||
await pool.execute('DELETE FROM post_tags WHERE post_id = ?', [postId]);
|
||||
|
||||
// 处理新标签
|
||||
if (tags && tags.length > 0) {
|
||||
for (const tagName of tags) {
|
||||
// 检查标签是否存在,不存在则创建
|
||||
let [tagRows] = await pool.execute('SELECT id FROM tags WHERE name = ?', [tagName]);
|
||||
let tagId;
|
||||
|
||||
if (tagRows.length === 0) {
|
||||
const [tagResult] = await pool.execute('INSERT INTO tags (name) VALUES (?)', [tagName]);
|
||||
tagId = tagResult.insertId;
|
||||
} else {
|
||||
tagId = tagRows[0].id;
|
||||
}
|
||||
|
||||
// 关联笔记和标签
|
||||
await pool.execute('INSERT INTO post_tags (post_id, tag_id) VALUES (?, ?)', [postId, tagId]);
|
||||
|
||||
// 更新标签使用次数
|
||||
await pool.execute('UPDATE tags SET use_count = use_count + 1 WHERE id = ?', [tagId]);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`更新笔记成功 - 用户ID: ${userId}, 笔记ID: ${postId}`);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '更新成功',
|
||||
data: { id: postId }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('更新笔记失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除笔记
|
||||
router.delete('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const postId = req.params.id;
|
||||
const userId = req.user.id;
|
||||
|
||||
// 检查笔记是否存在且属于当前用户
|
||||
const [postRows] = await pool.execute(
|
||||
'SELECT user_id FROM posts WHERE id = ?',
|
||||
[postId]
|
||||
);
|
||||
|
||||
if (postRows.length === 0) {
|
||||
return res.status(404).json({ code: 404, message: '笔记不存在' });
|
||||
}
|
||||
|
||||
if (postRows[0].user_id !== userId) {
|
||||
return res.status(403).json({ code: 403, message: '无权限删除此笔记' });
|
||||
}
|
||||
|
||||
// 获取笔记关联的标签,减少标签使用次数
|
||||
const [tagResult] = await pool.execute(
|
||||
'SELECT tag_id FROM post_tags WHERE post_id = ?',
|
||||
[postId]
|
||||
);
|
||||
|
||||
// 减少标签使用次数
|
||||
for (const tag of tagResult) {
|
||||
await pool.execute('UPDATE tags SET use_count = GREATEST(use_count - 1, 0) WHERE id = ?', [tag.tag_id]);
|
||||
}
|
||||
|
||||
// 删除相关数据(由于外键约束,需要按顺序删除)
|
||||
await pool.execute('DELETE FROM post_images WHERE post_id = ?', [postId]);
|
||||
await pool.execute('DELETE FROM post_tags WHERE post_id = ?', [postId]);
|
||||
await pool.execute('DELETE FROM likes WHERE target_type = 1 AND target_id = ?', [postId]);
|
||||
await pool.execute('DELETE FROM collections WHERE post_id = ?', [postId]);
|
||||
await pool.execute('DELETE FROM comments WHERE post_id = ?', [postId]);
|
||||
await pool.execute('DELETE FROM notifications WHERE target_id = ?', [postId]);
|
||||
|
||||
// 最后删除笔记
|
||||
await pool.execute('DELETE FROM posts WHERE id = ?', [postId]);
|
||||
|
||||
console.log(`删除笔记成功 - 用户ID: ${userId}, 笔记ID: ${postId}`);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '删除成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('删除笔记失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 取消收藏笔记
|
||||
router.delete('/:id/collect', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const postId = req.params.id;
|
||||
const userId = req.user.id;
|
||||
|
||||
console.log(`取消收藏 - 用户ID: ${userId}, 笔记ID: ${postId}`);
|
||||
|
||||
// 删除收藏记录
|
||||
const [result] = await pool.execute(
|
||||
'DELETE FROM collections WHERE user_id = ? AND post_id = ?',
|
||||
[userId, postId]
|
||||
);
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({ code: 404, message: '收藏记录不存在' });
|
||||
}
|
||||
|
||||
// 更新笔记收藏数
|
||||
await pool.execute('UPDATE posts SET collect_count = collect_count - 1 WHERE id = ?', [postId]);
|
||||
|
||||
console.log(`取消收藏成功 - 用户ID: ${userId}, 笔记ID: ${postId}`);
|
||||
res.json({ code: 200, message: '取消收藏成功', data: { collected: false } });
|
||||
} catch (error) {
|
||||
console.error('取消笔记收藏失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
268
express-project/routes/search.js
Normal file
@@ -0,0 +1,268 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { pool } = require('../config/database');
|
||||
const { optionalAuth } = require('../middleware/auth');
|
||||
|
||||
// 搜索(通用搜索接口)
|
||||
router.get('/', optionalAuth, async (req, res) => {
|
||||
try {
|
||||
const keyword = req.query.keyword || '';
|
||||
const tag = req.query.tag || '';
|
||||
const type = req.query.type || 'all'; // all, posts, users
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
const currentUserId = req.user ? req.user.id : null;
|
||||
|
||||
// 如果既没有关键词也没有标签,返回空结果
|
||||
if (!keyword.trim() && !tag.trim()) {
|
||||
return res.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: {
|
||||
keyword,
|
||||
tag,
|
||||
type,
|
||||
data: [],
|
||||
tagStats: [],
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: 0,
|
||||
pages: 0
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let result = {};
|
||||
|
||||
// all和posts都返回笔记内容(all=视频+图文,但目前只有图文,所以和posts一样)
|
||||
if (type === 'all' || type === 'posts') {
|
||||
// 构建搜索条件
|
||||
let whereConditions = [];
|
||||
let queryParams = [];
|
||||
|
||||
// 关键词搜索条件 - 匹配小石榴号、昵称、标题、正文内容、标签名称中的任意一种
|
||||
if (keyword.trim()) {
|
||||
whereConditions.push('(p.title LIKE ? OR p.content LIKE ? OR u.nickname LIKE ? OR u.user_id LIKE ? OR EXISTS (SELECT 1 FROM post_tags pt JOIN tags t ON pt.tag_id = t.id WHERE pt.post_id = p.id AND t.name LIKE ?))');
|
||||
queryParams.push(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`);
|
||||
}
|
||||
|
||||
// 标签搜索条件 - 如果有keyword,则在keyword结果基础上筛选;如果没有keyword,则直接按tag搜索
|
||||
if (tag.trim()) {
|
||||
if (keyword.trim()) {
|
||||
// 有keyword时,在keyword搜索结果基础上进行tag筛选(AND关系)
|
||||
whereConditions.push('EXISTS (SELECT 1 FROM post_tags pt JOIN tags t ON pt.tag_id = t.id WHERE pt.post_id = p.id AND t.name = ?)');
|
||||
queryParams.push(tag);
|
||||
} else {
|
||||
// 没有keyword时,直接按tag搜索
|
||||
whereConditions.push('EXISTS (SELECT 1 FROM post_tags pt JOIN tags t ON pt.tag_id = t.id WHERE pt.post_id = p.id AND t.name = ?)');
|
||||
queryParams.push(tag);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加is_draft条件,确保只搜索已发布的笔记
|
||||
whereConditions.push('p.is_draft = 0');
|
||||
|
||||
// 构建WHERE子句
|
||||
let whereClause = '';
|
||||
if (whereConditions.length > 0) {
|
||||
// 所有条件都用AND连接(keyword和tag是筛选关系)
|
||||
whereClause = `WHERE ${whereConditions.join(' AND ')}`;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 搜索笔记
|
||||
const [postRows] = await pool.execute(
|
||||
`SELECT p.*, u.nickname, u.avatar as user_avatar, u.user_id, u.location
|
||||
FROM posts p
|
||||
LEFT JOIN users u ON p.user_id = u.id
|
||||
${whereClause}
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...queryParams, limit, offset]
|
||||
);
|
||||
|
||||
// 获取每个笔记的图片、标签和用户点赞收藏状态
|
||||
for (let post of postRows) {
|
||||
// 获取笔记图片
|
||||
const [images] = await pool.execute('SELECT image_url FROM post_images WHERE post_id = ?', [post.id]);
|
||||
post.images = images.map(img => img.image_url);
|
||||
|
||||
// 获取笔记标签
|
||||
const [tags] = await pool.execute(
|
||||
'SELECT t.id, t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = ?',
|
||||
[post.id]
|
||||
);
|
||||
post.tags = tags;
|
||||
|
||||
// 检查当前用户是否已点赞和收藏(仅在用户已登录时检查)
|
||||
if (currentUserId) {
|
||||
const [likeResult] = await pool.execute(
|
||||
'SELECT id FROM likes WHERE user_id = ? AND target_type = 1 AND target_id = ?',
|
||||
[currentUserId, post.id]
|
||||
);
|
||||
post.liked = likeResult.length > 0;
|
||||
|
||||
const [collectResult] = await pool.execute(
|
||||
'SELECT id FROM collections WHERE user_id = ? AND post_id = ?',
|
||||
[currentUserId, post.id]
|
||||
);
|
||||
post.collected = collectResult.length > 0;
|
||||
} else {
|
||||
post.liked = false;
|
||||
post.collected = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取笔记总数 - 使用相同的搜索条件
|
||||
const [postCountResult] = await pool.execute(
|
||||
`SELECT COUNT(*) as total FROM posts p
|
||||
LEFT JOIN users u ON p.user_id = u.id
|
||||
${whereClause}`,
|
||||
queryParams
|
||||
);
|
||||
|
||||
// 统计标签频率 - 始终基于keyword搜索结果,不受当前tag筛选影响
|
||||
let tagStats = [];
|
||||
if (keyword.trim()) {
|
||||
// 构建仅基于keyword的搜索条件(包括标题、内容、用户名、小石榴号、标签名称),并确保只统计已激活的笔记
|
||||
const keywordWhereClause = 'WHERE p.is_draft = 0 AND (p.title LIKE ? OR p.content LIKE ? OR u.nickname LIKE ? OR u.user_id LIKE ? OR EXISTS (SELECT 1 FROM post_tags pt2 JOIN tags t2 ON pt2.tag_id = t2.id WHERE pt2.post_id = p.id AND t2.name LIKE ?))';
|
||||
const keywordParams = [`%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`];
|
||||
|
||||
// 获取keyword搜索结果中的标签统计
|
||||
const [tagStatsResult] = await pool.execute(
|
||||
`SELECT t.name, COUNT(*) as count
|
||||
FROM tags t
|
||||
JOIN post_tags pt ON t.id = pt.tag_id
|
||||
JOIN posts p ON pt.post_id = p.id
|
||||
LEFT JOIN users u ON p.user_id = u.id
|
||||
${keywordWhereClause}
|
||||
GROUP BY t.id, t.name
|
||||
ORDER BY count DESC
|
||||
LIMIT 10`,
|
||||
keywordParams
|
||||
);
|
||||
|
||||
tagStats = tagStatsResult.map(item => ({
|
||||
id: item.name,
|
||||
label: item.name,
|
||||
count: item.count
|
||||
}));
|
||||
}
|
||||
|
||||
// all模式和posts模式都只返回笔记数据
|
||||
if (type === 'all') {
|
||||
result = {
|
||||
data: postRows,
|
||||
tagStats: tagStats,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: postCountResult[0].total,
|
||||
pages: Math.ceil(postCountResult[0].total / limit)
|
||||
}
|
||||
};
|
||||
} else {
|
||||
result.posts = {
|
||||
data: postRows,
|
||||
tagStats: tagStats,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: postCountResult[0].total,
|
||||
pages: Math.ceil(postCountResult[0].total / limit)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 只有当type为'users'时才搜索用户
|
||||
if (type === 'users') {
|
||||
// 搜索用户
|
||||
const [userRows] = await pool.execute(
|
||||
`SELECT u.id, u.user_id, u.nickname, u.avatar, u.bio, u.location, u.follow_count, u.fans_count, u.like_count, u.created_at,
|
||||
(SELECT COUNT(*) FROM posts WHERE user_id = u.id AND is_draft = 0) as post_count
|
||||
FROM users u
|
||||
WHERE u.nickname LIKE ? OR u.user_id LIKE ?
|
||||
ORDER BY u.created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[`%${keyword}%`, `%${keyword}%`, limit, offset]
|
||||
);
|
||||
|
||||
// 检查关注状态(仅在用户已登录时)
|
||||
if (currentUserId) {
|
||||
for (let user of userRows) {
|
||||
// 检查是否已关注
|
||||
const [followResult] = await pool.execute(
|
||||
'SELECT id FROM follows WHERE follower_id = ? AND following_id = ?',
|
||||
[currentUserId, user.id]
|
||||
);
|
||||
user.isFollowing = followResult.length > 0;
|
||||
|
||||
// 检查是否互相关注
|
||||
const [mutualResult] = await pool.execute(
|
||||
'SELECT id FROM follows WHERE follower_id = ? AND following_id = ?',
|
||||
[user.id, currentUserId]
|
||||
);
|
||||
user.isMutual = user.isFollowing && mutualResult.length > 0;
|
||||
|
||||
// 设置按钮类型
|
||||
if (user.id === currentUserId) {
|
||||
user.buttonType = 'self';
|
||||
} else if (user.isMutual) {
|
||||
user.buttonType = 'mutual';
|
||||
} else if (user.isFollowing) {
|
||||
user.buttonType = 'unfollow';
|
||||
} else if (mutualResult.length > 0) {
|
||||
user.buttonType = 'back';
|
||||
} else {
|
||||
user.buttonType = 'follow';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 未登录用户,所有用户都显示为未关注状态
|
||||
for (let user of userRows) {
|
||||
user.isFollowing = false;
|
||||
user.isMutual = false;
|
||||
user.buttonType = 'follow';
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户总数
|
||||
const [userCountResult] = await pool.execute(
|
||||
`SELECT COUNT(*) as total FROM users
|
||||
WHERE nickname LIKE ? OR user_id LIKE ?`,
|
||||
[`%${keyword}%`, `%${keyword}%`]
|
||||
);
|
||||
|
||||
result.users = {
|
||||
data: userRows,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: userCountResult[0].total,
|
||||
pages: Math.ceil(userCountResult[0].total / limit)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: {
|
||||
keyword,
|
||||
tag,
|
||||
type,
|
||||
...result
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('搜索失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
25
express-project/routes/stats.js
Normal file
@@ -0,0 +1,25 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { success, error } = require('../utils/responseHelper');
|
||||
const { getMultipleTableStats } = require('../utils/statsHelper');
|
||||
|
||||
// 获取系统统计信息
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
// 定义需要统计的表
|
||||
const tables = [
|
||||
{ table: 'users', alias: 'users' },
|
||||
{ table: 'posts', alias: 'posts' },
|
||||
{ table: 'comments', alias: 'comments' },
|
||||
{ table: 'likes', alias: 'likes' }
|
||||
];
|
||||
|
||||
const stats = await getMultipleTableStats(tables);
|
||||
success(res, stats, '获取统计信息成功');
|
||||
} catch (err) {
|
||||
console.error('获取统计信息失败:', err);
|
||||
error(res, '获取统计信息失败');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
49
express-project/routes/tags.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { pool } = require('../config/database');
|
||||
|
||||
// 获取所有标签
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.execute(
|
||||
'SELECT * FROM tags ORDER BY name ASC'
|
||||
);
|
||||
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: rows
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取标签列表失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取热门标签
|
||||
router.get('/hot', async (req, res) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 10;
|
||||
// 直接使用 use_count 字段获取热门标签
|
||||
const [rows] = await pool.execute(
|
||||
`SELECT * FROM tags
|
||||
WHERE use_count > 0
|
||||
ORDER BY use_count DESC, name ASC
|
||||
LIMIT ?`,
|
||||
[limit]
|
||||
);
|
||||
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: rows
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取热门标签失败:', error);
|
||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
173
express-project/routes/upload.js
Normal file
@@ -0,0 +1,173 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const multer = require('multer');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const { uploadToImageHost, uploadBase64ToImageHost } = require('../utils/uploadHelper');
|
||||
|
||||
// 配置 multer 内存存储(用于云端图床)
|
||||
const storage = multer.memoryStorage();
|
||||
|
||||
// 文件过滤器
|
||||
const fileFilter = (req, file, cb) => {
|
||||
// 检查文件类型
|
||||
if (file.mimetype.startsWith('image/')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('只允许上传图片文件'), false);
|
||||
}
|
||||
};
|
||||
|
||||
// 配置 multer
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
fileFilter: fileFilter,
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024 // 5MB 限制
|
||||
}
|
||||
});
|
||||
|
||||
// 单文件上传到图床
|
||||
router.post('/single', authenticateToken, upload.single('file'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ code: 400, message: '没有上传文件' });
|
||||
}
|
||||
|
||||
// 直接使用图床上传函数(传入buffer数据)
|
||||
const result = await uploadToImageHost(
|
||||
req.file.buffer,
|
||||
req.file.originalname,
|
||||
req.file.mimetype
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
// 记录用户上传操作日志
|
||||
console.log(`单文件上传成功 - 用户ID: ${req.user.id}, 文件名: ${req.file.originalname}`);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '上传成功',
|
||||
data: {
|
||||
originalname: req.file.originalname,
|
||||
size: req.file.size,
|
||||
url: result.url
|
||||
}
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({ code: 400, message: result.message || '图床上传失败' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('单文件上传失败:', error);
|
||||
res.status(500).json({ code: 500, message: '上传失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 多文件上传到图床
|
||||
router.post('/multiple', authenticateToken, upload.array('files', 9), async (req, res) => {
|
||||
try {
|
||||
if (!req.files || req.files.length === 0) {
|
||||
return res.status(400).json({ code: 400, message: '没有上传文件' });
|
||||
}
|
||||
|
||||
const uploadResults = [];
|
||||
|
||||
for (const file of req.files) {
|
||||
const result = await uploadToImageHost(
|
||||
file.buffer,
|
||||
file.originalname,
|
||||
file.mimetype
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
uploadResults.push({
|
||||
originalname: file.originalname,
|
||||
size: file.size,
|
||||
url: result.url
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadResults.length === 0) {
|
||||
return res.status(400).json({ code: 400, message: '所有文件上传失败' });
|
||||
}
|
||||
|
||||
// 记录用户上传操作日志
|
||||
console.log(`多文件上传成功 - 用户ID: ${req.user.id}, 文件数量: ${uploadResults.length}`);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '上传成功',
|
||||
data: uploadResults
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('多文件上传失败:', error);
|
||||
res.status(500).json({ code: 500, message: '上传失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// Base64图片上传到图床
|
||||
router.post('/base64', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { images } = req.body;
|
||||
|
||||
if (!images || !Array.isArray(images) || images.length === 0) {
|
||||
return res.status(400).json({ code: 400, message: '没有提供图片数据' });
|
||||
}
|
||||
|
||||
const uploadResults = [];
|
||||
let processedCount = 0;
|
||||
|
||||
for (const base64Data of images) {
|
||||
processedCount++;
|
||||
|
||||
// 使用通用上传函数
|
||||
const result = await uploadBase64ToImageHost(base64Data);
|
||||
|
||||
if (result.success) {
|
||||
uploadResults.push(result.url);
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadResults.length === 0) {
|
||||
return res.status(400).json({ code: 400, message: '所有图片上传失败' });
|
||||
}
|
||||
|
||||
// 记录用户上传操作日志
|
||||
console.log(`Base64图片上传成功 - 用户ID: ${req.user.id}, 上传数量: ${uploadResults.length}`);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '上传成功',
|
||||
data: {
|
||||
urls: uploadResults,
|
||||
count: uploadResults.length
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Base64图片上传失败:', error);
|
||||
res.status(500).json({ code: 500, message: '上传失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 注意:使用云端图床后,文件删除由图床服务商管理
|
||||
|
||||
// 错误处理中间件
|
||||
router.use((error, req, res, next) => {
|
||||
if (error instanceof multer.MulterError) {
|
||||
if (error.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({ code: 400, message: '文件大小超过限制(5MB)' });
|
||||
}
|
||||
if (error.code === 'LIMIT_FILE_COUNT') {
|
||||
return res.status(400).json({ code: 400, message: '文件数量超过限制(9个)' });
|
||||
}
|
||||
}
|
||||
|
||||
if (error.message === '只允许上传图片文件') {
|
||||
return res.status(400).json({ code: 400, message: error.message });
|
||||
}
|
||||
|
||||
console.error('文件上传错误:', error);
|
||||
res.status(500).json({ code: 500, message: '文件上传失败' });
|
||||
});
|
||||
|
||||
module.exports = router;
|
1176
express-project/routes/users.js
Normal file
1225
express-project/scripts/generate-data.js
Normal file
378
express-project/scripts/init-database.js
Normal file
@@ -0,0 +1,378 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
const config = require('../config/config');
|
||||
|
||||
class DatabaseInitializer {
|
||||
constructor() {
|
||||
this.dbConfig = {
|
||||
host: config.database.host,
|
||||
user: config.database.user,
|
||||
password: config.database.password,
|
||||
port: config.database.port,
|
||||
charset: config.database.charset
|
||||
};
|
||||
}
|
||||
|
||||
async createDatabase() {
|
||||
const connection = await mysql.createConnection(this.dbConfig);
|
||||
|
||||
try {
|
||||
console.log('创建数据库...');
|
||||
await connection.execute(`CREATE DATABASE IF NOT EXISTS \`${config.database.database}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
|
||||
console.log(`数据库 ${config.database.database} 创建成功`);
|
||||
} catch (error) {
|
||||
console.error('创建数据库失败:', error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
async initializeTables() {
|
||||
const connection = await mysql.createConnection({
|
||||
...this.dbConfig,
|
||||
database: config.database.database
|
||||
});
|
||||
|
||||
try {
|
||||
console.log('开始创建数据表...');
|
||||
|
||||
// 创建用户表
|
||||
await this.createUsersTable(connection);
|
||||
|
||||
// 创建管理员表
|
||||
await this.createAdminTable(connection);
|
||||
|
||||
// 创建帖子表
|
||||
await this.createPostsTable(connection);
|
||||
|
||||
// 创建帖子图片表
|
||||
await this.createPostImagesTable(connection);
|
||||
|
||||
// 创建标签表
|
||||
await this.createTagsTable(connection);
|
||||
|
||||
// 创建帖子标签关联表
|
||||
await this.createPostTagsTable(connection);
|
||||
|
||||
// 创建关注关系表
|
||||
await this.createFollowsTable(connection);
|
||||
|
||||
// 创建点赞表
|
||||
await this.createLikesTable(connection);
|
||||
|
||||
// 创建收藏表
|
||||
await this.createCollectionsTable(connection);
|
||||
|
||||
// 创建评论表
|
||||
await this.createCommentsTable(connection);
|
||||
|
||||
// 创建通知表
|
||||
await this.createNotificationsTable(connection);
|
||||
|
||||
// 创建用户会话表
|
||||
await this.createUserSessionsTable(connection);
|
||||
|
||||
console.log('所有数据表创建完成!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('创建数据表失败:', error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
async createUsersTable(connection) {
|
||||
const sql = `
|
||||
CREATE TABLE IF NOT EXISTS \`users\` (
|
||||
\`id\` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '用户ID',
|
||||
\`password\` varchar(255) DEFAULT NULL COMMENT '密码',
|
||||
\`user_id\` varchar(50) NOT NULL COMMENT '小石榴号',
|
||||
\`nickname\` varchar(100) NOT NULL COMMENT '昵称',
|
||||
\`avatar\` varchar(500) DEFAULT NULL COMMENT '头像URL',
|
||||
\`bio\` text DEFAULT NULL COMMENT '个人简介',
|
||||
\`location\` varchar(100) DEFAULT NULL COMMENT 'IP属地',
|
||||
\`follow_count\` int(11) DEFAULT 0 COMMENT '关注数',
|
||||
\`fans_count\` int(11) DEFAULT 0 COMMENT '粉丝数',
|
||||
\`like_count\` int(11) DEFAULT 0 COMMENT '获赞数',
|
||||
\`is_active\` tinyint(1) DEFAULT 1 COMMENT '是否激活',
|
||||
\`last_login_at\` timestamp NULL DEFAULT NULL COMMENT '最后登录时间',
|
||||
\`created_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
\`updated_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
\`gender\` varchar(10) DEFAULT NULL COMMENT '性别',
|
||||
\`zodiac_sign\` varchar(20) DEFAULT NULL COMMENT '星座',
|
||||
\`mbti\` varchar(4) DEFAULT NULL COMMENT 'MBTI人格类型',
|
||||
\`education\` varchar(50) DEFAULT NULL COMMENT '学历',
|
||||
\`major\` varchar(100) DEFAULT NULL COMMENT '专业',
|
||||
\`interests\` json DEFAULT NULL COMMENT '兴趣爱好(JSON数组)',
|
||||
PRIMARY KEY (\`id\`),
|
||||
UNIQUE KEY \`user_id\` (\`user_id\`),
|
||||
KEY \`idx_user_id\` (\`user_id\`),
|
||||
KEY \`idx_created_at\` (\`created_at\`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
|
||||
`;
|
||||
await connection.execute(sql);
|
||||
console.log('✓ users 表创建成功');
|
||||
}
|
||||
|
||||
async createAdminTable(connection) {
|
||||
const sql = `
|
||||
CREATE TABLE IF NOT EXISTS \`admin\` (
|
||||
\`id\` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '管理员ID',
|
||||
\`username\` varchar(50) NOT NULL COMMENT '管理员用户名',
|
||||
\`password\` varchar(255) NOT NULL COMMENT '管理员密码',
|
||||
\`created_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
PRIMARY KEY (\`id\`),
|
||||
UNIQUE KEY \`username\` (\`username\`),
|
||||
KEY \`idx_admin_username\` (\`username\`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='管理员表';
|
||||
`;
|
||||
await connection.execute(sql);
|
||||
console.log('✓ admin 表创建成功');
|
||||
}
|
||||
|
||||
async createPostsTable(connection) {
|
||||
const sql = `
|
||||
CREATE TABLE IF NOT EXISTS \`posts\` (
|
||||
\`id\` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '帖子ID',
|
||||
\`user_id\` bigint(20) NOT NULL COMMENT '发布用户ID',
|
||||
\`title\` varchar(200) NOT NULL COMMENT '标题',
|
||||
\`content\` text NOT NULL COMMENT '内容',
|
||||
\`category\` varchar(50) DEFAULT NULL COMMENT '分类',
|
||||
\`view_count\` bigint(20) DEFAULT 0 COMMENT '浏览量',
|
||||
\`like_count\` int(11) DEFAULT 0 COMMENT '点赞数',
|
||||
\`collect_count\` int(11) DEFAULT 0 COMMENT '收藏数',
|
||||
\`comment_count\` int(11) DEFAULT 0 COMMENT '评论数',
|
||||
\`created_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '发布时间',
|
||||
\`is_draft\` tinyint(1) DEFAULT 1 COMMENT '是否为草稿:1-草稿,0-已发布',
|
||||
PRIMARY KEY (\`id\`),
|
||||
KEY \`idx_user_id\` (\`user_id\`),
|
||||
KEY \`idx_category\` (\`category\`),
|
||||
KEY \`idx_created_at\` (\`created_at\`),
|
||||
KEY \`idx_like_count\` (\`like_count\`),
|
||||
KEY \`idx_category_created_at\` (\`category\`, \`created_at\`),
|
||||
CONSTRAINT \`posts_ibfk_1\` FOREIGN KEY (\`user_id\`) REFERENCES \`users\` (\`id\`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='帖子表';
|
||||
`;
|
||||
await connection.execute(sql);
|
||||
console.log('✓ posts 表创建成功');
|
||||
}
|
||||
|
||||
async createPostImagesTable(connection) {
|
||||
const sql = `
|
||||
CREATE TABLE IF NOT EXISTS \`post_images\` (
|
||||
\`id\` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '图片ID',
|
||||
\`post_id\` bigint(20) NOT NULL COMMENT '帖子ID',
|
||||
\`image_url\` varchar(500) NOT NULL COMMENT '图片URL',
|
||||
PRIMARY KEY (\`id\`),
|
||||
KEY \`idx_post_id\` (\`post_id\`),
|
||||
CONSTRAINT \`post_images_ibfk_1\` FOREIGN KEY (\`post_id\`) REFERENCES \`posts\` (\`id\`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='帖子图片表';
|
||||
`;
|
||||
await connection.execute(sql);
|
||||
console.log('✓ post_images 表创建成功');
|
||||
}
|
||||
|
||||
async createTagsTable(connection) {
|
||||
const sql = `
|
||||
CREATE TABLE IF NOT EXISTS \`tags\` (
|
||||
\`id\` int(11) NOT NULL AUTO_INCREMENT COMMENT '标签ID',
|
||||
\`name\` varchar(50) NOT NULL COMMENT '标签名',
|
||||
\`use_count\` int(11) DEFAULT 0 COMMENT '使用次数',
|
||||
\`created_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
PRIMARY KEY (\`id\`),
|
||||
UNIQUE KEY \`name\` (\`name\`),
|
||||
KEY \`idx_name\` (\`name\`),
|
||||
KEY \`idx_use_count\` (\`use_count\`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='标签表';
|
||||
`;
|
||||
await connection.execute(sql);
|
||||
console.log('✓ tags 表创建成功');
|
||||
}
|
||||
|
||||
async createPostTagsTable(connection) {
|
||||
const sql = `
|
||||
CREATE TABLE IF NOT EXISTS \`post_tags\` (
|
||||
\`id\` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '关联ID',
|
||||
\`post_id\` bigint(20) NOT NULL COMMENT '帖子ID',
|
||||
\`tag_id\` int(11) NOT NULL COMMENT '标签ID',
|
||||
\`created_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
PRIMARY KEY (\`id\`),
|
||||
UNIQUE KEY \`uk_post_tag\` (\`post_id\`, \`tag_id\`),
|
||||
KEY \`idx_post_id\` (\`post_id\`),
|
||||
KEY \`idx_tag_id\` (\`tag_id\`),
|
||||
CONSTRAINT \`post_tags_ibfk_1\` FOREIGN KEY (\`post_id\`) REFERENCES \`posts\` (\`id\`) ON DELETE CASCADE,
|
||||
CONSTRAINT \`post_tags_ibfk_2\` FOREIGN KEY (\`tag_id\`) REFERENCES \`tags\` (\`id\`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='帖子标签关联表';
|
||||
`;
|
||||
await connection.execute(sql);
|
||||
console.log('✓ post_tags 表创建成功');
|
||||
}
|
||||
|
||||
async createFollowsTable(connection) {
|
||||
const sql = `
|
||||
CREATE TABLE IF NOT EXISTS \`follows\` (
|
||||
\`id\` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '关注ID',
|
||||
\`follower_id\` bigint(20) NOT NULL COMMENT '关注者ID',
|
||||
\`following_id\` bigint(20) NOT NULL COMMENT '被关注者ID',
|
||||
\`created_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '关注时间',
|
||||
PRIMARY KEY (\`id\`),
|
||||
UNIQUE KEY \`uk_follow\` (\`follower_id\`, \`following_id\`),
|
||||
KEY \`idx_follower_id\` (\`follower_id\`),
|
||||
KEY \`idx_following_id\` (\`following_id\`),
|
||||
KEY \`idx_follower_following\` (\`follower_id\`, \`following_id\`),
|
||||
CONSTRAINT \`follows_ibfk_1\` FOREIGN KEY (\`follower_id\`) REFERENCES \`users\` (\`id\`) ON DELETE CASCADE,
|
||||
CONSTRAINT \`follows_ibfk_2\` FOREIGN KEY (\`following_id\`) REFERENCES \`users\` (\`id\`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='关注关系表';
|
||||
`;
|
||||
await connection.execute(sql);
|
||||
console.log('✓ follows 表创建成功');
|
||||
}
|
||||
|
||||
async createLikesTable(connection) {
|
||||
const sql = `
|
||||
CREATE TABLE IF NOT EXISTS \`likes\` (
|
||||
\`id\` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '点赞ID',
|
||||
\`user_id\` bigint(20) NOT NULL COMMENT '用户ID',
|
||||
\`target_type\` tinyint(4) NOT NULL COMMENT '目标类型: 1-帖子, 2-评论',
|
||||
\`target_id\` bigint(20) NOT NULL COMMENT '目标ID',
|
||||
\`created_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '点赞时间',
|
||||
PRIMARY KEY (\`id\`),
|
||||
UNIQUE KEY \`uk_user_target\` (\`user_id\`, \`target_type\`, \`target_id\`),
|
||||
KEY \`idx_user_id\` (\`user_id\`),
|
||||
KEY \`idx_target\` (\`target_type\`, \`target_id\`),
|
||||
KEY \`idx_user_target_type\` (\`user_id\`, \`target_type\`, \`target_id\`),
|
||||
CONSTRAINT \`likes_ibfk_1\` FOREIGN KEY (\`user_id\`) REFERENCES \`users\` (\`id\`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='点赞表';
|
||||
`;
|
||||
await connection.execute(sql);
|
||||
console.log('✓ likes 表创建成功');
|
||||
}
|
||||
|
||||
async createCollectionsTable(connection) {
|
||||
const sql = `
|
||||
CREATE TABLE IF NOT EXISTS \`collections\` (
|
||||
\`id\` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '收藏ID',
|
||||
\`user_id\` bigint(20) NOT NULL COMMENT '用户ID',
|
||||
\`post_id\` bigint(20) NOT NULL COMMENT '帖子ID',
|
||||
\`created_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '收藏时间',
|
||||
PRIMARY KEY (\`id\`),
|
||||
UNIQUE KEY \`uk_user_post\` (\`user_id\`, \`post_id\`),
|
||||
KEY \`idx_user_id\` (\`user_id\`),
|
||||
KEY \`idx_post_id\` (\`post_id\`),
|
||||
CONSTRAINT \`collections_ibfk_1\` FOREIGN KEY (\`user_id\`) REFERENCES \`users\` (\`id\`) ON DELETE CASCADE,
|
||||
CONSTRAINT \`collections_ibfk_2\` FOREIGN KEY (\`post_id\`) REFERENCES \`posts\` (\`id\`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收藏表';
|
||||
`;
|
||||
await connection.execute(sql);
|
||||
console.log('✓ collections 表创建成功');
|
||||
}
|
||||
|
||||
async createCommentsTable(connection) {
|
||||
const sql = `
|
||||
CREATE TABLE IF NOT EXISTS \`comments\` (
|
||||
\`id\` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '评论ID',
|
||||
\`post_id\` bigint(20) NOT NULL COMMENT '帖子ID',
|
||||
\`user_id\` bigint(20) NOT NULL COMMENT '评论用户ID',
|
||||
\`parent_id\` bigint(20) DEFAULT NULL COMMENT '父评论ID',
|
||||
\`content\` text NOT NULL COMMENT '评论内容',
|
||||
\`like_count\` int(11) DEFAULT 0 COMMENT '点赞数',
|
||||
\`created_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '评论时间',
|
||||
PRIMARY KEY (\`id\`),
|
||||
KEY \`idx_post_id\` (\`post_id\`),
|
||||
KEY \`idx_user_id\` (\`user_id\`),
|
||||
KEY \`idx_parent_id\` (\`parent_id\`),
|
||||
KEY \`idx_created_at\` (\`created_at\`),
|
||||
CONSTRAINT \`comments_ibfk_1\` FOREIGN KEY (\`post_id\`) REFERENCES \`posts\` (\`id\`) ON DELETE CASCADE,
|
||||
CONSTRAINT \`comments_ibfk_2\` FOREIGN KEY (\`user_id\`) REFERENCES \`users\` (\`id\`) ON DELETE CASCADE,
|
||||
CONSTRAINT \`comments_ibfk_3\` FOREIGN KEY (\`parent_id\`) REFERENCES \`comments\` (\`id\`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='评论表';
|
||||
`;
|
||||
await connection.execute(sql);
|
||||
console.log('✓ comments 表创建成功');
|
||||
}
|
||||
|
||||
async createNotificationsTable(connection) {
|
||||
const sql = `
|
||||
CREATE TABLE IF NOT EXISTS \`notifications\` (
|
||||
\`id\` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '通知ID',
|
||||
\`user_id\` bigint(20) NOT NULL COMMENT '接收用户ID',
|
||||
\`sender_id\` bigint(20) NOT NULL COMMENT '发送用户ID',
|
||||
\`type\` tinyint(4) NOT NULL COMMENT '通知类型: 1-点赞, 2-评论, 3-关注',
|
||||
\`title\` varchar(200) NOT NULL COMMENT '通知标题',
|
||||
\`target_id\` bigint(20) DEFAULT NULL COMMENT '关联目标ID',
|
||||
\`comment_id\` bigint(20) DEFAULT NULL COMMENT '关联评论ID,用于评论和回复通知',
|
||||
\`is_read\` tinyint(1) DEFAULT 0 COMMENT '是否已读',
|
||||
\`created_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '通知时间',
|
||||
PRIMARY KEY (\`id\`),
|
||||
KEY \`idx_user_id\` (\`user_id\`),
|
||||
KEY \`idx_sender_id\` (\`sender_id\`),
|
||||
KEY \`idx_type\` (\`type\`),
|
||||
KEY \`idx_is_read\` (\`is_read\`),
|
||||
KEY \`idx_user_read\` (\`user_id\`, \`is_read\`),
|
||||
KEY \`idx_created_at\` (\`created_at\`),
|
||||
KEY \`idx_notifications_comment_id\` (\`comment_id\`),
|
||||
CONSTRAINT \`notifications_ibfk_1\` FOREIGN KEY (\`user_id\`) REFERENCES \`users\` (\`id\`) ON DELETE CASCADE,
|
||||
CONSTRAINT \`notifications_ibfk_2\` FOREIGN KEY (\`sender_id\`) REFERENCES \`users\` (\`id\`) ON DELETE CASCADE,
|
||||
CONSTRAINT \`fk_notifications_comment_id\` FOREIGN KEY (\`comment_id\`) REFERENCES \`comments\` (\`id\`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='通知表';
|
||||
`;
|
||||
await connection.execute(sql);
|
||||
console.log('✓ notifications 表创建成功');
|
||||
}
|
||||
|
||||
async createUserSessionsTable(connection) {
|
||||
const sql = `
|
||||
CREATE TABLE IF NOT EXISTS \`user_sessions\` (
|
||||
\`id\` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '会话ID',
|
||||
\`user_id\` bigint(20) NOT NULL COMMENT '用户ID',
|
||||
\`token\` varchar(255) NOT NULL COMMENT '访问令牌',
|
||||
\`refresh_token\` varchar(255) DEFAULT NULL COMMENT '刷新令牌',
|
||||
\`expires_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '过期时间',
|
||||
\`user_agent\` text DEFAULT NULL COMMENT '用户代理',
|
||||
\`is_active\` tinyint(1) DEFAULT 1 COMMENT '是否激活',
|
||||
\`created_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
\`updated_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (\`id\`),
|
||||
UNIQUE KEY \`token\` (\`token\`),
|
||||
KEY \`idx_user_id\` (\`user_id\`),
|
||||
KEY \`idx_token\` (\`token\`),
|
||||
KEY \`idx_expires_at\` (\`expires_at\`),
|
||||
CONSTRAINT \`user_sessions_ibfk_1\` FOREIGN KEY (\`user_id\`) REFERENCES \`users\` (\`id\`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户会话表';
|
||||
`;
|
||||
await connection.execute(sql);
|
||||
console.log('✓ user_sessions 表创建成功');
|
||||
}
|
||||
|
||||
async run() {
|
||||
try {
|
||||
console.log('=== 小石榴图文社区数据库初始化 ===\n');
|
||||
|
||||
// 创建数据库
|
||||
await this.createDatabase();
|
||||
|
||||
// 创建表结构
|
||||
await this.initializeTables();
|
||||
|
||||
console.log('\n=== 数据库初始化完成 ===');
|
||||
console.log('数据库名称:', config.database.database);
|
||||
console.log('字符集: utf8mb4');
|
||||
console.log('排序规则: utf8mb4_unicode_ci');
|
||||
console.log('存储引擎: InnoDB');
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n=== 数据库初始化失败 ===');
|
||||
console.error('错误信息:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果直接运行此脚本
|
||||
if (require.main === module) {
|
||||
const initializer = new DatabaseInitializer();
|
||||
initializer.run();
|
||||
}
|
||||
|
||||
module.exports = DatabaseInitializer;
|
215
express-project/scripts/init-database.sql
Normal file
@@ -0,0 +1,215 @@
|
||||
-- 小石榴图文社区数据库初始化脚本
|
||||
-- 创建数据库(如果不存在)
|
||||
CREATE DATABASE IF NOT EXISTS `xiaoshiliu_community` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
USE `xiaoshiliu_community`;
|
||||
|
||||
-- 1. 用户表
|
||||
CREATE TABLE IF NOT EXISTS `users` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '用户ID',
|
||||
`password` varchar(255) DEFAULT NULL COMMENT '密码',
|
||||
`user_id` varchar(50) NOT NULL COMMENT '小石榴号',
|
||||
`nickname` varchar(100) NOT NULL COMMENT '昵称',
|
||||
`avatar` varchar(500) DEFAULT NULL COMMENT '头像URL',
|
||||
`bio` text DEFAULT NULL COMMENT '个人简介',
|
||||
`location` varchar(100) DEFAULT NULL COMMENT 'IP属地',
|
||||
`follow_count` int(11) DEFAULT 0 COMMENT '关注数',
|
||||
`fans_count` int(11) DEFAULT 0 COMMENT '粉丝数',
|
||||
`like_count` int(11) DEFAULT 0 COMMENT '获赞数',
|
||||
`is_active` tinyint(1) DEFAULT 1 COMMENT '是否激活',
|
||||
`last_login_at` timestamp NULL DEFAULT NULL COMMENT '最后登录时间',
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`gender` varchar(10) DEFAULT NULL COMMENT '性别',
|
||||
`zodiac_sign` varchar(20) DEFAULT NULL COMMENT '星座',
|
||||
`mbti` varchar(4) DEFAULT NULL COMMENT 'MBTI人格类型',
|
||||
`education` varchar(50) DEFAULT NULL COMMENT '学历',
|
||||
`major` varchar(100) DEFAULT NULL COMMENT '专业',
|
||||
`interests` json DEFAULT NULL COMMENT '兴趣爱好(JSON数组)',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `user_id` (`user_id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_created_at` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
|
||||
|
||||
-- 2. 管理员表
|
||||
CREATE TABLE IF NOT EXISTS `admin` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '管理员ID',
|
||||
`username` varchar(50) NOT NULL COMMENT '管理员用户名',
|
||||
`password` varchar(255) NOT NULL COMMENT '管理员密码',
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `username` (`username`),
|
||||
KEY `idx_admin_username` (`username`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='管理员表';
|
||||
|
||||
-- 3. 帖子表
|
||||
CREATE TABLE IF NOT EXISTS `posts` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '帖子ID',
|
||||
`user_id` bigint(20) NOT NULL COMMENT '发布用户ID',
|
||||
`title` varchar(200) NOT NULL COMMENT '标题',
|
||||
`content` text NOT NULL COMMENT '内容',
|
||||
`category` varchar(50) DEFAULT NULL COMMENT '分类',
|
||||
`view_count` bigint(20) DEFAULT 0 COMMENT '浏览量',
|
||||
`like_count` int(11) DEFAULT 0 COMMENT '点赞数',
|
||||
`collect_count` int(11) DEFAULT 0 COMMENT '收藏数',
|
||||
`comment_count` int(11) DEFAULT 0 COMMENT '评论数',
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '发布时间',
|
||||
`is_draft` tinyint(1) DEFAULT 1 COMMENT '是否为草稿:1-草稿,0-已发布',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_category` (`category`),
|
||||
KEY `idx_created_at` (`created_at`),
|
||||
KEY `idx_like_count` (`like_count`),
|
||||
KEY `idx_category_created_at` (`category`, `created_at`),
|
||||
CONSTRAINT `posts_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='帖子表';
|
||||
|
||||
-- 4. 帖子图片表
|
||||
CREATE TABLE IF NOT EXISTS `post_images` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '图片ID',
|
||||
`post_id` bigint(20) NOT NULL COMMENT '帖子ID',
|
||||
`image_url` varchar(500) NOT NULL COMMENT '图片URL',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_post_id` (`post_id`),
|
||||
CONSTRAINT `post_images_ibfk_1` FOREIGN KEY (`post_id`) REFERENCES `posts` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='帖子图片表';
|
||||
|
||||
-- 5. 标签表
|
||||
CREATE TABLE IF NOT EXISTS `tags` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '标签ID',
|
||||
`name` varchar(50) NOT NULL COMMENT '标签名',
|
||||
`use_count` int(11) DEFAULT 0 COMMENT '使用次数',
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `name` (`name`),
|
||||
KEY `idx_name` (`name`),
|
||||
KEY `idx_use_count` (`use_count`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='标签表';
|
||||
|
||||
-- 6. 帖子标签关联表
|
||||
CREATE TABLE IF NOT EXISTS `post_tags` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '关联ID',
|
||||
`post_id` bigint(20) NOT NULL COMMENT '帖子ID',
|
||||
`tag_id` int(11) NOT NULL COMMENT '标签ID',
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_post_tag` (`post_id`, `tag_id`),
|
||||
KEY `idx_post_id` (`post_id`),
|
||||
KEY `idx_tag_id` (`tag_id`),
|
||||
CONSTRAINT `post_tags_ibfk_1` FOREIGN KEY (`post_id`) REFERENCES `posts` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `post_tags_ibfk_2` FOREIGN KEY (`tag_id`) REFERENCES `tags` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='帖子标签关联表';
|
||||
|
||||
-- 7. 关注关系表
|
||||
CREATE TABLE IF NOT EXISTS `follows` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '关注ID',
|
||||
`follower_id` bigint(20) NOT NULL COMMENT '关注者ID',
|
||||
`following_id` bigint(20) NOT NULL COMMENT '被关注者ID',
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '关注时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_follow` (`follower_id`, `following_id`),
|
||||
KEY `idx_follower_id` (`follower_id`),
|
||||
KEY `idx_following_id` (`following_id`),
|
||||
KEY `idx_follower_following` (`follower_id`, `following_id`),
|
||||
CONSTRAINT `follows_ibfk_1` FOREIGN KEY (`follower_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `follows_ibfk_2` FOREIGN KEY (`following_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='关注关系表';
|
||||
|
||||
-- 8. 点赞表
|
||||
CREATE TABLE IF NOT EXISTS `likes` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '点赞ID',
|
||||
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
|
||||
`target_type` tinyint(4) NOT NULL COMMENT '目标类型: 1-帖子, 2-评论',
|
||||
`target_id` bigint(20) NOT NULL COMMENT '目标ID',
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '点赞时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_user_target` (`user_id`, `target_type`, `target_id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_target` (`target_type`, `target_id`),
|
||||
KEY `idx_user_target_type` (`user_id`, `target_type`, `target_id`),
|
||||
CONSTRAINT `likes_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='点赞表';
|
||||
|
||||
-- 9. 收藏表
|
||||
CREATE TABLE IF NOT EXISTS `collections` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '收藏ID',
|
||||
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
|
||||
`post_id` bigint(20) NOT NULL COMMENT '帖子ID',
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '收藏时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_user_post` (`user_id`, `post_id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_post_id` (`post_id`),
|
||||
CONSTRAINT `collections_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `collections_ibfk_2` FOREIGN KEY (`post_id`) REFERENCES `posts` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收藏表';
|
||||
|
||||
-- 10. 评论表
|
||||
CREATE TABLE IF NOT EXISTS `comments` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '评论ID',
|
||||
`post_id` bigint(20) NOT NULL COMMENT '帖子ID',
|
||||
`user_id` bigint(20) NOT NULL COMMENT '评论用户ID',
|
||||
`parent_id` bigint(20) DEFAULT NULL COMMENT '父评论ID',
|
||||
`content` text NOT NULL COMMENT '评论内容',
|
||||
`like_count` int(11) DEFAULT 0 COMMENT '点赞数',
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '评论时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_post_id` (`post_id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_parent_id` (`parent_id`),
|
||||
KEY `idx_created_at` (`created_at`),
|
||||
CONSTRAINT `comments_ibfk_1` FOREIGN KEY (`post_id`) REFERENCES `posts` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `comments_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `comments_ibfk_3` FOREIGN KEY (`parent_id`) REFERENCES `comments` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='评论表';
|
||||
|
||||
-- 11. 通知表
|
||||
CREATE TABLE IF NOT EXISTS `notifications` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '通知ID',
|
||||
`user_id` bigint(20) NOT NULL COMMENT '接收用户ID',
|
||||
`sender_id` bigint(20) NOT NULL COMMENT '发送用户ID',
|
||||
`type` tinyint(4) NOT NULL COMMENT '通知类型: 1-点赞, 2-评论, 3-关注',
|
||||
`title` varchar(200) NOT NULL COMMENT '通知标题',
|
||||
`target_id` bigint(20) DEFAULT NULL COMMENT '关联目标ID',
|
||||
`comment_id` bigint(20) DEFAULT NULL COMMENT '关联评论ID,用于评论和回复通知',
|
||||
`is_read` tinyint(1) DEFAULT 0 COMMENT '是否已读',
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '通知时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_sender_id` (`sender_id`),
|
||||
KEY `idx_type` (`type`),
|
||||
KEY `idx_is_read` (`is_read`),
|
||||
KEY `idx_user_read` (`user_id`, `is_read`),
|
||||
KEY `idx_created_at` (`created_at`),
|
||||
KEY `idx_notifications_comment_id` (`comment_id`),
|
||||
CONSTRAINT `notifications_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `notifications_ibfk_2` FOREIGN KEY (`sender_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_notifications_comment_id` FOREIGN KEY (`comment_id`) REFERENCES `comments` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='通知表';
|
||||
|
||||
-- 12. 用户会话表
|
||||
CREATE TABLE IF NOT EXISTS `user_sessions` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '会话ID',
|
||||
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
|
||||
`token` varchar(255) NOT NULL COMMENT '访问令牌',
|
||||
`refresh_token` varchar(255) DEFAULT NULL COMMENT '刷新令牌',
|
||||
`expires_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '过期时间',
|
||||
`user_agent` text DEFAULT NULL COMMENT '用户代理',
|
||||
`is_active` tinyint(1) DEFAULT 1 COMMENT '是否激活',
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `token` (`token`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_token` (`token`),
|
||||
KEY `idx_expires_at` (`expires_at`),
|
||||
CONSTRAINT `user_sessions_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户会话表';
|
||||
|
||||
-- 插入默认管理员账号(密码: admin123,使用SHA2哈希加密)
|
||||
INSERT INTO `admin` (`username`, `password`) VALUES
|
||||
('admin', SHA2('admin123', 256));
|
||||
|
||||
-- 数据库初始化完成
|
||||
SELECT '数据库初始化完成!' AS message;
|
||||
SELECT COUNT(*) AS table_count FROM information_schema.tables WHERE table_schema = 'xiaoshiliu_community';
|
1
express-project/scripts/tempCodeRunnerFile.js
Normal file
@@ -0,0 +1 @@
|
||||
console.log(`
|
247
express-project/scripts/update-sample-images.js
Normal file
@@ -0,0 +1,247 @@
|
||||
//该文件用于更新数据库中来自栗次元api的图片链接
|
||||
const mysql = require('mysql2/promise');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
|
||||
// 数据库配置
|
||||
const dbConfig = {
|
||||
host: 'localhost',
|
||||
user: 'root',
|
||||
password: '123456',
|
||||
database: 'xiaoshiliu',
|
||||
charset: 'utf8mb4'
|
||||
};
|
||||
|
||||
// 图片更新器
|
||||
class ImageUpdater {
|
||||
constructor() {
|
||||
// 初始化时不读取文件,而是先更新链接文件
|
||||
this.newAvatarLinks = [];
|
||||
this.newImageLinks = [];
|
||||
}
|
||||
|
||||
// 从文件加载链接
|
||||
loadLinksFromFile(filename) {
|
||||
try {
|
||||
const filePath = path.join(__dirname, filename);
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
return content.trim().split('\n').filter(link => link.trim());
|
||||
} catch (error) {
|
||||
console.error(`读取文件 ${filename} 失败:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 获取随机图片URL
|
||||
getRandomImageUrl(imageType = 'post') {
|
||||
const links = imageType === 'avatar' ? this.newAvatarLinks : this.newImageLinks;
|
||||
if (links.length === 0) {
|
||||
console.warn(`没有可用的${imageType === 'avatar' ? '头像' : '帖子'}图片链接`);
|
||||
return null;
|
||||
}
|
||||
const randomIndex = Math.floor(Math.random() * links.length);
|
||||
return links[randomIndex];
|
||||
}
|
||||
|
||||
// 检查URL是否为tc.alcy.cc图床的图片
|
||||
isTcAlcyImage(url) {
|
||||
return url && typeof url === 'string' && url.includes('tc.alcy.cc');
|
||||
}
|
||||
|
||||
// 更新用户头像
|
||||
async updateUserAvatars(connection) {
|
||||
console.log('开始更新用户头像...');
|
||||
|
||||
try {
|
||||
// 查询所有使用tc.alcy.cc图床的用户头像
|
||||
const [users] = await connection.execute(
|
||||
'SELECT id, avatar FROM users WHERE avatar LIKE "%tc.alcy.cc%"'
|
||||
);
|
||||
|
||||
console.log(`找到 ${users.length} 个需要更新的用户头像`);
|
||||
|
||||
let updatedCount = 0;
|
||||
for (const user of users) {
|
||||
if (this.isTcAlcyImage(user.avatar)) {
|
||||
const newAvatarUrl = this.getRandomImageUrl('avatar');
|
||||
if (newAvatarUrl) {
|
||||
await connection.execute(
|
||||
'UPDATE users SET avatar = ? WHERE id = ?',
|
||||
[newAvatarUrl, user.id]
|
||||
);
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`用户头像更新完成,共更新 ${updatedCount} 个头像`);
|
||||
} catch (error) {
|
||||
console.error('❌ 更新用户头像失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新帖子图片
|
||||
async updatePostImages(connection) {
|
||||
console.log('开始更新帖子图片...');
|
||||
|
||||
try {
|
||||
// 查询所有使用tc.alcy.cc图床的帖子图片
|
||||
const [images] = await connection.execute(
|
||||
'SELECT id, image_url FROM post_images WHERE image_url LIKE "%tc.alcy.cc%"'
|
||||
);
|
||||
|
||||
console.log(`找到 ${images.length} 个需要更新的帖子图片`);
|
||||
|
||||
let updatedCount = 0;
|
||||
for (const image of images) {
|
||||
if (this.isTcAlcyImage(image.image_url)) {
|
||||
const newImageUrl = this.getRandomImageUrl('post');
|
||||
if (newImageUrl) {
|
||||
await connection.execute(
|
||||
'UPDATE post_images SET image_url = ? WHERE id = ?',
|
||||
[newImageUrl, image.id]
|
||||
);
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`帖子图片更新完成,共更新 ${updatedCount} 个图片`);
|
||||
} catch (error) {
|
||||
console.error('❌ 更新帖子图片失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 统计更新信息
|
||||
async printUpdateStats(connection) {
|
||||
console.log('\n更新统计信息:');
|
||||
|
||||
try {
|
||||
// 统计用户头像
|
||||
const [avatarStats] = await connection.execute(
|
||||
'SELECT COUNT(*) as total, SUM(CASE WHEN avatar LIKE "%tc.alcy.cc%" THEN 1 ELSE 0 END) as tc_alcy_count FROM users WHERE avatar IS NOT NULL'
|
||||
);
|
||||
|
||||
// 统计帖子图片
|
||||
const [imageStats] = await connection.execute(
|
||||
'SELECT COUNT(*) as total, SUM(CASE WHEN image_url LIKE "%tc.alcy.cc%" THEN 1 ELSE 0 END) as tc_alcy_count FROM post_images'
|
||||
);
|
||||
|
||||
console.log(`用户头像: 总计 ${avatarStats[0].total} 个,其中 ${avatarStats[0].tc_alcy_count} 个来自 tc.alcy.cc`);
|
||||
console.log(`帖子图片: 总计 ${imageStats[0].total} 个,其中 ${imageStats[0].tc_alcy_count} 个来自 tc.alcy.cc`);
|
||||
} catch (error) {
|
||||
console.error('❌ 获取统计信息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 从API获取图片链接
|
||||
async fetchImageLinks(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(url, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const links = data.trim().split('\n').filter(link => link.trim());
|
||||
resolve(links);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}).on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 更新图片链接文件
|
||||
async updateImageLinkFiles() {
|
||||
try {
|
||||
// 清空并获取头像链接
|
||||
const avatarLinks = await this.fetchImageLinks('https://t.alcy.cc/tx/?json&quantity=50');
|
||||
const avatarFilePath = path.join(__dirname, '../imgLinks/avatar_link.txt');
|
||||
fs.writeFileSync(avatarFilePath, avatarLinks.join('\n'), 'utf8');
|
||||
// 清空并获取帖子图片链接
|
||||
const postLinks1 = await this.fetchImageLinks('https://t.alcy.cc/moemp/?json&quantity=100');
|
||||
const postLinks2 = await this.fetchImageLinks('https://t.alcy.cc/mp/?json&quantity=200');
|
||||
const allPostLinks = [...postLinks1, ...postLinks2];
|
||||
const postFilePath = path.join(__dirname, '../imgLinks/post_img_link.txt');
|
||||
fs.writeFileSync(postFilePath, allPostLinks.join('\n'), 'utf8');
|
||||
// 更新内存中的链接数组
|
||||
this.newAvatarLinks = avatarLinks;
|
||||
this.newImageLinks = allPostLinks;
|
||||
|
||||
console.log('图片链接文件更新完成\n');
|
||||
} catch (error) {
|
||||
console.error('❌ 更新图片链接文件失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 执行更新
|
||||
async updateImages() {
|
||||
// 首先更新图片链接文件
|
||||
await this.updateImageLinkFiles();
|
||||
console.log(`可用头像链接: ${this.newAvatarLinks.length} 个`);
|
||||
console.log(`可用帖子图片链接: ${this.newImageLinks.length} 个\n`);
|
||||
|
||||
if (this.newAvatarLinks.length === 0 && this.newImageLinks.length === 0) {
|
||||
console.error('❌ 没有可用的图片链接,请检查图片链接文件');
|
||||
return;
|
||||
}
|
||||
|
||||
let connection;
|
||||
try {
|
||||
// 连接数据库
|
||||
connection = await mysql.createConnection(dbConfig);
|
||||
console.log('数据库连接成功\n');
|
||||
|
||||
// 显示更新前的统计信息
|
||||
console.log('更新前的统计信息:');
|
||||
await this.printUpdateStats(connection);
|
||||
console.log('');
|
||||
|
||||
// 更新用户头像
|
||||
if (this.newAvatarLinks.length > 0) {
|
||||
await this.updateUserAvatars(connection);
|
||||
console.log('');
|
||||
} else {
|
||||
console.log('⚠️ 跳过用户头像更新(没有可用的头像链接)\n');
|
||||
}
|
||||
|
||||
// 更新帖子图片
|
||||
if (this.newImageLinks.length > 0) {
|
||||
await this.updatePostImages(connection);
|
||||
console.log('');
|
||||
} else {
|
||||
console.log('⚠️ 跳过帖子图片更新(没有可用的帖子图片链接)\n');
|
||||
}
|
||||
|
||||
// 显示更新后的统计信息
|
||||
console.log('更新后的统计信息:');
|
||||
await this.printUpdateStats(connection);
|
||||
|
||||
console.log('\n图片更新完成!');
|
||||
} catch (error) {
|
||||
console.error('❌ 更新过程中发生错误:', error);
|
||||
} finally {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
console.log('\n数据库连接已关闭');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果直接运行此脚本
|
||||
if (require.main === module) {
|
||||
const updater = new ImageUpdater();
|
||||
updater.updateImages();
|
||||
}
|
||||
|
||||
module.exports = ImageUpdater;
|
227
express-project/utils/dbHelper.js
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* 通用数据库操作工具
|
||||
*/
|
||||
const { pool } = require('../config/database')
|
||||
|
||||
/**
|
||||
* 检查记录是否存在
|
||||
* @param {string} table - 表名
|
||||
* @param {string} field - 字段名
|
||||
* @param {*} value - 字段值
|
||||
* @returns {Promise<boolean>} 是否存在
|
||||
*/
|
||||
async function recordExists(table, field, value) {
|
||||
const [result] = await pool.execute(
|
||||
`SELECT 1 FROM ${table} WHERE ${field} = ? LIMIT 1`,
|
||||
[value]
|
||||
)
|
||||
return result.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查多个记录是否存在
|
||||
* @param {string} table - 表名
|
||||
* @param {string} field - 字段名
|
||||
* @param {Array} values - 字段值数组
|
||||
* @returns {Promise<Object>} {existingCount: number, missingValues: Array}
|
||||
*/
|
||||
async function recordsExist(table, field, values) {
|
||||
if (!values || values.length === 0) {
|
||||
return { existingCount: 0, missingValues: [] }
|
||||
}
|
||||
|
||||
const placeholders = values.map(() => '?').join(',')
|
||||
const [result] = await pool.execute(
|
||||
`SELECT ${field} FROM ${table} WHERE ${field} IN (${placeholders})`,
|
||||
values
|
||||
)
|
||||
|
||||
const existingValues = result.map(row => row[field])
|
||||
const missingValues = values.filter(value => !existingValues.includes(value))
|
||||
|
||||
return {
|
||||
existingCount: existingValues.length,
|
||||
missingValues
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查唯一性约束
|
||||
* @param {string} table - 表名
|
||||
* @param {string} field - 字段名
|
||||
* @param {*} value - 字段值
|
||||
* @param {number} excludeId - 排除的ID(用于更新操作)
|
||||
* @returns {Promise<boolean>} 是否唯一
|
||||
*/
|
||||
async function isUnique(table, field, value, excludeId = null) {
|
||||
let query = `SELECT 1 FROM ${table} WHERE ${field} = ?`
|
||||
const params = [value]
|
||||
|
||||
if (excludeId) {
|
||||
query += ' AND id != ?'
|
||||
params.push(excludeId)
|
||||
}
|
||||
|
||||
const [result] = await pool.execute(query, params)
|
||||
return result.length === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建记录
|
||||
* @param {string} table - 表名
|
||||
* @param {Object} data - 数据对象
|
||||
* @returns {Promise<number>} 插入的ID
|
||||
*/
|
||||
async function createRecord(table, data) {
|
||||
const fields = Object.keys(data)
|
||||
const values = Object.values(data)
|
||||
const placeholders = fields.map(() => '?').join(',')
|
||||
|
||||
const query = `INSERT INTO ${table} (${fields.join(',')}) VALUES (${placeholders})`
|
||||
const [result] = await pool.execute(query, values)
|
||||
|
||||
return result.insertId
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新记录
|
||||
* @param {string} table - 表名
|
||||
* @param {number} id - 记录ID
|
||||
* @param {Object} data - 更新数据
|
||||
* @returns {Promise<number>} 影响的行数
|
||||
*/
|
||||
async function updateRecord(table, id, data) {
|
||||
const fields = Object.keys(data)
|
||||
const values = Object.values(data)
|
||||
const setClause = fields.map(field => `${field} = ?`).join(', ')
|
||||
|
||||
const query = `UPDATE ${table} SET ${setClause} WHERE id = ?`
|
||||
const [result] = await pool.execute(query, [...values, id])
|
||||
|
||||
return result.affectedRows
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除记录
|
||||
* @param {string} table - 表名
|
||||
* @param {number} id - 记录ID
|
||||
* @returns {Promise<number>} 影响的行数
|
||||
*/
|
||||
async function deleteRecord(table, id) {
|
||||
const [result] = await pool.execute(`DELETE FROM ${table} WHERE id = ?`, [id])
|
||||
return result.affectedRows
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除记录
|
||||
* @param {string} table - 表名
|
||||
* @param {Array} ids - ID数组
|
||||
* @returns {Promise<number>} 影响的行数
|
||||
*/
|
||||
async function deleteRecords(table, ids) {
|
||||
if (!ids || ids.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const placeholders = ids.map(() => '?').join(',')
|
||||
const [result] = await pool.execute(
|
||||
`DELETE FROM ${table} WHERE id IN (${placeholders})`,
|
||||
ids
|
||||
)
|
||||
|
||||
return result.affectedRows
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取记录详情
|
||||
* @param {string} table - 表名
|
||||
* @param {number} id - 记录ID
|
||||
* @param {string} fields - 要查询的字段,默认为*
|
||||
* @returns {Promise<Object|null>} 记录对象或null
|
||||
*/
|
||||
async function getRecord(table, id, fields = '*') {
|
||||
const [result] = await pool.execute(
|
||||
`SELECT ${fields} FROM ${table} WHERE id = ? LIMIT 1`,
|
||||
[id]
|
||||
)
|
||||
|
||||
return result.length > 0 ? result[0] : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分页记录列表
|
||||
* @param {string} table - 表名
|
||||
* @param {Object} options - 查询选项
|
||||
* @param {number} options.page - 页码
|
||||
* @param {number} options.limit - 每页数量
|
||||
* @param {string} options.where - WHERE条件
|
||||
* @param {Array} options.params - 查询参数
|
||||
* @param {string} options.orderBy - 排序字段
|
||||
* @param {string} options.fields - 查询字段
|
||||
* @returns {Promise<Object>} {data: Array, total: number, page: number, limit: number}
|
||||
*/
|
||||
async function getRecords(table, options = {}) {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
where = '',
|
||||
params = [],
|
||||
orderBy = 'created_at DESC',
|
||||
fields = '*'
|
||||
} = options
|
||||
|
||||
const offset = (page - 1) * limit
|
||||
|
||||
// 构建查询条件
|
||||
const whereClause = where ? `WHERE ${where}` : ''
|
||||
|
||||
// 获取总数
|
||||
const countQuery = `SELECT COUNT(*) as total FROM ${table} ${whereClause}`
|
||||
const [countResult] = await pool.execute(countQuery, params)
|
||||
const total = countResult[0].total
|
||||
|
||||
// 获取数据
|
||||
const dataQuery = `SELECT ${fields} FROM ${table} ${whereClause} ORDER BY ${orderBy} LIMIT ? OFFSET ?`
|
||||
const [dataResult] = await pool.execute(dataQuery, [...params, limit, offset])
|
||||
|
||||
return {
|
||||
data: dataResult,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行级联删除
|
||||
* @param {Array} cascadeRules - 级联删除规则数组
|
||||
* @param {number|Array} targetIds - 目标ID或ID数组
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function cascadeDelete(cascadeRules, targetIds) {
|
||||
const ids = Array.isArray(targetIds) ? targetIds : [targetIds]
|
||||
|
||||
for (const rule of cascadeRules) {
|
||||
const { table, field } = rule
|
||||
const placeholders = ids.map(() => '?').join(',')
|
||||
|
||||
await pool.execute(
|
||||
`DELETE FROM ${table} WHERE ${field} IN (${placeholders})`,
|
||||
ids
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
recordExists,
|
||||
recordsExist,
|
||||
isUnique,
|
||||
createRecord,
|
||||
updateRecord,
|
||||
deleteRecord,
|
||||
deleteRecords,
|
||||
getRecord,
|
||||
getRecords,
|
||||
cascadeDelete
|
||||
}
|
85
express-project/utils/ipLocation.js
Normal file
@@ -0,0 +1,85 @@
|
||||
const axios = require('axios');
|
||||
|
||||
/**
|
||||
* 获取IP属地信息
|
||||
* @param {string} ip - IP地址
|
||||
* @returns {Promise<string>} 返回省份信息
|
||||
*/
|
||||
async function getIPLocation(ip) {
|
||||
try {
|
||||
// 如果是本地IP,返回默认值
|
||||
if (!ip || ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.') || ip.startsWith('172.')) {
|
||||
return '本地';
|
||||
}
|
||||
|
||||
// 调用IP属地API
|
||||
const response = await axios.get(`https://api.pearktrue.cn/api/ip/details`, {
|
||||
params: {
|
||||
ip: ip
|
||||
},
|
||||
timeout: 10000 // 10秒超时
|
||||
});
|
||||
|
||||
if (response.data && response.data.code === 200 && response.data.data) {
|
||||
const locationData = response.data.data;
|
||||
// 根据API返回的数据结构提取省份信息
|
||||
if (locationData.subdivisions) {
|
||||
return locationData.subdivisions.replace('省', '').replace('壮族自治区', '').replace('回族自治区', '').replace('回族自治区', '').replace('特别行政区', '').replace('市', '').replace('维吾尔自治区', '').replace('自治区', '');
|
||||
} else if (locationData.region) {
|
||||
return locationData.region.replace('省', '').replace('壮族自治区', '').replace('回族自治区', '').replace('回族自治区', '').replace('特别行政区', '').replace('市', '').replace('维吾尔自治区', '').replace('自治区', '');
|
||||
}
|
||||
}
|
||||
|
||||
// 如果主接口返回未知,尝试备用接口
|
||||
try {
|
||||
const backupResponse = await axios.get(`https://api.pearktrue.cn/api/ip/high`, {
|
||||
params: {
|
||||
ip: ip
|
||||
},
|
||||
timeout: 5000 // 5秒超时
|
||||
});
|
||||
|
||||
if (backupResponse.data && backupResponse.data.code === 200 && backupResponse.data.data && backupResponse.data.data.province) {
|
||||
return backupResponse.data.data.province.replace('省', '').replace('壮族自治区', '').replace('回族自治区', '').replace('回族自治区', '').replace('特别行政区', '').replace('市', '').replace('维吾尔自治区', '').replace('自治区', '');
|
||||
}
|
||||
} catch (backupError) {
|
||||
console.error('备用IP属地接口调用失败:', backupError.message);
|
||||
}
|
||||
|
||||
return '未知';
|
||||
} catch (error) {
|
||||
console.error('获取IP属地失败:', error.message);
|
||||
return '未知';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求中获取真实IP地址
|
||||
* @param {Object} req - Express请求对象
|
||||
* @returns {string} IP地址
|
||||
*/
|
||||
function getRealIP(req) {
|
||||
let ip = req.headers['x-forwarded-for'] ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.socket.remoteAddress ||
|
||||
(req.connection.socket ? req.connection.socket.remoteAddress : null) ||
|
||||
req.ip;
|
||||
|
||||
// 处理IPv4映射的IPv6地址格式,去掉::ffff:前缀
|
||||
if (ip && typeof ip === 'string' && ip.startsWith('::ffff:')) {
|
||||
ip = ip.substring(7); // 去掉'::ffff:'前缀
|
||||
}
|
||||
|
||||
// 如果是x-forwarded-for头,可能包含多个IP,取第一个
|
||||
if (ip && typeof ip === 'string' && ip.includes(',')) {
|
||||
ip = ip.split(',')[0].trim();
|
||||
}
|
||||
|
||||
return ip;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getIPLocation,
|
||||
getRealIP
|
||||
};
|
57
express-project/utils/jwt.js
Normal file
@@ -0,0 +1,57 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const config = require('../config/config');
|
||||
|
||||
// JWT配置
|
||||
const { secret: JWT_SECRET, expiresIn: JWT_EXPIRES_IN, refreshExpiresIn: REFRESH_TOKEN_EXPIRES_IN } = config.jwt;
|
||||
|
||||
/**
|
||||
* 生成访问令牌
|
||||
* @param {Object} payload - 用户信息
|
||||
* @returns {String} JWT token
|
||||
*/
|
||||
function generateAccessToken(payload) {
|
||||
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成刷新令牌
|
||||
* @param {Object} payload - 用户信息
|
||||
* @returns {String} JWT refresh token
|
||||
*/
|
||||
function generateRefreshToken(payload) {
|
||||
return jwt.sign(payload, JWT_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRES_IN });
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证令牌
|
||||
* @param {String} token - JWT token
|
||||
* @returns {Object} 解码后的用户信息
|
||||
*/
|
||||
function verifyToken(token) {
|
||||
try {
|
||||
return jwt.verify(token, JWT_SECRET);
|
||||
} catch (error) {
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求头中提取token
|
||||
* @param {Object} req - Express请求对象
|
||||
* @returns {String|null} token
|
||||
*/
|
||||
function extractTokenFromHeader(req) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateAccessToken,
|
||||
generateRefreshToken,
|
||||
verifyToken,
|
||||
extractTokenFromHeader,
|
||||
JWT_SECRET
|
||||
};
|
64
express-project/utils/mentionParser.js
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Mention文本解析工具 - 后端版本
|
||||
* 处理[@nickname:user_id]格式的文本,提取被@的用户信息
|
||||
*/
|
||||
|
||||
/**
|
||||
* 从文本中提取所有被@的用户ID
|
||||
* @param {string} text - 包含mention标记的文本
|
||||
* @returns {Array} - 用户ID数组
|
||||
*/
|
||||
function extractMentionedUsers(text) {
|
||||
if (!text) return []
|
||||
|
||||
const mentionedUsers = []
|
||||
|
||||
// 匹配[@nickname:user_id]格式的正则表达式
|
||||
const mentionRegex = /\[@([^:]+):([^\]]+)\]/g
|
||||
let match
|
||||
|
||||
while ((match = mentionRegex.exec(text)) !== null) {
|
||||
const [, nickname, userId] = match
|
||||
mentionedUsers.push({
|
||||
nickname,
|
||||
userId
|
||||
})
|
||||
}
|
||||
|
||||
// 匹配HTML格式的mention链接
|
||||
const htmlMentionRegex = /<a[^>]*class="mention[^"]*"[^>]*data-user-id="([^"]+)"[^>]*>@([^<]+)<\/a>/g
|
||||
|
||||
while ((match = htmlMentionRegex.exec(text)) !== null) {
|
||||
const [, userId, nickname] = match
|
||||
// 避免重复添加
|
||||
if (!mentionedUsers.some(user => user.userId === userId)) {
|
||||
mentionedUsers.push({
|
||||
nickname,
|
||||
userId
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return mentionedUsers
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文本是否包含mention标记
|
||||
* @param {string} text - 要检查的文本
|
||||
* @returns {boolean} - 是否包含mention
|
||||
*/
|
||||
function hasMentions(text) {
|
||||
if (!text) return false
|
||||
|
||||
// 检查[@nickname:user_id]格式
|
||||
const mentionRegex = /\[@([^:]+):([^\]]+)\]/
|
||||
// 检查HTML格式的mention链接
|
||||
const htmlMentionRegex = /<a[^>]*class="mention[^"]*"[^>]*data-user-id[^>]*>[^<]*@[^<]*<\/a>/
|
||||
|
||||
return mentionRegex.test(text) || htmlMentionRegex.test(text)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
extractMentionedUsers,
|
||||
hasMentions
|
||||
}
|
273
express-project/utils/notificationHelper.js
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* 通知工具类
|
||||
* 统一管理通知类型和随机文字
|
||||
*/
|
||||
|
||||
class NotificationHelper {
|
||||
// 通知类型定义
|
||||
static TYPES = {
|
||||
LIKE_POST: 1, // 点赞笔记
|
||||
LIKE_COMMENT: 2, // 点赞评论
|
||||
COLLECT_POST: 3, // 收藏笔记
|
||||
COMMENT_POST: 4, // 评论笔记
|
||||
REPLY_COMMENT: 5, // 回复评论
|
||||
FOLLOW: 6, // 关注
|
||||
MENTION: 7 // @提及
|
||||
};
|
||||
|
||||
// 通知标题模板
|
||||
static TITLES = {
|
||||
[this.TYPES.LIKE_POST]: [
|
||||
'赞了你的笔记',
|
||||
'给你的笔记点了赞',
|
||||
'觉得你的笔记很赞',
|
||||
'给你点了个赞',
|
||||
'为你的笔记点赞',
|
||||
'喜欢你的笔记'
|
||||
],
|
||||
[this.TYPES.LIKE_COMMENT]: [
|
||||
'赞了你的评论',
|
||||
'给你的评论点了赞',
|
||||
'觉得你的评论很赞',
|
||||
'为你的评论点赞',
|
||||
'喜欢你的评论'
|
||||
],
|
||||
[this.TYPES.COLLECT_POST]: [
|
||||
'收藏了你的笔记',
|
||||
'把你的笔记加入收藏',
|
||||
'觉得你的内容值得收藏',
|
||||
'收藏了你的内容',
|
||||
'将你的笔记收藏了',
|
||||
'把你的作品收藏了'
|
||||
],
|
||||
[this.TYPES.COMMENT_POST]: [
|
||||
'评论了你的笔记',
|
||||
'在你的笔记下留言了',
|
||||
'对你的笔记发表了评论',
|
||||
'在你的内容下评论了',
|
||||
'给你的笔记留言了'
|
||||
],
|
||||
[this.TYPES.REPLY_COMMENT]: [
|
||||
'回复了你的评论',
|
||||
'回复了你',
|
||||
'对你的评论进行了回复',
|
||||
'回应了你的评论',
|
||||
'给你回复了'
|
||||
],
|
||||
[this.TYPES.FOLLOW]: [
|
||||
'关注了你',
|
||||
'成为了你的粉丝',
|
||||
'开始关注你了',
|
||||
'关注了你的账号'
|
||||
],
|
||||
[this.TYPES.MENTION]: [
|
||||
'在评论中@了你',
|
||||
'在评论中提到了你',
|
||||
'在评论中艾特了你',
|
||||
'评论中@了你',
|
||||
'提及了你'
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取随机通知标题
|
||||
* @param {number} type 通知类型
|
||||
* @returns {string} 随机标题
|
||||
*/
|
||||
static getRandomTitle(type) {
|
||||
const titles = this.TITLES[type];
|
||||
if (!titles || titles.length === 0) {
|
||||
return '有新的通知';
|
||||
}
|
||||
return titles[Math.floor(Math.random() * titles.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建通知数据对象
|
||||
* @param {Object} params 通知参数
|
||||
* @param {number} params.userId 接收通知的用户ID
|
||||
* @param {number} params.senderId 发送通知的用户ID
|
||||
* @param {number} params.type 通知类型
|
||||
* @param {number|null} params.targetId 目标ID(笔记ID等)
|
||||
* @param {number|null} params.commentId 评论ID(可选)
|
||||
* @param {boolean} params.isRead 是否已读,默认false
|
||||
* @returns {Object} 通知数据对象
|
||||
*/
|
||||
static createNotificationData({
|
||||
userId,
|
||||
senderId,
|
||||
type,
|
||||
targetId = null,
|
||||
commentId = null,
|
||||
isRead = false
|
||||
}) {
|
||||
return {
|
||||
user_id: userId,
|
||||
sender_id: senderId,
|
||||
type: type,
|
||||
title: this.getRandomTitle(type),
|
||||
target_id: targetId,
|
||||
comment_id: commentId,
|
||||
is_read: isRead ? 1 : 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建点赞笔记通知
|
||||
* @param {number} postAuthorId 笔记作者ID
|
||||
* @param {number} likerId 点赞者ID
|
||||
* @param {number} postId 笔记ID
|
||||
* @returns {Object} 通知数据对象
|
||||
*/
|
||||
static createLikePostNotification(postAuthorId, likerId, postId) {
|
||||
return this.createNotificationData({
|
||||
userId: postAuthorId,
|
||||
senderId: likerId,
|
||||
type: this.TYPES.LIKE_POST,
|
||||
targetId: postId
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建点赞评论通知
|
||||
* @param {number} commentAuthorId 评论作者ID
|
||||
* @param {number} likerId 点赞者ID
|
||||
* @param {number} postId 笔记ID(用于跳转)
|
||||
* @param {number} commentId 评论ID
|
||||
* @returns {Object} 通知数据对象
|
||||
*/
|
||||
static createLikeCommentNotification(commentAuthorId, likerId, postId, commentId) {
|
||||
return this.createNotificationData({
|
||||
userId: commentAuthorId,
|
||||
senderId: likerId,
|
||||
type: this.TYPES.LIKE_COMMENT,
|
||||
targetId: postId,
|
||||
commentId: commentId
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建收藏笔记通知
|
||||
* @param {number} postAuthorId 笔记作者ID
|
||||
* @param {number} collectorId 收藏者ID
|
||||
* @param {number} postId 笔记ID
|
||||
* @returns {Object} 通知数据对象
|
||||
*/
|
||||
static createCollectPostNotification(postAuthorId, collectorId, postId) {
|
||||
return this.createNotificationData({
|
||||
userId: postAuthorId,
|
||||
senderId: collectorId,
|
||||
type: this.TYPES.COLLECT_POST,
|
||||
targetId: postId
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建评论笔记通知
|
||||
* @param {number} postAuthorId 笔记作者ID
|
||||
* @param {number} commenterId 评论者ID
|
||||
* @param {number} postId 笔记ID
|
||||
* @param {number} commentId 评论ID
|
||||
* @returns {Object} 通知数据对象
|
||||
*/
|
||||
static createCommentPostNotification(postAuthorId, commenterId, postId, commentId) {
|
||||
return this.createNotificationData({
|
||||
userId: postAuthorId,
|
||||
senderId: commenterId,
|
||||
type: this.TYPES.COMMENT_POST,
|
||||
targetId: postId,
|
||||
commentId: commentId
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建回复评论通知
|
||||
* @param {number} parentCommentAuthorId 被回复评论的作者ID
|
||||
* @param {number} replierId 回复者ID
|
||||
* @param {number} postId 笔记ID
|
||||
* @param {number} replyCommentId 回复评论ID
|
||||
* @returns {Object} 通知数据对象
|
||||
*/
|
||||
static createReplyCommentNotification(parentCommentAuthorId, replierId, postId, replyCommentId) {
|
||||
return this.createNotificationData({
|
||||
userId: parentCommentAuthorId,
|
||||
senderId: replierId,
|
||||
type: this.TYPES.REPLY_COMMENT,
|
||||
targetId: postId,
|
||||
commentId: replyCommentId
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建关注通知
|
||||
* @param {number} followedUserId 被关注者ID
|
||||
* @param {number} followerId 关注者ID
|
||||
* @returns {Object} 通知数据对象
|
||||
*/
|
||||
static createFollowNotification(followedUserId, followerId) {
|
||||
return this.createNotificationData({
|
||||
userId: followedUserId,
|
||||
senderId: followerId,
|
||||
type: this.TYPES.FOLLOW
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建@提及通知
|
||||
* @param {number} mentionedUserId 被@用户ID
|
||||
* @param {number} mentionerId @用户的人ID
|
||||
* @param {number} postId 笔记ID
|
||||
* @param {number} commentId 评论ID
|
||||
* @returns {Object} 通知数据对象
|
||||
*/
|
||||
static createMentionNotification(mentionedUserId, mentionerId, postId, commentId) {
|
||||
return this.createNotificationData({
|
||||
userId: mentionedUserId,
|
||||
senderId: mentionerId,
|
||||
type: this.TYPES.MENTION,
|
||||
targetId: postId,
|
||||
commentId: commentId
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 插入通知到数据库
|
||||
* @param {Object} pool 数据库连接池
|
||||
* @param {Object} notificationData 通知数据
|
||||
* @returns {Promise<Object>} 插入结果
|
||||
*/
|
||||
static async insertNotification(pool, notificationData) {
|
||||
const [result] = await pool.execute(
|
||||
'INSERT INTO notifications (user_id, sender_id, type, title, target_id, comment_id, is_read) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
notificationData.user_id,
|
||||
notificationData.sender_id,
|
||||
notificationData.type,
|
||||
notificationData.title,
|
||||
notificationData.target_id,
|
||||
notificationData.comment_id,
|
||||
notificationData.is_read
|
||||
]
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建并插入通知(便捷方法)
|
||||
* @param {Object} pool 数据库连接池
|
||||
* @param {Object} params 通知参数
|
||||
* @returns {Promise<Object>} 插入结果
|
||||
*/
|
||||
static async createAndInsertNotification(pool, params) {
|
||||
// 检查是否给自己发通知
|
||||
if (params.userId === params.senderId) {
|
||||
console.log('⚠️ 不给自己发通知');
|
||||
return null;
|
||||
}
|
||||
|
||||
const notificationData = this.createNotificationData(params);
|
||||
return await this.insertNotification(pool, notificationData);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NotificationHelper;
|
126
express-project/utils/responseHelper.js
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 通用响应处理工具
|
||||
*/
|
||||
|
||||
/**
|
||||
* 成功响应
|
||||
* @param {Object} res - Express响应对象
|
||||
* @param {*} data - 响应数据
|
||||
* @param {string} message - 响应消息
|
||||
* @param {number} code - 响应代码
|
||||
*/
|
||||
function success(res, data = null, message = '操作成功', code = 200) {
|
||||
const response = {
|
||||
code,
|
||||
message
|
||||
}
|
||||
|
||||
if (data !== null) {
|
||||
response.data = data
|
||||
}
|
||||
|
||||
res.status(200).json(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误响应
|
||||
* @param {Object} res - Express响应对象
|
||||
* @param {string} message - 错误消息
|
||||
* @param {number} code - 错误代码
|
||||
* @param {number} httpStatus - HTTP状态码
|
||||
*/
|
||||
function error(res, message = '操作失败', code = 500, httpStatus = null) {
|
||||
const statusCode = httpStatus || (code >= 400 && code < 600 ? code : 500)
|
||||
|
||||
res.status(statusCode).json({
|
||||
code,
|
||||
message
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用错误处理中间件
|
||||
* @param {Error} err - 错误对象
|
||||
* @param {Object} res - Express响应对象
|
||||
* @param {string} operation - 操作名称
|
||||
*/
|
||||
function handleError(err, res, operation = '操作') {
|
||||
console.error(`${operation}失败:`, err)
|
||||
|
||||
// 数据库约束错误
|
||||
if (err.code === 'ER_DUP_ENTRY') {
|
||||
return error(res, '数据已存在,请检查唯一性约束', 409, 409)
|
||||
}
|
||||
|
||||
// 外键约束错误
|
||||
if (err.code === 'ER_NO_REFERENCED_ROW_2') {
|
||||
return error(res, '关联数据不存在', 400, 400)
|
||||
}
|
||||
|
||||
// 数据格式错误
|
||||
if (err.code === 'ER_TRUNCATED_WRONG_VALUE_FOR_FIELD') {
|
||||
return error(res, '数据格式错误', 400, 400)
|
||||
}
|
||||
|
||||
// 默认服务器错误
|
||||
return error(res, '服务器内部错误', 500, 500)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证必填字段
|
||||
* @param {Object} data - 要验证的数据
|
||||
* @param {Array} requiredFields - 必填字段数组
|
||||
* @returns {Object} 验证结果 {isValid: boolean, message: string}
|
||||
*/
|
||||
function validateRequired(data, requiredFields) {
|
||||
const missingFields = []
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (data[field] === undefined || data[field] === null || data[field] === '') {
|
||||
missingFields.push(field)
|
||||
}
|
||||
}
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
return {
|
||||
isValid: false,
|
||||
message: `缺少必填字段: ${missingFields.join(', ')}`
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证ID数组
|
||||
* @param {Array} ids - ID数组
|
||||
* @param {string} fieldName - 字段名称
|
||||
* @returns {Object} 验证结果
|
||||
*/
|
||||
function validateIds(ids, fieldName = 'ID') {
|
||||
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
||||
return {
|
||||
isValid: false,
|
||||
message: `请提供要操作的${fieldName}列表`
|
||||
}
|
||||
}
|
||||
|
||||
// 验证所有ID都是有效的数字
|
||||
const invalidIds = ids.filter(id => !Number.isInteger(Number(id)) || Number(id) <= 0)
|
||||
if (invalidIds.length > 0) {
|
||||
return {
|
||||
isValid: false,
|
||||
message: `无效的${fieldName}: ${invalidIds.join(', ')}`
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
success,
|
||||
error,
|
||||
handleError,
|
||||
validateRequired,
|
||||
validateIds
|
||||
}
|
93
express-project/utils/statsHelper.js
Normal file
@@ -0,0 +1,93 @@
|
||||
const pool = require('../config/database');
|
||||
|
||||
/**
|
||||
* 获取表的记录总数
|
||||
* @param {string} table - 表名
|
||||
* @param {string} whereClause - WHERE条件(可选)
|
||||
* @param {Array} params - 查询参数(可选)
|
||||
* @returns {Promise<number>} 记录总数
|
||||
*/
|
||||
async function getTableCount(table, whereClause = '', params = []) {
|
||||
try {
|
||||
const query = `SELECT COUNT(*) as count FROM ${table} ${whereClause}`;
|
||||
const [result] = await pool.execute(query, params);
|
||||
return result[0].count;
|
||||
} catch (error) {
|
||||
console.error(`获取${table}表记录数失败:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取多个表的统计信息
|
||||
* @param {Array} tables - 表配置数组,每个元素包含 {table, whereClause?, params?}
|
||||
* @returns {Promise<Object>} 统计结果对象
|
||||
*/
|
||||
async function getMultipleTableStats(tables) {
|
||||
try {
|
||||
const results = {};
|
||||
|
||||
for (const config of tables) {
|
||||
const { table, alias, whereClause = '', params = [] } = config;
|
||||
const count = await getTableCount(table, whereClause, params);
|
||||
results[alias || table] = count;
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('获取多表统计信息失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分页查询的总数和数据
|
||||
* @param {string} table - 表名
|
||||
* @param {Object} options - 查询选项
|
||||
* @param {string} options.fields - 查询字段,默认为 '*'
|
||||
* @param {string} options.whereClause - WHERE条件
|
||||
* @param {Array} options.params - 查询参数
|
||||
* @param {string} options.orderBy - 排序条件
|
||||
* @param {number} options.page - 页码
|
||||
* @param {number} options.limit - 每页数量
|
||||
* @returns {Promise<Object>} 包含total和data的对象
|
||||
*/
|
||||
async function getPaginatedData(table, options = {}) {
|
||||
const {
|
||||
fields = '*',
|
||||
whereClause = '',
|
||||
params = [],
|
||||
orderBy = 'id DESC',
|
||||
page = 1,
|
||||
limit = 20
|
||||
} = options;
|
||||
|
||||
try {
|
||||
// 获取总数
|
||||
const total = await getTableCount(table, whereClause, params);
|
||||
|
||||
// 获取分页数据
|
||||
const offset = (page - 1) * limit;
|
||||
const dataQuery = `SELECT ${fields} FROM ${table} ${whereClause} ORDER BY ${orderBy} LIMIT ? OFFSET ?`;
|
||||
const dataParams = [...params, limit, offset];
|
||||
|
||||
const [data] = await pool.execute(dataQuery, dataParams);
|
||||
|
||||
return {
|
||||
total,
|
||||
data,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取分页数据失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getTableCount,
|
||||
getMultipleTableStats,
|
||||
getPaginatedData
|
||||
};
|
176
express-project/utils/uploadHelper.js
Normal file
@@ -0,0 +1,176 @@
|
||||
const axios = require('axios');
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* 上传文件到图床
|
||||
* @param {Buffer} fileBuffer - 文件缓冲区
|
||||
* @param {string} filename - 文件名
|
||||
* @param {string} mimetype - 文件MIME类型
|
||||
* @returns {Promise<{success: boolean, url?: string, message?: string}>}
|
||||
*/
|
||||
async function uploadToImageHost(fileBuffer, filename, mimetype) {
|
||||
try {
|
||||
// 构建multipart/form-data请求体
|
||||
const boundary = `----formdata-${Date.now()}`;
|
||||
|
||||
const formDataBody = Buffer.concat([
|
||||
Buffer.from(`--${boundary}\r\n`),
|
||||
Buffer.from(`Content-Disposition: form-data; name="file"; filename="${filename}"\r\n`),
|
||||
Buffer.from(`Content-Type: ${mimetype}\r\n\r\n`),
|
||||
fileBuffer,
|
||||
Buffer.from(`\r\n--${boundary}--\r\n`)
|
||||
]);
|
||||
|
||||
// 上传到图床
|
||||
const response = await axios.post('https://api.xinyew.cn/api/jdtc', formDataBody, {
|
||||
headers: {
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'Content-Length': formDataBody.length
|
||||
},
|
||||
timeout: 60000,
|
||||
httpsAgent: new https.Agent({
|
||||
rejectUnauthorized: false
|
||||
})
|
||||
});
|
||||
|
||||
if (response.data && response.data.errno === 0 && response.data.data && response.data.data.url) {
|
||||
const imageUrl = response.data.data.url.trim().replace(/\`/g, '').replace(/\s+/g, '');
|
||||
return {
|
||||
success: true,
|
||||
url: imageUrl
|
||||
};
|
||||
} else {
|
||||
console.log('❌ 图床返回错误:', response.data);
|
||||
return {
|
||||
success: false,
|
||||
message: '图床上传失败'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 图床上传失败:', error.message);
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || '图床上传失败'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从base64数据上传到图床
|
||||
* @param {string} base64Data - base64格式的图片数据
|
||||
* @returns {Promise<{success: boolean, url?: string, message?: string}>}
|
||||
*/
|
||||
async function uploadBase64ToImageHost(base64Data) {
|
||||
try {
|
||||
// 验证base64格式
|
||||
if (!base64Data || typeof base64Data !== 'string' || !base64Data.startsWith('data:image/')) {
|
||||
return {
|
||||
success: false,
|
||||
message: '无效的base64数据'
|
||||
};
|
||||
}
|
||||
|
||||
// 解析base64数据
|
||||
const matches = base64Data.match(/^data:image\/([a-zA-Z]+);base64,(.+)$/);
|
||||
if (!matches) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'base64格式不正确'
|
||||
};
|
||||
}
|
||||
|
||||
const imageType = matches[1];
|
||||
const imageBuffer = Buffer.from(matches[2], 'base64');
|
||||
|
||||
// 检查文件大小(5MB限制)
|
||||
if (imageBuffer.length > 5 * 1024 * 1024) {
|
||||
return {
|
||||
success: false,
|
||||
message: '图片大小超过5MB限制'
|
||||
};
|
||||
}
|
||||
|
||||
const filename = `image_${Date.now()}_${Math.random().toString(36).substr(2, 9)}.${imageType}`;
|
||||
const mimetype = `image/${imageType}`;
|
||||
|
||||
return await uploadToImageHost(imageBuffer, filename, mimetype);
|
||||
} catch (error) {
|
||||
console.error('❌ Base64图片上传失败:', error.message);
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || 'Base64图片上传失败'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件路径上传到图床
|
||||
* @param {string} filePath - 文件路径
|
||||
* @param {string} originalname - 原始文件名
|
||||
* @param {string} mimetype - 文件MIME类型
|
||||
* @param {boolean} deleteAfterUpload - 上传后是否删除本地文件
|
||||
* @returns {Promise<{success: boolean, url?: string, message?: string}>}
|
||||
*/
|
||||
async function uploadFileToImageHost(filePath, originalname, mimetype, deleteAfterUpload = true) {
|
||||
try {
|
||||
// 读取文件
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
const filename = originalname || path.basename(filePath);
|
||||
|
||||
const result = await uploadToImageHost(fileBuffer, filename, mimetype);
|
||||
|
||||
// 上传成功后删除本地文件
|
||||
if (result.success && deleteAfterUpload && fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('❌ 文件上传失败:', error.message);
|
||||
// 确保删除临时文件
|
||||
if (deleteAfterUpload && fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || '文件上传失败'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员权限验证中间件
|
||||
* @param {Object} req - 请求对象
|
||||
* @param {Object} res - 响应对象
|
||||
* @param {Function} next - 下一个中间件函数
|
||||
*/
|
||||
function adminAuth(req, res, next) {
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
|
||||
authenticateToken(req, res, (err) => {
|
||||
if (err) {
|
||||
return res.status(401).json({
|
||||
code: 401,
|
||||
message: '认证失败'
|
||||
});
|
||||
}
|
||||
|
||||
if (!req.user.type || req.user.type !== 'admin') {
|
||||
return res.status(403).json({
|
||||
code: 403,
|
||||
message: '权限不足,需要管理员权限'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
uploadToImageHost,
|
||||
uploadBase64ToImageHost,
|
||||
uploadFileToImageHost,
|
||||
adminAuth
|
||||
};
|
96
express-project/utils/validationHelpers.js
Normal file
@@ -0,0 +1,96 @@
|
||||
const { recordExists } = require('./dbHelper');
|
||||
|
||||
/**
|
||||
* 验证用户是否存在
|
||||
* @param {number} userId - 用户ID
|
||||
* @returns {Promise<boolean>} - 用户是否存在
|
||||
*/
|
||||
async function validateUserExists(userId) {
|
||||
if (!userId) {
|
||||
throw new Error('用户ID不能为空');
|
||||
}
|
||||
|
||||
const exists = await recordExists('users', 'id', userId);
|
||||
if (!exists) {
|
||||
throw new Error('用户不存在');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证目标类型是否有效
|
||||
* @param {number} targetType - 目标类型 (1: 笔记, 2: 评论)
|
||||
* @returns {boolean} - 目标类型是否有效
|
||||
*/
|
||||
function validateTargetType(targetType) {
|
||||
if (![1, 2].includes(targetType)) {
|
||||
throw new Error('目标类型只能是1(笔记)或2(评论)');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户不能关注自己
|
||||
* @param {number} followerId - 关注者ID
|
||||
* @param {number} followingId - 被关注者ID
|
||||
* @returns {boolean} - 验证是否通过
|
||||
*/
|
||||
function validateNotSelfFollow(followerId, followingId) {
|
||||
if (followerId === followingId) {
|
||||
throw new Error('不能关注自己');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用的点赞/收藏数据验证
|
||||
* @param {Object} data - 数据对象
|
||||
* @param {number} data.user_id - 用户ID
|
||||
* @param {number} data.target_type - 目标类型
|
||||
* @returns {Promise<boolean>} - 验证是否通过
|
||||
*/
|
||||
async function validateLikeOrFavoriteData(data) {
|
||||
validateTargetType(data.target_type);
|
||||
await validateUserExists(data.user_id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用的关注数据验证
|
||||
* @param {Object} data - 数据对象
|
||||
* @param {number} data.follower_id - 关注者ID
|
||||
* @param {number} data.following_id - 被关注者ID
|
||||
* @returns {Promise<boolean>} - 验证是否通过
|
||||
*/
|
||||
async function validateFollowData(data) {
|
||||
await validateUserExists(data.follower_id);
|
||||
await validateUserExists(data.following_id);
|
||||
validateNotSelfFollow(data.follower_id, data.following_id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用的通知数据验证
|
||||
* @param {Object} data - 数据对象
|
||||
* @param {number} data.user_id - 用户ID
|
||||
* @returns {Promise<boolean>} - 验证是否通过
|
||||
*/
|
||||
async function validateNotificationData(data) {
|
||||
await validateUserExists(data.user_id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateUserExists,
|
||||
validateTargetType,
|
||||
validateNotSelfFollow,
|
||||
validateLikeOrFavoriteData,
|
||||
validateFollowData,
|
||||
validateNotificationData
|
||||
};
|
10
vue3-project/.env.example
Normal file
@@ -0,0 +1,10 @@
|
||||
# 环境变量配置文件示例
|
||||
# 复制此文件为 .env.local 并根据需要修改配置
|
||||
|
||||
# API基础URL
|
||||
VITE_API_BASE_URL=http://localhost:3001/api
|
||||
|
||||
# 是否使用真实API (true/false)
|
||||
VITE_USE_REAL_API=false
|
||||
|
||||
# 其他配置...
|
30
vue3-project/.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
13
vue3-project/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/logo.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>小石榴 - 你的校园图文部落</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
8
vue3-project/jsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
7742
vue3-project/package-lock.json
generated
Normal file
30
vue3-project/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "vue3-project",
|
||||
"version": "1.0.0",
|
||||
"description": "小石榴校园图文社区Vue3前端项目",
|
||||
"author": "@ZTMYO",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^13.5.0",
|
||||
"axios": "^1.11.0",
|
||||
"cropperjs": "^1.6.2",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.5.17",
|
||||
"vue-router": "^4.5.1",
|
||||
"vue3-emoji-picker": "^1.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"patch-package": "^8.0.0",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-svg-icons": "^2.0.1",
|
||||
"vite-plugin-vue-devtools": "^7.7.7"
|
||||
}
|
||||
}
|
BIN
vue3-project/public/logo.ico
Normal file
After Width: | Height: | Size: 24 KiB |
54
vue3-project/src/App.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script setup>
|
||||
import { RouterView } from 'vue-router'
|
||||
import { onMounted } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAboutStore } from '@/stores/about'
|
||||
import { useChangePasswordStore } from '@/stores/changePassword'
|
||||
import AuthModal from '@/components/modals/AuthModal.vue'
|
||||
import AboutModal from '@/components/modals/AboutModal.vue'
|
||||
import ChangePasswordModal from '@/components/modals/ChangePasswordModal.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const authStore = useAuthStore()
|
||||
const aboutStore = useAboutStore()
|
||||
const changePasswordStore = useChangePasswordStore()
|
||||
|
||||
// 应用启动时初始化用户信息
|
||||
onMounted(() => {
|
||||
userStore.initUserInfo()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<RouterView />
|
||||
<AuthModal v-if="authStore.showAuthModal" :initial-mode="authStore.initialMode" @close="authStore.closeAuthModal"
|
||||
@success="authStore.closeAuthModal" />
|
||||
<AboutModal v-if="aboutStore.showAboutModal" @close="aboutStore.closeAboutModal" />
|
||||
<ChangePasswordModal v-if="changePasswordStore.showChangePasswordModal"
|
||||
:userInfo="userStore.userInfo"
|
||||
@close="changePasswordStore.closeChangePasswordModal" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.app-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
width: 100vw;
|
||||
min-width: 100%;
|
||||
background-color: var(--bg-color-primary);
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
76
vue3-project/src/api/categories.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import request from './request.js'
|
||||
|
||||
// 获取所有分类
|
||||
export async function getCategories(params = {}) {
|
||||
try {
|
||||
const response = await request.get('/categories', { params })
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('获取分类失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
message: error.message || '获取分类失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取分类详情
|
||||
export async function getCategoryDetail(categoryId) {
|
||||
try {
|
||||
const response = await request.get(`/categories/${categoryId}`)
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('获取分类详情失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
message: error.message || '获取分类详情失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建分类
|
||||
export async function createCategory(data) {
|
||||
try {
|
||||
const response = await request.post('/categories', data)
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('创建分类失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
message: error.message || '创建分类失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新分类
|
||||
export async function updateCategory(categoryId, data) {
|
||||
try {
|
||||
const response = await request.put(`/categories/${categoryId}`, data)
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('更新分类失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
message: error.message || '更新分类失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除分类
|
||||
export async function deleteCategory(categoryId) {
|
||||
try {
|
||||
const response = await request.delete(`/categories/${categoryId}`)
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('删除分类失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
message: error.message || '删除分类失败'
|
||||
}
|
||||
}
|
||||
}
|
724
vue3-project/src/api/index.js
Normal file
@@ -0,0 +1,724 @@
|
||||
import request from './request.js'
|
||||
|
||||
// 用户相关API
|
||||
export const userApi = {
|
||||
// 获取用户信息
|
||||
getUserInfo(userId) {
|
||||
return request.get(`/users/${userId}`)
|
||||
},
|
||||
|
||||
// 获取用户个性标签
|
||||
getUserPersonalityTags(userId) {
|
||||
return request.get(`/users/${userId}/personality-tags`)
|
||||
},
|
||||
|
||||
// 更新用户信息
|
||||
updateUserInfo(userId, data) {
|
||||
return request.put(`/users/${userId}`, data)
|
||||
},
|
||||
|
||||
// 关注用户
|
||||
followUser(userId) {
|
||||
return request.post(`/users/${userId}/follow`)
|
||||
},
|
||||
|
||||
// 取消关注用户
|
||||
unfollowUser(userId) {
|
||||
return request.delete(`/users/${userId}/follow`)
|
||||
},
|
||||
|
||||
// 搜索用户
|
||||
searchUsers(keyword, params = {}) {
|
||||
return request.get('/users/search', { params: { keyword, ...params } })
|
||||
},
|
||||
|
||||
// 获取互相关注列表
|
||||
getMutualFollows(userId, params = {}) {
|
||||
return request.get(`/users/${userId}/mutual-follows`, { params })
|
||||
},
|
||||
|
||||
// 获取关注列表
|
||||
getFollowing(userId, params = {}) {
|
||||
return request.get(`/users/${userId}/following`, { params })
|
||||
},
|
||||
|
||||
// 获取粉丝列表
|
||||
getFollowers(userId, params = {}) {
|
||||
return request.get(`/users/${userId}/followers`, { params })
|
||||
},
|
||||
|
||||
// 获取关注状态
|
||||
getFollowStatus(userId) {
|
||||
return request.get(`/users/${userId}/follow-status`)
|
||||
},
|
||||
|
||||
// 获取用户统计信息
|
||||
getUserStats(userId) {
|
||||
return request.get(`/users/${userId}/stats`)
|
||||
},
|
||||
|
||||
// 修改密码
|
||||
changePassword(userId, data) {
|
||||
return request.put(`/users/${userId}/password`, data)
|
||||
}
|
||||
}
|
||||
|
||||
// 笔记相关API
|
||||
export const postApi = {
|
||||
// 获取笔记列表
|
||||
getPosts(params = {}) {
|
||||
return request.get('/posts', { params })
|
||||
},
|
||||
|
||||
// 获取笔记详情
|
||||
getPostDetail(postId) {
|
||||
return request.get(`/posts/${postId}`)
|
||||
},
|
||||
|
||||
// 搜索笔记
|
||||
searchPosts(keyword, params = {}) {
|
||||
return request.get('/search/posts', { params: { keyword, ...params } })
|
||||
},
|
||||
|
||||
// 创建笔记
|
||||
createPost(data) {
|
||||
return request.post('/posts', data)
|
||||
},
|
||||
|
||||
// 更新笔记
|
||||
updatePost(postId, data) {
|
||||
return request.put(`/posts/${postId}`, data)
|
||||
},
|
||||
|
||||
// 删除笔记
|
||||
deletePost(postId) {
|
||||
return request.delete(`/posts/${postId}`)
|
||||
},
|
||||
|
||||
// 点赞笔记
|
||||
likePost(postId) {
|
||||
return request.post('/likes', { target_type: 1, target_id: postId })
|
||||
},
|
||||
|
||||
// 取消点赞笔记
|
||||
unlikePost(postId) {
|
||||
return request.delete('/likes', { data: { target_type: 1, target_id: postId } })
|
||||
},
|
||||
|
||||
// 收藏笔记
|
||||
collectPost(postId) {
|
||||
return request.post(`/posts/${postId}/collect`)
|
||||
},
|
||||
|
||||
// 取消收藏笔记
|
||||
uncollectPost(postId) {
|
||||
return request.delete(`/posts/${postId}/collect`)
|
||||
},
|
||||
|
||||
// 获取用户笔记
|
||||
getUserPosts(userId, params = {}) {
|
||||
return request.get(`/users/${userId}/posts`, { params })
|
||||
},
|
||||
|
||||
// 获取用户收藏
|
||||
getUserCollections(userId, params = {}) {
|
||||
return request.get(`/users/${userId}/collections`, { params })
|
||||
}
|
||||
}
|
||||
|
||||
// 评论相关API
|
||||
export const commentApi = {
|
||||
// 获取评论列表
|
||||
getComments(postId, params = {}) {
|
||||
// 确保postId是有效的
|
||||
if (!postId) {
|
||||
console.error('获取评论失败: 笔记ID无效')
|
||||
return Promise.reject(new Error('笔记ID无效'))
|
||||
}
|
||||
|
||||
// 构建正确的API路径
|
||||
// 注意:后端API路由是 /api/posts/:id/comments
|
||||
// 但axios实例已配置baseURL为http://localhost:3001/api
|
||||
// 所以这里只需要/posts/:id/comments部分
|
||||
const url = `/posts/${postId}/comments`
|
||||
|
||||
return request.get(url, { params })
|
||||
.then(response => {
|
||||
// 响应已经在拦截器中被处理成 {success, data, message} 格式
|
||||
return response
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(`获取笔记[${postId}]评论失败:`, error.message)
|
||||
// 返回一个格式化的错误对象,与成功响应格式一致
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
message: error.message || '获取评论失败'
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 获取子评论列表
|
||||
getReplies(commentId, params = {}) {
|
||||
// 确保commentId是有效的
|
||||
if (!commentId) {
|
||||
console.error('获取回复失败: 评论ID无效')
|
||||
return Promise.reject(new Error('评论ID无效'))
|
||||
}
|
||||
|
||||
const url = `/comments/${commentId}/replies`
|
||||
|
||||
return request.get(url, { params })
|
||||
.then(response => {
|
||||
return response
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(`获取评论[${commentId}]回复失败:`, error.message)
|
||||
// 返回一个格式化的错误对象,与成功响应格式一致
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
message: error.message || '获取回复失败'
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 创建评论
|
||||
createComment(data) {
|
||||
return request.post('/comments', data)
|
||||
},
|
||||
|
||||
// 删除评论
|
||||
deleteComment(commentId) {
|
||||
return request.delete(`/comments/${commentId}`)
|
||||
},
|
||||
|
||||
// 点赞评论
|
||||
likeComment(commentId) {
|
||||
return request.post('/likes', { target_type: 2, target_id: commentId })
|
||||
},
|
||||
|
||||
// 取消点赞评论
|
||||
unlikeComment(commentId) {
|
||||
return request.delete('/likes', { data: { target_type: 2, target_id: commentId } })
|
||||
}
|
||||
}
|
||||
|
||||
// 认证相关API
|
||||
export const authApi = {
|
||||
// 用户登录
|
||||
login(data) {
|
||||
return request.post('/auth/login', data)
|
||||
},
|
||||
|
||||
// 用户注册
|
||||
register(data) {
|
||||
return request.post('/auth/register', data)
|
||||
},
|
||||
|
||||
// 退出登录
|
||||
logout() {
|
||||
return request.post('/auth/logout')
|
||||
},
|
||||
|
||||
// 刷新token
|
||||
refreshToken() {
|
||||
return request.post('/auth/refresh')
|
||||
},
|
||||
|
||||
// 获取当前用户信息
|
||||
getCurrentUser() {
|
||||
return request.get('/auth/me')
|
||||
}
|
||||
}
|
||||
|
||||
// 导入新的图片上传API
|
||||
import * as imageUploadApi from './upload.js'
|
||||
|
||||
// 文件上传API(保持向后兼容)
|
||||
export const uploadApi = {
|
||||
// 上传图片(后端接口)
|
||||
uploadImage(file) {
|
||||
const formData = new FormData()
|
||||
formData.append('image', file)
|
||||
return request.post('/upload/image', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 批量上传图片(后端接口)
|
||||
uploadImages(files) {
|
||||
const formData = new FormData()
|
||||
files.forEach(file => {
|
||||
formData.append('images', file)
|
||||
})
|
||||
return request.post('/upload/images', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 上传base64图片到图床(新接口)
|
||||
uploadBase64Images: imageUploadApi.uploadBase64Images,
|
||||
|
||||
// 上传图片到图床(新接口)
|
||||
uploadToImageHost: imageUploadApi.uploadImage,
|
||||
|
||||
// 批量上传图片到图床(新接口)
|
||||
uploadMultipleToImageHost: imageUploadApi.uploadImages,
|
||||
|
||||
// 上传裁剪后的图片(新接口)
|
||||
uploadCroppedImage: imageUploadApi.uploadCroppedImage,
|
||||
|
||||
// 验证图片文件(新接口)
|
||||
validateImageFile: imageUploadApi.validateImageFile,
|
||||
|
||||
// 格式化文件大小(新接口)
|
||||
formatFileSize: imageUploadApi.formatFileSize,
|
||||
|
||||
// 生成图片预览(新接口)
|
||||
createImagePreview: imageUploadApi.createImagePreview
|
||||
}
|
||||
|
||||
// 导出图片上传API(推荐使用)
|
||||
export { imageUploadApi }
|
||||
|
||||
// 通知相关API
|
||||
export const notificationApi = {
|
||||
// 获取评论通知
|
||||
getCommentNotifications(params = {}) {
|
||||
return request.get('/notifications/comments', { params })
|
||||
},
|
||||
|
||||
// 获取点赞通知
|
||||
getLikeNotifications(params = {}) {
|
||||
return request.get('/notifications/likes', { params })
|
||||
},
|
||||
|
||||
// 获取关注通知
|
||||
getFollowNotifications(params = {}) {
|
||||
return request.get('/notifications/follows', { params })
|
||||
},
|
||||
|
||||
// 获取收藏通知
|
||||
getCollectionNotifications(params = {}) {
|
||||
return request.get('/notifications/collections', { params })
|
||||
},
|
||||
|
||||
// 标记通知为已读
|
||||
markAsRead(notificationId) {
|
||||
return request.put(`/notifications/${notificationId}/read`)
|
||||
},
|
||||
|
||||
// 标记所有通知为已读
|
||||
markAllAsRead() {
|
||||
return request.put('/notifications/read-all')
|
||||
},
|
||||
|
||||
// 获取未读通知数量
|
||||
getUnreadCount() {
|
||||
return request.get('/notifications/unread-count')
|
||||
},
|
||||
|
||||
// 获取按类型分组的未读通知数量
|
||||
getUnreadCountByType() {
|
||||
return request.get('/notifications/unread-count-by-type')
|
||||
},
|
||||
|
||||
// 删除通知
|
||||
deleteNotification(notificationId) {
|
||||
return request.delete(`/notifications/${notificationId}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索相关API
|
||||
export const searchApi = {
|
||||
// 统一搜索接口
|
||||
search(params = {}) {
|
||||
return request.get('/search', { params })
|
||||
},
|
||||
|
||||
// 搜索笔记(支持关键词和标签)
|
||||
searchPosts(keyword = '', tag = '', params = {}) {
|
||||
return request.get('/search', {
|
||||
params: {
|
||||
keyword,
|
||||
tag,
|
||||
type: 'posts',
|
||||
...params
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 搜索用户
|
||||
searchUsers(keyword = '', params = {}) {
|
||||
return request.get('/search', {
|
||||
params: {
|
||||
keyword,
|
||||
type: 'users',
|
||||
...params
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 管理员相关API
|
||||
export const adminApi = {
|
||||
// 管理员登录
|
||||
login(data) {
|
||||
return request.post('/auth/admin/login', data)
|
||||
},
|
||||
|
||||
// 获取当前管理员信息
|
||||
getCurrentAdmin() {
|
||||
return request.get('/auth/admin/me')
|
||||
},
|
||||
|
||||
// 管理员退出登录
|
||||
logout() {
|
||||
return request.post('/auth/admin/logout')
|
||||
},
|
||||
|
||||
// ========== 用户管理 ==========
|
||||
// 获取用户列表
|
||||
getUsers(params = {}) {
|
||||
return request.get('/admin/users', { params })
|
||||
},
|
||||
|
||||
// 创建用户
|
||||
createUser(data) {
|
||||
return request.post('/admin/users', data)
|
||||
},
|
||||
|
||||
// 更新用户信息
|
||||
updateUser(userId, data) {
|
||||
return request.put(`/admin/users/${userId}`, data)
|
||||
},
|
||||
|
||||
// 删除用户
|
||||
deleteUser(userId) {
|
||||
return request.delete(`/admin/users/${userId}`)
|
||||
},
|
||||
|
||||
// 批量删除用户
|
||||
batchDeleteUsers(ids) {
|
||||
return request.delete('/admin/users', { data: { ids } })
|
||||
},
|
||||
|
||||
// 获取单个用户详情
|
||||
getUserDetail(userId) {
|
||||
return request.get(`/admin/users/${userId}`)
|
||||
},
|
||||
|
||||
// ========== 笔记管理 ==========
|
||||
// 获取笔记列表
|
||||
getPosts(params = {}) {
|
||||
return request.get('/admin/posts', { params })
|
||||
},
|
||||
|
||||
// 创建笔记
|
||||
createPost(data) {
|
||||
return request.post('/admin/posts', data)
|
||||
},
|
||||
|
||||
// 更新笔记
|
||||
updatePost(postId, data) {
|
||||
return request.put(`/admin/posts/${postId}`, data)
|
||||
},
|
||||
|
||||
// 删除笔记
|
||||
deletePost(postId) {
|
||||
return request.delete(`/admin/posts/${postId}`)
|
||||
},
|
||||
|
||||
// 批量删除笔记
|
||||
batchDeletePosts(ids) {
|
||||
return request.delete('/admin/posts', { data: { ids } })
|
||||
},
|
||||
|
||||
// 获取单个笔记详情
|
||||
getPostDetail(postId) {
|
||||
return request.get(`/admin/posts/${postId}`)
|
||||
},
|
||||
|
||||
// ========== 评论管理 ==========
|
||||
// 获取评论列表
|
||||
getComments(params = {}) {
|
||||
return request.get('/admin/comments', { params })
|
||||
},
|
||||
|
||||
// 创建评论
|
||||
createComment(data) {
|
||||
return request.post('/admin/comments', data)
|
||||
},
|
||||
|
||||
// 更新评论
|
||||
updateComment(commentId, data) {
|
||||
return request.put(`/admin/comments/${commentId}`, data)
|
||||
},
|
||||
|
||||
// 删除评论
|
||||
deleteComment(commentId) {
|
||||
return request.delete(`/admin/comments/${commentId}`)
|
||||
},
|
||||
|
||||
// 批量删除评论
|
||||
batchDeleteComments(ids) {
|
||||
return request.delete('/admin/comments', { data: { ids } })
|
||||
},
|
||||
|
||||
// 获取单个评论详情
|
||||
getCommentDetail(commentId) {
|
||||
return request.get(`/admin/comments/${commentId}`)
|
||||
},
|
||||
|
||||
// ========== 标签管理 ==========
|
||||
// 获取标签列表
|
||||
getTags(params = {}) {
|
||||
return request.get('/admin/tags', { params })
|
||||
},
|
||||
|
||||
// 创建标签
|
||||
createTag(data) {
|
||||
return request.post('/admin/tags', data)
|
||||
},
|
||||
|
||||
// 更新标签
|
||||
updateTag(tagId, data) {
|
||||
return request.put(`/admin/tags/${tagId}`, data)
|
||||
},
|
||||
|
||||
// 删除标签
|
||||
deleteTag(tagId) {
|
||||
return request.delete(`/admin/tags/${tagId}`)
|
||||
},
|
||||
|
||||
// 批量删除标签
|
||||
batchDeleteTags(ids) {
|
||||
return request.delete('/admin/tags', { data: { ids } })
|
||||
},
|
||||
|
||||
// 获取单个标签详情
|
||||
getTagDetail(tagId) {
|
||||
return request.get(`/admin/tags/${tagId}`)
|
||||
},
|
||||
|
||||
// ========== 点赞管理 ==========
|
||||
// 获取点赞列表
|
||||
getLikes(params = {}) {
|
||||
return request.get('/admin/likes', { params })
|
||||
},
|
||||
|
||||
// 创建点赞
|
||||
createLike(data) {
|
||||
return request.post('/admin/likes', data)
|
||||
},
|
||||
|
||||
// 更新点赞
|
||||
updateLike(likeId, data) {
|
||||
return request.put(`/admin/likes/${likeId}`, data)
|
||||
},
|
||||
|
||||
// 删除点赞
|
||||
deleteLike(likeId) {
|
||||
return request.delete(`/admin/likes/${likeId}`)
|
||||
},
|
||||
|
||||
// 批量删除点赞
|
||||
batchDeleteLikes(ids) {
|
||||
return request.delete('/admin/likes', { data: { ids } })
|
||||
},
|
||||
|
||||
// 获取单个点赞详情
|
||||
getLikeDetail(likeId) {
|
||||
return request.get(`/admin/likes/${likeId}`)
|
||||
},
|
||||
|
||||
// ========== 收藏管理 ==========
|
||||
// 获取收藏列表
|
||||
getCollections(params = {}) {
|
||||
return request.get('/admin/collections', { params })
|
||||
},
|
||||
|
||||
// 创建收藏
|
||||
createCollection(data) {
|
||||
return request.post('/admin/collections', data)
|
||||
},
|
||||
|
||||
// 更新收藏
|
||||
updateCollection(collectionId, data) {
|
||||
return request.put(`/admin/collections/${collectionId}`, data)
|
||||
},
|
||||
|
||||
// 删除收藏
|
||||
deleteCollection(collectionId) {
|
||||
return request.delete(`/admin/collections/${collectionId}`)
|
||||
},
|
||||
|
||||
// 批量删除收藏
|
||||
batchDeleteCollections(ids) {
|
||||
return request.delete('/admin/collections', { data: { ids } })
|
||||
},
|
||||
|
||||
// 获取单个收藏详情
|
||||
getCollectionDetail(collectionId) {
|
||||
return request.get(`/admin/collections/${collectionId}`)
|
||||
},
|
||||
|
||||
// ========== 关注管理 ==========
|
||||
// 获取关注列表
|
||||
getFollows(params = {}) {
|
||||
return request.get('/admin/follows', { params })
|
||||
},
|
||||
|
||||
// 创建关注
|
||||
createFollow(data) {
|
||||
return request.post('/admin/follows', data)
|
||||
},
|
||||
|
||||
// 更新关注
|
||||
updateFollow(followId, data) {
|
||||
return request.put(`/admin/follows/${followId}`, data)
|
||||
},
|
||||
|
||||
// 删除关注
|
||||
deleteFollow(followId) {
|
||||
return request.delete(`/admin/follows/${followId}`)
|
||||
},
|
||||
|
||||
// 批量删除关注
|
||||
batchDeleteFollows(ids) {
|
||||
return request.delete('/admin/follows', { data: { ids } })
|
||||
},
|
||||
|
||||
// 获取单个关注详情
|
||||
getFollowDetail(followId) {
|
||||
return request.get(`/admin/follows/${followId}`)
|
||||
},
|
||||
|
||||
// ========== 通知管理 ==========
|
||||
// 获取通知列表
|
||||
getNotifications(params = {}) {
|
||||
return request.get('/admin/notifications', { params })
|
||||
},
|
||||
|
||||
// 创建通知
|
||||
createNotification(data) {
|
||||
return request.post('/admin/notifications', data)
|
||||
},
|
||||
|
||||
// 更新通知
|
||||
updateNotification(notificationId, data) {
|
||||
return request.put(`/admin/notifications/${notificationId}`, data)
|
||||
},
|
||||
|
||||
// 删除通知
|
||||
deleteNotification(notificationId) {
|
||||
return request.delete(`/admin/notifications/${notificationId}`)
|
||||
},
|
||||
|
||||
// 批量删除通知
|
||||
batchDeleteNotifications(ids) {
|
||||
return request.delete('/admin/notifications', { data: { ids } })
|
||||
},
|
||||
|
||||
// 获取单个通知详情
|
||||
getNotificationDetail(notificationId) {
|
||||
return request.get(`/admin/notifications/${notificationId}`)
|
||||
},
|
||||
|
||||
// ========== 会话管理 ==========
|
||||
// 获取会话列表
|
||||
getSessions(params = {}) {
|
||||
return request.get('/admin/sessions', { params })
|
||||
},
|
||||
|
||||
// 创建会话
|
||||
createSession(data) {
|
||||
return request.post('/admin/sessions', data)
|
||||
},
|
||||
|
||||
// 更新会话
|
||||
updateSession(sessionId, data) {
|
||||
return request.put(`/admin/sessions/${sessionId}`, data)
|
||||
},
|
||||
|
||||
// 删除会话
|
||||
deleteSession(sessionId) {
|
||||
return request.delete(`/admin/sessions/${sessionId}`)
|
||||
},
|
||||
|
||||
// 批量删除会话
|
||||
batchDeleteSessions(ids) {
|
||||
return request.delete('/admin/sessions', { data: { ids } })
|
||||
},
|
||||
|
||||
// 获取单个会话详情
|
||||
getSessionDetail(sessionId) {
|
||||
return request.get(`/admin/sessions/${sessionId}`)
|
||||
},
|
||||
|
||||
// ========== 管理员管理 ==========
|
||||
// 获取管理员列表(两个路由都支持)
|
||||
getAdmins(params = {}) {
|
||||
return request.get('/admin/admins', { params })
|
||||
},
|
||||
|
||||
// 获取管理员列表(认证路由)
|
||||
getAdminsAuth(params = {}) {
|
||||
return request.get('/auth/admin/admins', { params })
|
||||
},
|
||||
|
||||
// 创建管理员
|
||||
createAdmin(data) {
|
||||
return request.post('/admin/admins', data)
|
||||
},
|
||||
|
||||
// 创建管理员(认证路由)
|
||||
createAdminAuth(data) {
|
||||
return request.post('/auth/admin/admins', data)
|
||||
},
|
||||
|
||||
// 更新管理员信息
|
||||
updateAdmin(adminId, data) {
|
||||
return request.put(`/admin/admins/${adminId}`, data)
|
||||
},
|
||||
|
||||
// 更新管理员信息(认证路由)
|
||||
updateAdminAuth(adminId, data) {
|
||||
return request.put(`/auth/admin/admins/${adminId}`, data)
|
||||
},
|
||||
|
||||
// 删除管理员
|
||||
deleteAdmin(adminId) {
|
||||
return request.delete(`/admin/admins/${adminId}`)
|
||||
},
|
||||
|
||||
// 删除管理员(认证路由)
|
||||
deleteAdminAuth(adminId) {
|
||||
return request.delete(`/auth/admin/admins/${adminId}`)
|
||||
},
|
||||
|
||||
// 批量删除管理员
|
||||
batchDeleteAdmins(ids) {
|
||||
return request.delete('/admin/admins', { data: { ids } })
|
||||
},
|
||||
|
||||
// 批量删除管理员(认证路由)
|
||||
batchDeleteAdminsAuth(ids) {
|
||||
return request.delete('/auth/admin/admins', { data: { ids } })
|
||||
},
|
||||
|
||||
// 获取单个管理员详情
|
||||
getAdminDetail(adminId) {
|
||||
return request.get(`/admin/admins/${adminId}`)
|
||||
},
|
||||
|
||||
// 获取单个管理员详情(认证路由)
|
||||
getAdminDetailAuth(adminId) {
|
||||
return request.get(`/auth/admin/admins/${adminId}`)
|
||||
}
|
||||
}
|
78
vue3-project/src/api/notification.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import { notificationApi } from './index.js'
|
||||
import request from './request.js'
|
||||
|
||||
// 获取评论通知
|
||||
export const getCommentNotifications = (params = {}) => {
|
||||
return request.get('/notifications/comments', { params })
|
||||
}
|
||||
|
||||
// 获取点赞通知
|
||||
export const getLikeNotifications = (params = {}) => {
|
||||
return request.get('/notifications/likes', { params })
|
||||
}
|
||||
|
||||
// 获取关注通知
|
||||
export const getFollowNotifications = (params = {}) => {
|
||||
return request.get('/notifications/follows', { params })
|
||||
}
|
||||
|
||||
// 获取收藏通知
|
||||
export const getCollectionNotifications = (params = {}) => {
|
||||
return request.get('/notifications/collections', { params })
|
||||
}
|
||||
|
||||
// 标记通知为已读
|
||||
export async function markNotificationAsRead(notificationId) {
|
||||
try {
|
||||
const response = await notificationApi.markAsRead(notificationId)
|
||||
return response.data || response
|
||||
} catch (error) {
|
||||
console.error('标记通知已读失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 标记所有通知为已读
|
||||
export async function markAllNotificationsAsRead() {
|
||||
try {
|
||||
const response = await notificationApi.markAllAsRead()
|
||||
return response.data || response
|
||||
} catch (error) {
|
||||
console.error('标记所有通知已读失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 获取未读通知数量
|
||||
export async function getUnreadNotificationCount() {
|
||||
try {
|
||||
const response = await notificationApi.getUnreadCount()
|
||||
// 获取未读通知数量
|
||||
return response.data || response
|
||||
} catch (error) {
|
||||
console.error('获取未读通知数量失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 获取按类型分组的未读通知数量
|
||||
export async function getUnreadNotificationCountByType() {
|
||||
try {
|
||||
const response = await notificationApi.getUnreadCountByType()
|
||||
return response.data || response
|
||||
} catch (error) {
|
||||
console.error('获取按类型分组的未读通知数量失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 删除通知
|
||||
export async function deleteNotification(notificationId) {
|
||||
try {
|
||||
const response = await notificationApi.deleteNotification(notificationId)
|
||||
return response.data || response
|
||||
} catch (error) {
|
||||
console.error('删除通知失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
430
vue3-project/src/api/posts.js
Normal file
@@ -0,0 +1,430 @@
|
||||
import { postApi } from './index.js'
|
||||
import request from './request.js'
|
||||
import apiConfig from '@/config/api.js'
|
||||
|
||||
// 转换后端数据格式为前端瀑布流需要的格式
|
||||
function transformPostData(backendPost) {
|
||||
|
||||
const likeCount = backendPost.like_count || 0
|
||||
const liked = backendPost.liked || false
|
||||
|
||||
|
||||
const collectCount = backendPost.collect_count || 0
|
||||
const commentCount = backendPost.comment_count || 0
|
||||
|
||||
const transformedData = {
|
||||
id: backendPost.id,
|
||||
image: (backendPost.images && backendPost.images[0]) || new URL('@/assets/imgs/未加载.png', import.meta.url).href,
|
||||
title: backendPost.title,
|
||||
avatar: backendPost.user_avatar || new URL('@/assets/imgs/未加载.png', import.meta.url).href,
|
||||
author: backendPost.nickname || '匿名用户',
|
||||
location: backendPost.location || '', // 添加作者的location字段
|
||||
likeCount: likeCount,
|
||||
liked: liked, // 使用后端返回的真实点赞状态
|
||||
collected: backendPost.collected || false, // 使用后端返回的真实收藏状态
|
||||
collectCount: collectCount, // 添加收藏数量到顶层
|
||||
commentCount: commentCount, // 添加评论数量到顶层
|
||||
path: `/post/${backendPost.id}`,
|
||||
category: backendPost.category || 'general',
|
||||
author_auto_id: backendPost.author_auto_id, // 作者自增ID
|
||||
author_account: backendPost.user_id, // 作者小石榴号
|
||||
user_id: backendPost.user_id, // 保持兼容性
|
||||
// 保留原始数据以备需要
|
||||
originalData: {
|
||||
content: backendPost.content,
|
||||
images: backendPost.images || [],
|
||||
tags: backendPost.tags || [],
|
||||
viewCount: backendPost.view_count || 0,
|
||||
commentCount: backendPost.comment_count || 0,
|
||||
collectCount: collectCount,
|
||||
createdAt: backendPost.created_at,
|
||||
userId: backendPost.user_id
|
||||
}
|
||||
}
|
||||
|
||||
return transformedData;
|
||||
}
|
||||
|
||||
// 获取笔记列表
|
||||
export async function getPostList(params = {}) {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
category,
|
||||
searchKeyword,
|
||||
searchTag,
|
||||
userId,
|
||||
type,
|
||||
sort
|
||||
} = params
|
||||
|
||||
try {
|
||||
|
||||
|
||||
let response
|
||||
|
||||
// 如果指定了用户ID和类型(收藏或点赞),获取用户的收藏或点赞内容
|
||||
if (userId && type) {
|
||||
if (type === 'collections') {
|
||||
// 获取用户收藏的笔记
|
||||
response = await fetch(`${apiConfig.baseURL}/users/${userId}/collections?page=${page}&limit=${limit}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
}).then(res => res.json())
|
||||
|
||||
if (response && response.code === 200 && response.data && response.data.collections) {
|
||||
return {
|
||||
posts: response.data.collections.map(transformPostData),
|
||||
pagination: response.data.pagination,
|
||||
hasMore: response.data.pagination.page < response.data.pagination.pages
|
||||
}
|
||||
}
|
||||
} else if (type === 'likes') {
|
||||
// 获取用户点赞的笔记
|
||||
response = await fetch(`${apiConfig.baseURL}/users/${userId}/likes?page=${page}&limit=${limit}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
}).then(res => res.json())
|
||||
|
||||
if (response && response.code === 200 && response.data && response.data.posts) {
|
||||
return {
|
||||
posts: response.data.posts.map(transformPostData),
|
||||
pagination: response.data.pagination,
|
||||
hasMore: response.data.pagination.page < response.data.pagination.pages
|
||||
}
|
||||
}
|
||||
} else if (type === 'posts') {
|
||||
// 获取用户自己发布的笔记
|
||||
const searchParams = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
limit: limit.toString()
|
||||
})
|
||||
|
||||
if (category) {
|
||||
searchParams.append('category', category)
|
||||
}
|
||||
|
||||
if (searchKeyword && searchKeyword.trim()) {
|
||||
searchParams.append('keyword', searchKeyword.trim())
|
||||
}
|
||||
|
||||
if (sort) {
|
||||
searchParams.append('sort', sort)
|
||||
}
|
||||
|
||||
response = await fetch(`${apiConfig.baseURL}/users/${userId}/posts?${searchParams.toString()}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
}).then(res => res.json())
|
||||
|
||||
if (response && response.code === 200 && response.data && response.data.posts) {
|
||||
return {
|
||||
posts: response.data.posts.map(transformPostData),
|
||||
pagination: response.data.pagination,
|
||||
hasMore: response.data.pagination.page < response.data.pagination.pages
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if ((searchKeyword && searchKeyword.trim()) || (searchTag && searchTag.trim())) {
|
||||
// 如果有搜索关键词或标签,使用新的统一搜索API
|
||||
const searchParams = new URLSearchParams({
|
||||
type: 'posts',
|
||||
page: page.toString(),
|
||||
limit: limit.toString()
|
||||
})
|
||||
|
||||
if (searchKeyword && searchKeyword.trim()) {
|
||||
searchParams.append('keyword', searchKeyword.trim())
|
||||
}
|
||||
|
||||
if (searchTag && searchTag.trim()) {
|
||||
searchParams.append('tag', searchTag.trim())
|
||||
}
|
||||
|
||||
response = await fetch(`${apiConfig.baseURL}/search?${searchParams.toString()}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
}).then(res => res.json())
|
||||
|
||||
// 适配新的搜索API返回格式 - posts模式返回笔记数据
|
||||
if (response && response.code === 200 && response.data && response.data.posts && response.data.posts.data) {
|
||||
return {
|
||||
posts: response.data.posts.data.map(transformPostData),
|
||||
pagination: response.data.posts.pagination,
|
||||
hasMore: response.data.posts.pagination.page < response.data.posts.pagination.pages
|
||||
}
|
||||
}
|
||||
} else if (userId) {
|
||||
// 如果指定了用户ID,获取该用户发布的笔记
|
||||
const apiParams = { page, limit, user_id: userId }
|
||||
if (category && category !== 'general') {
|
||||
apiParams.category = category
|
||||
}
|
||||
response = await postApi.getPosts(apiParams)
|
||||
} else {
|
||||
// 否则使用普通的获取笔记列表API
|
||||
const apiParams = { page, limit }
|
||||
if (category && category !== 'general') {
|
||||
apiParams.category = category
|
||||
}
|
||||
response = await postApi.getPosts(apiParams)
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (response && response.data && response.data.posts) {
|
||||
const transformedPosts = response.data.posts.map(transformPostData)
|
||||
|
||||
return {
|
||||
posts: transformedPosts,
|
||||
pagination: response.data.pagination,
|
||||
hasMore: response.data.pagination.page < response.data.pagination.pages
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取笔记列表失败:', error)
|
||||
}
|
||||
|
||||
// 如果API调用失败,返回空数据
|
||||
return {
|
||||
posts: [],
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: 0,
|
||||
pages: 0
|
||||
},
|
||||
hasMore: false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取笔记详情
|
||||
export async function getPostDetail(postId) {
|
||||
try {
|
||||
const response = await postApi.getPostDetail(postId)
|
||||
if (response && response.data) {
|
||||
return transformPostData(response.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取笔记详情失败:', error)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// 点赞笔记
|
||||
export async function likePost(postId) {
|
||||
try {
|
||||
const response = await postApi.likePost(postId)
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('点赞失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 取消点赞笔记
|
||||
export async function unlikePost(postId) {
|
||||
try {
|
||||
const response = await postApi.unlikePost(postId)
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('取消点赞失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 收藏笔记
|
||||
export async function collectPost(postId) {
|
||||
try {
|
||||
const response = await postApi.collectPost(postId)
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('收藏失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 取消收藏笔记
|
||||
export async function uncollectPost(postId) {
|
||||
try {
|
||||
const response = await postApi.uncollectPost(postId)
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('取消收藏失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 创建笔记
|
||||
export async function createPost(data) {
|
||||
try {
|
||||
const response = await postApi.createPost(data)
|
||||
return {
|
||||
success: true,
|
||||
data: response.data,
|
||||
message: response.message
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建笔记失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || '创建笔记失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户笔记列表
|
||||
export async function getUserPosts(params = {}) {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 10,
|
||||
keyword,
|
||||
category,
|
||||
sort = 'created_at',
|
||||
user_id
|
||||
} = params
|
||||
|
||||
const queryParams = {
|
||||
page,
|
||||
limit,
|
||||
userId: user_id,
|
||||
type: 'posts',
|
||||
searchKeyword: keyword,
|
||||
category,
|
||||
sort
|
||||
}
|
||||
|
||||
const response = await getPostList(queryParams)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
posts: response.posts || [],
|
||||
pagination: response.pagination || {
|
||||
page: 1,
|
||||
pages: 1,
|
||||
total: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户笔记失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || '获取笔记失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新笔记
|
||||
export async function updatePost(postId, data) {
|
||||
try {
|
||||
const response = await postApi.updatePost(postId, data)
|
||||
return {
|
||||
success: true,
|
||||
data: response.data,
|
||||
message: response.message || '更新成功'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新笔记失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || '更新笔记失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除笔记
|
||||
export async function deletePost(postId) {
|
||||
try {
|
||||
const response = await postApi.deletePost(postId)
|
||||
return {
|
||||
success: true,
|
||||
message: response.message || '删除成功'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除笔记失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || '删除笔记失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取草稿列表
|
||||
export async function getDraftPosts(params = {}) {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 10,
|
||||
keyword = '',
|
||||
category = '',
|
||||
sort = 'created_at',
|
||||
user_id
|
||||
} = params
|
||||
|
||||
const queryParams = {
|
||||
page,
|
||||
limit,
|
||||
keyword,
|
||||
category,
|
||||
sort,
|
||||
user_id,
|
||||
is_draft: 1 // 只获取草稿
|
||||
}
|
||||
|
||||
// 过滤空值参数
|
||||
Object.keys(queryParams).forEach(key => {
|
||||
if (queryParams[key] === '' || queryParams[key] === null || queryParams[key] === undefined) {
|
||||
delete queryParams[key]
|
||||
}
|
||||
})
|
||||
|
||||
const response = await request.get('/posts', { params: queryParams })
|
||||
|
||||
if (response.success && response.data && response.data.posts) {
|
||||
const transformedPosts = response.data.posts.map(transformPostData)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
posts: transformedPosts,
|
||||
pagination: response.data.pagination
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: response.message || '获取草稿列表失败',
|
||||
data: {
|
||||
posts: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
pages: 1,
|
||||
total: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取草稿列表失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || '获取草稿列表失败,请重试',
|
||||
data: {
|
||||
posts: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
pages: 1,
|
||||
total: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
155
vue3-project/src/api/request.js
Normal file
@@ -0,0 +1,155 @@
|
||||
import axios from 'axios'
|
||||
import apiConfig from '@/config/api.js'
|
||||
|
||||
// 创建axios实例
|
||||
const request = axios.create({
|
||||
baseURL: apiConfig.baseURL,
|
||||
timeout: apiConfig.timeout,
|
||||
headers: apiConfig.defaultHeaders
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
request.interceptors.request.use(
|
||||
config => {
|
||||
const isAdminRequest = config.url && config.url.includes('/auth/admin/')
|
||||
const isInAdminPage = window.location.pathname.startsWith('/admin')
|
||||
|
||||
if (isAdminRequest || isInAdminPage) {
|
||||
// admin相关请求或在admin页面时使用admin token
|
||||
const adminToken = localStorage.getItem('admin_token')
|
||||
if (adminToken) {
|
||||
config.headers.Authorization = `Bearer ${adminToken}`
|
||||
}
|
||||
} else {
|
||||
// 普通用户请求使用普通token
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
error => {
|
||||
// 对请求错误做些什么
|
||||
console.error('❌ 请求配置错误:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
request.interceptors.response.use(
|
||||
(response) => {
|
||||
// 对于后端返回的 { code, message, data } 格式,转换为前端期望的 { success, message, data } 格式
|
||||
if (response.data && response.data.hasOwnProperty('code')) {
|
||||
return {
|
||||
success: response.data.code === 200,
|
||||
message: response.data.message,
|
||||
data: response.data.data
|
||||
}
|
||||
}
|
||||
|
||||
// 其他情况直接返回原始数据
|
||||
return response.data
|
||||
},
|
||||
async error => {
|
||||
// 对响应错误做点什么
|
||||
if (error.response) {
|
||||
// 处理特定的HTTP状态码
|
||||
let errorMessage = '请求失败'
|
||||
switch (error.response.status) {
|
||||
case 401:
|
||||
// 未授权,需要区分是会话过期还是未登录状态
|
||||
console.log('检测到401错误,开始处理未授权访问')
|
||||
|
||||
// 判断是管理员还是普通用户
|
||||
const isAdminPage = window.location.pathname.startsWith('/admin')
|
||||
const isAdminRequest = error.config?.url?.includes('/auth/admin/')
|
||||
|
||||
console.log('页面类型判断:', { isAdminPage, isAdminRequest })
|
||||
|
||||
if (isAdminPage || isAdminRequest) {
|
||||
// 管理员相关请求
|
||||
const adminToken = localStorage.getItem('admin_token')
|
||||
if (adminToken) {
|
||||
// 有token但401,说明是会话过期
|
||||
console.log('管理员会话过期,清除本地存储')
|
||||
localStorage.removeItem('admin_token')
|
||||
localStorage.removeItem('admin_refresh_token')
|
||||
localStorage.removeItem('admin_info')
|
||||
// 只有在登录页面才跳转,避免死循环
|
||||
if (!window.location.pathname.includes('/admin/login')) {
|
||||
window.location.href = '/admin/login'
|
||||
}
|
||||
errorMessage = '会话已过期,已自动退出登录'
|
||||
} else {
|
||||
// 没有token,说明是未登录状态,不需要跳转
|
||||
errorMessage = '未授权访问'
|
||||
}
|
||||
} else {
|
||||
// 普通用户相关请求
|
||||
const userToken = localStorage.getItem('token')
|
||||
if (userToken) {
|
||||
// 有token但401,说明是会话过期
|
||||
console.log('普通用户会话过期,清除本地存储')
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refreshToken')
|
||||
localStorage.removeItem('userInfo')
|
||||
// 跳转到首页
|
||||
window.location.href = '/'
|
||||
errorMessage = '会话已过期,已自动退出登录'
|
||||
} else {
|
||||
// 没有token,说明是未登录状态,不需要跳转
|
||||
errorMessage = '未授权访问'
|
||||
}
|
||||
}
|
||||
break
|
||||
case 403:
|
||||
errorMessage = '禁止访问'
|
||||
break
|
||||
case 404:
|
||||
errorMessage = '资源不存在'
|
||||
break
|
||||
case 500:
|
||||
errorMessage = '服务器内部错误'
|
||||
console.error('服务器内部错误:', error.response.data)
|
||||
break
|
||||
default:
|
||||
errorMessage = error.response.data?.message || `请求失败 (${error.response.status})`
|
||||
}
|
||||
|
||||
// 如果服务器返回了code字段,使用服务器的错误信息
|
||||
if (error.response.data && error.response.data.hasOwnProperty('code')) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.response.data.message || errorMessage,
|
||||
data: error.response.data.data
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
data: null
|
||||
}
|
||||
} else if (error.request) {
|
||||
// 请求已经成功发起,但没有收到响应
|
||||
console.error('网络连接失败,请检查网络设置')
|
||||
return {
|
||||
success: false,
|
||||
message: '网络连接失败,请检查网络设置',
|
||||
data: null
|
||||
}
|
||||
} else {
|
||||
// 发送请求时出了点问题
|
||||
console.error('请求配置错误:', error.message)
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || '请求配置错误',
|
||||
data: null
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
65
vue3-project/src/api/tags.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import request from './request.js'
|
||||
|
||||
// 获取所有标签
|
||||
export async function getTags(params = {}) {
|
||||
try {
|
||||
const response = await request.get('/tags', { params })
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('获取标签失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
message: error.message || '获取标签失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取热门标签
|
||||
export async function getHotTags(limit = 20) {
|
||||
try {
|
||||
const response = await request.get('/tags/hot', {
|
||||
params: { limit }
|
||||
})
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('获取热门标签失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
message: error.message || '获取热门标签失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索标签
|
||||
export async function searchTags(keyword, params = {}) {
|
||||
try {
|
||||
const response = await request.get('/tags/search', {
|
||||
params: { keyword, ...params }
|
||||
})
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('搜索标签失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
message: error.message || '搜索标签失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建标签
|
||||
export async function createTag(data) {
|
||||
try {
|
||||
const response = await request.post('/tags', data)
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('创建标签失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
message: error.message || '创建标签失败'
|
||||
}
|
||||
}
|
||||
}
|
301
vue3-project/src/api/upload.js
Normal file
@@ -0,0 +1,301 @@
|
||||
import request from './request.js'
|
||||
|
||||
// 压缩图片函数
|
||||
const compressImage = (file, maxSizeMB = 0.8, quality = 0.4) => {
|
||||
return new Promise((resolve) => {
|
||||
// 对于800KB以下的文件不进行压缩
|
||||
if (file.size <= maxSizeMB * 1024 * 1024) {
|
||||
resolve(file)
|
||||
return
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
const img = new Image()
|
||||
|
||||
img.onload = () => {
|
||||
// 超过800KB的图片使用强力压缩
|
||||
const compressQuality = 0.4
|
||||
const maxDimension = 1200
|
||||
|
||||
// 计算新的尺寸
|
||||
let { width, height } = img
|
||||
if (width > maxDimension || height > maxDimension) {
|
||||
const ratio = Math.min(maxDimension / width, maxDimension / height)
|
||||
width = Math.floor(width * ratio)
|
||||
height = Math.floor(height * ratio)
|
||||
}
|
||||
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
// 绘制并压缩
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
canvas.toBlob((blob) => {
|
||||
const compressedFile = new File([blob], file.name, {
|
||||
type: file.type,
|
||||
lastModified: Date.now()
|
||||
})
|
||||
|
||||
resolve(compressedFile)
|
||||
}, file.type, compressQuality)
|
||||
}
|
||||
|
||||
img.onerror = () => resolve(file) // 加载失败,返回原文件
|
||||
img.src = URL.createObjectURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
export async function uploadImage(file, options = {}) {
|
||||
try {
|
||||
if (!file) throw new Error('请选择要上传的文件')
|
||||
if (file instanceof File && !file.type.startsWith('image/')) throw new Error('请选择图片文件')
|
||||
if (file.size > 5 * 1024 * 1024) throw new Error('图片大小不能超过5MB')
|
||||
|
||||
// 压缩图片
|
||||
const compressedFile = await compressImage(file)
|
||||
|
||||
const formData = new FormData()
|
||||
const filename = options.filename || (compressedFile instanceof File ? compressedFile.name : 'image.png')
|
||||
formData.append('file', compressedFile, filename)
|
||||
|
||||
// 创建AbortController用于超时控制
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 60000) // 60秒超时
|
||||
|
||||
const response = await fetch('/api/upload/single', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP错误: ${response.status}`)
|
||||
|
||||
const result = await response.json()
|
||||
if (result.code !== 200) throw new Error(result.message || '上传失败')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { url: result.data.url, originalName: filename, size: file.size },
|
||||
message: '上传成功'
|
||||
}
|
||||
} catch (error) {
|
||||
let errorMessage = '上传失败,请重试'
|
||||
|
||||
if (error.name === 'AbortError') {
|
||||
errorMessage = '上传超时,请检查网络连接或稍后重试'
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
message: errorMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadImages(files, options = {}) {
|
||||
try {
|
||||
const { maxCount = 9, onProgress, onSingleComplete } = options
|
||||
const fileArray = Array.from(files)
|
||||
|
||||
if (fileArray.length === 0) throw new Error('请选择要上传的文件')
|
||||
if (fileArray.length > maxCount) throw new Error(`最多只能上传${maxCount}张图片`)
|
||||
|
||||
const results = []
|
||||
const errors = []
|
||||
|
||||
for (let i = 0; i < fileArray.length; i++) {
|
||||
const file = fileArray[i]
|
||||
|
||||
try {
|
||||
onProgress?.({
|
||||
current: i + 1,
|
||||
total: fileArray.length,
|
||||
percent: Math.round(((i + 1) / fileArray.length) * 100)
|
||||
})
|
||||
|
||||
const result = await uploadImage(file)
|
||||
|
||||
if (result.success) {
|
||||
results.push(result.data)
|
||||
onSingleComplete?.({ index: i, file, result: result.data, success: true })
|
||||
} else {
|
||||
errors.push({ file: file.name, error: result.message })
|
||||
onSingleComplete?.({ index: i, file, result: null, success: false, error: result.message })
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push({ file: file.name, error: error.message })
|
||||
onSingleComplete?.({ index: i, file, result: null, success: false, error: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: results.length > 0,
|
||||
data: {
|
||||
uploaded: results,
|
||||
errors,
|
||||
total: fileArray.length,
|
||||
successCount: results.length,
|
||||
errorCount: errors.length
|
||||
},
|
||||
message: errors.length === 0 ? '所有图片上传成功' : `${results.length}张上传成功,${errors.length}张失败`
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
message: error.message || '批量上传失败,请重试'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadCroppedImage(blob, options = {}) {
|
||||
try {
|
||||
if (!blob) throw new Error('请选择要上传的文件')
|
||||
|
||||
const formData = new FormData()
|
||||
const filename = options.filename || 'avatar.png'
|
||||
formData.append('file', blob, filename)
|
||||
|
||||
// 自动检测token类型(管理员或普通用户)
|
||||
const adminToken = localStorage.getItem('admin_token')
|
||||
const userToken = localStorage.getItem('token')
|
||||
const token = adminToken || userToken
|
||||
|
||||
if (!token) {
|
||||
throw new Error('未登录,请先登录')
|
||||
}
|
||||
|
||||
// 使用后端的单文件上传接口
|
||||
const response = await fetch('/api/upload/single', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP错误: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.code === 200) {
|
||||
return {
|
||||
success: true,
|
||||
data: { url: result.data.url, originalName: filename, size: blob.size },
|
||||
message: '上传成功'
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.message || '上传失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('头像上传失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
message: error.message || '上传失败,请重试'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function validateImageFile(file, options = {}) {
|
||||
const {
|
||||
maxSize = 5 * 1024 * 1024,
|
||||
allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
|
||||
} = options
|
||||
|
||||
if (!file) return { valid: false, error: '请选择文件' }
|
||||
if (!file.type.startsWith('image/')) return { valid: false, error: '请选择图片文件' }
|
||||
if (allowedTypes.length > 0 && !allowedTypes.includes(file.type)) {
|
||||
return { valid: false, error: `不支持的文件类型` }
|
||||
}
|
||||
if (file.size > maxSize) {
|
||||
const maxSizeMB = Math.round(maxSize / (1024 * 1024))
|
||||
return { valid: false, error: `文件大小不能超过${maxSizeMB}MB` }
|
||||
}
|
||||
return { valid: true, error: null }
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
export function createImagePreview(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!file || !file.type.startsWith('image/')) {
|
||||
reject(new Error('不是有效的图片文件'))
|
||||
return
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => resolve(e.target.result)
|
||||
reader.onerror = () => reject(new Error('读取文件失败'))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
export async function uploadBase64Images(base64Images) {
|
||||
try {
|
||||
if (!base64Images || !Array.isArray(base64Images) || base64Images.length === 0) {
|
||||
throw new Error('没有提供图片数据')
|
||||
}
|
||||
|
||||
|
||||
|
||||
const response = await request.post('/upload/base64', {
|
||||
images: base64Images
|
||||
})
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || '上传失败')
|
||||
}
|
||||
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data.urls,
|
||||
message: response.message
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 上传base64图片到图床失败:', error.message)
|
||||
|
||||
let errorMessage = '上传失败,请重试'
|
||||
if (error.message && error.message.includes('timeout')) {
|
||||
errorMessage = '上传超时,图片较多时需要更长时间,请稍后重试'
|
||||
} else if (error.message && error.message.includes('网络')) {
|
||||
errorMessage = '网络连接失败,请检查网络后重试'
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
message: errorMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
uploadImage,
|
||||
uploadImages,
|
||||
uploadCroppedImage,
|
||||
validateImageFile,
|
||||
formatFileSize,
|
||||
createImagePreview,
|
||||
uploadBase64Images
|
||||
}
|
23
vue3-project/src/api/user.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { userApi } from './index.js'
|
||||
|
||||
// 获取用户信息API
|
||||
export async function getUserInfo(userId) {
|
||||
try {
|
||||
const userInfo = await userApi.getUserInfo(userId)
|
||||
return userInfo
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error)
|
||||
// 如果API调用失败,返回基础数据
|
||||
return {
|
||||
id: userId,
|
||||
avatar: null,
|
||||
nickname: `用户${userId}`,
|
||||
bio: '还没有简介',
|
||||
followCount: 0,
|
||||
fansCount: 0,
|
||||
likeAndCollectCount: 0,
|
||||
isFollowing: false,
|
||||
images: []
|
||||
}
|
||||
}
|
||||
}
|
24
vue3-project/src/assets/css/animations.css
Normal file
@@ -0,0 +1,24 @@
|
||||
/* 骨架屏通用动画样式 */
|
||||
@keyframes skeleton-shimmer {
|
||||
0% {
|
||||
background-position: -200px 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: calc(200px + 100%) 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes skeleton-loading {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
43
vue3-project/src/assets/css/index.css
Normal file
@@ -0,0 +1,43 @@
|
||||
/* 引入主题变量 */
|
||||
@import './theme.css';
|
||||
/* 手机端特殊样式 */
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: var(--font-family);
|
||||
background-color: var(--bg-color-primary);
|
||||
color: var(--text-color-primary);
|
||||
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
overflow-x: hidden;
|
||||
width: 100vw;
|
||||
min-width: 100%;
|
||||
min-height: 100vh;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
/* 让按钮和表单元素继承字体族 */
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100vw;
|
||||
min-width: 100%;
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
background-color: var(--bg-color-primary);
|
||||
box-sizing: border-box;
|
||||
}
|
67
vue3-project/src/assets/css/theme.css
Normal file
@@ -0,0 +1,67 @@
|
||||
:root {
|
||||
/* 主题色 */
|
||||
--primary-color: #ff2e4d;
|
||||
--primary-color-dark: #e02a44;
|
||||
|
||||
/* 文本颜色 */
|
||||
--text-color-primary: #333;
|
||||
--text-color-secondary: #5c5c5c;
|
||||
--text-color-tertiary: #858585;
|
||||
--text-color-quaternary: #bbbbbb;
|
||||
--text-color-inverse: #fff;
|
||||
--text-color-tag: #13386c;
|
||||
/* 背景颜色 */
|
||||
--bg-color-primary: #fff;
|
||||
--bg-color-secondary: #f7f7f7;
|
||||
--bg-color-tertiary: #e8e4e4;
|
||||
--bg-color-inverse: #474747;
|
||||
/* 边框颜色 */
|
||||
--border-color-primary: #ebebeb;
|
||||
--border-color-secondary: #eee;
|
||||
|
||||
/* 禁用状态颜色 */
|
||||
--disabled-bg: #f5f5f5;
|
||||
--disabled-text: #ccc;
|
||||
|
||||
/* 阴影颜色 */
|
||||
--shadow-color: rgba(0, 0, 0, 0.08);
|
||||
--font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
|
||||
/* 追加:通用遮罩背景(半透明) */
|
||||
--overlay-bg: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
/* 深色主题 */
|
||||
[data-theme="dark"] {
|
||||
/* 主题色 */
|
||||
--primary-color: #ff2e4d;
|
||||
--primary-color-dark: #e02a44;
|
||||
|
||||
/* 文本颜色 */
|
||||
--text-color-primary: #e0e0e0;
|
||||
--text-color-secondary: #b0b0b0;
|
||||
--text-color-tertiary: #909090;
|
||||
--text-color-quaternary: #707070;
|
||||
--text-color-inverse: #0a0a0a;
|
||||
--text-color-tag: #c7daef;
|
||||
|
||||
/* 背景颜色 */
|
||||
--bg-color-primary: #121212;
|
||||
--bg-color-secondary: #1e1e1e;
|
||||
--bg-color-tertiary: #2a2a2a;
|
||||
--bg-color-inverse: #e7e7e7;
|
||||
|
||||
/* 边框颜色 */
|
||||
--border-color-primary: #242424;
|
||||
--border-color-secondary: #252525;
|
||||
|
||||
/* 禁用状态颜色 */
|
||||
--disabled-bg: #2a2a2a;
|
||||
--disabled-text: #555;
|
||||
|
||||
/* 阴影颜色 */
|
||||
--shadow-color: rgba(0, 0, 0, 0.3);
|
||||
|
||||
/* 追加:通用遮罩背景(半透明) */
|
||||
--overlay-bg: rgba(18, 18, 18, 0.9);
|
||||
}
|
4
vue3-project/src/assets/icons/alert.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 20 20">
|
||||
<path fill="currentColor" d="M9.25 6.105c0-.38.336-.688.75-.688s.75.308.75.688v5.29c0 .38-.336.688-.75.688s-.75-.308-.75-.688zM10 14.584a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5"/>
|
||||
<path fill="currentColor" d="M18.333 10a8.333 8.333 0 1 1-16.666 0 8.333 8.333 0 0 1 16.666 0m-1.5 0a6.833 6.833 0 1 0-13.666 0 6.833 6.833 0 0 0 13.666 0"/>
|
||||
</svg>
|
After Width: | Height: | Size: 429 B |
3
vue3-project/src/assets/icons/arrowTop.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12.646 3.268a.914.914 0 0 0-1.292 0L5.768 8.854a.914.914 0 1 0 1.292 1.292l4.026-4.025v13.965a.914.914 0 0 0 1.828 0V6.12l4.026 4.025a.914.914 0 1 0 1.292-1.292z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 291 B |
3
vue3-project/src/assets/icons/chat.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="m3.925 17.006 1.528-.95c.308.496.389 1.11.199 1.681L5.09 19.42l.237-.044c.44-.08.903-.162 1.299-.223.337-.053.784-.118 1.098-.118.459 0 .968.138 1.255.216l.077.02c.7.187 1.61.429 2.943.429 4.234 0 7.7-3.45 7.7-7.7a7.7 7.7 0 1 0-14.247 4.056zM2.917 20.25a.95.95 0 0 0 .882 1.25q.018 0 .036-.003c.252-.051 3.3-.662 3.89-.662.208 0 .492.076.868.176.739.197 1.833.489 3.407.489 5.225 0 9.5-4.253 9.5-9.5a9.5 9.5 0 1 0-17.576 5.006c.03.049.038.108.02.162z" clip-rule="evenodd"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 619 B |
1
vue3-project/src/assets/icons/clear.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg t="1756233044969" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4493" width="24" height="24"><path d="M942.08 864.1024h-309.248l308.8896-308.8384a100.6592 100.6592 0 0 0 0-142.1824l-295.936-295.936a100.5568 100.5568 0 0 0-142.1824 0l-412.5184 412.4672a100.608 100.608 0 0 0 0 142.1824l192.3072 192.3072H188.416a35.84 35.84 0 1 0 0 71.68H942.08a35.84 35.84 0 0 0 0-71.68z m-387.584-696.32a28.8768 28.8768 0 0 1 40.96 0L890.88 463.7696a28.9792 28.9792 0 0 1 0 40.96L750.2336 645.12 420.4032 301.6704z m-412.7232 412.5184l227.9424-227.9424 329.8304 343.6544-168.0896 168.0896H384.768l-242.9952-242.9952a28.8768 28.8768 0 0 1 0-40.8064z" fill="current-color" p-id="4494"></path></svg>
|
After Width: | Height: | Size: 718 B |
3
vue3-project/src/assets/icons/close.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M19.23 4.772a.92.92 0 0 1 0 1.301l-5.928 5.928 5.926 5.925a.92.92 0 0 1-1.302 1.302l-5.925-5.926-5.928 5.929a.92.92 0 0 1-1.214.076l-.087-.076a.92.92 0 0 1 0-1.302l5.928-5.928-5.93-5.93a.92.92 0 0 1 1.3-1.301l5.931 5.93 5.928-5.928a.92.92 0 0 1 1.215-.077z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 387 B |
3
vue3-project/src/assets/icons/collect.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M18.865 19.503a1.81 1.81 0 0 1-.737 1.649 1.82 1.82 0 0 1-1.8.196L12.2 19.546a.5.5 0 0 0-.4 0l-4.127 1.802a1.82 1.82 0 0 1-1.801-.196 1.81 1.81 0 0 1-.737-1.65l.452-4.384a.5.5 0 0 0-.127-.386l-2.994-3.32a1.81 1.81 0 0 1-.377-1.77c.2-.615.713-1.077 1.347-1.213l4.404-.945a.5.5 0 0 0 .326-.235l2.265-3.853a1.82 1.82 0 0 1 3.138 0l2.265 3.853a.5.5 0 0 0 .326.235l4.404.945a1.808 1.808 0 0 1 .97 2.984l-2.994 3.319a.5.5 0 0 0-.127.386zm-1.662-5.977 2.994-3.32q.004-.004.003-.007-.001-.006-.013-.01l-4.404-.945a2.3 2.3 0 0 1-1.5-1.083l-2.266-3.853Q12.014 4.302 12 4.3q-.015.002-.017.008L9.718 8.161a2.3 2.3 0 0 1-1.5 1.083l-4.405.945q-.012.004-.013.01 0 .003.003.008l2.994 3.32a2.3 2.3 0 0 1 .58 1.776l-.452 4.384q-.001.001.005.01l.01.003h.002q.006 0 .01-.002l4.128-1.801a2.3 2.3 0 0 1 1.84 0l4.127 1.801.012.002.01-.004q.008-.008.006-.009l-.452-4.384a2.3 2.3 0 0 1 .58-1.777"></path><path fill="currentColor" d="M18.865 19.503a1.81 1.81 0 0 1-.737 1.649 1.82 1.82 0 0 1-1.8.196L12.2 19.546a.5.5 0 0 0-.4 0l-4.127 1.802a1.82 1.82 0 0 1-1.801-.196 1.81 1.81 0 0 1-.737-1.65l.452-4.384a.5.5 0 0 0-.127-.386l-2.994-3.32a1.81 1.81 0 0 1-.377-1.77c.2-.615.713-1.077 1.347-1.213l4.404-.945a.5.5 0 0 0 .326-.235l2.265-3.853a1.82 1.82 0 0 1 3.138 0l2.265 3.853a.5.5 0 0 0 .326.235l4.404.945a1.808 1.808 0 0 1 .97 2.984l-2.994 3.319a.5.5 0 0 0-.127.386zm-1.662-5.977 2.994-3.32q.004-.004.003-.007-.001-.006-.013-.01l-4.404-.945a2.3 2.3 0 0 1-1.5-1.083l-2.266-3.853Q12.014 4.302 12 4.3q-.015.002-.017.008L9.718 8.161a2.3 2.3 0 0 1-1.5 1.083l-4.405.945q-.012.004-.013.01 0 .003.003.008l2.994 3.32a2.3 2.3 0 0 1 .58 1.776l-.452 4.384q-.001.001.005.01l.01.003h.002q.006 0 .01-.002l4.128-1.801a2.3 2.3 0 0 1 1.84 0l4.127 1.801.012.002.01-.004q.008-.008.006-.009l-.452-4.384a2.3 2.3 0 0 1 .58-1.777"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 1.9 KiB |
3
vue3-project/src/assets/icons/collected.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path fill="#FDBC5F" d="M18.865 19.503a1.81 1.81 0 0 1-.737 1.649 1.82 1.82 0 0 1-1.8.196L12.2 19.546a.5.5 0 0 0-.4 0l-4.127 1.802a1.82 1.82 0 0 1-1.801-.196 1.81 1.81 0 0 1-.737-1.65l.452-4.384a.5.5 0 0 0-.127-.386l-2.994-3.32a1.81 1.81 0 0 1-.377-1.77c.2-.615.713-1.077 1.347-1.213l4.404-.945a.5.5 0 0 0 .326-.235l2.265-3.853a1.82 1.82 0 0 1 3.138 0l2.265 3.853a.5.5 0 0 0 .326.235l4.404.945a1.808 1.808 0 0 1 .97 2.984l-2.994 3.319a.5.5 0 0 0-.127.386z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 555 B |
3
vue3-project/src/assets/icons/data.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M13.8 3.75a.75.75 0 011.5 0v16.5a.75.75 0 01-1.5 0V3.75zM4.25 8a.75.75 0 00-.75.75v11.5a.75.75 0 001.5 0V8.75A.75.75 0 004.25 8zm5.15 4a.75.75 0 00-.75.75v7.5a.75.75 0 001.5 0v-7.5A.75.75 0 009.4 12zm10.35-3a.75.75 0 00-.75.75v10.5a.75.75 0 001.5 0V9.75a.75.75 0 00-.75-.75z" clip-rule="evenodd" fill-rule="evenodd"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 444 B |
4
vue3-project/src/assets/icons/delete.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 20 20">
|
||||
<path fill="currentColor" d="M8.125 2.5h3.75a.625.625 0 1 1 0 1.25h-3.75a.625.625 0 1 1 0-1.25M8.125 7.5a.625.625 0 0 0-.625.625v6.25a.625.625 0 1 0 1.25 0v-6.25a.625.625 0 0 0-.625-.625M11.25 8.125a.625.625 0 1 1 1.25 0v6.25a.625.625 0 1 1-1.25 0z"/>
|
||||
<path fill="currentColor" d="M16.875 5a.625.625 0 1 1 0 1.25h-1.042v7.917A3.333 3.333 0 0 1 12.5 17.5h-5a3.333 3.333 0 0 1-3.333-3.333V6.25H3.125a.625.625 0 1 1 0-1.25zM5.417 6.25v7.917c0 1.15.932 2.083 2.083 2.083h5c1.15 0 2.083-.933 2.083-2.083V6.25z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 603 B |
3
vue3-project/src/assets/icons/down.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 16 16">
|
||||
<path fill="currentColor" d="M5.527 13.805c.26.26.683.26.943 0l5.33-5.334c.26-.26.26-.682 0-.942l-5.33-5.334a.667.667 0 0 0-.943.943L10.39 8l-4.867 4.862a.667.667 0 0 0 0 .943z" transform="rotate(90 8 8)"/>
|
||||
</svg>
|
After Width: | Height: | Size: 297 B |
1
vue3-project/src/assets/icons/draft.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg t="1756620391140" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="20" height="20"><path fill="current-color" d="M145.984 911.488a73.216 73.216 0 0 1-73.984-73.984V567.616c0-16.512 20.736-58.304 60.992-64.512l0.64-316.608a74.88 74.88 0 0 1 73.856-73.984h609.024c40.704 0 73.92 33.216 73.92 73.984V502.4c37.76 6.272 61.568 46.144 61.568 65.216v269.888c0 35.584-28.352 73.984-73.984 73.984H145.92z m5.76-75.84l717.888 0.768 0.768-46.08V577.92l-164.096-0.768-0.768 46.08c0 35.84-31.936 65.088-71.168 65.088H388.352c-39.296 0-71.232-29.184-71.232-65.088v-45.312l-165.248-0.768-0.128 258.56z m62.144-334.336l64.384 0.64 46.464 0.064c50.24 0.448 73.6 44.672 73.6 74.432v36.48l226.176 0.704 0.704-37.12c0-30.464 26.816-74.496 75.136-74.496h108.544l0.32-314.496-594.56-0.768-0.768 314.56z"></path></svg>
|
After Width: | Height: | Size: 844 B |
4
vue3-project/src/assets/icons/edit.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M4.26586 8.91693L10.6231 2.55968C11.3391 1.84371 12.4999 1.84371 13.2158 2.55968C13.9318 3.27564 13.9318 4.43644 13.2158 5.1524L6.86853 11.4997C6.67116 11.6971 6.40959 11.8171 6.13127 11.8381L4.66242 11.9488C4.20348 11.9834 3.8034 11.6395 3.7688 11.1805C3.76487 11.1284 3.76585 11.0761 3.77172 11.0242L3.93153 9.61081C3.96121 9.34833 4.07908 9.1037 4.26586 8.91693ZM6.0561 10.8409C6.09586 10.8379 6.13322 10.8208 6.16142 10.7926L12.5087 4.44529C12.8342 4.11986 12.8342 3.59222 12.5087 3.26678C12.1833 2.94135 11.6556 2.94135 11.3302 3.26678L4.97296 9.62403C4.94628 9.65071 4.92944 9.68566 4.9252 9.72316L4.78801 10.9365L6.0561 10.8409Z" fill="currentColor"/>
|
||||
<path d="M3.1665 13C2.89036 13 2.6665 13.2238 2.6665 13.5C2.6665 13.7761 2.89036 14 3.1665 14H12.8332C13.1093 14 13.3332 13.7761 13.3332 13.5C13.3332 13.2238 13.1093 13 12.8332 13H3.1665Z" fill="currentColor"/>
|
||||
</svg>
|
After Width: | Height: | Size: 973 B |
4
vue3-project/src/assets/icons/emoji.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 20 20">
|
||||
<path fill="currentColor" d="M8.334 9A1.167 1.167 0 1 1 6 9a1.167 1.167 0 0 1 2.334 0M14 9a1.167 1.167 0 1 1-2.333 0A1.167 1.167 0 0 1 14 9M8.022 12.379c.515.616 1.195.939 1.984.939.793 0 1.47-.326 1.975-.947a.739.739 0 0 0-1.146-.932c-.226.277-.483.401-.829.401-.348 0-.613-.126-.85-.41a.739.739 0 0 0-1.134.949"></path>
|
||||
<path fill="currentColor" d="M1.667 10a8.333 8.333 0 1 1 16.667 0 8.333 8.333 0 0 1-16.667 0M10 16.832a6.833 6.833 0 1 0 0-13.667 6.833 6.833 0 0 0 0 13.667"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 580 B |
3
vue3-project/src/assets/icons/female.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 12 12">
|
||||
<path fill="#FF7084" d="M6 1.75a2.25 2.25 0 1 1 0 4.5 2.25 2.25 0 0 1 0-4.5M3 4a3 3 0 0 0 2.625 2.977v.648h-2.25a.375.375 0 1 0 0 .75h2.25v2.25a.375.375 0 0 0 .75 0v-2.25h2.25a.375.375 0 1 0 0-.75h-2.25v-.648A3 3 0 1 0 3 4"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 322 B |
6
vue3-project/src/assets/icons/follow.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<g>
|
||||
<path fill="none" d="M0 0h24v24H0z"/>
|
||||
<path fill="currentColor" d="M14 14.252v2.09A6 6 0 0 0 6 22l-2-.001a8 8 0 0 1 10-7.748zM12 13c-3.315 0-6-2.685-6-6s2.685-6 6-6 6 2.685 6 6-2.685 6-6 6zm0-2c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm5.793 8.914l3.535-3.535 1.415 1.414-4.95 4.95-3.536-3.536 1.415-1.414 2.12 2.121z"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 447 B |
6
vue3-project/src/assets/icons/hash.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="4" y1="9" x2="20" y2="9"/>
|
||||
<line x1="4" y1="15" x2="20" y2="15"/>
|
||||
<line x1="10" y1="3" x2="8" y2="21"/>
|
||||
<line x1="16" y1="3" x2="14" y2="21"/>
|
||||
</svg>
|
After Width: | Height: | Size: 344 B |
5
vue3-project/src/assets/icons/home.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M20.768 7.934a4 4 0 0 0-1.825-2.153l-5-2.778-.055-.03a4 4 0 0 0-3.83.03l-5 2.778A4 4 0 0 0 3 9.278v7.646q0 .1.005.2A4 4 0 0 0 7 20.924h10q.1 0 .2-.005a4 4 0 0 0 3.8-3.995V9.243a4 4 0 0 0-.232-1.309m-9.776-3.452a2 2 0 0 1 2.016 0l5.2 3.033a2 2 0 0 1 .992 1.728v7.881a2 2 0 0 1-2 2H6.8a2 2 0 0 1-2-2V9.243a2 2 0 0 1 .992-1.728z"></path>
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M9.72 14.883h-.001l-.002-.003-.003-.003-.004-.006-.007-.008-.001-.002.001.002z" clip-rule="evenodd"></path>
|
||||
<path fill="currentColor" d="m9.703 14.863-.001-.002zl.047.045a1.7 1.7 0 0 0 .33.222c.33.176.934.394 1.92.394s1.59-.218 1.92-.394a1.7 1.7 0 0 0 .377-.268.9.9 0 0 1 1.423 1.102l-.035.044-.056.065q-.067.076-.183.18a3.5 3.5 0 0 1-.679.465c-.606.324-1.503.606-2.767.606s-2.16-.282-2.767-.606c-.301-.16-.523-.327-.679-.465a2.5 2.5 0 0 1-.24-.245l-.034-.044a.9.9 0 0 1 1.423-1.101m.016.02-.002-.003-.003-.003-.004-.006-.007-.008z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 1.0 KiB |