初版代码提交
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
/.idea/
|
||||
/.vscode/
|
||||
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>fun.nojava</groupId>
|
||||
<artifactId>jog-system</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>jog-admin</artifactId>
|
||||
<name>Jog Admin Application</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>fun.nojava</groupId>
|
||||
<artifactId>jog-module-system</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>fun.nojava</groupId>
|
||||
<artifactId>jog-module-blog</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>fun.nojava</groupId>
|
||||
<artifactId>jog-module-comment</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-mysql</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>jog-admin</finalName>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,70 @@
|
||||
server:
|
||||
port: 8080
|
||||
servlet:
|
||||
context-path: /
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: jog-system
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: jdbc:mysql://localhost:3306/jog_db?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
|
||||
username: root
|
||||
password: root
|
||||
data:
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
database: 0
|
||||
timeout: 5000ms
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: 20
|
||||
max-idle: 10
|
||||
min-idle: 5
|
||||
mail:
|
||||
host: smtp.example.com
|
||||
port: 587
|
||||
username: noreply@example.com
|
||||
password: your-mail-password
|
||||
properties:
|
||||
mail:
|
||||
smtp:
|
||||
auth: true
|
||||
starttls:
|
||||
enable: true
|
||||
flyway:
|
||||
enabled: true
|
||||
locations: classpath:db/migration
|
||||
baseline-on-migrate: true
|
||||
encoding: UTF-8
|
||||
|
||||
mybatis-plus:
|
||||
mapper-locations: classpath*:/mapper/**/*.xml
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||
global-config:
|
||||
db-config:
|
||||
logic-delete-field: deleted
|
||||
logic-delete-value: 1
|
||||
logic-not-delete-value: 0
|
||||
|
||||
jwt:
|
||||
secret: YourSuperSecretKeyForJwtTokenGenerationMustBeAtLeast256BitsLongForHS256Algorithm
|
||||
access-token-expire: 7200
|
||||
refresh-token-expire: 86400
|
||||
refresh-token-expire-remember: 1209600
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: true
|
||||
path: /v3/api-docs
|
||||
swagger-ui:
|
||||
enabled: true
|
||||
path: /swagger-ui.html
|
||||
|
||||
logging:
|
||||
level:
|
||||
fun.nojava: DEBUG
|
||||
org.springframework.security: DEBUG
|
||||
@@ -0,0 +1,207 @@
|
||||
-- V1__init_schema.sql
|
||||
-- 博客系统数据库初始化脚本
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS blog_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
USE blog_db;
|
||||
|
||||
-- 用户表
|
||||
CREATE TABLE IF NOT EXISTS sys_user (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(50) DEFAULT NULL COMMENT '用户名',
|
||||
email VARCHAR(100) NOT NULL COMMENT '邮箱',
|
||||
phone VARCHAR(20) DEFAULT NULL COMMENT '手机号',
|
||||
password VARCHAR(255) NOT NULL COMMENT '密码',
|
||||
nickname VARCHAR(50) DEFAULT NULL COMMENT '昵称',
|
||||
avatar VARCHAR(500) DEFAULT NULL COMMENT '头像URL',
|
||||
user_type TINYINT NOT NULL DEFAULT 1 COMMENT '用户类型:0管理员 1普通用户',
|
||||
status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0正常 1锁定',
|
||||
locked_until DATETIME DEFAULT NULL COMMENT '锁定截止时间',
|
||||
login_fail_count INT NOT NULL DEFAULT 0 COMMENT '登录失败次数',
|
||||
email_verified TINYINT(1) NOT NULL DEFAULT 0 COMMENT '邮箱是否验证',
|
||||
last_login_ip VARCHAR(50) DEFAULT NULL COMMENT '最后登录IP',
|
||||
last_login_time DATETIME DEFAULT NULL COMMENT '最后登录时间',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除',
|
||||
UNIQUE KEY uk_email (email),
|
||||
UNIQUE KEY uk_username (username),
|
||||
KEY idx_phone (phone)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
|
||||
|
||||
-- 用户会话表
|
||||
CREATE TABLE IF NOT EXISTS sys_user_session (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||||
token VARCHAR(500) NOT NULL COMMENT '访问令牌',
|
||||
device_fingerprint VARCHAR(100) DEFAULT NULL COMMENT '设备指纹',
|
||||
ip_address VARCHAR(50) DEFAULT NULL COMMENT 'IP地址',
|
||||
user_agent VARCHAR(500) DEFAULT NULL COMMENT 'User-Agent',
|
||||
expire_time DATETIME NOT NULL COMMENT '过期时间',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_user_id (user_id),
|
||||
KEY idx_token (token(255))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户会话表';
|
||||
|
||||
-- 登录日志表
|
||||
CREATE TABLE IF NOT EXISTS sys_login_log (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT DEFAULT NULL COMMENT '用户ID',
|
||||
username VARCHAR(100) DEFAULT NULL COMMENT '登录账号',
|
||||
login_type TINYINT NOT NULL COMMENT '登录类型:0管理员 1用户',
|
||||
result TINYINT NOT NULL COMMENT '结果:0失败 1成功',
|
||||
fail_reason VARCHAR(200) DEFAULT NULL COMMENT '失败原因',
|
||||
ip_address VARCHAR(50) DEFAULT NULL COMMENT 'IP地址',
|
||||
device_fingerprint VARCHAR(100) DEFAULT NULL COMMENT '设备指纹',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_user_id (user_id),
|
||||
KEY idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='登录日志表';
|
||||
|
||||
-- 用户关注表
|
||||
CREATE TABLE IF NOT EXISTS sys_user_follow (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
follower_id BIGINT NOT NULL COMMENT '关注者ID',
|
||||
followee_id BIGINT NOT NULL COMMENT '被关注者ID',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_follow (follower_id, followee_id),
|
||||
KEY idx_followee (followee_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户关注表';
|
||||
|
||||
-- 通知表
|
||||
CREATE TABLE IF NOT EXISTS sys_notification (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
receiver_id BIGINT NOT NULL COMMENT '接收者ID',
|
||||
sender_id BIGINT DEFAULT NULL COMMENT '发送者ID',
|
||||
type TINYINT NOT NULL COMMENT '类型:0回复 1点赞 2系统',
|
||||
content VARCHAR(500) NOT NULL COMMENT '内容',
|
||||
related_id BIGINT DEFAULT NULL COMMENT '关联ID',
|
||||
is_read TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已读',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_receiver (receiver_id),
|
||||
KEY idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='通知表';
|
||||
|
||||
-- 系统配置表
|
||||
CREATE TABLE IF NOT EXISTS sys_config (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
config_key VARCHAR(100) NOT NULL COMMENT '配置键',
|
||||
config_value VARCHAR(500) NOT NULL COMMENT '配置值',
|
||||
description VARCHAR(200) DEFAULT NULL COMMENT '描述',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_config_key (config_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置表';
|
||||
|
||||
-- 敏感词表
|
||||
CREATE TABLE IF NOT EXISTS sys_sensitive_word (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
word VARCHAR(50) NOT NULL COMMENT '敏感词',
|
||||
replacement VARCHAR(50) DEFAULT '*' COMMENT '替换词',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_word (word)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='敏感词表';
|
||||
|
||||
-- 分类表
|
||||
CREATE TABLE IF NOT EXISTS blog_category (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(50) NOT NULL COMMENT '分类名称',
|
||||
description VARCHAR(200) DEFAULT NULL COMMENT '描述',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
|
||||
sort INT NOT NULL DEFAULT 0 COMMENT '排序',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='分类表';
|
||||
|
||||
-- 标签表
|
||||
CREATE TABLE IF NOT EXISTS blog_tag (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(50) NOT NULL COMMENT '标签名称',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='标签表';
|
||||
|
||||
-- 文章表
|
||||
CREATE TABLE IF NOT EXISTS blog_article (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL COMMENT '作者ID',
|
||||
category_id BIGINT DEFAULT NULL COMMENT '分类ID',
|
||||
title VARCHAR(200) NOT NULL COMMENT '标题',
|
||||
summary VARCHAR(500) DEFAULT NULL COMMENT '摘要',
|
||||
content LONGTEXT NOT NULL COMMENT '内容',
|
||||
cover_image VARCHAR(500) DEFAULT NULL COMMENT '封面图URL',
|
||||
status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0草稿 1已发布 2回收站',
|
||||
visibility TINYINT NOT NULL DEFAULT 0 COMMENT '可见性:0公开 1私密 2仅粉丝',
|
||||
view_count INT NOT NULL DEFAULT 0 COMMENT '浏览量',
|
||||
like_count INT NOT NULL DEFAULT 0 COMMENT '点赞数',
|
||||
comment_count INT NOT NULL DEFAULT 0 COMMENT '评论数',
|
||||
allow_comment TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否允许评论',
|
||||
published_at DATETIME DEFAULT NULL COMMENT '发布时间',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除',
|
||||
KEY idx_user_id (user_id),
|
||||
KEY idx_category_id (category_id),
|
||||
KEY idx_status (status),
|
||||
KEY idx_published_at (published_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文章表';
|
||||
|
||||
-- 文章标签关联表
|
||||
CREATE TABLE IF NOT EXISTS blog_article_tag (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
article_id BIGINT NOT NULL COMMENT '文章ID',
|
||||
tag_id BIGINT NOT NULL COMMENT '标签ID',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_article_tag (article_id, tag_id),
|
||||
KEY idx_tag_id (tag_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文章标签关联表';
|
||||
|
||||
-- 文章点赞表
|
||||
CREATE TABLE IF NOT EXISTS blog_article_like (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
article_id BIGINT NOT NULL COMMENT '文章ID',
|
||||
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_article_user (article_id, user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文章点赞表';
|
||||
|
||||
-- 评论表
|
||||
CREATE TABLE IF NOT EXISTS blog_comment (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
article_id BIGINT NOT NULL COMMENT '文章ID',
|
||||
user_id BIGINT NOT NULL COMMENT '评论者ID',
|
||||
parent_id BIGINT DEFAULT NULL COMMENT '父评论ID',
|
||||
reply_to_user_id BIGINT DEFAULT NULL COMMENT '回复目标用户ID',
|
||||
content VARCHAR(1000) NOT NULL COMMENT '评论内容',
|
||||
level TINYINT NOT NULL DEFAULT 1 COMMENT '评论层级',
|
||||
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0待审核 1已通过 2已屏蔽 3已删除',
|
||||
like_count INT NOT NULL DEFAULT 0 COMMENT '点赞数',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除',
|
||||
KEY idx_article_id (article_id),
|
||||
KEY idx_user_id (user_id),
|
||||
KEY idx_parent_id (parent_id),
|
||||
KEY idx_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='评论表';
|
||||
|
||||
-- 评论点赞表
|
||||
CREATE TABLE IF NOT EXISTS blog_comment_like (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
comment_id BIGINT NOT NULL COMMENT '评论ID',
|
||||
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_comment_user (comment_id, user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='评论点赞表';
|
||||
|
||||
-- 图片表
|
||||
CREATE TABLE IF NOT EXISTS blog_image (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL COMMENT '上传者ID',
|
||||
original_name VARCHAR(200) NOT NULL COMMENT '原始文件名',
|
||||
stored_name VARCHAR(200) NOT NULL COMMENT '存储文件名',
|
||||
file_path VARCHAR(500) NOT NULL COMMENT '文件路径',
|
||||
url VARCHAR(500) NOT NULL COMMENT '访问URL',
|
||||
file_size BIGINT NOT NULL COMMENT '文件大小(字节)',
|
||||
mime_type VARCHAR(50) NOT NULL COMMENT 'MIME类型',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_user_id (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='图片表';
|
||||
@@ -0,0 +1,29 @@
|
||||
-- V2__init_data.sql
|
||||
-- 初始化数据
|
||||
|
||||
USE blog_db;
|
||||
|
||||
-- 插入管理员账号(密码:Admin@123)
|
||||
INSERT INTO sys_user (username, email, password, nickname, user_type, status, email_verified)
|
||||
VALUES ('admin', 'admin@blog.com', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi', '系统管理员', 0, 0, 1);
|
||||
|
||||
-- 插入默认分类
|
||||
INSERT INTO blog_category (name, description, enabled, sort) VALUES
|
||||
('技术', '技术相关文章', 1, 1),
|
||||
('生活', '生活随笔', 1, 2),
|
||||
('读书', '读书笔记', 1, 3);
|
||||
|
||||
-- 插入默认标签
|
||||
INSERT INTO blog_tag (name) VALUES
|
||||
('Java'),
|
||||
('Spring Boot'),
|
||||
('Vue'),
|
||||
('前端'),
|
||||
('数据库');
|
||||
|
||||
-- 插入系统配置
|
||||
INSERT INTO sys_config (config_key, config_value, description) VALUES
|
||||
('site_name', '我的博客', '站点名称'),
|
||||
('site_description', '一个简洁的博客系统', '站点描述'),
|
||||
('comment_audit', 'false', '评论是否需要审核'),
|
||||
('max_comment_level', '3', '最大评论层级');
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>fun.nojava</groupId>
|
||||
<artifactId>jog-system</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>jog-common</artifactId>
|
||||
<name>Jog Common</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-annotations</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>jakarta.validation</groupId>
|
||||
<artifactId>jakarta.validation-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.data</groupId>
|
||||
<artifactId>spring-data-commons</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.swagger.core.v3</groupId>
|
||||
<artifactId>swagger-annotations-jakarta</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,18 @@
|
||||
package fun.nojava.common.annotation;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface RateLimit {
|
||||
|
||||
String key() default "";
|
||||
|
||||
int maxCount() default 10;
|
||||
|
||||
int duration() default 60;
|
||||
|
||||
TimeUnit unit() default TimeUnit.SECONDS;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package fun.nojava.common.constant;
|
||||
|
||||
public final class BlogConstants {
|
||||
|
||||
private BlogConstants() {}
|
||||
|
||||
public static final int DEFAULT_PAGE_SIZE = 15;
|
||||
public static final int MAX_PAGE_SIZE = 100;
|
||||
public static final int ARTICLE_SUMMARY_LENGTH = 120;
|
||||
public static final int MAX_TAGS_PER_ARTICLE = 5;
|
||||
public static final int MAX_COMMENT_LEVEL = 3;
|
||||
public static final int IMAGE_MAX_SIZE_MB = 10;
|
||||
public static final long DRAFT_AUTO_SAVE_INTERVAL_SECONDS = 30L;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package fun.nojava.common.constant;
|
||||
|
||||
public final class SecurityConstants {
|
||||
|
||||
private SecurityConstants() {}
|
||||
|
||||
public static final String TOKEN_HEADER = "Authorization";
|
||||
public static final String TOKEN_PREFIX = "Bearer ";
|
||||
public static final String REFRESH_TOKEN_KEY = "refresh:token:";
|
||||
public static final String CAPTCHA_KEY = "captcha:";
|
||||
public static final String LOGIN_FAIL_COUNT_KEY = "login:fail:";
|
||||
public static final String IP_BLOCK_KEY = "ip:block:";
|
||||
public static final String REGISTER_LIMIT_KEY = "register:limit:";
|
||||
|
||||
public static final int MAX_LOGIN_FAIL_ADMIN = 5;
|
||||
public static final int MAX_LOGIN_FAIL_USER = 5;
|
||||
public static final int CAPTCHA_TRIGGER_COUNT = 3;
|
||||
public static final int ADMIN_LOCK_MINUTES = 30;
|
||||
public static final int USER_LOCK_MINUTES = 15;
|
||||
public static final int IP_BLOCK_THRESHOLD = 10;
|
||||
public static final int IP_BLOCK_MINUTES = 15;
|
||||
public static final int REGISTER_LIMIT_PER_HOUR = 3;
|
||||
|
||||
public static final int MAX_ACTIVE_SESSIONS = 3;
|
||||
|
||||
public static final long ACCESS_TOKEN_EXPIRE_SECONDS = 7200L;
|
||||
public static final long REFRESH_TOKEN_EXPIRE_SECONDS = 86400L;
|
||||
public static final long REFRESH_TOKEN_REMEMBER_EXPIRE_SECONDS = 1209600L;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package fun.nojava.common.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum ArticleStatus {
|
||||
|
||||
DRAFT(0, "草稿"),
|
||||
PUBLISHED(1, "已发布"),
|
||||
RECYCLE(2, "回收站");
|
||||
|
||||
private final int code;
|
||||
private final String description;
|
||||
|
||||
public static ArticleStatus fromCode(int code) {
|
||||
for (ArticleStatus status : values()) {
|
||||
if (status.code == code) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Invalid ArticleStatus code: " + code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package fun.nojava.common.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum ArticleVisibility {
|
||||
|
||||
PUBLIC(0, "公开"),
|
||||
PRIVATE(1, "私密"),
|
||||
FOLLOWERS_ONLY(2, "仅粉丝可见");
|
||||
|
||||
private final int code;
|
||||
private final String description;
|
||||
|
||||
public static ArticleVisibility fromCode(int code) {
|
||||
for (ArticleVisibility v : values()) {
|
||||
if (v.code == code) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Invalid ArticleVisibility code: " + code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package fun.nojava.common.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum CommentStatus {
|
||||
|
||||
PENDING(0, "待审核"),
|
||||
APPROVED(1, "已通过"),
|
||||
BLOCKED(2, "已屏蔽"),
|
||||
DELETED(3, "已删除");
|
||||
|
||||
private final int code;
|
||||
private final String description;
|
||||
|
||||
public static CommentStatus fromCode(int code) {
|
||||
for (CommentStatus status : values()) {
|
||||
if (status.code == code) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Invalid CommentStatus code: " + code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package fun.nojava.common.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum LoginType {
|
||||
|
||||
ADMIN(0, "管理员登录"),
|
||||
USER(1, "用户登录");
|
||||
|
||||
private final int code;
|
||||
private final String description;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package fun.nojava.common.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum NotificationType {
|
||||
|
||||
COMMENT_REPLY(0, "回复评论"),
|
||||
LIKE(1, "点赞"),
|
||||
SYSTEM(2, "系统通知");
|
||||
|
||||
private final int code;
|
||||
private final String description;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package fun.nojava.common.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum UserStatus {
|
||||
|
||||
NORMAL(0, "正常"),
|
||||
LOCKED(1, "锁定");
|
||||
|
||||
private final int code;
|
||||
private final String description;
|
||||
|
||||
public static UserStatus fromCode(int code) {
|
||||
for (UserStatus status : values()) {
|
||||
if (status.code == code) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Invalid UserStatus code: " + code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package fun.nojava.common.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum UserType {
|
||||
|
||||
ADMIN(0, "超级管理员"),
|
||||
USER(1, "注册用户");
|
||||
|
||||
private final int code;
|
||||
private final String description;
|
||||
|
||||
public static UserType fromCode(int code) {
|
||||
for (UserType type : values()) {
|
||||
if (type.code == code) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Invalid UserType code: " + code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package fun.nojava.common.exception;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public class BusinessException extends RuntimeException {
|
||||
|
||||
private final int code;
|
||||
|
||||
public BusinessException(int code, String message) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public BusinessException(ErrorCode errorCode) {
|
||||
super(errorCode.getMessage());
|
||||
this.code = errorCode.getCode();
|
||||
}
|
||||
|
||||
public BusinessException(ErrorCode errorCode, String message) {
|
||||
super(message);
|
||||
this.code = errorCode.getCode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package fun.nojava.common.exception;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum ErrorCode {
|
||||
|
||||
BAD_REQUEST(400, "参数错误"),
|
||||
UNAUTHORIZED(401, "未认证"),
|
||||
FORBIDDEN(403, "无权限"),
|
||||
NOT_FOUND(404, "资源不存在"),
|
||||
METHOD_NOT_ALLOWED(405, "请求方法不允许"),
|
||||
CONFLICT(409, "操作冲突"),
|
||||
RATE_LIMITED(429, "请求过于频繁"),
|
||||
INTERNAL_ERROR(500, "服务器内部错误"),
|
||||
|
||||
ACCOUNT_LOCKED(1001, "账号已被锁定"),
|
||||
ACCOUNT_DISABLED(1002, "账号已被禁用"),
|
||||
EMAIL_NOT_VERIFIED(1003, "邮箱未验证"),
|
||||
EMAIL_ALREADY_EXISTS(1004, "邮箱已被注册"),
|
||||
PASSWORD_TOO_WEAK(1005, "密码强度不足"),
|
||||
LOGIN_FAIL_TOO_MANY(1006, "登录失败次数过多"),
|
||||
IP_BLOCKED(1007, "IP已被临时封禁"),
|
||||
CAPTCHA_REQUIRED(1008, "需要输入验证码"),
|
||||
CAPTCHA_INVALID(1009, "验证码无效"),
|
||||
TOKEN_INVALID(1010, "Token无效"),
|
||||
TOKEN_EXPIRED(1011, "Token已过期"),
|
||||
SESSION_KICKED(1012, "会话已被踢出"),
|
||||
|
||||
ARTICLE_NOT_FOUND(2001, "文章不存在"),
|
||||
ARTICLE_NO_PERMISSION(2002, "无权操作该文章"),
|
||||
TAG_LIMIT_EXCEEDED(2003, "标签数量超出限制"),
|
||||
CATEGORY_NOT_FOUND(2004, "分类不存在"),
|
||||
CATEGORY_DISABLED(2005, "分类已禁用"),
|
||||
CATEGORY_HAS_ARTICLES(2006, "分类下存在文章,无法删除"),
|
||||
UPLOAD_FILE_INVALID(2007, "上传文件不合法"),
|
||||
UPLOAD_FILE_TOO_LARGE(2008, "上传文件过大"),
|
||||
|
||||
COMMENT_NOT_FOUND(3001, "评论不存在"),
|
||||
COMMENT_AUDIT_PENDING(3002, "评论待审核"),
|
||||
COMMENT_NO_PERMISSION(3003, "无权操作该评论"),
|
||||
COMMENT_LEVEL_EXCEEDED(3004, "评论层级超限");
|
||||
|
||||
private final int code;
|
||||
private final String message;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package fun.nojava.common.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@Schema(description = "统一响应结果")
|
||||
public class ApiResult<T> {
|
||||
|
||||
@Schema(description = "业务状态码")
|
||||
private int code;
|
||||
|
||||
@Schema(description = "提示信息")
|
||||
private String message;
|
||||
|
||||
@Schema(description = "响应数据")
|
||||
private T data;
|
||||
|
||||
@Schema(description = "响应时间戳")
|
||||
private long timestamp;
|
||||
|
||||
public static <T> ApiResult<T> success() {
|
||||
return new ApiResult<>(200, "success", null, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
public static <T> ApiResult<T> success(T data) {
|
||||
return new ApiResult<>(200, "success", data, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
public static <T> ApiResult<T> success(String message, T data) {
|
||||
return new ApiResult<>(200, message, data, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
public static <T> ApiResult<T> error(int code, String message) {
|
||||
return new ApiResult<>(code, message, null, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
public static <T> ApiResult<T> error(fun.nojava.common.exception.ErrorCode errorCode) {
|
||||
return new ApiResult<>(errorCode.getCode(), errorCode.getMessage(), null, System.currentTimeMillis());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package fun.nojava.common.model;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "ID返回")
|
||||
public class IdVO {
|
||||
|
||||
@Schema(description = "ID")
|
||||
private Long id;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package fun.nojava.common.model;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "分页请求参数")
|
||||
public class PageParam {
|
||||
|
||||
@Schema(description = "页码,从1开始")
|
||||
@Min(value = 1, message = "页码最小为1")
|
||||
private int page = 1;
|
||||
|
||||
@Schema(description = "每页条数")
|
||||
@Min(value = 1, message = "每页条数最小为1")
|
||||
@Max(value = 100, message = "每页条数最大为100")
|
||||
private int pageSize = 15;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package fun.nojava.common.model;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "分页结果")
|
||||
public class PageResult<T> {
|
||||
|
||||
@Schema(description = "数据列表")
|
||||
private List<T> records;
|
||||
|
||||
@Schema(description = "总记录数")
|
||||
private long total;
|
||||
|
||||
@Schema(description = "当前页码")
|
||||
private int page;
|
||||
|
||||
@Schema(description = "每页条数")
|
||||
private int pageSize;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package fun.nojava.common.util;
|
||||
|
||||
import fun.nojava.common.model.ApiResult;
|
||||
import fun.nojava.common.model.PageResult;
|
||||
import org.springframework.data.domain.Page;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class ApiResultUtils {
|
||||
|
||||
private ApiResultUtils() {}
|
||||
|
||||
public static <T> ApiResult<PageResult<T>> page(List<T> records, long total, int page, int pageSize) {
|
||||
PageResult<T> pageResult = new PageResult<>(records, total, page, pageSize);
|
||||
return ApiResult.success(pageResult);
|
||||
}
|
||||
|
||||
public static <T> ApiResult<PageResult<T>> page(Page<T> springPage) {
|
||||
PageResult<T> pageResult = new PageResult<>(
|
||||
springPage.getContent(),
|
||||
springPage.getTotalElements(),
|
||||
springPage.getNumber() + 1,
|
||||
springPage.getSize()
|
||||
);
|
||||
return ApiResult.success(pageResult);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>fun.nojava</groupId>
|
||||
<artifactId>jog-system</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>jog-framework</artifactId>
|
||||
<name>Jog Framework</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>fun.nojava</groupId>
|
||||
<artifactId>jog-common</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-aop</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jsoup</groupId>
|
||||
<artifactId>jsoup</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>jakarta.servlet</groupId>
|
||||
<artifactId>jakarta.servlet-api</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,60 @@
|
||||
package fun.nojava.framework.aspect;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
@Slf4j
|
||||
@Aspect
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class LogAspect {
|
||||
|
||||
@Around("@annotation(org.springframework.web.bind.annotation.PostMapping) || " +
|
||||
"@annotation(org.springframework.web.bind.annotation.PutMapping) || " +
|
||||
"@annotation(org.springframework.web.bind.annotation.DeleteMapping) || " +
|
||||
"@annotation(org.springframework.web.bind.annotation.PatchMapping)")
|
||||
public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
ServletRequestAttributes attributes =
|
||||
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attributes == null) {
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
|
||||
HttpServletRequest request = attributes.getRequest();
|
||||
String method = request.getMethod();
|
||||
String uri = request.getRequestURI();
|
||||
String ip = getClientIp(request);
|
||||
|
||||
log.info("Request: {} {} from IP: {}", method, uri, ip);
|
||||
|
||||
try {
|
||||
Object result = joinPoint.proceed();
|
||||
log.info("Response: {} {} - success", method, uri);
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
log.error("Response: {} {} - error: {}", method, uri, e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private String getClientIp(HttpServletRequest request) {
|
||||
String ip = request.getHeader("X-Forwarded-For");
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("X-Real-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getRemoteAddr();
|
||||
}
|
||||
if (ip != null && ip.contains(",")) {
|
||||
ip = ip.split(",")[0].trim();
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package fun.nojava.framework.aspect;
|
||||
|
||||
import fun.nojava.common.annotation.RateLimit;
|
||||
import fun.nojava.common.exception.BusinessException;
|
||||
import fun.nojava.common.exception.ErrorCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Collections;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Slf4j
|
||||
@Aspect
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class RateLimitAspect {
|
||||
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
|
||||
private static final String LUA_SCRIPT =
|
||||
"local current = redis.call('INCR', KEYS[1]) " +
|
||||
"if current == 1 then " +
|
||||
" redis.call('EXPIRE', KEYS[1], ARGV[2]) " +
|
||||
"end " +
|
||||
"if current > tonumber(ARGV[1]) then " +
|
||||
" return 0 " +
|
||||
"end " +
|
||||
"return 1";
|
||||
|
||||
@Around("@annotation(fun.nojava.common.annotation.RateLimit)")
|
||||
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
|
||||
Method method = signature.getMethod();
|
||||
RateLimit rateLimit = method.getAnnotation(RateLimit.class);
|
||||
|
||||
String key = "rate_limit:" + rateLimit.key();
|
||||
long duration = rateLimit.unit().toSeconds(rateLimit.duration());
|
||||
|
||||
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(LUA_SCRIPT, Long.class);
|
||||
Long result = redisTemplate.execute(
|
||||
redisScript,
|
||||
Collections.singletonList(key),
|
||||
String.valueOf(rateLimit.maxCount()),
|
||||
String.valueOf(duration)
|
||||
);
|
||||
|
||||
if (result == null || result == 0) {
|
||||
log.warn("Rate limit exceeded for key: {}", key);
|
||||
throw new BusinessException(ErrorCode.RATE_LIMITED);
|
||||
}
|
||||
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package fun.nojava.framework.config;
|
||||
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "jwt")
|
||||
public class JwtProperties {
|
||||
|
||||
private String secret;
|
||||
private long accessTokenExpire = 7200L;
|
||||
private long refreshTokenExpire = 86400L;
|
||||
private long refreshTokenExpireRemember = 1209600L;
|
||||
|
||||
public SecretKey getSigningKey() {
|
||||
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
|
||||
return Keys.hmacShaKeyFor(keyBytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package fun.nojava.framework.config;
|
||||
|
||||
import fun.nojava.framework.security.JwtAuthenticationFilter;
|
||||
import fun.nojava.framework.security.JwtAuthenticationEntryPoint;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.exceptionHandling(ex -> ex.authenticationEntryPoint(jwtAuthenticationEntryPoint))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/public/**").permitAll()
|
||||
.requestMatchers("/api/oauth/**").permitAll()
|
||||
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||
.requestMatchers("/static/**", "/index.html", "/").permitAll()
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
||||
.requestMatchers("/api/app/**").hasAnyRole("USER", "ADMIN")
|
||||
.anyRequest().permitAll()
|
||||
)
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
config.setAllowedOriginPatterns(List.of("*"));
|
||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||
config.setAllowedHeaders(List.of("*"));
|
||||
config.setAllowCredentials(true);
|
||||
config.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", config);
|
||||
return source;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
|
||||
return config.getAuthenticationManager();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package fun.nojava.framework.config;
|
||||
|
||||
import fun.nojava.framework.security.JwtTokenProvider;
|
||||
import fun.nojava.framework.filter.XssFilter;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class WebConfig {
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<XssFilter> xssFilterRegistration() {
|
||||
FilterRegistrationBean<XssFilter> registration = new FilterRegistrationBean<>();
|
||||
registration.setFilter(new XssFilter());
|
||||
registration.addUrlPatterns("/api/*");
|
||||
registration.setOrder(1);
|
||||
return registration;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package fun.nojava.framework.filter;
|
||||
|
||||
import fun.nojava.framework.wrapper.XssHttpServletRequestWrapper;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class XssFilter extends OncePerRequestFilter {
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
filterChain.doFilter(new XssHttpServletRequestWrapper(request), response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package fun.nojava.framework.handler;
|
||||
|
||||
import fun.nojava.common.exception.BusinessException;
|
||||
import fun.nojava.common.exception.ErrorCode;
|
||||
import fun.nojava.common.model.ApiResult;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
public ApiResult<Void> handleBusinessException(BusinessException e) {
|
||||
log.warn("Business exception: code={}, message={}", e.getCode(), e.getMessage());
|
||||
return ApiResult.error(e.getCode(), e.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public ApiResult<Void> handleValidationException(MethodArgumentNotValidException e) {
|
||||
String message = e.getBindingResult().getFieldErrors().stream()
|
||||
.map(FieldError::getDefaultMessage)
|
||||
.collect(Collectors.joining("; "));
|
||||
log.warn("Validation error: {}", message);
|
||||
return ApiResult.error(ErrorCode.BAD_REQUEST.getCode(), message);
|
||||
}
|
||||
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
@ResponseStatus(HttpStatus.FORBIDDEN)
|
||||
public ApiResult<Void> handleAccessDeniedException(AccessDeniedException e) {
|
||||
log.warn("Access denied: {}", e.getMessage());
|
||||
return ApiResult.error(ErrorCode.FORBIDDEN);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
public ApiResult<Void> handleException(Exception e) {
|
||||
log.error("Unexpected error", e);
|
||||
return ApiResult.error(ErrorCode.INTERNAL_ERROR);
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package fun.nojava.framework.security;
|
||||
|
||||
import fun.nojava.common.model.ApiResult;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
public void commence(HttpServletRequest request, HttpServletResponse response,
|
||||
AuthenticationException authException) throws IOException {
|
||||
log.warn("Unauthorized access: {} {}", request.getMethod(), request.getRequestURI());
|
||||
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
|
||||
ApiResult<?> result = ApiResult.error(401, "未认证,请先登录");
|
||||
response.getWriter().write(objectMapper.writeValueAsString(result));
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package fun.nojava.framework.security;
|
||||
|
||||
import fun.nojava.common.constant.SecurityConstants;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private final TokenBlacklistService tokenBlacklistService;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
String token = resolveToken(request);
|
||||
|
||||
if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
|
||||
if (tokenBlacklistService.isAccessTokenBlacklisted(token)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
Long userId = jwtTokenProvider.getUserIdFromToken(token);
|
||||
String role = jwtTokenProvider.getRoleFromToken(token);
|
||||
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
userId,
|
||||
null,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_" + role))
|
||||
);
|
||||
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private String resolveToken(HttpServletRequest request) {
|
||||
String bearerToken = request.getHeader(SecurityConstants.TOKEN_HEADER);
|
||||
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(SecurityConstants.TOKEN_PREFIX)) {
|
||||
return bearerToken.substring(SecurityConstants.TOKEN_PREFIX.length());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package fun.nojava.framework.security;
|
||||
|
||||
import fun.nojava.common.constant.SecurityConstants;
|
||||
import fun.nojava.framework.config.JwtProperties;
|
||||
import io.jsonwebtoken.*;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Date;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class JwtTokenProvider {
|
||||
|
||||
private final JwtProperties jwtProperties;
|
||||
|
||||
public String generateAccessToken(Long userId, String role) {
|
||||
Date now = new Date();
|
||||
Date expiryDate = new Date(now.getTime() + jwtProperties.getAccessTokenExpire() * 1000);
|
||||
|
||||
return Jwts.builder()
|
||||
.subject(String.valueOf(userId))
|
||||
.claim("role", role)
|
||||
.issuedAt(now)
|
||||
.expiration(expiryDate)
|
||||
.signWith(jwtProperties.getSigningKey())
|
||||
.compact();
|
||||
}
|
||||
|
||||
public String generateRefreshToken(Long userId) {
|
||||
Date now = new Date();
|
||||
Date expiryDate = new Date(now.getTime() + jwtProperties.getRefreshTokenExpire() * 1000);
|
||||
|
||||
return Jwts.builder()
|
||||
.subject(String.valueOf(userId))
|
||||
.claim("type", "refresh")
|
||||
.issuedAt(now)
|
||||
.expiration(expiryDate)
|
||||
.signWith(jwtProperties.getSigningKey())
|
||||
.compact();
|
||||
}
|
||||
|
||||
public String generateRefreshToken(Long userId, boolean rememberMe) {
|
||||
long expireSeconds = rememberMe
|
||||
? jwtProperties.getRefreshTokenExpireRemember()
|
||||
: jwtProperties.getRefreshTokenExpire();
|
||||
Date now = new Date();
|
||||
Date expiryDate = new Date(now.getTime() + expireSeconds * 1000);
|
||||
|
||||
return Jwts.builder()
|
||||
.subject(String.valueOf(userId))
|
||||
.claim("type", "refresh")
|
||||
.issuedAt(now)
|
||||
.expiration(expiryDate)
|
||||
.signWith(jwtProperties.getSigningKey())
|
||||
.compact();
|
||||
}
|
||||
|
||||
public Long getUserIdFromToken(String token) {
|
||||
Claims claims = parseToken(token);
|
||||
return Long.parseLong(claims.getSubject());
|
||||
}
|
||||
|
||||
public String getRoleFromToken(String token) {
|
||||
Claims claims = parseToken(token);
|
||||
return claims.get("role", String.class);
|
||||
}
|
||||
|
||||
public boolean validateToken(String token) {
|
||||
try {
|
||||
parseToken(token);
|
||||
return true;
|
||||
} catch (JwtException | IllegalArgumentException e) {
|
||||
log.warn("JWT validation failed: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private Claims parseToken(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(jwtProperties.getSigningKey())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package fun.nojava.framework.security;
|
||||
|
||||
import fun.nojava.common.constant.SecurityConstants;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class TokenBlacklistService {
|
||||
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
|
||||
public void blacklistAccessToken(String token, long remainingSeconds) {
|
||||
String key = "blacklist:access:" + token;
|
||||
redisTemplate.opsForValue().set(key, "1", remainingSeconds, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
public void storeRefreshToken(Long userId, String refreshToken, long expireSeconds) {
|
||||
String key = SecurityConstants.REFRESH_TOKEN_KEY + userId;
|
||||
redisTemplate.opsForValue().set(key, refreshToken, expireSeconds, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
public boolean isRefreshTokenValid(Long userId, String refreshToken) {
|
||||
String key = SecurityConstants.REFRESH_TOKEN_KEY + userId;
|
||||
String stored = redisTemplate.opsForValue().get(key);
|
||||
return refreshToken.equals(stored);
|
||||
}
|
||||
|
||||
public boolean isAccessTokenBlacklisted(String token) {
|
||||
String key = "blacklist:access:" + token;
|
||||
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
|
||||
}
|
||||
|
||||
public void removeRefreshToken(Long userId) {
|
||||
String key = SecurityConstants.REFRESH_TOKEN_KEY + userId;
|
||||
redisTemplate.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package fun.nojava.framework.util;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
public final class WebUtils {
|
||||
|
||||
private WebUtils() {}
|
||||
|
||||
public static String getClientIp() {
|
||||
ServletRequestAttributes attributes =
|
||||
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attributes == null) {
|
||||
return "unknown";
|
||||
}
|
||||
return getClientIp(attributes.getRequest());
|
||||
}
|
||||
|
||||
public static String getClientIp(HttpServletRequest request) {
|
||||
String ip = request.getHeader("X-Forwarded-For");
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("X-Real-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getRemoteAddr();
|
||||
}
|
||||
if (ip != null && ip.contains(",")) {
|
||||
ip = ip.split(",")[0].trim();
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
|
||||
public static String getUserAgent() {
|
||||
ServletRequestAttributes attributes =
|
||||
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attributes == null) {
|
||||
return "";
|
||||
}
|
||||
return attributes.getRequest().getHeader("User-Agent");
|
||||
}
|
||||
|
||||
public static String generateDeviceFingerprint(HttpServletRequest request) {
|
||||
String ua = request.getHeader("User-Agent");
|
||||
String accept = request.getHeader("Accept-Language");
|
||||
return String.valueOf((ua + accept).hashCode());
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package fun.nojava.framework.wrapper;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletRequestWrapper;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.safety.Safelist;
|
||||
|
||||
public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper {
|
||||
|
||||
public XssHttpServletRequestWrapper(HttpServletRequest request) {
|
||||
super(request);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getParameter(String name) {
|
||||
String value = super.getParameter(name);
|
||||
return cleanXss(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String[] getParameterValues(String name) {
|
||||
String[] values = super.getParameterValues(name);
|
||||
if (values == null) {
|
||||
return null;
|
||||
}
|
||||
String[] cleaned = new String[values.length];
|
||||
for (int i = 0; i < values.length; i++) {
|
||||
cleaned[i] = cleanXss(values[i]);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getHeader(String name) {
|
||||
String value = super.getHeader(name);
|
||||
return cleanXss(value);
|
||||
}
|
||||
|
||||
private String cleanXss(String value) {
|
||||
if (value == null || value.isEmpty()) {
|
||||
return value;
|
||||
}
|
||||
return Jsoup.clean(value, Safelist.basic());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>博客系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "jog-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
"pinia": "^2.3.0",
|
||||
"axios": "^1.7.9",
|
||||
"element-plus": "^2.9.1",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@tiptap/vue-3": "^2.11.5",
|
||||
"@tiptap/starter-kit": "^2.11.5",
|
||||
"@tiptap/extension-image": "^2.11.5",
|
||||
"@tiptap/extension-link": "^2.11.5",
|
||||
"@tiptap/extension-placeholder": "^2.11.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"vite": "^6.0.7",
|
||||
"unplugin-auto-import": "^0.18.6",
|
||||
"unplugin-vue-components": "^0.27.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function getUserList(params) {
|
||||
return request.get('/api/admin/user/list', { params })
|
||||
}
|
||||
|
||||
export function resetUserPassword(id, newPassword) {
|
||||
return request.put(`/api/admin/user/${id}/reset-password`, null, { params: { newPassword } })
|
||||
}
|
||||
|
||||
export function updateUserStatus(id, status) {
|
||||
return request.put(`/api/admin/user/${id}/status`, null, { params: { status } })
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function getPublicArticles(params) {
|
||||
return request.get('/api/public/article/list', { params })
|
||||
}
|
||||
|
||||
export function getArticleDetail(id) {
|
||||
return request.get(`/api/public/article/${id}`)
|
||||
}
|
||||
|
||||
export function createArticle(data) {
|
||||
return request.post('/api/app/article', data)
|
||||
}
|
||||
|
||||
export function updateArticle(id, data) {
|
||||
return request.put(`/api/app/article/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteArticle(id) {
|
||||
return request.delete(`/api/app/article/${id}`)
|
||||
}
|
||||
|
||||
export function getMyArticles(params) {
|
||||
return request.get('/api/app/article/mine', { params })
|
||||
}
|
||||
|
||||
export function moveToRecycleBin(id) {
|
||||
return request.put(`/api/app/article/${id}/recycle`)
|
||||
}
|
||||
|
||||
export function restoreArticle(id) {
|
||||
return request.put(`/api/app/article/${id}/restore`)
|
||||
}
|
||||
|
||||
export function toggleArticleLike(id) {
|
||||
return request.post(`/api/app/article/${id}/like`)
|
||||
}
|
||||
|
||||
export function getCategories() {
|
||||
return request.get('/api/public/category/list')
|
||||
}
|
||||
|
||||
export function getTags() {
|
||||
return request.get('/api/public/tag/list')
|
||||
}
|
||||
|
||||
export function createCategory(data) {
|
||||
return request.post('/api/admin/category', data)
|
||||
}
|
||||
|
||||
export function updateCategory(id, data) {
|
||||
return request.put(`/api/admin/category/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteCategory(id) {
|
||||
return request.delete(`/api/admin/category/${id}`)
|
||||
}
|
||||
|
||||
export function createTag(name) {
|
||||
return request.post('/api/admin/tag', null, { params: { name } })
|
||||
}
|
||||
|
||||
export function getAdminArticles(params) {
|
||||
return request.get('/api/admin/article/list', { params })
|
||||
}
|
||||
|
||||
export function adminDeleteArticle(id) {
|
||||
return request.delete(`/api/admin/article/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function adminLogin(data) {
|
||||
return request.post('/api/public/admin/login', data)
|
||||
}
|
||||
|
||||
export function userLogin(data) {
|
||||
return request.post('/api/public/user/login', data)
|
||||
}
|
||||
|
||||
export function register(data) {
|
||||
return request.post('/api/public/user/register', data)
|
||||
}
|
||||
|
||||
export function getCaptcha() {
|
||||
return request.get('/api/public/captcha')
|
||||
}
|
||||
|
||||
export function refreshToken(token) {
|
||||
return request.post('/api/public/refresh-token', null, { params: { refreshToken: token } })
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return request.post('/api/app/user/logout')
|
||||
}
|
||||
|
||||
export function getCurrentUser() {
|
||||
return request.get('/api/app/user/me')
|
||||
}
|
||||
|
||||
export function getUserInfo(id) {
|
||||
return request.get(`/api/app/user/${id}`)
|
||||
}
|
||||
|
||||
export function followUser(id) {
|
||||
return request.post(`/api/app/user/${id}/follow`)
|
||||
}
|
||||
|
||||
export function unfollowUser(id) {
|
||||
return request.delete(`/api/app/user/${id}/follow`)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function getArticleComments(articleId) {
|
||||
return request.get(`/api/app/comment/article/${articleId}`)
|
||||
}
|
||||
|
||||
export function createComment(data) {
|
||||
return request.post('/api/app/comment', data)
|
||||
}
|
||||
|
||||
export function deleteComment(id) {
|
||||
return request.delete(`/api/app/comment/${id}`)
|
||||
}
|
||||
|
||||
export function toggleCommentLike(id) {
|
||||
return request.post(`/api/app/comment/${id}/like`)
|
||||
}
|
||||
|
||||
export function getAdminComments(params) {
|
||||
return request.get('/api/admin/comment/list', { params })
|
||||
}
|
||||
|
||||
export function auditComment(id, status) {
|
||||
return request.put(`/api/admin/comment/${id}/audit`, null, { params: { status } })
|
||||
}
|
||||
|
||||
export function adminDeleteComment(id) {
|
||||
return request.delete(`/api/admin/comment/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="admin-layout">
|
||||
<el-container>
|
||||
<el-aside width="220px">
|
||||
<div class="admin-logo">管理后台</div>
|
||||
<el-menu :default-active="$route.path" router background-color="#304156" text-color="#bfcbd9" active-text-color="#409eff">
|
||||
<el-menu-item index="/admin">
|
||||
<el-icon><DataAnalysis /></el-icon>
|
||||
<span>仪表盘</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/users">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>用户管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/articles">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>文章管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/comments">
|
||||
<el-icon><ChatDotSquare /></el-icon>
|
||||
<span>评论管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/categories">
|
||||
<el-icon><Menu /></el-icon>
|
||||
<span>分类管理</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-container>
|
||||
<el-header class="admin-header">
|
||||
<el-button @click="$router.push('/')">返回前台</el-button>
|
||||
<el-button @click="handleLogout">退出</el-button>
|
||||
</el-header>
|
||||
<el-main>
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
|
||||
async function handleLogout() {
|
||||
await userStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.el-aside {
|
||||
background: #304156;
|
||||
}
|
||||
.admin-logo {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.admin-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
.el-main {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<el-container>
|
||||
<el-aside width="200px">
|
||||
<el-menu :default-active="$route.path" router>
|
||||
<el-menu-item index="/app/dashboard">
|
||||
<el-icon><DataAnalysis /></el-icon>
|
||||
<span>控制台</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/app/articles">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>我的文章</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/app/editor">
|
||||
<el-icon><EditPen /></el-icon>
|
||||
<span>写文章</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-main>
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.el-aside {
|
||||
background: #fff;
|
||||
border-right: 1px solid #e4e7ed;
|
||||
}
|
||||
.el-main {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div class="jog-layout">
|
||||
<el-header class="header">
|
||||
<div class="header-content">
|
||||
<router-link to="/" class="logo">Jog</router-link>
|
||||
<el-menu mode="horizontal" :ellipsis="false" class="nav-menu">
|
||||
<el-menu-item index="home">
|
||||
<router-link to="/">首页</router-link>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
<div class="header-right">
|
||||
<template v-if="userStore.isLoggedIn">
|
||||
<el-dropdown>
|
||||
<span class="user-info">
|
||||
<el-avatar :size="28" :src="userStore.userInfo?.avatar" />
|
||||
{{ userStore.userInfo?.nickname || '用户' }}
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="$router.push('/app/dashboard')">控制台</el-dropdown-item>
|
||||
<el-dropdown-item v-if="userStore.isAdmin" @click="$router.push('/admin')">管理后台</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="handleLogout">退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button type="primary" @click="$router.push('/login')">登录</el-button>
|
||||
<el-button @click="$router.push('/register')">注册</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</el-header>
|
||||
<el-main class="main">
|
||||
<router-view />
|
||||
</el-main>
|
||||
<el-footer class="footer">Jog © 2026</el-footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
|
||||
async function handleLogout() {
|
||||
await userStore.logout()
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.jog-layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.header {
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
background: #fff;
|
||||
}
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 60px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
.logo {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #409eff;
|
||||
text-decoration: none;
|
||||
margin-right: 40px;
|
||||
}
|
||||
.nav-menu {
|
||||
flex: 1;
|
||||
border-bottom: none;
|
||||
}
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.main {
|
||||
flex: 1;
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: #909399;
|
||||
border-top: 1px solid #e4e7ed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,119 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
meta: { title: '登录' },
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('@/views/Register.vue'),
|
||||
meta: { title: '注册' },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/BlogLayout.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'Home',
|
||||
component: () => import('@/views/Home.vue'),
|
||||
meta: { title: '首页' },
|
||||
},
|
||||
{
|
||||
path: 'article/:id',
|
||||
name: 'ArticleDetail',
|
||||
component: () => import('@/views/ArticleDetail.vue'),
|
||||
meta: { title: '文章详情' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/app',
|
||||
component: () => import('@/layouts/AppLayout.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/app/Dashboard.vue'),
|
||||
meta: { title: '控制台', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'articles',
|
||||
name: 'MyArticles',
|
||||
component: () => import('@/views/app/MyArticles.vue'),
|
||||
meta: { title: '我的文章', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'editor',
|
||||
name: 'NewArticle',
|
||||
component: () => import('@/views/app/ArticleEditor.vue'),
|
||||
meta: { title: '写文章', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'editor/:id',
|
||||
name: 'EditArticle',
|
||||
component: () => import('@/views/app/ArticleEditor.vue'),
|
||||
meta: { title: '编辑文章', requiresAuth: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => import('@/layouts/AdminLayout.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'AdminDashboard',
|
||||
component: () => import('@/views/admin/Dashboard.vue'),
|
||||
meta: { title: '管理后台', requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'AdminUsers',
|
||||
component: () => import('@/views/admin/Users.vue'),
|
||||
meta: { title: '用户管理', requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: 'articles',
|
||||
name: 'AdminArticles',
|
||||
component: () => import('@/views/admin/Articles.vue'),
|
||||
meta: { title: '文章管理', requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: 'comments',
|
||||
name: 'AdminComments',
|
||||
component: () => import('@/views/admin/Comments.vue'),
|
||||
meta: { title: '评论管理', requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: 'categories',
|
||||
name: 'AdminCategories',
|
||||
component: () => import('@/views/admin/Categories.vue'),
|
||||
meta: { title: '分类管理', requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
document.title = to.meta.title ? `${to.meta.title} - 博客系统` : '博客系统'
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (to.meta.requiresAuth && !token) {
|
||||
next({ name: 'Login', query: { redirect: to.fullPath } })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,80 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { adminLogin, userLogin, register, getCurrentUser, logout as apiLogout } from '@/api/auth'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const token = ref(localStorage.getItem('access_token') || '')
|
||||
const refreshToken = ref(localStorage.getItem('refresh_token') || '')
|
||||
const userInfo = ref(JSON.parse(localStorage.getItem('user_info') || 'null'))
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const isAdmin = computed(() => userInfo.value?.role === 'ADMIN')
|
||||
|
||||
async function loginAdmin(data) {
|
||||
const res = await adminLogin(data)
|
||||
const loginData = res.data
|
||||
setTokens(loginData.accessToken, loginData.refreshToken)
|
||||
userInfo.value = { ...loginData.userInfo, role: 'ADMIN' }
|
||||
localStorage.setItem('user_info', JSON.stringify(userInfo.value))
|
||||
return loginData
|
||||
}
|
||||
|
||||
async function loginUser(data) {
|
||||
const res = await userLogin(data)
|
||||
const loginData = res.data
|
||||
setTokens(loginData.accessToken, loginData.refreshToken)
|
||||
userInfo.value = { ...loginData.userInfo, role: 'USER' }
|
||||
localStorage.setItem('user_info', JSON.stringify(userInfo.value))
|
||||
return loginData
|
||||
}
|
||||
|
||||
async function registerUser(data) {
|
||||
return await register(data)
|
||||
}
|
||||
|
||||
async function fetchUserInfo() {
|
||||
const res = await getCurrentUser()
|
||||
const role = isAdmin.value ? 'ADMIN' : 'USER'
|
||||
userInfo.value = { ...res.data, role }
|
||||
localStorage.setItem('user_info', JSON.stringify(userInfo.value))
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await apiLogout()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
clearAuth()
|
||||
}
|
||||
|
||||
function setTokens(accessToken, refresh) {
|
||||
token.value = accessToken
|
||||
refreshToken.value = refresh
|
||||
localStorage.setItem('access_token', accessToken)
|
||||
localStorage.setItem('refresh_token', refresh)
|
||||
}
|
||||
|
||||
function clearAuth() {
|
||||
token.value = ''
|
||||
refreshToken.value = ''
|
||||
userInfo.value = null
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
localStorage.removeItem('user_info')
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
refreshToken,
|
||||
userInfo,
|
||||
isLoggedIn,
|
||||
isAdmin,
|
||||
loginAdmin,
|
||||
loginUser,
|
||||
registerUser,
|
||||
fetchUserInfo,
|
||||
logout,
|
||||
clearAuth,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import router from '@/router'
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: '',
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
request.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
request.interceptors.response.use(
|
||||
(response) => {
|
||||
const res = response.data
|
||||
if (res.code !== 200) {
|
||||
ElMessage.error(res.message || '请求失败')
|
||||
if (res.code === 401) {
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
localStorage.removeItem('user_info')
|
||||
router.push({ name: 'Login' })
|
||||
}
|
||||
return Promise.reject(new Error(res.message))
|
||||
}
|
||||
return res
|
||||
},
|
||||
async (error) => {
|
||||
if (error.response) {
|
||||
const status = error.response.status
|
||||
if (status === 401) {
|
||||
const refreshToken = localStorage.getItem('refresh_token')
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const res = await axios.post('/api/public/refresh-token', null, {
|
||||
params: { refreshToken },
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
localStorage.setItem('access_token', res.data.data.accessToken)
|
||||
localStorage.setItem('refresh_token', res.data.data.refreshToken)
|
||||
error.config.headers.Authorization = `Bearer ${res.data.data.accessToken}`
|
||||
return request(error.config)
|
||||
}
|
||||
} catch {
|
||||
// refresh failed
|
||||
}
|
||||
}
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
localStorage.removeItem('user_info')
|
||||
router.push({ name: 'Login' })
|
||||
} else if (status === 403) {
|
||||
ElMessage.error('无权限访问')
|
||||
} else if (status === 429) {
|
||||
ElMessage.error('请求过于频繁,请稍后再试')
|
||||
} else {
|
||||
ElMessage.error(error.response.data?.message || '服务器错误')
|
||||
}
|
||||
} else {
|
||||
ElMessage.error('网络连接失败')
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div class="article-detail" v-loading="loading">
|
||||
<template v-if="article">
|
||||
<h1>{{ article.title }}</h1>
|
||||
<div class="meta">
|
||||
<span><el-icon><User /></el-icon> {{ article.authorNickname }}</span>
|
||||
<span><el-icon><View /></el-icon> {{ article.viewCount }}</span>
|
||||
<span><el-icon><ChatDotSquare /></el-icon> {{ article.commentCount }}</span>
|
||||
<span><el-icon><Timer /></el-icon> {{ formatDate(article.publishedAt) }}</span>
|
||||
<el-tag v-if="article.categoryName" size="small">{{ article.categoryName }}</el-tag>
|
||||
</div>
|
||||
<div class="tags">
|
||||
<el-tag v-for="tag in article.tags" :key="tag.id" type="info" size="small">{{ tag.name }}</el-tag>
|
||||
</div>
|
||||
<el-divider />
|
||||
<div class="content" v-html="article.content"></div>
|
||||
<el-divider />
|
||||
<div class="actions">
|
||||
<el-button :type="article.liked ? 'danger' : 'default'" @click="handleLike">
|
||||
<el-icon><Star /></el-icon> {{ article.liked ? '已点赞' : '点赞' }} ({{ article.likeCount }})
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="comments-section">
|
||||
<h3>评论 ({{ article.commentCount }})</h3>
|
||||
<div v-if="userStore.isLoggedIn" class="comment-form">
|
||||
<el-input v-model="newComment" type="textarea" :rows="3" placeholder="写下你的评论..." />
|
||||
<el-button type="primary" @click="submitComment" :loading="submitting" style="margin-top: 8px">发表评论</el-button>
|
||||
</div>
|
||||
<div v-else class="login-tip">
|
||||
<router-link to="/login">登录</router-link> 后可以评论
|
||||
</div>
|
||||
<div class="comment-list">
|
||||
<div v-for="comment in comments" :key="comment.id" class="comment-item">
|
||||
<div class="comment-header">
|
||||
<el-avatar :size="32" :src="comment.avatar" />
|
||||
<span class="nickname">{{ comment.nickname }}</span>
|
||||
<span class="time">{{ formatDate(comment.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="comment-body">{{ comment.content }}</div>
|
||||
<div class="comment-actions">
|
||||
<el-button text size="small" @click="handleReply(comment)">
|
||||
<el-icon><ChatDotSquare /></el-icon> 回复
|
||||
</el-button>
|
||||
<el-button text size="small" @click="toggleCommentLike(comment)">
|
||||
<el-icon><Star /></el-icon> {{ comment.likeCount }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="comment.children && comment.children.length" class="replies">
|
||||
<div v-for="reply in comment.children" :key="reply.id" class="reply-item">
|
||||
<span class="nickname">{{ reply.nickname }}</span>
|
||||
<span v-if="reply.replyToNickname" class="reply-to">回复 {{ reply.replyToNickname }}</span>
|
||||
:{{ reply.content }}
|
||||
<span class="time">{{ formatDate(reply.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getArticleDetail, toggleArticleLike } from '@/api/article'
|
||||
import { getArticleComments, createComment, toggleCommentLike } from '@/api/comment'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const article = ref(null)
|
||||
const comments = ref([])
|
||||
const loading = ref(true)
|
||||
const newComment = ref('')
|
||||
const submitting = ref(false)
|
||||
const replyTo = ref(null)
|
||||
|
||||
async function loadArticle() {
|
||||
loading.value = true
|
||||
const res = await getArticleDetail(route.params.id)
|
||||
article.value = res.data
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function loadComments() {
|
||||
const res = await getArticleComments(route.params.id)
|
||||
comments.value = res.data
|
||||
}
|
||||
|
||||
async function handleLike() {
|
||||
if (!userStore.isLoggedIn) {
|
||||
ElMessage.warning('请先登录')
|
||||
return
|
||||
}
|
||||
const res = await toggleArticleLike(article.value.id)
|
||||
article.value.liked = res.data
|
||||
article.value.likeCount += res.data ? 1 : -1
|
||||
}
|
||||
|
||||
function handleReply(comment) {
|
||||
replyTo.value = comment
|
||||
newComment.value = `@${comment.nickname} `
|
||||
}
|
||||
|
||||
async function submitComment() {
|
||||
if (!newComment.value.trim()) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const data = {
|
||||
articleId: article.value.id,
|
||||
content: newComment.value,
|
||||
}
|
||||
if (replyTo.value) {
|
||||
data.parentId = replyTo.value.id
|
||||
data.replyToUserId = replyTo.value.userId
|
||||
}
|
||||
await createComment(data)
|
||||
ElMessage.success('评论成功')
|
||||
newComment.value = ''
|
||||
replyTo.value = null
|
||||
loadComments()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCommentLike(comment) {
|
||||
if (!userStore.isLoggedIn) {
|
||||
ElMessage.warning('请先登录')
|
||||
return
|
||||
}
|
||||
const res = await toggleCommentLike(comment.id)
|
||||
comment.liked = res.data
|
||||
comment.likeCount += res.data ? 1 : -1
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadArticle()
|
||||
loadComments()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.article-detail { max-width: 800px; margin: 0 auto; }
|
||||
.article-detail h1 { font-size: 28px; margin-bottom: 16px; }
|
||||
.meta { display: flex; gap: 16px; align-items: center; color: #909399; font-size: 14px; margin-bottom: 12px; }
|
||||
.meta span { display: flex; align-items: center; gap: 4px; }
|
||||
.tags { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||
.content { line-height: 1.8; font-size: 16px; }
|
||||
.actions { margin: 20px 0; }
|
||||
.comments-section { margin-top: 30px; }
|
||||
.comment-form { margin-bottom: 20px; }
|
||||
.login-tip { color: #909399; margin-bottom: 20px; }
|
||||
.comment-item { padding: 16px 0; border-bottom: 1px solid #f0f0f0; }
|
||||
.comment-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.nickname { font-weight: 500; }
|
||||
.time { color: #909399; font-size: 12px; }
|
||||
.comment-body { margin-left: 40px; }
|
||||
.comment-actions { margin-left: 40px; margin-top: 4px; }
|
||||
.replies { margin-left: 40px; padding: 12px; background: #f5f7fa; border-radius: 4px; margin-top: 8px; }
|
||||
.reply-item { padding: 4px 0; font-size: 14px; }
|
||||
.reply-to { color: #409eff; }
|
||||
</style>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="home">
|
||||
<div class="hero">
|
||||
<h1>欢迎来到博客系统</h1>
|
||||
<p>分享知识,记录生活</p>
|
||||
</div>
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="filters.categoryId" placeholder="选择分类" clearable @change="loadArticles">
|
||||
<el-option v-for="cat in categories" :key="cat.id" :label="cat.name" :value="cat.id" />
|
||||
</el-select>
|
||||
<el-input v-model="filters.keyword" placeholder="搜索文章" clearable style="width: 300px" @keyup.enter="loadArticles">
|
||||
<template #append>
|
||||
<el-button @click="loadArticles"><el-icon><Search /></el-icon></el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
<div class="article-list">
|
||||
<el-card v-for="article in articles" :key="article.id" class="article-card" @click="goDetail(article.id)">
|
||||
<div class="article-content">
|
||||
<h3>{{ article.title }}</h3>
|
||||
<p class="summary">{{ article.summary }}</p>
|
||||
<div class="meta">
|
||||
<span><el-icon><User /></el-icon> {{ article.authorNickname }}</span>
|
||||
<span><el-icon><View /></el-icon> {{ article.viewCount }}</span>
|
||||
<span><el-icon><ChatDotSquare /></el-icon> {{ article.commentCount }}</span>
|
||||
<span><el-icon><Timer /></el-icon> {{ formatDate(article.publishedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-image v-if="article.coverImage" :src="article.coverImage" class="cover" fit="cover" />
|
||||
</el-card>
|
||||
</div>
|
||||
<el-pagination
|
||||
v-model:current-page="filters.page"
|
||||
:page-size="filters.pageSize"
|
||||
:total="total"
|
||||
layout="prev, pager, next"
|
||||
@current-change="loadArticles"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getPublicArticles, getCategories } from '@/api/article'
|
||||
|
||||
const router = useRouter()
|
||||
const articles = ref([])
|
||||
const categories = ref([])
|
||||
const total = ref(0)
|
||||
const filters = ref({ page: 1, pageSize: 15, categoryId: null, keyword: '' })
|
||||
|
||||
async function loadArticles() {
|
||||
const res = await getPublicArticles(filters.value)
|
||||
articles.value = res.data.records
|
||||
total.value = res.data.total
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
const res = await getCategories()
|
||||
categories.value = res.data
|
||||
}
|
||||
|
||||
function goDetail(id) {
|
||||
router.push(`/article/${id}`)
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadArticles()
|
||||
loadCategories()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: 60px 0 30px;
|
||||
}
|
||||
.hero h1 { font-size: 32px; color: #303133; }
|
||||
.hero p { color: #909399; font-size: 16px; }
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
.article-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.article-card {
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.3s;
|
||||
}
|
||||
.article-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
||||
.article-card :deep(.el-card__body) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.article-content { flex: 1; }
|
||||
.article-content h3 { margin: 0 0 8px; font-size: 18px; }
|
||||
.summary { color: #606266; font-size: 14px; margin: 0 0 12px; }
|
||||
.meta { display: flex; gap: 16px; color: #909399; font-size: 13px; }
|
||||
.meta span { display: flex; align-items: center; gap: 4px; }
|
||||
.cover { width: 160px; height: 100px; border-radius: 4px; margin-left: 16px; }
|
||||
.el-pagination { margin-top: 20px; justify-content: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<el-card class="login-card">
|
||||
<h2>登录</h2>
|
||||
<el-tabs v-model="loginType">
|
||||
<el-tab-pane label="管理员登录" name="admin">
|
||||
<el-form :model="adminForm" :rules="rules" ref="adminFormRef" @submit.prevent="handleAdminLogin">
|
||||
<el-form-item prop="account">
|
||||
<el-input v-model="adminForm.account" placeholder="用户名或手机号" prefix-icon="User" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input v-model="adminForm.password" type="password" placeholder="密码" prefix-icon="Lock" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="showCaptcha" prop="captchaCode">
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<el-input v-model="adminForm.captchaCode" placeholder="验证码" />
|
||||
<img :src="captchaImage" @click="loadCaptcha" style="cursor: pointer; height: 40px;" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" native-type="submit" :loading="loading" style="width: 100%">登录</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="用户登录" name="user">
|
||||
<el-form :model="userForm" :rules="rules" ref="userFormRef" @submit.prevent="handleUserLogin">
|
||||
<el-form-item prop="email">
|
||||
<el-input v-model="userForm.email" placeholder="邮箱" prefix-icon="Message" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input v-model="userForm.password" type="password" placeholder="密码" prefix-icon="Lock" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="userForm.rememberMe">记住我</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" native-type="submit" :loading="loading" style="width: 100%">登录</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<div class="footer-link">
|
||||
还没有账号?<router-link to="/register">立即注册</router-link>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { getCaptcha } from '@/api/auth'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loginType = ref('user')
|
||||
const loading = ref(false)
|
||||
const showCaptcha = ref(false)
|
||||
const captchaImage = ref('')
|
||||
|
||||
const adminForm = ref({ account: '', password: '', captchaKey: '', captchaCode: '' })
|
||||
const userForm = ref({ email: '', password: '', rememberMe: false })
|
||||
const adminFormRef = ref()
|
||||
const userFormRef = ref()
|
||||
|
||||
const rules = {
|
||||
account: [{ required: true, message: '请输入账号', trigger: 'blur' }],
|
||||
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
captchaCode: [{ required: true, message: '请输入验证码', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function loadCaptcha() {
|
||||
const res = await getCaptcha()
|
||||
adminForm.value.captchaKey = res.data.captchaKey
|
||||
captchaImage.value = res.data.captchaImage
|
||||
showCaptcha.value = true
|
||||
}
|
||||
|
||||
async function handleAdminLogin() {
|
||||
await adminFormRef.value.validate()
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.loginAdmin(adminForm.value)
|
||||
ElMessage.success('登录成功')
|
||||
router.push(route.query.redirect || '/admin')
|
||||
} catch (e) {
|
||||
if (e?.response?.data?.code === 1008) {
|
||||
loadCaptcha()
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUserLogin() {
|
||||
await userFormRef.value.validate()
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.loginUser(userForm.value)
|
||||
ElMessage.success('登录成功')
|
||||
router.push(route.query.redirect || '/app/dashboard')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.login-card {
|
||||
width: 420px;
|
||||
padding: 20px;
|
||||
}
|
||||
.login-card h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.footer-link {
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="register-page">
|
||||
<el-card class="register-card">
|
||||
<h2>注册</h2>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" @submit.prevent="handleRegister">
|
||||
<el-form-item prop="email">
|
||||
<el-input v-model="form.email" placeholder="邮箱" prefix-icon="Message" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input v-model="form.password" type="password" placeholder="密码(8-32位,含大小写字母、数字和特殊字符中的至少三类)" prefix-icon="Lock" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item prop="confirmPassword">
|
||||
<el-input v-model="form.confirmPassword" type="password" placeholder="确认密码" prefix-icon="Lock" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" native-type="submit" :loading="loading" style="width: 100%">注册</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="footer-link">
|
||||
已有账号?<router-link to="/login">立即登录</router-link>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const loading = ref(false)
|
||||
const formRef = ref()
|
||||
const form = ref({ email: '', password: '', confirmPassword: '' })
|
||||
|
||||
const validateConfirm = (rule, value, callback) => {
|
||||
if (value !== form.value.password) {
|
||||
callback(new Error('两次密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
const rules = {
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
||||
{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 8, max: 32, message: '密码长度8-32位', trigger: 'blur' },
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请确认密码', trigger: 'blur' },
|
||||
{ validator: validateConfirm, trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
await formRef.value.validate()
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.registerUser(form.value)
|
||||
ElMessage.success('注册成功,请查看邮箱完成验证')
|
||||
router.push('/login')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.register-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.register-card {
|
||||
width: 420px;
|
||||
padding: 20px;
|
||||
}
|
||||
.register-card h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.footer-link {
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>文章管理</h2>
|
||||
<div style="margin-bottom: 16px; display: flex; gap: 12px;">
|
||||
<el-input v-model="keyword" placeholder="搜索文章" clearable style="width: 300px" @keyup.enter="loadArticles">
|
||||
<template #append>
|
||||
<el-button @click="loadArticles"><el-icon><Search /></el-icon></el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-select v-model="status" placeholder="状态" clearable @change="loadArticles">
|
||||
<el-option label="草稿" :value="0" />
|
||||
<el-option label="已发布" :value="1" />
|
||||
<el-option label="回收站" :value="2" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-table :data="articles" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="title" label="标题" />
|
||||
<el-table-column prop="authorNickname" label="作者" width="120" />
|
||||
<el-table-column prop="viewCount" label="浏览" width="80" />
|
||||
<el-table-column prop="likeCount" label="点赞" width="80" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusType(row)">{{ statusText(row) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="danger" @click="handleDelete(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="15"
|
||||
:total="total"
|
||||
layout="prev, pager, next"
|
||||
@current-change="loadArticles"
|
||||
style="margin-top: 20px; justify-content: center;"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getAdminArticles, adminDeleteArticle } from '@/api/article'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const articles = ref([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const keyword = ref('')
|
||||
const status = ref(null)
|
||||
|
||||
async function loadArticles() {
|
||||
loading.value = true
|
||||
const res = await getAdminArticles({ page: page.value, pageSize: 15, keyword: keyword.value, status: status.value })
|
||||
articles.value = res.data.records
|
||||
total.value = res.data.total
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
await ElMessageBox.confirm('确定删除该文章?', '警告', { type: 'warning' })
|
||||
await adminDeleteArticle(id)
|
||||
ElMessage.success('已删除')
|
||||
loadArticles()
|
||||
}
|
||||
|
||||
function statusType(row) {
|
||||
return [null, 'success', 'warning', 'info'][row.status] || ''
|
||||
}
|
||||
function statusText(row) {
|
||||
return ['草稿', '已发布', '回收站'][row.status] || '未知'
|
||||
}
|
||||
|
||||
onMounted(loadArticles)
|
||||
</script>
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>分类管理</h2>
|
||||
<div style="margin-bottom: 16px;">
|
||||
<el-button type="primary" @click="showAddDialog">新增分类</el-button>
|
||||
</div>
|
||||
<el-table :data="categories" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="description" label="描述" />
|
||||
<el-table-column prop="articleCount" label="文章数" width="100" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.enabled ? 'success' : 'danger'">{{ row.enabled ? '启用' : '禁用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" @click="showEditDialog(row)">编辑</el-button>
|
||||
<el-button text type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑分类' : '新增分类'" width="400px">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="form.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="form.enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sort" :min="0" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getCategories, createCategory, updateCategory, deleteCategory } from '@/api/article'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const categories = ref([])
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const editingId = ref(null)
|
||||
const form = ref({ name: '', description: '', enabled: true, sort: 0 })
|
||||
|
||||
async function loadCategories() {
|
||||
loading.value = true
|
||||
const res = await getCategories()
|
||||
categories.value = res.data
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function showAddDialog() {
|
||||
isEdit.value = false
|
||||
editingId.value = null
|
||||
form.value = { name: '', description: '', enabled: true, sort: 0 }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function showEditDialog(row) {
|
||||
isEdit.value = true
|
||||
editingId.value = row.id
|
||||
form.value = { name: row.name, description: row.description, enabled: row.enabled, sort: row.sort }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.value.name) {
|
||||
ElMessage.warning('请输入分类名称')
|
||||
return
|
||||
}
|
||||
if (isEdit.value) {
|
||||
await updateCategory(editingId.value, form.value)
|
||||
} else {
|
||||
await createCategory(form.value)
|
||||
}
|
||||
ElMessage.success('操作成功')
|
||||
dialogVisible.value = false
|
||||
loadCategories()
|
||||
}
|
||||
|
||||
async function handleDelete(row) {
|
||||
await ElMessageBox.confirm(`确定删除分类"${row.name}"?`, '提示')
|
||||
try {
|
||||
await deleteCategory(row.id)
|
||||
ElMessage.success('已删除')
|
||||
loadCategories()
|
||||
} catch {
|
||||
// error handled by interceptor
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadCategories)
|
||||
</script>
|
||||
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>评论管理</h2>
|
||||
<div style="margin-bottom: 16px; display: flex; gap: 12px;">
|
||||
<el-select v-model="status" placeholder="状态" clearable @change="loadComments">
|
||||
<el-option label="待审核" :value="0" />
|
||||
<el-option label="已通过" :value="1" />
|
||||
<el-option label="已屏蔽" :value="2" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-table :data="comments" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="nickname" label="评论者" width="120" />
|
||||
<el-table-column prop="content" label="内容" show-overflow-tooltip />
|
||||
<el-table-column prop="articleId" label="文章ID" width="80" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="[null, 'success', 'danger'][row.status] || 'info'">
|
||||
{{ ['待审核', '已通过', '已屏蔽', '已删除'][row.status] }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="success" @click="handleAudit(row.id, 1)">通过</el-button>
|
||||
<el-button text type="warning" @click="handleAudit(row.id, 2)">屏蔽</el-button>
|
||||
<el-button text type="danger" @click="handleDelete(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="15"
|
||||
:total="total"
|
||||
layout="prev, pager, next"
|
||||
@current-change="loadComments"
|
||||
style="margin-top: 20px; justify-content: center;"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getAdminComments, auditComment, adminDeleteComment } from '@/api/comment'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const comments = ref([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const status = ref(null)
|
||||
|
||||
async function loadComments() {
|
||||
loading.value = true
|
||||
const res = await getAdminComments({ page: page.value, pageSize: 15, status: status.value })
|
||||
comments.value = res.data.records
|
||||
total.value = res.data.total
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function handleAudit(id, auditStatus) {
|
||||
await auditComment(id, auditStatus)
|
||||
ElMessage.success('操作成功')
|
||||
loadComments()
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
await ElMessageBox.confirm('确定删除该评论?', '警告', { type: 'warning' })
|
||||
await adminDeleteComment(id)
|
||||
ElMessage.success('已删除')
|
||||
loadComments()
|
||||
}
|
||||
|
||||
onMounted(loadComments)
|
||||
</script>
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>管理后台仪表盘</h2>
|
||||
<el-row :gutter="20" style="margin-top: 20px;">
|
||||
<el-col :span="6">
|
||||
<el-card><el-statistic title="用户数" :value="0" /></el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card><el-statistic title="文章数" :value="0" /></el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card><el-statistic title="评论数" :value="0" /></el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card><el-statistic title="今日访问" :value="0" /></el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>用户管理</h2>
|
||||
<div style="margin-bottom: 16px; display: flex; gap: 12px;">
|
||||
<el-input v-model="keyword" placeholder="搜索用户" clearable style="width: 300px" @keyup.enter="loadUsers">
|
||||
<template #append>
|
||||
<el-button @click="loadUsers"><el-icon><Search /></el-icon></el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
<el-table :data="users" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="nickname" label="昵称" width="150" />
|
||||
<el-table-column prop="email" label="邮箱" />
|
||||
<el-table-column label="操作" width="240">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="warning" @click="handleResetPassword(row.id)">重置密码</el-button>
|
||||
<el-button text :type="row.status === 1 ? 'success' : 'danger'" @click="handleToggleStatus(row)">
|
||||
{{ row.status === 1 ? '解锁' : '锁定' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="15"
|
||||
:total="total"
|
||||
layout="prev, pager, next"
|
||||
@current-change="loadUsers"
|
||||
style="margin-top: 20px; justify-content: center;"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getUserList, resetUserPassword, updateUserStatus } from '@/api/admin'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const users = ref([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const keyword = ref('')
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
const res = await getUserList({ page: page.value, pageSize: 15, keyword: keyword.value })
|
||||
users.value = res.data.records
|
||||
total.value = res.data.total
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function handleResetPassword(id) {
|
||||
const { value } = await ElMessageBox.prompt('请输入新密码', '重置密码', {
|
||||
inputPattern: /^.{8,32}$/,
|
||||
inputErrorMessage: '密码长度8-32位',
|
||||
})
|
||||
await resetUserPassword(id, value)
|
||||
ElMessage.success('密码已重置')
|
||||
}
|
||||
|
||||
async function handleToggleStatus(row) {
|
||||
const newStatus = row.status === 0 ? 1 : 0
|
||||
const action = newStatus === 1 ? '锁定' : '解锁'
|
||||
await ElMessageBox.confirm(`确定${action}该用户?`, '提示')
|
||||
await updateUserStatus(row.id, newStatus)
|
||||
ElMessage.success(`已${action}`)
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
onMounted(loadUsers)
|
||||
</script>
|
||||
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div class="article-editor">
|
||||
<h2>{{ isEdit ? '编辑文章' : '写文章' }}</h2>
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="标题">
|
||||
<el-input v-model="form.title" placeholder="请输入标题" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="form.categoryId" placeholder="选择分类" clearable>
|
||||
<el-option v-for="cat in categories" :key="cat.id" :label="cat.name" :value="cat.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="标签">
|
||||
<el-select v-model="form.tagIds" multiple placeholder="选择标签(最多5个)" :max-collapse-tags="5">
|
||||
<el-option v-for="tag in tags" :key="tag.id" :label="tag.name" :value="tag.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="内容">
|
||||
<div class="editor-wrapper">
|
||||
<div v-if="editor" class="toolbar">
|
||||
<el-button-group>
|
||||
<el-button size="small" @click="editor.chain().focus().toggleBold().run()" :type="editor.isActive('bold') ? 'primary' : ''">B</el-button>
|
||||
<el-button size="small" @click="editor.chain().focus().toggleItalic().run()" :type="editor.isActive('italic') ? 'primary' : ''">I</el-button>
|
||||
<el-button size="small" @click="editor.chain().focus().toggleHeading({ level: 2 }).run()" :type="editor.isActive('heading', { level: 2 }) ? 'primary' : ''">H2</el-button>
|
||||
<el-button size="small" @click="editor.chain().focus().toggleHeading({ level: 3 }).run()" :type="editor.isActive('heading', { level: 3 }) ? 'primary' : ''">H3</el-button>
|
||||
<el-button size="small" @click="editor.chain().focus().toggleBulletList().run()" :type="editor.isActive('bulletList') ? 'primary' : ''">列表</el-button>
|
||||
<el-button size="small" @click="editor.chain().focus().toggleBlockquote().run()" :type="editor.isActive('blockquote') ? 'primary' : ''">引用</el-button>
|
||||
<el-button size="small" @click="editor.chain().focus().setHorizontalRule().run()">分割线</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
<EditorContent :editor="editor" class="editor-content" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="摘要">
|
||||
<el-input v-model="form.summary" type="textarea" :rows="2" placeholder="文章摘要" />
|
||||
</el-form-item>
|
||||
<el-form-item label="可见性">
|
||||
<el-radio-group v-model="form.visibility">
|
||||
<el-radio :value="0">公开</el-radio>
|
||||
<el-radio :value="1">私密</el-radio>
|
||||
<el-radio :value="2">仅粉丝</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSubmit(false)" :loading="saving">保存草稿</el-button>
|
||||
<el-button type="success" @click="handleSubmit(true)" :loading="saving">发布</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useEditor, EditorContent } from '@tiptap/vue-3'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import { createArticle, updateArticle, getArticleDetail, getCategories, getTags } from '@/api/article'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
const saving = ref(false)
|
||||
const categories = ref([])
|
||||
const tags = ref([])
|
||||
const form = ref({
|
||||
title: '',
|
||||
categoryId: null,
|
||||
tagIds: [],
|
||||
summary: '',
|
||||
visibility: 0,
|
||||
allowComment: true,
|
||||
})
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Placeholder.configure({ placeholder: '开始写作...' }),
|
||||
],
|
||||
content: '',
|
||||
})
|
||||
|
||||
async function loadCategories() {
|
||||
const res = await getCategories()
|
||||
categories.value = res.data
|
||||
}
|
||||
|
||||
async function loadTags() {
|
||||
const res = await getTags()
|
||||
tags.value = res.data
|
||||
}
|
||||
|
||||
async function loadArticle() {
|
||||
if (!isEdit.value) return
|
||||
const res = await getArticleDetail(route.params.id)
|
||||
const article = res.data
|
||||
form.value.title = article.title
|
||||
form.value.categoryId = article.categoryId
|
||||
form.value.tagIds = article.tags?.map(t => t.id) || []
|
||||
form.value.summary = article.summary
|
||||
form.value.visibility = article.visibility
|
||||
if (editor.value) {
|
||||
editor.value.commands.setContent(article.content || '')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(publish) {
|
||||
if (!form.value.title) {
|
||||
ElMessage.warning('请输入标题')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const data = {
|
||||
...form.value,
|
||||
content: editor.value?.getHTML() || '',
|
||||
publish,
|
||||
}
|
||||
if (isEdit.value) {
|
||||
await updateArticle(route.params.id, data)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
const res = await createArticle(data)
|
||||
ElMessage.success(publish ? '发布成功' : '已保存草稿')
|
||||
if (!isEdit.value) {
|
||||
router.push(`/app/editor/${res.data}`)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCategories()
|
||||
loadTags()
|
||||
loadArticle()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editor.value?.destroy()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.article-editor { max-width: 900px; }
|
||||
.editor-wrapper { border: 1px solid #dcdfe6; border-radius: 4px; width: 100%; }
|
||||
.toolbar { padding: 8px; border-bottom: 1px solid #dcdfe6; background: #fafafa; }
|
||||
.editor-content { min-height: 400px; padding: 16px; }
|
||||
.editor-content :deep(.tiptap) { outline: none; min-height: 360px; }
|
||||
.editor-content :deep(.tiptap p.is-editor-empty:first-child::before) {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: #adb5bd;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>控制台</h2>
|
||||
<p>欢迎,{{ userStore.userInfo?.nickname }}</p>
|
||||
<el-row :gutter="20" style="margin-top: 20px;">
|
||||
<el-col :span="8">
|
||||
<el-card><el-statistic title="我的文章" :value="0" /></el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card><el-statistic title="总浏览量" :value="0" /></el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card><el-statistic title="获赞数" :value="0" /></el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useUserStore } from '@/stores/user'
|
||||
const userStore = useUserStore()
|
||||
</script>
|
||||
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<h2>我的文章</h2>
|
||||
<el-button type="primary" @click="$router.push('/app/editor')">写文章</el-button>
|
||||
</div>
|
||||
<el-table :data="articles" v-loading="loading">
|
||||
<el-table-column prop="title" label="标题" />
|
||||
<el-table-column prop="categoryName" label="分类" width="120" />
|
||||
<el-table-column prop="viewCount" label="浏览" width="80" />
|
||||
<el-table-column prop="likeCount" label="点赞" width="80" />
|
||||
<el-table-column prop="publishedAt" label="发布时间" width="180">
|
||||
<template #default="{ row }">{{ formatDate(row.publishedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" @click="$router.push(`/app/editor/${row.id}`)">编辑</el-button>
|
||||
<el-button text type="warning" @click="handleRecycle(row.id)">回收站</el-button>
|
||||
<el-button text type="danger" @click="handleDelete(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="15"
|
||||
:total="total"
|
||||
layout="prev, pager, next"
|
||||
@current-change="loadArticles"
|
||||
style="margin-top: 20px; justify-content: center;"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getMyArticles, moveToRecycleBin, deleteArticle } from '@/api/article'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const articles = ref([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
|
||||
async function loadArticles() {
|
||||
loading.value = true
|
||||
const res = await getMyArticles({ page: page.value, pageSize: 15 })
|
||||
articles.value = res.data.records
|
||||
total.value = res.data.total
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function handleRecycle(id) {
|
||||
await ElMessageBox.confirm('确定移入回收站?', '提示')
|
||||
await moveToRecycleBin(id)
|
||||
ElMessage.success('已移入回收站')
|
||||
loadArticles()
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
await ElMessageBox.confirm('确定永久删除?此操作不可恢复', '警告', { type: 'warning' })
|
||||
await deleteArticle(id)
|
||||
ElMessage.success('已删除')
|
||||
loadArticles()
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
onMounted(loadArticles)
|
||||
</script>
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
imports: ['vue', 'vue-router', 'pinia'],
|
||||
dts: 'src/auto-imports.d.ts',
|
||||
}),
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
dts: 'src/components.d.ts',
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: '../jog-admin/src/main/resources/static',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>fun.nojava</groupId>
|
||||
<artifactId>jog-system</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>jog-module-blog</artifactId>
|
||||
<name>Jog Module - Blog</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>fun.nojava</groupId>
|
||||
<artifactId>jog-module-system</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>fun.nojava</groupId>
|
||||
<artifactId>jog-system</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>jog-module-comment</artifactId>
|
||||
<name>Jog Module - Comment</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>fun.nojava</groupId>
|
||||
<artifactId>jog-module-blog</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>fun.nojava</groupId>
|
||||
<artifactId>jog-system</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>jog-module-system</artifactId>
|
||||
<name>Jog Module - System</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>fun.nojava</groupId>
|
||||
<artifactId>jog-framework</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,114 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.4.5</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>fun.nojava</groupId>
|
||||
<artifactId>jog-system</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>pom</packaging>
|
||||
<name>Jog System</name>
|
||||
|
||||
<modules>
|
||||
<module>jog-common</module>
|
||||
<module>jog-framework</module>
|
||||
<module>jog-module-system</module>
|
||||
<module>jog-module-blog</module>
|
||||
<module>jog-module-comment</module>
|
||||
<module>jog-admin</module>
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<mybatis-plus.version>3.5.9</mybatis-plus.version>
|
||||
<jjwt.version>0.12.6</jjwt.version>
|
||||
<springdoc.version>2.8.6</springdoc.version>
|
||||
<swagger-annotations.version>2.2.26</swagger-annotations.version>
|
||||
<jsoup.version>1.18.1</jsoup.version>
|
||||
<hutool.version>5.8.34</hutool.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>fun.nojava</groupId>
|
||||
<artifactId>jog-common</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>fun.nojava</groupId>
|
||||
<artifactId>jog-framework</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>fun.nojava</groupId>
|
||||
<artifactId>jog-module-system</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>fun.nojava</groupId>
|
||||
<artifactId>jog-module-blog</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>fun.nojava</groupId>
|
||||
<artifactId>jog-module-comment</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
<version>${mybatis-plus.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>${springdoc.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.swagger.core.v3</groupId>
|
||||
<artifactId>swagger-annotations-jakarta</artifactId>
|
||||
<version>${swagger-annotations.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jsoup</groupId>
|
||||
<artifactId>jsoup</artifactId>
|
||||
<version>${jsoup.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.hutool</groupId>
|
||||
<artifactId>hutool-all</artifactId>
|
||||
<version>${hutool.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
</project>
|
||||
Reference in New Issue
Block a user