合并 #2

Merged
p6m75oc2z merged 29 commits from TEST into main 1 week ago

@ -0,0 +1,32 @@
name: Backend CI
on:
push:
branches: [ main, TEST ]
pull_request:
branches: [ main, TEST ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven'
- name: Build with Maven
working-directory: backend
run: mvn clean package
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: studyingspace-backend
path: backend/target/*.jar

@ -0,0 +1,34 @@
name: Build and Push Backend to Docker Hub
on:
push:
branches: [ main ,TEST]
tags:
- 'v*'
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_HUB_USERNAME }}/studyingspace-backend
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: ./backend
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

@ -0,0 +1,53 @@
name: Build and Deploy to Server
on:
push:
branches: [ main ]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build backend image
run: |
cd backend
docker build -t hetianci/studyingspace-backend:TEST .
- name: Build frontend image
run: |
cd frontend
docker build -t hetianci/studyingspace-frontend:TEST .
- name: Save backend image to tar
run: |
docker save -o studyingspace-backend.tar hetianci/studyingspace-backend:TEST
- name: Save frontend image to tar
run: |
docker save -o studyingspace-frontend.tar hetianci/studyingspace-frontend:TEST
- name: Copy images to server
uses: appleboy/scp-action@v0.1.4
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_PRIVATE_KEY }}
source: "studyingspace-backend.tar,studyingspace-frontend.tar"
target: "my_project/StudyingSpace/"
- name: Deploy to server
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_PRIVATE_KEY }}
script: |
cd my_project/StudyingSpace
git pull
docker load -i studyingspace-backend.tar
docker load -i studyingspace-frontend.tar
docker compose up -d
rm -f studyingspace-backend.tar studyingspace-frontend.tar

@ -0,0 +1,27 @@
name: Frontend CI
on:
push:
branches: [ main ,TEST]
pull_request:
branches: [ main ,TEST]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
working-directory: ./frontend
run: npm install
- name: Build
working-directory: ./frontend
run: npm run build

@ -0,0 +1,34 @@
name: Build and Push Frontend to Docker Hub
on:
push:
branches: [ main ,TEST]
tags:
- 'v*'
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_HUB_USERNAME }}/studyingspace-frontend
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: ./frontend
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

@ -1,21 +1,25 @@
# StudyingSpace - 个人学习空间
## 📖 项目简介
个人学习空间,帮助学习者记录学习过程、管理学习笔记,打造个性化的学习环境。
## ✨ 功能特性
### 🔐 用户系统
- [ ] 用户注册:创建个人账号
- [ ] 用户登录:安全登录个人空间
### 📅 每日打卡
- [ ] 每日学习打卡记录
- [ ] 打卡日历展示
- [ ] 连续打卡统计
- [ ] 学习时长记录
### 📝 笔记管理
- [ ] 笔记导入:支持从多种格式导入笔记
- [ ] 笔记导出:将笔记导出为常见格式
- [ ] 笔记分类与整理
@ -23,15 +27,18 @@
## 🛠️ 技术栈
### 前端
- **框架**: Vue 3
- **构建工具**: Vite
### 后端
- **框架**: Spring Boot
- **语言**: Java
- **数据库**: PostgreSQL
## 📁 项目结构
项目采用前后端分离架构,代码分别存放于两个目录:
```
@ -43,21 +50,86 @@ StudyingSpace/
## 🚀 快速开始
### 环境要求
- Node.js (前端)
- JDK 21+ (后端)
- PostgreSQL
### 安装步骤
### 运行项目
## 🐳 Docker 快速部署
## 📖 使用文档
你可以直接使用 Docker 一键部署整个应用,无需本地配置 Java/Node.js/PostgreSQL。
### 前置要求
- 安装 [Docker Desktop](https://www.docker.com/products/docker-desktop/)
- Docker Desktop 已启动
### 部署步骤
1. 克隆项目到本地:
```bash
git clone https://github.com/hetianci/StudyingSpace.git
cd StudyingSpace
```
1. 直接运行:
```bash
docker compose up -d
```
项目中已经包含 `docker-compose.yml`,会自动从 Docker Hub 拉取镜像并启动。
### 访问应用
启动成功后,打开浏览器访问:
- 后端 API: <http://localhost:8080>
- Swagger API 文档: <http://localhost:8080/swagger-ui.html>
### 常用命令
```bash
# 查看运行状态
docker compose ps
# 查看后端日志
docker compose logs backend
# 实时查看日志
docker compose logs -f backend
# 停止所有容器
docker compose down
# 停止但保留数据
docker compose stop
# 重新启动
docker compose start
# 更新到最新镜像
docker compose pull
docker compose up -d
```
### 注意事项
- 如果本地 5432 端口已被占用,需要先停止本地 PostgreSQL 服务,或者修改 `docker-compose.yml` 中的端口映射
- 数据库数据会保存在 Docker 卷 `postgres-data` 中,即使删除容器数据也不会丢失
- 首次启动会自动拉取镜像,需要等待一段时间
## 📖 使用文档
### 用户注册登录
### 每日打卡使用
### 笔记导入导出
## 📈 开发计划
@ -69,5 +141,4 @@ StudyingSpace/
## 🤝 贡献指南
## 📄 许可证

@ -0,0 +1,19 @@
FROM maven:3.9.6-eclipse-temurin-21-alpine AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn clean package -DskipTests
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

@ -132,8 +132,8 @@ Authorization: Bearer <token>
```
## 📝 开发计划
- [ ] 用户认证模块
- [x] 登录注册功能
- [x] 用户认证模块
- [ ] 每日打卡模块
- [ ] 笔记导入导出模块

@ -1,11 +1,13 @@
package com.studyingspace.config;
import com.studyingspace.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
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;
@ -16,6 +18,12 @@ import java.util.Arrays;
@EnableWebSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
@ -24,7 +32,6 @@ public class SecurityConfig {
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/**",
"/api/auth/**",
"/v3/api-docs/**",
"/swagger-ui/**",
@ -32,7 +39,8 @@ public class SecurityConfig {
"/webjars/**"
).permitAll()
.anyRequest().authenticated()
);
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@ -40,7 +48,7 @@ public class SecurityConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("http://localhost:5173"));
configuration.addAllowedOriginPattern("*");
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);

@ -0,0 +1,111 @@
package com.studyingspace.controller;
import com.studyingspace.common.Result;
import com.studyingspace.entity.CheckIn;
import com.studyingspace.entity.User;
import com.studyingspace.repository.CheckInRepository;
import com.studyingspace.repository.UserRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.*;
@RestController
@RequestMapping("/api/checkin")
@Tag(name = "每日打卡", description = "每日学习打卡功能")
@RequiredArgsConstructor
public class CheckInController {
private final CheckInRepository checkInRepository;
private final UserRepository userRepository;
@GetMapping
@Operation(summary = "获取打卡记录", description = "获取当前用户的所有打卡记录,按时间倒序排列")
public Result<List<CheckIn>> getCheckInList(Authentication authentication) {
Long userId = getCurrentUserId(authentication);
List<CheckIn> checkInList = checkInRepository.findByUserIdOrderByCheckinDateDesc(userId);
return Result.success(checkInList);
}
@PostMapping
@Operation(summary = "每日打卡", description = "今天打卡学习")
public Result<String> dailyCheckIn(
Authentication authentication,
@RequestParam(required = false) String remark
) {
Long userId = getCurrentUserId(authentication);
LocalDate today = LocalDate.now();
if (checkInRepository.findByUserIdAndCheckinDate(userId, today).isPresent()) {
return Result.error("今天已经打过卡了");
}
CheckIn checkIn = new CheckIn();
checkIn.setUserId(userId);
checkIn.setCheckinDate(today);
checkIn.setRemark(remark);
checkInRepository.save(checkIn);
return Result.success("打卡成功");
}
@GetMapping("/calendar")
@Operation(summary = "获取打卡日历数据", description = "获取当前用户所有打卡日期,用于日历展示")
public Result<List<String>> getCheckinCalendar(Authentication authentication) {
Long userId = getCurrentUserId(authentication);
List<LocalDate> dates = checkInRepository.findAllCheckinDatesByUserId(userId);
List<String> result = dates.stream()
.map(LocalDate::toString)
.toList();
return Result.success(result);
}
@GetMapping("/stats")
@Operation(summary = "获取打卡统计", description = "获取连续打卡天数、总打卡天数等统计信息")
public Result<Map<String, Object>> getStats(Authentication authentication) {
Long userId = getCurrentUserId(authentication);
long totalDays = checkInRepository.countByUserId(userId);
List<CheckIn> checkIns = checkInRepository.findByUserIdOrderByCheckinDateDesc(userId);
int consecutiveDays = calculateConsecutiveDays(checkIns);
Map<String, Object> stats = new HashMap<>();
stats.put("totalDays", totalDays);
stats.put("consecutiveDays", consecutiveDays);
return Result.success(stats);
}
private Long getCurrentUserId(Authentication authentication) {
String username = authentication.getName();
Optional<User> userOpt = userRepository.findByUsername(username);
if (userOpt.isPresent()) {
return userOpt.get().getId();
}
return 1L;
}
private int calculateConsecutiveDays(List<CheckIn> checkIns) {
if (checkIns.isEmpty()) {
return 0;
}
int consecutive = 0;
LocalDate current = LocalDate.now();
for (CheckIn checkIn : checkIns) {
if (checkIn.getCheckinDate().equals(current)) {
consecutive++;
current = current.minusDays(1);
} else {
break;
}
}
return consecutive;
}
}

@ -0,0 +1,166 @@
package com.studyingspace.controller;
import com.studyingspace.common.Result;
import com.studyingspace.entity.Friend;
import com.studyingspace.entity.FriendRequest;
import com.studyingspace.entity.User;
import com.studyingspace.repository.CheckInRepository;
import com.studyingspace.repository.FriendRepository;
import com.studyingspace.repository.FriendRequestRepository;
import com.studyingspace.repository.UserRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@RestController
@RequestMapping("/api/friend")
@Tag(name = "好友关系", description = "好友申请和好友列表功能")
@RequiredArgsConstructor
public class FriendController {
private final FriendRepository friendRepository;
private final FriendRequestRepository friendRequestRepository;
private final UserRepository userRepository;
private final CheckInRepository checkInRepository;
@PostMapping("/request")
@Operation(summary = "发送好友申请", description = "向指定用户发送好友申请")
public Result<String> sendFriendRequest(
Authentication authentication,
@RequestParam String username
) {
Long fromUserId = getCurrentUserId(authentication);
Optional<User> toUserOpt = userRepository.findByUsername(username);
if (toUserOpt.isEmpty()) {
return Result.error("用户不存在");
}
User toUser = toUserOpt.get();
Long toUserId = toUser.getId();
if (toUserId.equals(fromUserId)) {
return Result.error("不能添加自己为好友");
}
if (friendRepository.existsByUserIdAndFriendId(fromUserId, toUserId)) {
return Result.error("该用户已经是你的好友");
}
if (friendRequestRepository.existsByFromUserIdAndToUserIdAndStatus(
fromUserId, toUserId, FriendRequest.RequestStatus.PENDING)) {
return Result.error("已经发送过申请,请等待对方处理");
}
FriendRequest request = new FriendRequest();
request.setFromUserId(fromUserId);
request.setToUserId(toUserId);
friendRequestRepository.save(request);
return Result.success("好友申请已发送");
}
@PostMapping("/handle")
@Operation(summary = "处理好友申请", description = "同意或拒绝好友申请")
public Result<String> handleFriendRequest(
Authentication authentication,
@RequestParam Long requestId,
@RequestParam boolean accept
) {
Long toUserId = getCurrentUserId(authentication);
Optional<FriendRequest> requestOpt = friendRequestRepository.findByIdAndToUserId(requestId, toUserId);
if (requestOpt.isEmpty()) {
return Result.error("申请不存在");
}
FriendRequest request = requestOpt.get();
if (request.getStatus() != FriendRequest.RequestStatus.PENDING) {
return Result.error("该申请已经处理过了");
}
request.setHandleTime(LocalDateTime.now());
if (accept) {
request.setStatus(FriendRequest.RequestStatus.ACCEPTED);
friendRequestRepository.save(request);
Friend friend1 = new Friend();
friend1.setUserId(toUserId);
friend1.setFriendId(request.getFromUserId());
friendRepository.save(friend1);
Friend friend2 = new Friend();
friend2.setUserId(request.getFromUserId());
friend2.setFriendId(toUserId);
friendRepository.save(friend2);
return Result.success("已同意好友申请");
} else {
request.setStatus(FriendRequest.RequestStatus.REJECTED);
friendRequestRepository.save(request);
return Result.success("已拒绝好友申请");
}
}
@GetMapping("/requests/pending")
@Operation(summary = "获取待处理的好友申请", description = "获取当前用户收到的待处理好友申请列表")
public Result<List<Map<String, Object>>> getPendingRequests(Authentication authentication) {
Long userId = getCurrentUserId(authentication);
List<FriendRequest> requests = friendRequestRepository.findByToUserIdAndStatus(
userId, FriendRequest.RequestStatus.PENDING);
List<Map<String, Object>> result = new ArrayList<>();
for (FriendRequest request : requests) {
User fromUser = userRepository.findById(request.getFromUserId()).orElse(null);
if (fromUser != null) {
Map<String, Object> item = new HashMap<>();
item.put("id", request.getId());
item.put("fromUserId", request.getFromUserId());
item.put("fromUsername", fromUser.getUsername());
item.put("createTime", request.getCreateTime().toString());
result.add(item);
}
}
return Result.success(result);
}
@GetMapping("/list")
@Operation(summary = "获取好友列表", description = "获取当前用户的所有好友,包含好友打卡天数统计")
public Result<List<Map<String, Object>>> getFriendList(Authentication authentication) {
Long userId = getCurrentUserId(authentication);
List<Friend> friends = friendRepository.findByUserId(userId);
List<Map<String, Object>> result = new ArrayList<>();
for (Friend friend : friends) {
User friendUser = userRepository.findById(friend.getFriendId()).orElse(null);
if (friendUser != null) {
Map<String, Object> item = new HashMap<>();
item.put("id", friendUser.getId());
item.put("username", friendUser.getUsername());
long totalDays = checkInRepository.countByUserId(friendUser.getId());
item.put("totalCheckinDays", totalDays);
result.add(item);
}
}
return Result.success(result);
}
private Long getCurrentUserId(Authentication authentication) {
String username = authentication.getName();
Optional<User> userOpt = userRepository.findByUsername(username);
if (userOpt.isPresent()) {
return userOpt.get().getId();
}
return 1L;
}
}

@ -0,0 +1,31 @@
package com.studyingspace.entity;
import jakarta.persistence.*;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "checkin")
public class CheckIn {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long userId;
@Column(nullable = false)
private LocalDate checkinDate;
private String remark;
private LocalDateTime createTime;
@PrePersist
protected void onCreate() {
createTime = LocalDateTime.now();
}
}

@ -0,0 +1,28 @@
package com.studyingspace.entity;
import jakarta.persistence.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "friends")
public class Friend {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long userId;
@Column(nullable = false)
private Long friendId;
private LocalDateTime createTime;
@PrePersist
protected void onCreate() {
createTime = LocalDateTime.now();
}
}

@ -0,0 +1,39 @@
package com.studyingspace.entity;
import jakarta.persistence.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "friend_requests")
public class FriendRequest {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long fromUserId;
@Column(nullable = false)
private Long toUserId;
@Enumerated(EnumType.STRING)
private RequestStatus status;
private LocalDateTime createTime;
private LocalDateTime handleTime;
public enum RequestStatus {
PENDING,
ACCEPTED,
REJECTED
}
@PrePersist
protected void onCreate() {
createTime = LocalDateTime.now();
status = RequestStatus.PENDING;
}
}

@ -0,0 +1,22 @@
package com.studyingspace.repository;
import com.studyingspace.entity.CheckIn;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
public interface CheckInRepository extends JpaRepository<CheckIn, Long> {
Optional<CheckIn> findByUserIdAndCheckinDate(Long userId, LocalDate checkinDate);
List<CheckIn> findByUserIdOrderByCheckinDateDesc(Long userId);
@Query("SELECT c.checkinDate FROM CheckIn c WHERE c.userId = :userId")
List<LocalDate> findAllCheckinDatesByUserId(@Param("userId") Long userId);
long countByUserId(Long userId);
}

@ -0,0 +1,20 @@
package com.studyingspace.repository;
import com.studyingspace.entity.Friend;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
public interface FriendRepository extends JpaRepository<Friend, Long> {
List<Friend> findByUserId(Long userId);
Optional<Friend> findByUserIdAndFriendId(Long userId, Long friendId);
@Query("SELECT f.friendId FROM Friend f WHERE f.userId = :userId")
List<Long> findFriendIdsByUserId(@Param("userId") Long userId);
boolean existsByUserIdAndFriendId(Long userId, Long friendId);
}

@ -0,0 +1,19 @@
package com.studyingspace.repository;
import com.studyingspace.entity.FriendRequest;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
public interface FriendRequestRepository extends JpaRepository<FriendRequest, Long> {
List<FriendRequest> findByToUserIdAndStatus(Long toUserId, FriendRequest.RequestStatus status);
List<FriendRequest> findByFromUserIdAndStatus(Long fromUserId, FriendRequest.RequestStatus status);
boolean existsByFromUserIdAndToUserIdAndStatus(Long fromUserId, Long toUserId, FriendRequest.RequestStatus status);
Optional<FriendRequest> findByIdAndToUserId(Long id, Long toUserId);
}

@ -0,0 +1,55 @@
package com.studyingspace.security;
import com.studyingspace.utils.JwtUtils;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetails;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
@Autowired
private JwtUtils jwtUtils;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
String jwt = getJwtFromRequest(request);
if (jwt != null && !jwt.isEmpty() && jwtUtils.validateToken(jwt)) {
String username = jwtUtils.getUsernameFromToken(jwt);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(username, null, null);
authentication.setDetails(new WebAuthenticationDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
logger.error("Cannot set user authentication: {}", e.getMessage());
}
filterChain.doFilter(request, response);
}
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}

@ -7,9 +7,9 @@ spring:
#数据库账号密码
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://localhost:5432/StudyingSystem
username: postgres
password: 123456
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/StudyingSystem}
username: ${SPRING_DATASOURCE_USERNAME:postgres}
password: ${SPRING_DATASOURCE_PASSWORD:123456}
jpa:
hibernate:

@ -0,0 +1,38 @@
services:
postgres:
image: postgres:15-alpine
container_name: studyingspace-postgres
environment:
POSTGRES_DB: studyingspace
POSTGRES_USER: postgres
POSTGRES_PASSWORD: 123456
volumes:
- postgres-data:/var/lib/postgresql/data
ports:
- "5432:5432"
restart: unless-stopped
backend:
image: hetianci/studyingspace-backend:TEST
container_name: studyingspace-backend
ports:
- "8081:8080"
depends_on:
- postgres
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/studyingspace
SPRING_DATASOURCE_USERNAME: postgres
SPRING_DATASOURCE_PASSWORD: 123456
restart: unless-stopped
frontend:
image: hetianci/studyingspace-frontend:TEST
container_name: studyingspace-frontend
ports:
- "8000:80"
depends_on:
- backend
restart: unless-stopped
volumes:
postgres-data:

@ -0,0 +1,18 @@
FROM node:21-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

@ -8,6 +8,10 @@
</head>
<body>
<div id="app"></div>
<!-- CDN fallback for GSAP and Rough.js when local install is unavailable -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.0/dist/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/roughjs@4.5.0/bundled/rough.min.js"></script>
<script type="module" src="/src/main.js"></script>
</body>
</html>

@ -0,0 +1,18 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}

@ -12,7 +12,9 @@
"vue-router": "^4.2.5",
"pinia": "^2.1.7",
"axios": "^1.6.2",
"element-plus": "^2.4.4"
"element-plus": "^2.4.4",
"roughjs": "^4.5.0",
"gsap": "^3.12.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.2",

@ -0,0 +1,30 @@
import request from '@/utils/request'
export function getCheckinList() {
return request({
url: '/checkin',
method: 'get'
})
}
export function dailyCheckin(remark) {
return request({
url: '/checkin',
method: 'post',
params: { remark }
})
}
export function getCheckinCalendar() {
return request({
url: '/checkin/calendar',
method: 'get'
})
}
export function getCheckinStats() {
return request({
url: '/checkin/stats',
method: 'get'
})
}

@ -0,0 +1,31 @@
import request from '@/utils/request'
export function sendFriendRequest(username) {
return request({
url: '/friend/request',
method: 'post',
params: { username }
})
}
export function handleFriendRequest(requestId, accept) {
return request({
url: '/friend/handle',
method: 'post',
params: { requestId, accept }
})
}
export function getPendingFriendRequests() {
return request({
url: '/friend/requests/pending',
method: 'get'
})
}
export function getFriendList() {
return request({
url: '/friend/list',
method: 'get'
})
}

@ -15,6 +15,16 @@ const routes = [
path: '/',
name: 'home',
component: () => import('../views/home/Home.vue')
},
{
path: '/checkin',
name: 'checkin',
component: () => import('../views/checkin/Checkin.vue')
},
{
path: '/profile',
name: 'profile',
component: () => import('../views/profile/Profile.vue')
}
]

@ -9,5 +9,56 @@ body,
#app {
width: 100%;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #333;
background-color: #f7f8fa;
}
:root {
--primary-green: #4caf50;
--primary-green-light: #e8f5e9;
--primary-blue: #42a5f5;
--primary-blue-light: #e3f2fd;
--primary-orange: #f5a623;
--gradient-orange: linear-gradient(135deg, #f7c948 0%, #f5a623 100%);
--gradient-orange-light: linear-gradient(135deg, #fbe9a0 0%, #f5c842 100%);
--bg-card: #ffffff;
--bg-page: #f7f8fa;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--text-placeholder: #c0c0c0;
--border-light: #f0f0f0;
--shadow-card: 0 2px 12px rgba(0, 0, 0, 0.06);
--shadow-soft: 0 1px 6px rgba(0, 0, 0, 0.04);
--radius-lg: 16px;
--radius-md: 12px;
--radius-sm: 8px;
--radius-full: 9999px;
}
input, button, textarea {
font-family: inherit;
}
a {
text-decoration: none;
color: inherit;
}
button {
border: none;
cursor: pointer;
background: none;
}
ul, ol {
list-style: none;
}
::-webkit-scrollbar {
width: 0;
height: 0;
}

@ -0,0 +1,598 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import {
getCheckinList,
dailyCheckin,
getCheckinCalendar,
getCheckinStats
} from '@/api/checkin'
import { ElMessage } from 'element-plus'
const router = useRouter()
const loading = ref(false)
const checkedIn = ref(false)
const todayDate = ref('')
const form = ref({
remark: ''
})
const stats = ref({
totalDays: 0,
consecutiveDays: 0
})
const checkinList = ref([])
const checkedDates = ref([])
const calendar = ref([])
const year = ref(new Date().getFullYear())
const month = ref(new Date().getMonth())
const handleLogout = () => {
localStorage.removeItem('token')
localStorage.removeItem('username')
router.push('/login')
}
const generateCalendar = () => {
const result = []
const firstDay = new Date(year.value, month.value, 1).getDay()
const daysInMonth = new Date(year.value, month.value + 1, 0).getDate()
let week = []
for (let i = 0; i < firstDay; i++) {
week.push({ date: '', checked: false, empty: true })
}
for (let i = 1; i <= daysInMonth; i++) {
const dateStr = `${year.value}-${String(month.value + 1).padStart(2, '0')}-${String(i).padStart(2, '0')}`
week.push({
date: i,
checked: checkedDates.value.includes(dateStr),
empty: false
})
if (week.length === 7) {
result.push(week)
week = []
}
}
if (week.length > 0 && week.length < 7) {
for (let i = week.length; i < 7; i++) {
week.push({ date: '', checked: false, empty: true })
}
result.push(week)
}
calendar.value = result
}
const loadData = async () => {
try {
const [listRes, calendarRes, statsRes] = await Promise.all([
getCheckinList(),
getCheckinCalendar(),
getCheckinStats()
])
if (listRes.code === 200) {
checkinList.value = listRes.data
}
if (calendarRes.code === 200) {
checkedDates.value = calendarRes.data
generateCalendar()
const today = new Date()
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
checkedIn.value = checkedDates.value.includes(todayStr)
}
if (statsRes.code === 200) {
stats.value = statsRes.data
}
} catch (error) {
console.error(error)
ElMessage.error('加载数据失败')
}
}
const handleCheckin = async () => {
loading.value = true
try {
const res = await dailyCheckin(form.value.remark)
if (res.code === 200) {
ElMessage.success(res.data)
checkedIn.value = true
form.value.remark = ''
await loadData()
} else {
ElMessage.error(res.message)
}
} catch (error) {
console.error(error)
ElMessage.error('打卡失败')
} finally {
loading.value = false
}
}
const formatTime = (time) => {
return time?.replace('T', ' ').substring(0, 16)
}
const goToHome = () => {
router.push('/')
}
const goToCheckin = () => {
router.push('/checkin')
}
const goToProfile = () => {
router.push('/profile')
}
const today = computed(() => {
const d = new Date()
const year = d.getFullYear()
const month = d.getMonth() + 1
const date = d.getDate()
return `${year}${month}${date}`
})
onMounted(() => {
todayDate.value = today.value
loadData()
})
</script>
<template>
<div class="home-layout">
<nav class="glass-nav">
<div class="logo">MindSpace.</div>
<div class="nav-links">
<span class="nav-btn-text" @click="goToHome"></span>
<span class="nav-btn-text" @click="goToCheckin"></span>
<button class="nav-btn-secondary" @click="goToProfile"></button>
<button class="nav-btn-primary" @click="handleLogout">退</button>
</div>
</nav>
<main class="checkin-section">
<div class="vibe-badge">
<span :class="['pulse-dot', checkedIn ? 'green' : 'orange']"></span>
{{ checkedIn ? '今日已打卡' : '今日待打卡' }}
</div>
<h1 class="checkin-title">
坚持每日打卡<br />
<span class="text-gradient">养成自律好习惯</span>
</h1>
<p class="checkin-subtitle">
{{ todayDate }} · 记录学习点滴见证成长轨迹
</p>
<div class="stats-grid">
<article class="stat-card">
<div class="stat-number">{{ stats.totalDays }}</div>
<div class="stat-label">总打卡天数</div>
</article>
<article class="stat-card">
<div class="stat-number">{{ stats.consecutiveDays }}</div>
<div class="stat-label">连续打卡</div>
</article>
</div>
<div class="checkin-card">
<div class="form-group">
<label>打卡备注</label>
<textarea
v-model="form.remark"
placeholder="今天学习了什么?留下一点记录吧"
rows="3"
/>
</div>
<button
class="btn-large"
:disabled="checkedIn || loading"
@click="handleCheckin"
>
{{ loading ? '打卡中...' : checkedIn ? '今日已打卡' : '立即打卡' }}
</button>
</div>
<div class="content-grid">
<article class="content-card">
<h3>打卡日历</h3>
<div class="calendar-grid">
<div
v-for="(week, weekIndex) in calendar"
:key="weekIndex"
class="calendar-week"
>
<div
v-for="(day, dayIndex) in week"
:key="dayIndex"
:class="['calendar-day', { checked: day.checked, empty: day.empty }]"
>
{{ day.date }}
</div>
</div>
</div>
</article>
<article class="content-card history-card">
<h3>最近打卡记录</h3>
<div class="history-list">
<div v-for="item in checkinList.slice(0, 5)" :key="item.id" class="history-item">
<div>
<div class="history-date">{{ item.checkinDate }}</div>
<div v-if="item.remark" class="history-remark">{{ item.remark }}</div>
</div>
<el-tag type="success">已打卡</el-tag>
</div>
</div>
</article>
</div>
</main>
</div>
</template>
<style scoped>
.home-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: #fafafa;
color: #1a1a1a;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-image: radial-gradient(#d1d5db 1px, transparent 1px);
background-size: 24px 24px;
}
.glass-nav {
position: fixed;
top: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 40px;
background: rgba(250, 250, 250, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
z-index: 50;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.logo {
font-size: 20px;
font-weight: 800;
letter-spacing: -0.5px;
}
.nav-links {
display: flex;
align-items: center;
gap: 24px;
}
.nav-btn-text {
color: #666;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: color 0.3s;
}
.nav-btn-text:hover {
color: #000;
}
.nav-btn-primary {
background: #000;
color: #fff;
padding: 10px 24px;
border-radius: 40px;
border: none;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.nav-btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.nav-btn-secondary {
background: transparent;
color: #000;
padding: 10px 24px;
border-radius: 40px;
border: 1px solid rgba(0, 0, 0, 0.1);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.nav-btn-secondary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
background: rgba(0, 0, 0, 0.02);
}
.checkin-section {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 120px 20px 60px;
}
.vibe-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 16px;
border-radius: 40px;
background: #fff;
border: 1px solid #e5e7eb;
color: #4b5563;
font-size: 14px;
margin-bottom: 32px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02);
}
.pulse-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #f59e0b;
animation: pulse 2s infinite;
}
.pulse-dot.green {
background: #10b981;
}
.pulse-dot.orange {
background: #f59e0b;
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.4;
}
100% {
opacity: 1;
}
}
.checkin-title {
font-size: 64px;
font-weight: 800;
line-height: 1.1;
letter-spacing: -2px;
margin: 0 0 24px 0;
}
.text-gradient {
background: linear-gradient(90deg, #111, #888);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.checkin-subtitle {
font-size: 20px;
color: #6b7280;
max-width: 600px;
line-height: 1.6;
margin-bottom: 40px;
font-weight: 300;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 18px;
width: min(600px, 100%);
margin-bottom: 32px;
}
.stat-card {
background: rgba(255, 255, 255, 0.62);
border: 1px solid rgba(0,0,0,0.08);
border-radius: 20px;
padding: 24px 18px;
text-align: center;
box-shadow: 0 10px 22px rgba(0,0,0,0.06);
transition: transform 0.25s ease, box-shadow 0.25s ease;
}
.stat-card:hover {
transform: translateY(-6px);
box-shadow: 0 16px 36px rgba(0,0,0,0.12);
}
.stat-number {
font-size: 48px;
font-weight: 800;
color: #111827;
line-height: 1;
margin-bottom: 8px;
}
.stat-label {
color: #4b5563;
font-size: 14px;
font-weight: 500;
}
.checkin-card {
background: rgba(255, 255, 255, 0.62);
border: 1px solid rgba(0,0,0,0.08);
border-radius: 20px;
padding: 32px;
width: min(600px, 100%);
box-shadow: 0 10px 22px rgba(0,0,0,0.06);
margin-bottom: 32px;
}
.form-group {
margin-bottom: 24px;
text-align: left;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-size: 14px;
font-weight: 500;
color: #111827;
}
.form-group textarea {
width: 100%;
padding: 12px 16px;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
font-size: 14px;
transition: border-color 0.2s;
background: rgba(255, 255, 255, 0.5);
resize: vertical;
font-family: inherit;
}
.form-group textarea:focus {
outline: none;
border-color: #000;
}
.btn-large {
background: #000;
color: #fff;
padding: 18px 40px;
border-radius: 50px;
border: none;
font-size: 18px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
}
.btn-large:hover:not(:disabled) {
transform: scale(1.02);
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.2);
}
.btn-large:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.content-grid {
margin-top: 8px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
width: min(900px, 100%);
}
.content-card {
background: rgba(255, 255, 255, 0.62);
border: 1px solid rgba(0,0,0,0.08);
border-radius: 20px;
padding: 24px;
text-align: left;
box-shadow: 0 10px 22px rgba(0,0,0,0.06);
transition: transform 0.25s ease, box-shadow 0.25s ease;
}
.content-card:hover {
transform: translateY(-6px);
box-shadow: 0 16px 36px rgba(0,0,0,0.12);
}
.content-card h3 {
margin: 0 0 16px 0;
font-size: 18px;
color: #111827;
}
.calendar-grid {
display: flex;
flex-direction: column;
gap: 4px;
}
.calendar-week {
display: flex;
gap: 4px;
}
.calendar-day {
width: calc(100% / 7 - 4px * 6 / 7);
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.05);
border-radius: 8px;
font-size: 14px;
cursor: default;
color: #1a1a1a;
}
.calendar-day.checked {
background: #10b981;
color: #fff;
}
.calendar-day.empty {
background: transparent;
}
.history-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.history-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 12px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.history-item:last-child {
border-bottom: none;
}
.history-date {
font-size: 14px;
font-weight: 600;
color: #111827;
margin-bottom: 4px;
}
.history-remark {
font-size: 13px;
color: #6b7280;
}
.history-card {
grid-column: span 1;
}
</style>

@ -1,30 +1,297 @@
<template>
<div class="home-container">
<h1>欢迎来到 StudyingSpace</h1>
<p>个人学习空间</p>
<div class="home-layout">
<nav class="glass-nav">
<div class="logo">MindSpace.</div>
<div class="nav-links">
<span class="nav-btn-text" @click="goToHome"></span>
<span class="nav-btn-text" @click="goToCheckin"></span>
<button v-if="isLoggedIn" class="nav-btn-secondary" @click="goToProfile"></button>
<button v-if="isLoggedIn" class="nav-btn-primary" @click="handleLogout">退</button>
<button v-else class="nav-btn-primary" @click="goToLogin"></button>
</div>
</nav>
<main class="hero-section">
<div class="vibe-badge">
<span class="pulse-dot"></span> 智能番茄钟已就绪
</div>
<h1 class="hero-title">
沉浸式算法学习<br />
<span class="text-gradient"> RAG 智能驱动</span>
</h1>
<p class="hero-subtitle">
专属思维空间融合 AI 深度问答与专注番茄钟为你打造无干扰的极致自律环境
</p>
<button class="btn-large" @click="startLearning"></button>
<section class="feature-grid">
<article class="feature-card">
<h3>实时学习建议</h3>
<p>根据你当前进度智能推荐最优题目与复盘策略</p>
</article>
<article class="feature-card">
<h3>专注番茄钟</h3>
<p>内置番茄时钟和任务列表专注效率提升 30% 以上</p>
</article>
<article class="feature-card">
<h3>一键导出记录</h3>
<p>统计学习成果支持 CSV/JSON 导出便于总结与追踪</p>
</article>
</section>
</main>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { ref, onMounted } from 'vue'
const router = useRouter()
const isLoggedIn = ref(false)
onMounted(() => {
isLoggedIn.value = !!localStorage.getItem('token')
})
const handleLogout = () => {
localStorage.removeItem('token')
localStorage.removeItem('username')
router.push('/login')
}
const goToHome = () => {
router.push('/')
}
const goToCheckin = () => {
router.push('/checkin')
}
const goToProfile = () => {
router.push('/profile')
}
const startLearning = () => {
alert('功能即将上线!')
}
const goToLogin = () => {
router.push('/login')
}
</script>
<style scoped>
.home-container {
width: 100%;
height: 100%;
/* 整体布局与手绘点阵背景 */
.home-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: #fafafa;
color: #1a1a1a;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-image: radial-gradient(#d1d5db 1px, transparent 1px);
background-size: 24px 24px;
}
.glass-nav {
position: fixed;
top: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 40px;
background: rgba(250, 250, 250, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
z-index: 50;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.logo {
font-size: 20px;
font-weight: 800;
letter-spacing: -0.5px;
}
.nav-links {
display: flex;
align-items: center;
gap: 24px;
}
.nav-btn-text {
color: #666;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: color 0.3s;
}
.nav-btn-text:hover {
color: #000;
}
.nav-btn-primary {
background: #000;
color: #fff;
padding: 10px 24px;
border-radius: 40px;
border: none;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.nav-btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.nav-btn-secondary {
background: transparent;
color: #000;
padding: 10px 24px;
border-radius: 40px;
border: 1px solid rgba(0, 0, 0, 0.1);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.nav-btn-secondary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
background: rgba(0, 0, 0, 0.02);
}
.hero-section {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 120px 20px 60px;
}
.vibe-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 16px;
border-radius: 40px;
background: #fff;
border: 1px solid #e5e7eb;
color: #4b5563;
font-size: 14px;
margin-bottom: 32px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02);
}
h1 {
font-size: 32px;
margin-bottom: 16px;
.pulse-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #10b981;
animation: pulse 2s infinite;
}
p {
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.4;
}
100% {
opacity: 1;
}
}
.hero-title {
font-size: 64px;
font-weight: 800;
line-height: 1.1;
letter-spacing: -2px;
margin: 0 0 24px 0;
}
.text-gradient {
background: linear-gradient(90deg, #111, #888);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.hero-subtitle {
font-size: 20px;
color: #6b7280;
max-width: 600px;
line-height: 1.6;
margin-bottom: 40px;
font-weight: 300;
}
.feature-grid {
margin-top: 44px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
width: min(900px, 100%);
}
.feature-card {
background: rgba(255, 255, 255, 0.62);
border: 1px solid rgba(0,0,0,0.08);
border-radius: 20px;
padding: 18px;
text-align: left;
box-shadow: 0 10px 22px rgba(0,0,0,0.06);
transition: transform 0.25s ease, box-shadow 0.25s ease;
}
.feature-card:hover {
transform: translateY(-6px);
box-shadow: 0 16px 36px rgba(0,0,0,0.12);
}
.feature-card h3 {
margin: 0 0 10px 0;
font-size: 18px;
color: #666;
color: #111827;
}
.feature-card p {
margin: 0;
color: #4b5563;
font-size: 14px;
}
.btn-large {
background: #000;
color: #fff;
padding: 18px 40px;
border-radius: 50px;
border: none;
font-size: 18px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
}
.btn-large:hover {
transform: scale(1.02);
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.2);
}
</style>

@ -1,77 +1,453 @@
<template>
<template>
<div class="login-container">
<div class="login-box">
<h2>登录</h2>
<el-form :model="form" label-width="80px">
<el-form-item label="用户名">
<el-input v-model="form.username" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleLogin"></el-button>
<el-button @click="goRegister"></el-button>
</el-form-item>
</el-form>
<div class="bg-shape shape1"></div>
<div class="bg-shape shape2"></div>
<div class="bg-shape shape3"></div>
<div class="glass-panel">
<div class="brand-vip">
<div class="brand-logo">MS</div>
<div class="brand-text">
<h2>欢迎来到 MindSpace</h2>
<p>连接你的专属算法思维空间</p>
</div>
</div>
<form class="login-form" @submit.prevent="handleLogin">
<div v-if="errorMessage" class="error-message">
{{ errorMessage }}
</div>
<div class="input-wrapper">
<label>账号 / 邮箱</label>
<input type="text" v-model="loginForm.username" required placeholder="输入邮箱或用户名" :disabled="isLoading" />
</div>
<div class="input-wrapper">
<label>密码</label>
<div class="password-wrap">
<input :type="showPassword ? 'text' : 'password'" v-model="loginForm.password" required placeholder="••••••••" :disabled="isLoading" />
<button type="button" class="eye-btn" @click="showPassword = !showPassword" :aria-label="showPassword ? '' : ''">
{{ showPassword ? '隐藏' : '显示' }}
</button>
</div>
</div>
<router-link to="#" class="forgot-link">忘记密码</router-link>
<button type="submit" class="submit-btn" :disabled="isLoading">
{{ isLoading ? '登录中...' : '登 录' }}
</button>
</form>
<div class="auth-footer">
<span>还没有账号</span>
<router-link to="/register" class="link-btn">立即注册</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { login } from '../../api/auth'
import { ElMessage } from 'element-plus'
import { login } from '@/api/auth'
const router = useRouter()
const form = ref({
username: '',
// (username/password) Request Body
const loginForm = reactive({
username: '',
password: ''
})
const isLoading = ref(false)
const errorMessage = ref('')
const showPassword = ref(false)
const handleLogin = async () => {
if (!loginForm.username || !loginForm.password) {
errorMessage.value = '请输入账号和密码'
return
}
try {
const res = await login(form.value)
if (res.code === 200) {
isLoading.value = true
errorMessage.value = ''
// Result<LoginResponse>
const res = await login({
username: loginForm.username,
password: loginForm.password
})
// code=200
if (res.code === 200 && res.data) {
// localStorage
localStorage.setItem('token', res.data.token)
localStorage.setItem('username', res.data.username)
ElMessage.success('登录成功')
//
router.push('/')
} else {
ElMessage.error(res.message)
errorMessage.value = res.message || '登录失败,请稍后重试'
}
} catch (error) {
ElMessage.error('登录失败')
console.error(error)
console.error('登录请求异常:', error)
errorMessage.value = error.response?.data?.message || '网络或服务器错误,请稍后再试'
} finally {
isLoading.value = false
}
}
const goRegister = () => {
router.push('/register')
}
</script>
<style scoped>
.login-container {
position: relative;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #e5e7eb 0%, #f8fafc 40%, #f3f4f6 100%);
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.bg-shape {
position: absolute;
border-radius: 50%;
filter: blur(70px);
opacity: 0.6;
animation: float 8s infinite ease-in-out;
}
.shape1 {
top: -10%;
left: -10%;
width: 450px;
height: 450px;
background: #bfdbfe;
}
.shape2 {
top: 20%;
right: -10%;
width: 400px;
height: 400px;
background: #e9d5ff;
animation-delay: 2s;
}
.shape3 {
bottom: -15%;
left: 20%;
width: 500px;
height: 500px;
background: #fbcfe8;
animation-delay: 4s;
}
@keyframes float {
0%, 100% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(20px, -30px) scale(1.05);
}
}
.glass-panel {
position: relative;
z-index: 10;
width: 100%;
height: 100%;
max-width: 420px;
padding: 48px 40px;
margin: 0 20px;
background: rgba(255, 255, 255, 0.45);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.6);
border-radius: 32px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.04);
}
.brand-vip {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 26px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255,255,255,0.35);
}
.brand-logo {
width: 50px;
height: 50px;
background: linear-gradient(135deg, #000, #4b5563);
color: #fff;
font-size: 20px;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14px;
box-shadow: 0 8px 24px rgba(0,0,0,0.2);
}
.brand-text h2 {
margin: 0;
font-size: 24px;
font-weight: 800;
color: #111;
}
.brand-text p {
margin: 0;
color: #4b5563;
font-size: 13px;
}
.login-form {
display: flex;
flex-direction: column;
gap: 18px;
}
.password-wrap {
position: relative;
display: flex;
align-items: center;
background: #f5f5f5;
}
.login-box {
width: 400px;
padding: 40px;
.password-wrap input {
flex: 1;
}
.eye-btn {
border: none;
background: transparent;
color: #4b5563;
font-weight: 700;
margin-left: 8px;
cursor: pointer;
}
.forgot-link {
text-align: right;
color: #2563eb;
font-size: 13px;
margin-top: -4px;
margin-bottom: 4px;
text-decoration: none;
}
.forgot-link:hover {
text-decoration: underline;
}
@media (max-width: 480px) {
.glass-panel {
padding: 28px 20px;
}
.brand-text h2 {
font-size: 20px;
}
.submit-btn {
padding: 16px;
}
}
.error-message {
padding: 16px;
background: #fee2e2;
border: 1px solid #fecaca;
color: #dc2626;
border-radius: 12px;
font-size: 14px;
text-align: center;
margin: 0 -8px 8px -8px;
}
.input-wrapper label {
display: block;
font-size: 13px;
font-weight: 600;
color: #444;
margin-bottom: 8px;
margin-left: 4px;
}
.input-wrapper input {
width: 100%;
box-sizing: border-box;
padding: 16px;
border-radius: 16px;
border: 1.5px solid #eaeaea;
background: rgba(255, 255, 255, 0.8);
font-size: 15px;
outline: none;
transition: all 0.3s;
}
.input-wrapper input::placeholder {
color: #bbb;
}
.input-wrapper input:focus {
border-color: #000;
box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.05);
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
h2 {
.input-wrapper input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.submit-btn {
background: #000;
color: #fff;
padding: 18px;
border-radius: 16px;
font-size: 16px;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.3s;
margin-top: 8px;
}
.submit-btn:hover:not(:disabled) {
background: #222;
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
}
.submit-btn:disabled {
background: #999;
cursor: not-allowed;
}
.auth-footer {
text-align: center;
margin-bottom: 30px;
margin-top: 24px;
font-size: 14px;
color: #666;
}
.auth-footer span {
margin-right: 8px;
}
.link-btn {
color: #000;
font-weight: 600;
text-decoration: none;
cursor: pointer;
border: none;
background: none;
padding: 0;
transition: opacity 0.3s;
}
.link-btn:hover {
opacity: 0.7;
}
.auth-header p {
font-size: 14px;
color: var(--text-secondary);
}
.auth-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.input-group {
display: flex;
align-items: center;
background: var(--bg-page);
border-radius: var(--radius-md);
padding: 0 14px;
height: 50px;
border: 1.5px solid transparent;
transition: border-color 0.2s;
}
.input-group:focus-within {
border-color: var(--primary-green);
background: #fff;
}
.input-icon {
margin-right: 10px;
display: flex;
align-items: center;
flex-shrink: 0;
}
.input-group input {
flex: 1;
border: none;
outline: none;
background: transparent;
font-size: 15px;
color: var(--text-primary);
height: 100%;
}
.input-group input::placeholder {
color: var(--text-placeholder);
}
.toggle-pwd {
display: flex;
align-items: center;
padding: 4px;
flex-shrink: 0;
}
.submit-btn {
width: 100%;
height: 50px;
border-radius: var(--radius-full);
font-size: 16px;
font-weight: 600;
margin-top: 8px;
transition: all 0.2s;
}
.submit-btn:active {
transform: scale(0.98);
}
.submit-btn.primary {
background: var(--gradient-orange);
color: #fff;
box-shadow: 0 4px 16px rgba(245, 166, 35, 0.35);
}
.auth-footer {
text-align: center;
margin-top: 24px;
font-size: 14px;
color: var(--text-secondary);
}
.link-btn {
color: var(--primary-green);
font-weight: 600;
font-size: 14px;
margin-left: 4px;
}
.link-btn:active {
opacity: 0.7;
}
</style>

@ -1,90 +1,358 @@
<template>
<template>
<div class="register-container">
<div class="register-box">
<h2>注册</h2>
<el-form :model="form" label-width="80px">
<el-form-item label="用户名">
<el-input v-model="form.username" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" />
</el-form-item>
<el-form-item label="确认密码">
<el-input v-model="form.confirmPassword" type="password" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleRegister"></el-button>
<el-button @click="goLogin"></el-button>
</el-form-item>
</el-form>
<div class="bg-shape shape1"></div>
<div class="bg-shape shape2"></div>
<div class="bg-shape shape3"></div>
<div class="glass-panel">
<div class="brand-vip">
<div class="brand-logo">MS</div>
<div class="brand-text">
<h2>加入 MindSpace</h2>
<p>创建账号开启算法思维之旅</p>
</div>
</div>
<form class="register-form" @submit.prevent="handleRegister">
<div v-if="errorMessage" class="error-message">
{{ errorMessage }}
</div>
<div class="input-wrapper">
<label>账号 / 邮箱</label>
<input type="text" v-model="registerForm.username" required placeholder="输入账号" :disabled="isLoading" />
</div>
<div class="input-wrapper">
<label>密码</label>
<div class="password-wrap">
<input :type="showPassword ? 'text' : 'password'" v-model="registerForm.password" required placeholder="••••••••" :disabled="isLoading" />
<button type="button" class="eye-btn" @click="showPassword = !showPassword">
{{ showPassword ? '隐藏' : '显示' }}
</button>
</div>
</div>
<div class="input-wrapper">
<label>确认密码</label>
<div class="password-wrap">
<input :type="showPassword ? 'text' : 'password'" v-model="registerForm.confirmPassword" required placeholder="••••••••" :disabled="isLoading" />
<button type="button" class="eye-btn" @click="showPassword = !showPassword">
{{ showPassword ? '隐藏' : '显示' }}
</button>
</div>
</div>
<button type="submit" class="submit-btn" :disabled="isLoading">
{{ isLoading ? '注册中...' : '注 册' }}
</button>
</form>
<div class="auth-footer">
<span>已有账号</span>
<router-link to="/login" class="link-btn">立即登录</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { register } from '../../api/auth'
import { ElMessage } from 'element-plus'
import { register } from '@/api/auth'
const router = useRouter()
const form = ref({
const registerForm = reactive({
username: '',
password: '',
confirmPassword: ''
})
const isLoading = ref(false)
const errorMessage = ref('')
const showPassword = ref(false)
const handleRegister = async () => {
if (!form.value.username || !form.value.password) {
ElMessage.warning('请填写用户名和密码')
if (!registerForm.username || !registerForm.password || !registerForm.confirmPassword) {
errorMessage.value = '请填写所有字段'
return
}
if (registerForm.password !== registerForm.confirmPassword) {
errorMessage.value = '两次输入的密码不一致'
return
}
if (form.value.password !== form.value.confirmPassword) {
ElMessage.warning('两次输入的密码不一致')
if (registerForm.password.length < 6) {
errorMessage.value = '密码长度至少为 6 位'
return
}
try {
isLoading.value = true
errorMessage.value = ''
const res = await register({
username: form.value.username,
password: form.value.password
username: registerForm.username,
password: registerForm.password
})
if (res.code === 200) {
ElMessage.success('注册成功,请登录')
//
router.push('/login')
} else {
ElMessage.error(res.message)
errorMessage.value = res.message || '注册失败,请稍后重试'
}
} catch (error) {
ElMessage.error('注册失败')
console.error(error)
console.error('注册请求异常:', error)
errorMessage.value = error.response?.data?.message || '网络或服务器错误,请稍后再试'
} finally {
isLoading.value = false
}
}
const goLogin = () => {
router.push('/login')
}
</script>
<style scoped>
.register-container {
position: relative;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f4f4f5 0%, #f8fafc 45%, #eef2ff 100%);
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.bg-shape {
position: absolute;
border-radius: 50%;
filter: blur(70px);
opacity: 0.6;
animation: float 8s infinite ease-in-out;
}
.shape1 {
top: -10%;
left: -10%;
width: 450px;
height: 450px;
background: #bfdbfe;
}
.shape2 {
top: 20%;
right: -10%;
width: 400px;
height: 400px;
background: #e9d5ff;
animation-delay: 2s;
}
.shape3 {
bottom: -15%;
left: 20%;
width: 500px;
height: 500px;
background: #fbcfe8;
animation-delay: 4s;
}
@keyframes float {
0%, 100% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(20px, -30px) scale(1.05);
}
}
.glass-panel {
position: relative;
z-index: 10;
width: 100%;
height: 100%;
max-width: 420px;
padding: 48px 40px;
margin: 0 20px;
background: rgba(255, 255, 255, 0.45);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.6);
border-radius: 32px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.04);
}
.brand-vip {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 26px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255,255,255,0.35);
}
.brand-logo {
width: 50px;
height: 50px;
background: linear-gradient(135deg, #000, #4b5563);
color: #fff;
font-size: 20px;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14px;
box-shadow: 0 8px 24px rgba(0,0,0,0.2);
}
.brand-text h2 {
margin: 0;
font-size: 24px;
font-weight: 800;
color: #111;
}
.brand-text p {
margin: 0;
color: #4b5563;
font-size: 13px;
}
.register-form {
display: flex;
flex-direction: column;
gap: 18px;
}
.password-wrap {
position: relative;
display: flex;
align-items: center;
background: #f5f5f5;
}
.register-box {
width: 400px;
padding: 40px;
.password-wrap input {
flex: 1;
}
.eye-btn {
border: none;
background: transparent;
color: #4b5563;
font-weight: 700;
margin-left: 8px;
cursor: pointer;
}
@media (max-width: 480px) {
.glass-panel {
padding: 28px 20px;
}
.brand-text h2 {
font-size: 20px;
}
.submit-btn {
padding: 16px;
}
}
.error-message {
padding: 16px;
background: #fee2e2;
border: 1px solid #fecaca;
color: #dc2626;
border-radius: 12px;
font-size: 14px;
text-align: center;
margin: 0 -8px 8px -8px;
}
.input-wrapper label {
display: block;
font-size: 13px;
font-weight: 600;
color: #444;
margin-bottom: 8px;
margin-left: 4px;
}
.input-wrapper input {
width: 100%;
box-sizing: border-box;
padding: 16px;
border-radius: 16px;
border: 1.5px solid #eaeaea;
background: rgba(255, 255, 255, 0.8);
font-size: 15px;
outline: none;
transition: all 0.3s;
}
.input-wrapper input::placeholder {
color: #bbb;
}
.input-wrapper input:focus {
border-color: #000;
box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.05);
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
h2 {
.input-wrapper input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.submit-btn {
background: #000;
color: #fff;
padding: 18px;
border-radius: 16px;
font-size: 16px;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.3s;
margin-top: 8px;
}
.submit-btn:hover:not(:disabled) {
background: #222;
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
}
.submit-btn:disabled {
background: #999;
cursor: not-allowed;
}
.auth-footer {
text-align: center;
margin-bottom: 30px;
margin-top: 24px;
font-size: 14px;
color: #666;
}
.auth-footer span {
margin-right: 8px;
}
.link-btn {
color: #000;
font-weight: 600;
text-decoration: none;
cursor: pointer;
border: none;
background: none;
padding: 0;
transition: opacity 0.3s;
}
.link-btn:hover {
opacity: 0.7;
}
</style>

@ -0,0 +1,622 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { getCheckinStats } from '@/api/checkin'
import { sendFriendRequest, handleFriendRequest, getPendingFriendRequests, getFriendList } from '@/api/friend'
import { ElMessage } from 'element-plus'
const router = useRouter()
const stats = ref({
totalDays: 0,
consecutiveDays: 0
})
const loading = ref(false)
const friendUsername = ref('')
const friendList = ref([])
const pendingRequests = ref([])
const handleLogout = () => {
localStorage.removeItem('token')
localStorage.removeItem('username')
router.push('/login')
}
const goToHome = () => {
router.push('/')
}
const goToCheckin = () => {
router.push('/checkin')
}
const goToProfile = () => {
router.push('/profile')
}
const loadStats = async () => {
try {
const res = await getCheckinStats()
if (res.code === 200) {
stats.value = res.data
}
} catch (error) {
console.error(error)
ElMessage.error('加载数据失败')
}
}
const loadFriends = async () => {
try {
const res = await getFriendList()
if (res.code === 200) {
friendList.value = res.data
}
} catch (error) {
console.error(error)
}
}
const loadPendingRequests = async () => {
try {
const res = await getPendingFriendRequests()
if (res.code === 200) {
pendingRequests.value = res.data
}
} catch (error) {
console.error(error)
}
}
const handleSendRequest = async () => {
if (!friendUsername.value.trim()) {
ElMessage.warning('请输入用户名')
return
}
loading.value = true
try {
const res = await sendFriendRequest(friendUsername.value.trim())
if (res.code === 200) {
ElMessage.success(res.data)
friendUsername.value = ''
await loadFriends()
} else {
ElMessage.error(res.message)
}
} catch (error) {
console.error(error)
ElMessage.error('发送申请失败')
} finally {
loading.value = false
}
}
const handleAccept = async (requestId) => {
try {
const res = await handleFriendRequest(requestId, true)
if (res.code === 200) {
ElMessage.success(res.data)
await loadPendingRequests()
await loadFriends()
} else {
ElMessage.error(res.message)
}
} catch (error) {
console.error(error)
ElMessage.error('处理失败')
}
}
const handleReject = async (requestId) => {
try {
const res = await handleFriendRequest(requestId, false)
if (res.code === 200) {
ElMessage.success(res.data)
await loadPendingRequests()
} else {
ElMessage.error(res.message)
}
} catch (error) {
console.error(error)
ElMessage.error('处理失败')
}
}
const currentUsername = computed(() => {
return localStorage.getItem('username') || ''
})
onMounted(() => {
loadStats()
loadFriends()
loadPendingRequests()
})
</script>
<template>
<div class="home-layout">
<nav class="glass-nav">
<div class="logo">MindSpace.</div>
<div class="nav-links">
<span class="nav-btn-text" @click="goToHome"></span>
<span class="nav-btn-text" @click="goToCheckin"></span>
<button class="nav-btn-secondary" @click="goToProfile"></button>
<button class="nav-btn-primary" @click="handleLogout">退</button>
</div>
</nav>
<main class="profile-section">
<div class="profile-card">
<div class="avatar-circle">
<span class="avatar-text">{{ currentUsername.charAt(0).toUpperCase() }}</span>
</div>
<h1 class="username">{{ currentUsername }}</h1>
<p class="welcome">欢迎回来持续学习中</p>
</div>
<div class="stats-grid">
<article class="stat-card">
<div class="stat-number">{{ stats.totalDays }}</div>
<div class="stat-label">总打卡天数</div>
</article>
<article class="stat-card">
<div class="stat-number">{{ stats.consecutiveDays }}</div>
<div class="stat-label">连续打卡</div>
</article>
</div>
<div class="content-grid">
<article class="content-card">
<h3>添加好友</h3>
<div class="add-friend-form">
<input
v-model="friendUsername"
type="text"
placeholder="输入好友用户名"
class="friend-input"
@keyup.enter="handleSendRequest"
/>
<button
class="btn-add"
:disabled="loading"
@click="handleSendRequest"
>
{{ loading ? '发送中...' : '发送申请' }}
</button>
</div>
</article>
<article class="content-card request-list-card" v-if="pendingRequests.length > 0">
<h3>待处理申请 ({{ pendingRequests.length }})</h3>
<div class="request-list">
<div v-for="request in pendingRequests" :key="request.id" class="request-item">
<div class="request-info">
<div class="request-avatar">
<span>{{ request.fromUsername.charAt(0).toUpperCase() }}</span>
</div>
<div>
<div class="request-name">{{ request.fromUsername }}</div>
<div class="request-time">请求添加你为好友</div>
</div>
</div>
<div class="request-actions">
<button class="btn-accept" @click="handleAccept(request.id)"></button>
<button class="btn-reject" @click="handleReject(request.id)"></button>
</div>
</div>
</div>
</article>
<article class="content-card friend-list-card">
<h3>好友列表</h3>
<div v-if="friendList.length === 0" class="empty-state">
还没有好友
</div>
<div v-else class="friend-list">
<div v-for="friend in friendList" :key="friend.id" class="friend-item">
<div class="friend-avatar">
<span>{{ friend.username.charAt(0).toUpperCase() }}</span>
</div>
<div class="friend-info">
<div class="friend-name">{{ friend.username }}</div>
<div class="friend-stats">
已打卡 {{ friend.totalCheckinDays }}
</div>
</div>
<el-tag type="success">已添加</el-tag>
</div>
</div>
</article>
</div>
</main>
</div>
</template>
<style scoped>
.home-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: #fafafa;
color: #1a1a1a;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-image: radial-gradient(#d1d5db 1px, transparent 1px);
background-size: 24px 24px;
}
.glass-nav {
position: fixed;
top: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 40px;
background: rgba(250, 250, 250, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
z-index: 50;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.logo {
font-size: 20px;
font-weight: 800;
letter-spacing: -0.5px;
}
.nav-links {
display: flex;
align-items: center;
gap: 12px;
}
.nav-btn-text {
color: #666;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: color 0.3s;
}
.nav-btn-text:hover {
color: #000;
}
.nav-btn-secondary {
background: transparent;
color: #000;
padding: 10px 24px;
border-radius: 40px;
border: 1px solid rgba(0, 0, 0, 0.1);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.nav-btn-secondary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
background: rgba(0, 0, 0, 0.02);
}
.nav-btn-primary {
background: #000;
color: #fff;
padding: 10px 24px;
border-radius: 40px;
border: none;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.nav-btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.profile-section {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 120px 20px 60px;
}
.profile-card {
text-align: center;
margin-bottom: 32px;
}
.avatar-circle {
width: 100px;
height: 100px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3);
}
.avatar-text {
font-size: 40px;
font-weight: 800;
color: #fff;
}
.username {
font-size: 32px;
font-weight: 800;
margin: 0 0 8px 0;
color: #111827;
}
.welcome {
font-size: 14px;
color: #6b7280;
margin: 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 18px;
width: min(600px, 100%);
margin-bottom: 32px;
}
.stat-card {
background: rgba(255, 255, 255, 0.62);
border: 1px solid rgba(0,0,0,0.08);
border-radius: 20px;
padding: 24px 18px;
text-align: center;
box-shadow: 0 10px 22px rgba(0,0,0,0.06);
transition: transform 0.25s ease, box-shadow 0.25s ease;
}
.stat-card:hover {
transform: translateY(-6px);
box-shadow: 0 16px 36px rgba(0,0,0,0.12);
}
.stat-number {
font-size: 48px;
font-weight: 800;
color: #111827;
line-height: 1;
margin-bottom: 8px;
}
.stat-label {
color: #4b5563;
font-size: 14px;
font-weight: 500;
}
.content-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
width: min(900px, 100%);
}
.content-card {
background: rgba(255, 255, 255, 0.62);
border: 1px solid rgba(0,0,0,0.08);
border-radius: 20px;
padding: 24px;
text-align: left;
box-shadow: 0 10px 22px rgba(0,0,0,0.06);
transition: transform 0.25s ease, box-shadow 0.25s ease;
}
.content-card:hover {
transform: translateY(-6px);
box-shadow: 0 16px 36px rgba(0,0,0,0.12);
}
.content-card h3 {
margin: 0 0 16px 0;
font-size: 18px;
color: #111827;
}
.add-friend-form {
display: flex;
gap: 12px;
}
.friend-input {
flex: 1;
padding: 12px 16px;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
font-size: 14px;
background: rgba(255, 255, 255, 0.5);
transition: border-color 0.2s;
}
.friend-input:focus {
outline: none;
border-color: #000;
}
.btn-add {
background: #000;
color: #fff;
padding: 12px 24px;
border-radius: 12px;
border: none;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-add:hover:not(:disabled) {
transform: scale(1.02);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
}
.btn-add:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.empty-state {
text-align: center;
color: #6b7280;
font-size: 14px;
padding: 40px 0;
}
.friend-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.friend-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.5);
border: 1px solid rgba(0, 0, 0, 0.05);
transition: background 0.2s;
}
.friend-item:hover {
background: rgba(255, 255, 255, 0.8);
}
.friend-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #10b981, #059669);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 16px;
font-weight: 600;
}
.friend-info {
flex: 1;
}
.friend-name {
font-size: 15px;
font-weight: 600;
color: #111827;
margin-bottom: 2px;
}
.friend-stats {
font-size: 12px;
color: #6b7280;
}
.friend-list-card {
grid-column: span 1;
}
.request-list-card {
grid-column: 1 / -1;
}
.request-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.request-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.5);
border: 1px solid rgba(0, 0, 0, 0.05);
}
.request-info {
display: flex;
align-items: center;
gap: 12px;
}
.request-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea, #764ba2);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 16px;
font-weight: 600;
}
.request-name {
font-size: 15px;
font-weight: 600;
color: #111827;
margin-bottom: 2px;
}
.request-time {
font-size: 12px;
color: #6b7280;
}
.request-actions {
display: flex;
gap: 8px;
}
.btn-accept {
background: #10b981;
color: #fff;
padding: 8px 16px;
border-radius: 8px;
border: none;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-accept:hover {
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}
.btn-reject {
background: transparent;
color: #ef4444;
padding: 8px 16px;
border-radius: 8px;
border: 1px solid #ef4444;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-reject:hover {
background: #ef4444;
color: #fff;
}
</style>

@ -1,12 +1,12 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {

Loading…
Cancel
Save