Compare commits
2 Commits
6db060381f
...
83f4fd4d58
| Author | SHA1 | Date |
|---|---|---|
|
|
83f4fd4d58 | |
|
|
b286e36ab0 |
|
|
@ -0,0 +1,34 @@
|
|||
package com.aisi.newsclassifier.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
@Configuration
|
||||
public class JacksonConfig {
|
||||
|
||||
@Bean
|
||||
public ObjectMapper objectMapper() {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
// 配置日期时间格式
|
||||
JavaTimeModule javaTimeModule = new JavaTimeModule();
|
||||
|
||||
// 定义日期时间格式
|
||||
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(dateTimeFormatter));
|
||||
|
||||
mapper.registerModule(javaTimeModule);
|
||||
|
||||
// 禁用时间戳格式
|
||||
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
|
||||
return mapper;
|
||||
}
|
||||
}
|
||||
|
|
@ -20,7 +20,9 @@ public enum RestCode {
|
|||
TOKEN_EMPTY(403, "token不能为空"),
|
||||
TOKEN_INVALID(403, "token非法"),
|
||||
SYSTEM_ERROR(500,"系统错误,请联系管理员" ),
|
||||
DATA_NOT_FOUND(404,"数据不存在");
|
||||
DATA_NOT_FOUND(404,"数据不存在"),
|
||||
DATA_ALREADY_FOUND(409,"数据已存在"),
|
||||
METHOD_NOT_SUPPORT(405,"不支持该请求方法");
|
||||
|
||||
private final int code;
|
||||
private final String message;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.aisi.newsclassifier.domain.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
|
|
@ -25,6 +26,7 @@ public class NewsCreateDto {
|
|||
private Integer categoryId;
|
||||
|
||||
@Schema(description = "发布时间")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private LocalDateTime publishTime;
|
||||
|
||||
@Schema(description = "作者")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.aisi.newsclassifier.domain.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
|
@ -28,6 +29,7 @@ public class NewsDto {
|
|||
private String categoryName;
|
||||
|
||||
@Schema(description = "发布时间")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private LocalDateTime publishTime;
|
||||
|
||||
@Schema(description = "作者")
|
||||
|
|
@ -40,5 +42,6 @@ public class NewsDto {
|
|||
private String content;
|
||||
|
||||
@Schema(description = "入库时间")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.aisi.newsclassifier.domain.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
|
|
@ -26,6 +27,7 @@ public class NewsQueryDto {
|
|||
private String keyword;
|
||||
|
||||
@Schema(description = "排序字段", example = "createdAt")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private String sortBy = "createdAt";
|
||||
|
||||
@Schema(description = "排序方向", example = "DESC")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.aisi.newsclassifier.domain.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
|
@ -22,6 +23,7 @@ public class NewsUpdateDto {
|
|||
private Integer categoryId;
|
||||
|
||||
@Schema(description = "发布时间")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private LocalDateTime publishTime;
|
||||
|
||||
@Schema(description = "作者")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.aisi.newsclassifier.domain.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
|
@ -28,8 +29,10 @@ public class UserDto {
|
|||
private String role;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Schema(description = "更新时间")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ public class News {
|
|||
private Integer categoryId;
|
||||
|
||||
@Column(name = "publish_time")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private LocalDateTime publishTime;
|
||||
|
||||
@Column(name = "author", length = 100)
|
||||
|
|
@ -56,7 +56,7 @@ public class News {
|
|||
private String contentHash;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -42,12 +42,12 @@ public class User {
|
|||
|
||||
@CreatedDate
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@LastModifiedDate
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
package com.aisi.newsclassifier.domain.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum ErrorCode {
|
||||
|
||||
// 通用错误
|
||||
PARAM_ERROR(400, "参数错误"),
|
||||
SYSTEM_ERROR(500, "系统繁忙,请稍后再试"),
|
||||
|
||||
// 用户相关 (1000 - 1999)
|
||||
USER_NOT_FOUND(1001, "用户不存在"),
|
||||
PASSWORD_ERROR(1002, "密码错误"),
|
||||
USER_LOCKED(1003, "账号已被锁定"),
|
||||
|
||||
// 新闻相关 (2000 - 2999)
|
||||
NEWS_NOT_FOUND(2001, "请求的新闻不存在"),
|
||||
CATEGORY_INVALID(2002, "无效的分类ID");
|
||||
|
||||
private final int code;
|
||||
private final String message;
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ package com.aisi.newsclassifier.domain.vo;
|
|||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
|
|
@ -25,6 +25,7 @@ public class NewsVo {
|
|||
private String categoryName;
|
||||
|
||||
@Schema(description = "发布时间")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private LocalDateTime publishTime;
|
||||
|
||||
@Schema(description = "作者")
|
||||
|
|
@ -37,5 +38,6 @@ public class NewsVo {
|
|||
private String content;
|
||||
|
||||
@Schema(description = "入库时间")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.aisi.newsclassifier.domain.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
|
@ -25,8 +26,10 @@ public class UserVo {
|
|||
private String role;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Schema(description = "更新时间")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
package com.aisi.newsclassifier.exception;
|
||||
import com.aisi.newsclassifier.domain.enums.ErrorCode;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 自定义业务异常
|
||||
* 用于在 Service 层中断逻辑,并返回具体的错误码和错误信息
|
||||
*/
|
||||
@Getter // 使用 Lombok 自动生成 getCode() 方法
|
||||
public class BusinessException extends RuntimeException {
|
||||
|
||||
// 错误码 (例如 400, 403, 1001 等)
|
||||
private final int code;
|
||||
|
||||
/**
|
||||
* 构造方法 1:手动指定 code 和 message
|
||||
* 使用:throw new BusinessException(404, "找不到该新闻");
|
||||
*/
|
||||
public BusinessException(int code, String message) {
|
||||
super(message); // 把 message 传给父类,方便 log 打印
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造方法 2:使用通用错误码 (默认为 400 或 500)
|
||||
* 使用:throw new BusinessException("操作失败");
|
||||
*/
|
||||
public BusinessException(String message) {
|
||||
super(message);
|
||||
this.code = 400; // 默认给个 400
|
||||
}
|
||||
|
||||
// 在 BusinessException 类里添加这个构造方法
|
||||
public BusinessException(ErrorCode errorCode) {
|
||||
super(errorCode.getMessage());
|
||||
this.code = errorCode.getCode();
|
||||
}
|
||||
}
|
||||
|
|
@ -4,8 +4,20 @@ import com.aisi.newsclassifier.domain.RestBean;
|
|||
import com.aisi.newsclassifier.domain.RestCode;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.authorization.AuthorizationDeniedException;
|
||||
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.validation.BindException;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
import com.aisi.newsclassifier.exception.BusinessException;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 全局异常处理器
|
||||
|
|
@ -13,6 +25,116 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
|
|||
@Slf4j
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
// ==========================================
|
||||
// 1. 客户端请求错误 (400 Bad Request 等)
|
||||
// 这类错误是前端传参不对,记录 WARN 日志,不需要打印堆栈
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* 1. 参数校验失败异常 (@Valid / @Validated)
|
||||
* 场景:前端传的 JSON 缺字段,或者字段不符合 @NotNull, @Size 等注解要求
|
||||
*/
|
||||
@ExceptionHandler({MethodArgumentNotValidException.class, BindException.class})
|
||||
public RestBean<String> handleValidationException(Exception e) {
|
||||
BindingResult bindingResult = null;
|
||||
if (e instanceof MethodArgumentNotValidException) {
|
||||
bindingResult = ((MethodArgumentNotValidException) e).getBindingResult();
|
||||
} else if (e instanceof BindException) {
|
||||
bindingResult = ((BindException) e).getBindingResult();
|
||||
}
|
||||
|
||||
// 提取具体的错误信息(例如:"email: 邮箱格式不正确")
|
||||
String msg = "参数校验失败";
|
||||
if (bindingResult != null) {
|
||||
msg = bindingResult.getFieldErrors().stream()
|
||||
.map(error -> error.getField() + ": " + error.getDefaultMessage())
|
||||
.collect(Collectors.joining(", "));
|
||||
}
|
||||
|
||||
log.warn("参数校验未通过: {}", msg);
|
||||
return RestBean.failure(RestCode.FAILURE, msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 2. JSON 格式解析错误
|
||||
* 场景:前端传的 JSON 少了括号,或者把 String 传给了 Integer 类型的字段
|
||||
*/
|
||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||
public RestBean<String> handleJsonParseException(HttpMessageNotReadableException e) {
|
||||
log.warn("JSON解析失败: {}", e.getMessage());
|
||||
return RestBean.failure(RestCode.FAILURE, "请求Body格式错误,请检查JSON语法");
|
||||
}
|
||||
|
||||
/**
|
||||
* 3. 缺少必要的 URL 参数
|
||||
* 场景:接口定义了 @RequestParam(required=true) 但前端没传
|
||||
*/
|
||||
@ExceptionHandler(MissingServletRequestParameterException.class)
|
||||
public RestBean<String> handleMissingParam(MissingServletRequestParameterException e) {
|
||||
log.warn("缺少请求参数: {}", e.getParameterName());
|
||||
return RestBean.failure(RestCode.FAILURE, "缺少必要参数: " + e.getParameterName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 4. 请求方法不支持
|
||||
* 场景:接口只写了 @PostMapping,前端却用 GET 请求访问
|
||||
*/
|
||||
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
|
||||
public RestBean<String> handleMethodNotSupported(HttpRequestMethodNotSupportedException e) {
|
||||
log.warn("请求方法不支持: method={}, supported={}", e.getMethod(), e.getSupportedHttpMethods());
|
||||
return RestBean.failure(RestCode.METHOD_NOT_SUPPORT, "不支持该请求方法: " + e.getMethod());
|
||||
}
|
||||
|
||||
/**
|
||||
* 5. 参数类型不匹配 (刚才你遇到的那个)
|
||||
*/
|
||||
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
|
||||
public RestBean<String> handleTypeMismatch(MethodArgumentTypeMismatchException e) {
|
||||
String msg = String.format("参数类型错误: 参数[%s] 需要 [%s]", e.getName(), e.getRequiredType().getSimpleName());
|
||||
log.warn("参数类型不匹配: {}", msg);
|
||||
return RestBean.failure(RestCode.FAILURE, msg);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2. 业务逻辑与数据库错误
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* 6. 自定义业务异常 (最常用!)
|
||||
* 场景:你在 Service 层手动抛出 throw new BusinessException(403, "权限不足");
|
||||
*/
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
public RestBean<String> handleBusinessException(BusinessException e) {
|
||||
// 业务异常通常是预期内的,记录 WARN 即可
|
||||
log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage());
|
||||
return RestBean.failure(e.getCode(), e.getMessage(),"");
|
||||
}
|
||||
|
||||
/**
|
||||
* 7. 数据库唯一键冲突
|
||||
* 场景:注册时用户名已存在,插入数据库时触发 Unique Constraint
|
||||
*/
|
||||
@ExceptionHandler(DuplicateKeyException.class)
|
||||
public RestBean<String> handleDuplicateKeyException(DuplicateKeyException e) {
|
||||
log.warn("数据库数据冲突: {}", e.getMessage());
|
||||
return RestBean.failure(RestCode.DATA_ALREADY_FOUND, "数据已存在,请勿重复操作");
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 3. 致命系统错误 (500)
|
||||
// 这类错误是 Bug,必须记录堆栈信息 (e),并报警
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* 8. 空指针异常 (NullPointerException)
|
||||
* 场景:代码里没做判空,a.b() 时 a 是 null
|
||||
*/
|
||||
@ExceptionHandler(NullPointerException.class)
|
||||
public RestBean<String> handleNPE(NullPointerException e) {
|
||||
// 必须打印堆栈!
|
||||
log.error("发生空指针异常: ", e);
|
||||
return RestBean.failure(RestCode.SYSTEM_ERROR, "系统内部数据异常,请联系管理员");
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理权限拒绝异常
|
||||
|
|
@ -28,7 +150,7 @@ public class GlobalExceptionHandler {
|
|||
*/
|
||||
@ExceptionHandler(Exception.class)
|
||||
public RestBean<Void> handleException(Exception e) {
|
||||
log.error("系统异常: ", e);
|
||||
log.error("系统异常: ", e.getMessage());
|
||||
return RestBean.failure(RestCode.SYSTEM_ERROR);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
spring:
|
||||
datasource:
|
||||
url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:news}?useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8&allowPublicKeyRetrieval=true
|
||||
url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:news}?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8&allowPublicKeyRetrieval=true
|
||||
username: ${DB_USER:root}
|
||||
password: ${DB_PASS:root}
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
|
|
|
|||
|
|
@ -1,13 +1,26 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import TitleBar from '@/components/TitleBar.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 禁用全局右键菜单的事件处理函数
|
||||
const handleContextMenu = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
// 初始化时加载用户信息
|
||||
onMounted(() => {
|
||||
userStore.loadUserInfo()
|
||||
|
||||
// 添加全局右键菜单禁用事件
|
||||
document.addEventListener('contextmenu', handleContextMenu)
|
||||
})
|
||||
|
||||
// 组件卸载时移除事件监听器
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('contextmenu', handleContextMenu)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -92,7 +92,8 @@ export const newsApi = {
|
|||
* @param size 每页大小
|
||||
* @returns 分页新闻列表
|
||||
*/
|
||||
getLatest(page = 1, size = 20): Promise<PageResult<NewsDto>> {
|
||||
getLatest(params: { page?: number; size?: number } = {}): Promise<PageResult<NewsDto>> {
|
||||
const { page = 1, size = 20 } = params
|
||||
return http.get('/v1/news/latest', { params: { page, size } })
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,12 @@ const routes = [
|
|||
component: () => import('@/views/auth/LoginView.vue'),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('@/views/auth/RegisterView.vue'),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/home',
|
||||
name: 'Home',
|
||||
|
|
|
|||
|
|
@ -58,11 +58,10 @@ async function handleSubmit() {
|
|||
}
|
||||
|
||||
/**
|
||||
* 跳转到注册页面(预留)
|
||||
* 跳转到注册页面
|
||||
*/
|
||||
function goToRegister() {
|
||||
// TODO: 实现注册页面
|
||||
console.log('跳转到注册页面')
|
||||
router.push('/register')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -103,7 +102,7 @@ function goToRegister() {
|
|||
v-model="form.username"
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
autocomplete="username"
|
||||
autocomplete="off"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -116,7 +115,7 @@ function goToRegister() {
|
|||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
autocomplete="current-password"
|
||||
autocomplete="off"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -218,18 +217,21 @@ function goToRegister() {
|
|||
|
||||
.form-group input {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid hsl(var(--input));
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: var(--radius);
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.875rem;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
/* 增强默认边框可见性 */
|
||||
border-color: #d1d5db !important;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--ring));
|
||||
box-shadow: 0 0 0 3px hsl(var(--ring) / 0.2);
|
||||
/* 增强聚焦时的边框效果 */
|
||||
border-color: #3b82f6 !important;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.form-group input:disabled {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,315 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { userApi } from '@/api/user'
|
||||
import type { RegisterRequest } from '@/types/api'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 表单数据
|
||||
const form = ref<RegisterRequest>({
|
||||
username: '',
|
||||
password: '',
|
||||
email: ''
|
||||
})
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 错误信息
|
||||
const errorMessage = ref('')
|
||||
|
||||
/**
|
||||
* 处理注册提交
|
||||
*/
|
||||
async function handleSubmit() {
|
||||
// 表单验证
|
||||
if (!form.value.username.trim()) {
|
||||
errorMessage.value = '请输入用户名'
|
||||
return
|
||||
}
|
||||
if (!form.value.password.trim()) {
|
||||
errorMessage.value = '请输入密码'
|
||||
return
|
||||
}
|
||||
if (!form.value.email.trim()) {
|
||||
errorMessage.value = '请输入邮箱'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
// 调用注册 API
|
||||
const token = await userApi.register(form.value)
|
||||
|
||||
// 保存 token
|
||||
userStore.setToken(token)
|
||||
|
||||
// 获取用户信息
|
||||
const userInfo = await userApi.getUserInfo()
|
||||
userStore.setUserInfo(userInfo)
|
||||
|
||||
// 跳转到首页
|
||||
router.push('/home')
|
||||
} catch (error: any) {
|
||||
errorMessage.value = error.message || '注册失败,请检查信息后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到登录页面
|
||||
*/
|
||||
function goToLogin() {
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<!-- Logo 和标题 -->
|
||||
<div class="login-header">
|
||||
<div class="logo">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"/>
|
||||
<path d="M18 14h-8"/>
|
||||
<path d="M15 18h-5"/>
|
||||
<path d="M10 6h8v4h-8V6Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="title">News Classifier</h1>
|
||||
<p class="subtitle">智能新闻分类管理系统</p>
|
||||
</div>
|
||||
|
||||
<!-- 注册表单 -->
|
||||
<form class="login-form" @submit.prevent="handleSubmit">
|
||||
<!-- 错误提示 -->
|
||||
<div v-if="errorMessage" class="error-message">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 用户名输入框 -->
|
||||
<div class="form-group">
|
||||
<label for="username">用户名</label>
|
||||
<input
|
||||
id="username"
|
||||
v-model="form.username"
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
autocomplete="off"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 邮箱输入框 -->
|
||||
<div class="form-group">
|
||||
<label for="email">邮箱</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
placeholder="请输入邮箱"
|
||||
autocomplete="off"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 密码输入框 -->
|
||||
<div class="form-group">
|
||||
<label for="password">密码</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
autocomplete="off"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 注册按钮 -->
|
||||
<button type="submit" class="login-btn" :disabled="loading">
|
||||
<span v-if="loading" class="spinner"></span>
|
||||
<span>{{ loading ? '注册中...' : '注册' }}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- 登录链接 -->
|
||||
<div class="register-link">
|
||||
<span>已有账号?</span>
|
||||
<a @click="goToLogin">立即登录</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--accent)) 100%);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: hsl(var(--card));
|
||||
border-radius: calc(var(--radius) + 4px);
|
||||
padding: 2.5rem;
|
||||
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--accent)) 100%);
|
||||
border-radius: calc(var(--radius) + 4px);
|
||||
color: hsl(var(--primary-foreground));
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: hsl(var(--destructive) / 0.1);
|
||||
border: 1px solid hsl(var(--destructive) / 0.2);
|
||||
border-radius: var(--radius);
|
||||
color: hsl(var(--destructive));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: var(--radius);
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.875rem;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
border-color: #d1d5db !important;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6 !important;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.form-group input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.login-btn:hover:not(:disabled) {
|
||||
background: hsl(var(--primary) / 0.9);
|
||||
}
|
||||
|
||||
.login-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid hsl(var(--primary-foreground));
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.register-link {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.register-link a {
|
||||
color: hsl(var(--primary));
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.register-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -19,7 +19,7 @@ const {
|
|||
data: latestNews,
|
||||
loading: newsLoading,
|
||||
fetch: fetchLatestNews
|
||||
} = usePagination(newsApi.getLatest.bind(newsApi), { size: 5 })
|
||||
} = usePagination(newsApi.getLatest.bind(newsApi), { page: 1, size: 5 })
|
||||
|
||||
// 分类统计(计算属性)
|
||||
const categoryStats = computed(() => {
|
||||
|
|
@ -149,7 +149,7 @@ onMounted(async () => {
|
|||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">最近更新</div>
|
||||
<div class="stat-value" style="font-size: 1rem;">{{ latestNews.length > 0 ? formatTime(latestNews[0].createdAt) : '-' }}</div>
|
||||
<div class="stat-value" style="font-size: 1rem;">{{ latestNews && latestNews.length > 0 && latestNews[0] ? formatTime(latestNews[0].createdAt) : '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue