Compare commits

...

2 Commits

Author SHA1 Message Date
shenjianZ 83f4fd4d58 feat: fix the time display 2026-01-13 17:59:24 +08:00
shenjianZ b286e36ab0 feat: fix the time display 2026-01-13 17:38:01 +08:00
21 changed files with 596 additions and 20 deletions

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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 = "作者")

View File

@ -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;
}

View File

@ -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")

View File

@ -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 = "作者")

View File

@ -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;
}

View File

@ -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;
/**

View File

@ -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;
/**

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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

View File

@ -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>

View File

@ -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 } })
},

View File

@ -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',

View File

@ -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 {

View File

@ -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>

View File

@ -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>