This commit is contained in:
王子琦
2026-01-14 15:11:25 +08:00
parent cd5a2a3255
commit f006ed4c89
73 changed files with 4632 additions and 0 deletions

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
# Maven
/backend/target/
# Node
/frontend/node_modules/
/frontend/dist/
# Logs
*.log
# OS / IDE
.DS_Store
Thumbs.db
.idea/
.vscode/
*.iml

View File

@@ -0,0 +1,39 @@
车智车辆租赁平台Vue + Spring Boot + MyBatis + MySQL + Arco Design Vue
## 功能模块
用户端:
- 登录注册、可租车辆、车辆详情、收藏夹、租车下单、归还车辆
- 实名认证、余额支付(模拟)、个人中心、我的订单
管理员端:
- 管理员登录、车辆管理、特价车辆
- 订单管理、实名审核、用户管理
- 数据统计、财务报表
## 后端启动
1. 初始化数据库MySQL
- 执行 `backend/sql/init.sql`
- 默认管理员账号:`admin`,密码:`admin123`
2. 修改配置
- `backend/src/main/resources/application.yml` 中的数据库连接信息
3. 启动
-`backend` 目录执行:`mvn spring-boot:run`
## 前端启动
1. 安装依赖
- `cd frontend`
- `npm install`
2. 启动
- `npm run dev`
3. 访问
- 前端地址:`http://localhost:5173`
- 后端地址:`http://localhost:8080`
## 说明
余额支付、实名认证为模拟流程:
- 余额可在个人中心“余额充值”完成
- 实名认证提交后由管理员审核

76
backend/pom.xml Normal file
View File

@@ -0,0 +1,76 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.car</groupId>
<artifactId>car-rental</artifactId>
<version>1.0.0</version>
<name>car-rental</name>
<description>Car rental platform</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<properties>
<java.version>17</java.version>
<mybatis.version>3.0.3</mybatis.version>
<sa.token.version>1.38.0</sa.token.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis.version}</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
<version>${sa.token.version}</version>
</dependency>
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-jwt</artifactId>
<version>${sa.token.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

85
backend/sql/init.sql Normal file
View File

@@ -0,0 +1,85 @@
create database if not exists car_rental default character set utf8mb4 collate utf8mb4_general_ci;
use car_rental;
create table if not exists users (
id bigint primary key auto_increment,
username varchar(50) not null unique,
password varchar(255) not null,
phone varchar(50),
email varchar(100),
role varchar(20) not null,
status varchar(20) not null,
balance decimal(10,2) default 0,
real_name_status varchar(20) default 'NONE',
real_name varchar(50),
id_number varchar(30),
id_front varchar(255),
id_back varchar(255),
created_at datetime,
updated_at datetime
);
create table if not exists cars (
id bigint primary key auto_increment,
brand varchar(50),
model varchar(50),
plate_no varchar(50),
price_per_day decimal(10,2),
deposit decimal(10,2),
status varchar(20),
is_special tinyint(1) default 0,
images text,
description text,
seats int,
transmission varchar(20),
fuel_type varchar(20),
mileage int,
created_at datetime,
updated_at datetime
);
create table if not exists favorites (
id bigint primary key auto_increment,
user_id bigint not null,
car_id bigint not null,
created_at datetime,
unique key uk_user_car(user_id, car_id)
);
create table if not exists orders (
id bigint primary key auto_increment,
order_no varchar(64) not null unique,
user_id bigint not null,
car_id bigint not null,
start_date date,
end_date date,
days int,
price_per_day decimal(10,2),
deposit decimal(10,2),
total_amount decimal(10,2),
status varchar(20),
pay_type varchar(20),
paid_at datetime,
created_at datetime,
updated_at datetime
);
create table if not exists payments (
id bigint primary key auto_increment,
order_id bigint not null,
user_id bigint not null,
amount decimal(10,2),
type varchar(20),
created_at datetime
);
insert into users(username, password, phone, email, role, status, balance, real_name_status, created_at, updated_at)
values ('admin', '$2a$10$7hErhcmS8xj6QcGbe0yE0eDBn5OQWw4tGxqVylxYxe3CxbJc88x76', '13800000000', 'admin@example.com', 'ADMIN', 'ACTIVE', 0, 'NONE', now(), now())
on duplicate key update username = username;
insert into cars(brand, model, plate_no, price_per_day, deposit, status, is_special, seats, transmission, fuel_type, mileage, description, created_at, updated_at)
values
('丰田', '卡罗拉', '粤A12345', 200, 1000, 'AVAILABLE', 0, 5, 'AT', '汽油', 32000, '经济实用,适合通勤', now(), now()),
('特斯拉', 'Model 3', '粤B54321', 500, 2000, 'AVAILABLE', 1, 5, 'AT', '电动', 18000, '电动轿跑,舒适安静', now(), now()),
('本田', 'CR-V', '粤C67890', 350, 1500, 'AVAILABLE', 0, 5, 'AT', '汽油', 40000, '空间大,适合家庭出行', now(), now())
on duplicate key update plate_no = plate_no;

View File

@@ -0,0 +1,11 @@
package com.car;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class CarRentalApplication {
public static void main(String[] args) {
SpringApplication.run(CarRentalApplication.class, args);
}
}

View File

@@ -0,0 +1,13 @@
package com.car.common;
import lombok.Getter;
@Getter
public class ApiException extends RuntimeException {
private final int code;
public ApiException(int code, String message) {
super(message);
this.code = code;
}
}

View File

@@ -0,0 +1,26 @@
package com.car.common;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse<T> {
private int code;
private String message;
private T data;
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(0, "OK", data);
}
public static ApiResponse<Void> ok() {
return new ApiResponse<>(0, "OK", null);
}
public static ApiResponse<Void> error(int code, String message) {
return new ApiResponse<>(code, message, null);
}
}

View File

@@ -0,0 +1,10 @@
package com.car.common;
public class ErrorCode {
public static final int BAD_REQUEST = 400;
public static final int UNAUTHORIZED = 401;
public static final int FORBIDDEN = 403;
public static final int NOT_FOUND = 404;
public static final int CONFLICT = 409;
public static final int SERVER_ERROR = 500;
}

View File

@@ -0,0 +1,39 @@
package com.car.common;
import cn.dev33.satoken.exception.NotLoginException;
import jakarta.validation.ConstraintViolationException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ApiException.class)
public ApiResponse<Void> handleApiException(ApiException ex) {
return ApiResponse.error(ex.getCode(), ex.getMessage());
}
@ExceptionHandler(NotLoginException.class)
public ApiResponse<Void> handleNotLogin(NotLoginException ex) {
return ApiResponse.error(ErrorCode.UNAUTHORIZED, "请先登录");
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ApiResponse<Void> handleValid(MethodArgumentNotValidException ex) {
String msg = ex.getBindingResult().getAllErrors().isEmpty()
? "参数错误"
: ex.getBindingResult().getAllErrors().get(0).getDefaultMessage();
return ApiResponse.error(ErrorCode.BAD_REQUEST, msg);
}
@ExceptionHandler(ConstraintViolationException.class)
public ApiResponse<Void> handleConstraint(ConstraintViolationException ex) {
return ApiResponse.error(ErrorCode.BAD_REQUEST, ex.getMessage());
}
@ExceptionHandler(Exception.class)
public ApiResponse<Void> handleGeneric(Exception ex) {
return ApiResponse.error(ErrorCode.SERVER_ERROR, ex.getMessage());
}
}

View File

@@ -0,0 +1,13 @@
package com.car.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@Configuration
public class SecurityConfig {
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@@ -0,0 +1,32 @@
package com.car.config;
import cn.dev33.satoken.stp.StpInterface;
import com.car.entity.User;
import com.car.mapper.UserMapper;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.List;
@Component
public class StpInterfaceImpl implements StpInterface {
private final UserMapper userMapper;
public StpInterfaceImpl(UserMapper userMapper) {
this.userMapper = userMapper;
}
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
return Collections.emptyList();
}
@Override
public List<String> getRoleList(Object loginId, String loginType) {
User user = userMapper.findById(Long.valueOf(loginId.toString()));
if (user == null || user.getRole() == null) {
return Collections.emptyList();
}
return Collections.singletonList(user.getRole());
}
}

View File

@@ -0,0 +1,17 @@
package com.car.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("*")
.allowedHeaders("*")
.allowCredentials(true);
}
}

View File

@@ -0,0 +1,107 @@
package com.car.controller;
import cn.dev33.satoken.stp.StpUtil;
import com.car.common.ApiResponse;
import com.car.common.ApiException;
import com.car.common.ErrorCode;
import com.car.entity.Car;
import com.car.entity.Order;
import com.car.entity.User;
import com.car.service.CarService;
import com.car.service.OrderService;
import com.car.service.StatsService;
import com.car.service.UserService;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/admin")
public class AdminController {
private final UserService userService;
private final CarService carService;
private final OrderService orderService;
private final StatsService statsService;
public AdminController(UserService userService, CarService carService, OrderService orderService, StatsService statsService) {
this.userService = userService;
this.carService = carService;
this.orderService = orderService;
this.statsService = statsService;
}
private void checkAdmin() {
if (!StpUtil.hasRole("ADMIN")) {
throw new ApiException(ErrorCode.FORBIDDEN, "需要管理员权限");
}
}
@GetMapping("/users")
public ApiResponse<List<User>> listUsers(@RequestParam(required = false) String keyword) {
checkAdmin();
return ApiResponse.ok(userService.listUsers(keyword));
}
@PostMapping("/users/{userId}/status")
public ApiResponse<User> changeStatus(@PathVariable Long userId, @RequestParam String status) {
checkAdmin();
return ApiResponse.ok(userService.changeStatus(userId, status));
}
@PostMapping("/real-name/{userId}/approve")
public ApiResponse<User> approve(@PathVariable Long userId) {
checkAdmin();
return ApiResponse.ok(userService.reviewRealName(userId, true));
}
@PostMapping("/real-name/{userId}/reject")
public ApiResponse<User> reject(@PathVariable Long userId) {
checkAdmin();
return ApiResponse.ok(userService.reviewRealName(userId, false));
}
@GetMapping("/cars")
public ApiResponse<List<Car>> listCars(@RequestParam(required = false) String keyword,
@RequestParam(required = false) Boolean isSpecial) {
checkAdmin();
return ApiResponse.ok(carService.list(keyword, isSpecial));
}
@PostMapping("/cars")
public ApiResponse<Car> createCar(@RequestBody Car car) {
checkAdmin();
return ApiResponse.ok(carService.create(car));
}
@PutMapping("/cars")
public ApiResponse<Car> updateCar(@RequestBody Car car) {
checkAdmin();
return ApiResponse.ok(carService.update(car));
}
@DeleteMapping("/cars/{id}")
public ApiResponse<Void> deleteCar(@PathVariable Long id) {
checkAdmin();
carService.delete(id);
return ApiResponse.ok();
}
@GetMapping("/orders")
public ApiResponse<List<Order>> listOrders(@RequestParam(required = false) String status) {
checkAdmin();
return ApiResponse.ok(orderService.listAll(status));
}
@GetMapping("/stats/orders")
public ApiResponse<Map<String, Object>> orderStats() {
checkAdmin();
return ApiResponse.ok(statsService.orderStats());
}
@GetMapping("/stats/finance")
public ApiResponse<Map<String, Object>> financeStats() {
checkAdmin();
return ApiResponse.ok(statsService.financeStats());
}
}

View File

@@ -0,0 +1,44 @@
package com.car.controller;
import cn.dev33.satoken.stp.StpUtil;
import com.car.common.ApiResponse;
import com.car.dto.LoginRequest;
import com.car.dto.RegisterRequest;
import com.car.entity.User;
import com.car.service.UserService;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final UserService userService;
public AuthController(UserService userService) {
this.userService = userService;
}
@PostMapping("/register")
public ApiResponse<User> register(@Valid @RequestBody RegisterRequest request) {
return ApiResponse.ok(userService.register(request));
}
@PostMapping("/login")
public ApiResponse<Map<String, Object>> login(@Valid @RequestBody LoginRequest request) {
User user = userService.login(request);
StpUtil.login(user.getId());
Map<String, Object> data = new HashMap<>();
data.put("token", StpUtil.getTokenValue());
data.put("role", user.getRole());
return ApiResponse.ok(data);
}
@PostMapping("/logout")
public ApiResponse<Void> logout() {
StpUtil.logout();
return ApiResponse.ok();
}
}

View File

@@ -0,0 +1,29 @@
package com.car.controller;
import com.car.common.ApiResponse;
import com.car.entity.Car;
import com.car.service.CarService;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/cars")
public class CarController {
private final CarService carService;
public CarController(CarService carService) {
this.carService = carService;
}
@GetMapping
public ApiResponse<List<Car>> list(@RequestParam(required = false) String keyword,
@RequestParam(required = false) Boolean isSpecial) {
return ApiResponse.ok(carService.list(keyword, isSpecial));
}
@GetMapping("/{id}")
public ApiResponse<Car> detail(@PathVariable Long id) {
return ApiResponse.ok(carService.get(id));
}
}

View File

@@ -0,0 +1,109 @@
package com.car.controller;
import cn.dev33.satoken.stp.StpUtil;
import com.car.common.ApiResponse;
import com.car.dto.BalanceRequest;
import com.car.dto.OrderCreateRequest;
import com.car.dto.PayRequest;
import com.car.dto.RealNameRequest;
import com.car.entity.Favorite;
import com.car.entity.Order;
import com.car.entity.User;
import com.car.service.FavoriteService;
import com.car.service.OrderService;
import com.car.service.UserService;
import com.car.util.AuthUtil;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/user")
public class UserController {
private final UserService userService;
private final FavoriteService favoriteService;
private final OrderService orderService;
public UserController(UserService userService, FavoriteService favoriteService, OrderService orderService) {
this.userService = userService;
this.favoriteService = favoriteService;
this.orderService = orderService;
}
@GetMapping("/me")
public ApiResponse<User> me() {
Long userId = AuthUtil.getUserId();
User user = userService.getById(userId);
if (user != null) {
user.setPassword(null);
}
return ApiResponse.ok(user);
}
@PutMapping("/profile")
public ApiResponse<User> updateProfile(@RequestBody User user) {
Long userId = AuthUtil.getUserId();
user.setId(userId);
return ApiResponse.ok(userService.updateProfile(user));
}
@PostMapping("/real-name")
public ApiResponse<User> realName(@Valid @RequestBody RealNameRequest request) {
Long userId = AuthUtil.getUserId();
return ApiResponse.ok(userService.submitRealName(userId, request));
}
@PostMapping("/balance")
public ApiResponse<User> addBalance(@Valid @RequestBody BalanceRequest request) {
Long userId = AuthUtil.getUserId();
return ApiResponse.ok(userService.addBalance(userId, request));
}
@PostMapping("/favorite/{carId}")
public ApiResponse<Void> addFavorite(@PathVariable Long carId) {
favoriteService.add(AuthUtil.getUserId(), carId);
return ApiResponse.ok();
}
@DeleteMapping("/favorite/{carId}")
public ApiResponse<Void> removeFavorite(@PathVariable Long carId) {
favoriteService.remove(AuthUtil.getUserId(), carId);
return ApiResponse.ok();
}
@GetMapping("/favorite")
public ApiResponse<List<Favorite>> listFavorites() {
return ApiResponse.ok(favoriteService.list(AuthUtil.getUserId()));
}
@PostMapping("/order")
public ApiResponse<Order> createOrder(@Valid @RequestBody OrderCreateRequest request) {
return ApiResponse.ok(orderService.create(AuthUtil.getUserId(), request));
}
@PostMapping("/order/pay")
public ApiResponse<Order> pay(@Valid @RequestBody PayRequest request) {
return ApiResponse.ok(orderService.pay(AuthUtil.getUserId(), request));
}
@PostMapping("/order/{orderId}/cancel")
public ApiResponse<Order> cancel(@PathVariable Long orderId) {
return ApiResponse.ok(orderService.cancel(AuthUtil.getUserId(), orderId));
}
@PostMapping("/order/{orderId}/return")
public ApiResponse<Order> returnCar(@PathVariable Long orderId) {
return ApiResponse.ok(orderService.returnCar(AuthUtil.getUserId(), orderId));
}
@GetMapping("/order")
public ApiResponse<List<Order>> myOrders(@RequestParam(required = false) String status) {
return ApiResponse.ok(orderService.listByUser(AuthUtil.getUserId(), status));
}
@GetMapping("/check-login")
public ApiResponse<Boolean> checkLogin() {
return ApiResponse.ok(StpUtil.isLogin());
}
}

View File

@@ -0,0 +1,10 @@
package com.car.dto;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class BalanceRequest {
@NotNull(message = "金额不能为空")
private Double amount;
}

View File

@@ -0,0 +1,12 @@
package com.car.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class LoginRequest {
@NotBlank(message = "用户名不能为空")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
}

View File

@@ -0,0 +1,16 @@
package com.car.dto;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDate;
@Data
public class OrderCreateRequest {
@NotNull(message = "车辆不能为空")
private Long carId;
@NotNull(message = "开始日期不能为空")
private LocalDate startDate;
@NotNull(message = "结束日期不能为空")
private LocalDate endDate;
}

View File

@@ -0,0 +1,13 @@
package com.car.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class PayRequest {
@NotNull(message = "订单不能为空")
private Long orderId;
@NotBlank(message = "支付方式不能为空")
private String payType;
}

View File

@@ -0,0 +1,14 @@
package com.car.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class RealNameRequest {
@NotBlank(message = "真实姓名不能为空")
private String realName;
@NotBlank(message = "身份证号不能为空")
private String idNumber;
private String idFront;
private String idBack;
}

View File

@@ -0,0 +1,14 @@
package com.car.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class RegisterRequest {
@NotBlank(message = "用户名不能为空")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
private String phone;
private String email;
}

View File

@@ -0,0 +1,25 @@
package com.car.entity;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class Car {
private Long id;
private String brand;
private String model;
private String plateNo;
private Double pricePerDay;
private Double deposit;
private String status;
private Boolean isSpecial;
private String images;
private String description;
private Integer seats;
private String transmission;
private String fuelType;
private Integer mileage;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,13 @@
package com.car.entity;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class Favorite {
private Long id;
private Long userId;
private Long carId;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,25 @@
package com.car.entity;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
public class Order {
private Long id;
private String orderNo;
private Long userId;
private Long carId;
private LocalDate startDate;
private LocalDate endDate;
private Integer days;
private Double pricePerDay;
private Double deposit;
private Double totalAmount;
private String status;
private String payType;
private LocalDateTime paidAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,15 @@
package com.car.entity;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class Payment {
private Long id;
private Long orderId;
private Long userId;
private Double amount;
private String type;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,24 @@
package com.car.entity;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class User {
private Long id;
private String username;
private String password;
private String phone;
private String email;
private String role;
private String status;
private Double balance;
private String realNameStatus;
private String realName;
private String idNumber;
private String idFront;
private String idBack;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,20 @@
package com.car.mapper;
import com.car.entity.Car;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface CarMapper {
Car findById(@Param("id") Long id);
List<Car> findAll(@Param("keyword") String keyword, @Param("isSpecial") Boolean isSpecial);
int insert(Car car);
int update(Car car);
int delete(@Param("id") Long id);
}

View File

@@ -0,0 +1,18 @@
package com.car.mapper;
import com.car.entity.Favorite;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface FavoriteMapper {
Favorite findByUserAndCar(@Param("userId") Long userId, @Param("carId") Long carId);
int insert(Favorite favorite);
int delete(@Param("id") Long id);
List<Favorite> findByUserId(@Param("userId") Long userId);
}

View File

@@ -0,0 +1,27 @@
package com.car.mapper;
import com.car.entity.Order;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDate;
import java.util.List;
@Mapper
public interface OrderMapper {
Order findById(@Param("id") Long id);
Order findByOrderNo(@Param("orderNo") String orderNo);
int insert(Order order);
int update(Order order);
List<Order> findByUserId(@Param("userId") Long userId, @Param("status") String status);
List<Order> findAll(@Param("status") String status);
int countOverlap(@Param("carId") Long carId,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
}

View File

@@ -0,0 +1,14 @@
package com.car.mapper;
import com.car.entity.Payment;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface PaymentMapper {
int insert(Payment payment);
List<Payment> findByUserId(@Param("userId") Long userId);
}

View File

@@ -0,0 +1,13 @@
package com.car.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.Map;
@Mapper
public interface StatsMapper {
Map<String, Object> orderStats(@Param("status") String status);
Map<String, Object> financeStats();
}

View File

@@ -0,0 +1,20 @@
package com.car.mapper;
import com.car.entity.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface UserMapper {
User findByUsername(@Param("username") String username);
User findById(@Param("id") Long id);
int insert(User user);
int update(User user);
List<User> findAll(@Param("keyword") String keyword);
}

View File

@@ -0,0 +1,54 @@
package com.car.service;
import com.car.common.ApiException;
import com.car.common.ErrorCode;
import com.car.entity.Car;
import com.car.mapper.CarMapper;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class CarService {
private final CarMapper carMapper;
public CarService(CarMapper carMapper) {
this.carMapper = carMapper;
}
public List<Car> list(String keyword, Boolean isSpecial) {
return carMapper.findAll(keyword, isSpecial);
}
public Car get(Long id) {
Car car = carMapper.findById(id);
if (car == null) {
throw new ApiException(ErrorCode.NOT_FOUND, "车辆不存在");
}
return car;
}
public Car create(Car car) {
if (car.getStatus() == null) {
car.setStatus("AVAILABLE");
}
if (car.getIsSpecial() == null) {
car.setIsSpecial(false);
}
carMapper.insert(car);
return car;
}
public Car update(Car car) {
Car db = carMapper.findById(car.getId());
if (db == null) {
throw new ApiException(ErrorCode.NOT_FOUND, "车辆不存在");
}
carMapper.update(car);
return carMapper.findById(car.getId());
}
public void delete(Long id) {
carMapper.delete(id);
}
}

View File

@@ -0,0 +1,41 @@
package com.car.service;
import com.car.common.ApiException;
import com.car.common.ErrorCode;
import com.car.entity.Favorite;
import com.car.mapper.FavoriteMapper;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class FavoriteService {
private final FavoriteMapper favoriteMapper;
public FavoriteService(FavoriteMapper favoriteMapper) {
this.favoriteMapper = favoriteMapper;
}
public void add(Long userId, Long carId) {
Favorite existing = favoriteMapper.findByUserAndCar(userId, carId);
if (existing != null) {
throw new ApiException(ErrorCode.CONFLICT, "已收藏");
}
Favorite favorite = new Favorite();
favorite.setUserId(userId);
favorite.setCarId(carId);
favoriteMapper.insert(favorite);
}
public void remove(Long userId, Long carId) {
Favorite existing = favoriteMapper.findByUserAndCar(userId, carId);
if (existing == null) {
throw new ApiException(ErrorCode.NOT_FOUND, "收藏不存在");
}
favoriteMapper.delete(existing.getId());
}
public List<Favorite> list(Long userId) {
return favoriteMapper.findByUserId(userId);
}
}

View File

@@ -0,0 +1,151 @@
package com.car.service;
import com.car.common.ApiException;
import com.car.common.ErrorCode;
import com.car.dto.OrderCreateRequest;
import com.car.dto.PayRequest;
import com.car.entity.Car;
import com.car.entity.Order;
import com.car.entity.Payment;
import com.car.entity.User;
import com.car.mapper.CarMapper;
import com.car.mapper.OrderMapper;
import com.car.mapper.PaymentMapper;
import com.car.mapper.UserMapper;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.UUID;
@Service
public class OrderService {
private final OrderMapper orderMapper;
private final CarMapper carMapper;
private final UserMapper userMapper;
private final PaymentMapper paymentMapper;
public OrderService(OrderMapper orderMapper, CarMapper carMapper, UserMapper userMapper, PaymentMapper paymentMapper) {
this.orderMapper = orderMapper;
this.carMapper = carMapper;
this.userMapper = userMapper;
this.paymentMapper = paymentMapper;
}
public Order create(Long userId, OrderCreateRequest request) {
Car car = carMapper.findById(request.getCarId());
if (car == null) {
throw new ApiException(ErrorCode.NOT_FOUND, "车辆不存在");
}
if (!"AVAILABLE".equals(car.getStatus())) {
throw new ApiException(ErrorCode.CONFLICT, "车辆不可租");
}
User user = userMapper.findById(userId);
if (user == null) {
throw new ApiException(ErrorCode.NOT_FOUND, "用户不存在");
}
if (!"APPROVED".equals(user.getRealNameStatus())) {
throw new ApiException(ErrorCode.FORBIDDEN, "请先完成实名认证审核");
}
LocalDate start = request.getStartDate();
LocalDate end = request.getEndDate();
if (end.isBefore(start)) {
throw new ApiException(ErrorCode.BAD_REQUEST, "结束日期不能早于开始日期");
}
int overlap = orderMapper.countOverlap(request.getCarId(), start, end);
if (overlap > 0) {
throw new ApiException(ErrorCode.CONFLICT, "该日期已被预订");
}
long days = ChronoUnit.DAYS.between(start, end) + 1;
Order order = new Order();
order.setOrderNo("ORD" + UUID.randomUUID().toString().replace("-", ""));
order.setUserId(userId);
order.setCarId(request.getCarId());
order.setStartDate(start);
order.setEndDate(end);
order.setDays((int) days);
order.setPricePerDay(car.getPricePerDay());
order.setDeposit(car.getDeposit());
order.setTotalAmount(car.getPricePerDay() * days + car.getDeposit());
order.setStatus("PENDING_PAY");
orderMapper.insert(order);
return orderMapper.findById(order.getId());
}
public Order pay(Long userId, PayRequest request) {
Order order = orderMapper.findById(request.getOrderId());
if (order == null || !order.getUserId().equals(userId)) {
throw new ApiException(ErrorCode.NOT_FOUND, "订单不存在");
}
if (!"PENDING_PAY".equals(order.getStatus())) {
throw new ApiException(ErrorCode.CONFLICT, "订单无法支付");
}
if (!"BALANCE".equalsIgnoreCase(request.getPayType())) {
throw new ApiException(ErrorCode.BAD_REQUEST, "仅支持余额支付");
}
User user = userMapper.findById(userId);
if (user.getBalance() < order.getTotalAmount()) {
throw new ApiException(ErrorCode.CONFLICT, "余额不足");
}
user.setBalance(user.getBalance() - order.getTotalAmount());
userMapper.update(user);
Car car = carMapper.findById(order.getCarId());
car.setStatus("RENTED");
carMapper.update(car);
order.setStatus("RENTING");
order.setPayType("BALANCE");
order.setPaidAt(java.time.LocalDateTime.now());
orderMapper.update(order);
Payment payment = new Payment();
payment.setOrderId(order.getId());
payment.setUserId(userId);
payment.setAmount(order.getTotalAmount());
payment.setType("BALANCE");
paymentMapper.insert(payment);
return orderMapper.findById(order.getId());
}
public Order cancel(Long userId, Long orderId) {
Order order = orderMapper.findById(orderId);
if (order == null || !order.getUserId().equals(userId)) {
throw new ApiException(ErrorCode.NOT_FOUND, "订单不存在");
}
if (!"PENDING_PAY".equals(order.getStatus())) {
throw new ApiException(ErrorCode.CONFLICT, "订单无法取消");
}
order.setStatus("CANCELLED");
orderMapper.update(order);
return orderMapper.findById(orderId);
}
public Order returnCar(Long userId, Long orderId) {
Order order = orderMapper.findById(orderId);
if (order == null || !order.getUserId().equals(userId)) {
throw new ApiException(ErrorCode.NOT_FOUND, "订单不存在");
}
if (!"RENTING".equals(order.getStatus())) {
throw new ApiException(ErrorCode.CONFLICT, "订单无法归还");
}
order.setStatus("RETURNED");
orderMapper.update(order);
Car car = carMapper.findById(order.getCarId());
car.setStatus("AVAILABLE");
carMapper.update(car);
return orderMapper.findById(orderId);
}
public List<Order> listByUser(Long userId, String status) {
return orderMapper.findByUserId(userId, status);
}
public List<Order> listAll(String status) {
return orderMapper.findAll(status);
}
}

View File

@@ -0,0 +1,23 @@
package com.car.service;
import com.car.mapper.StatsMapper;
import org.springframework.stereotype.Service;
import java.util.Map;
@Service
public class StatsService {
private final StatsMapper statsMapper;
public StatsService(StatsMapper statsMapper) {
this.statsMapper = statsMapper;
}
public Map<String, Object> orderStats() {
return statsMapper.orderStats(null);
}
public Map<String, Object> financeStats() {
return statsMapper.financeStats();
}
}

View File

@@ -0,0 +1,126 @@
package com.car.service;
import com.car.common.ApiException;
import com.car.common.ErrorCode;
import com.car.dto.BalanceRequest;
import com.car.dto.LoginRequest;
import com.car.dto.RealNameRequest;
import com.car.dto.RegisterRequest;
import com.car.entity.User;
import com.car.mapper.UserMapper;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class UserService {
private final UserMapper userMapper;
private final BCryptPasswordEncoder encoder;
public UserService(UserMapper userMapper, BCryptPasswordEncoder encoder) {
this.userMapper = userMapper;
this.encoder = encoder;
}
public User register(RegisterRequest request) {
User existing = userMapper.findByUsername(request.getUsername());
if (existing != null) {
throw new ApiException(ErrorCode.CONFLICT, "用户名已存在");
}
User user = new User();
user.setUsername(request.getUsername());
user.setPassword(encoder.encode(request.getPassword()));
user.setPhone(request.getPhone());
user.setEmail(request.getEmail());
user.setRole("USER");
user.setStatus("ACTIVE");
user.setBalance(0.0);
user.setRealNameStatus("NONE");
userMapper.insert(user);
user.setPassword(null);
return user;
}
public User login(LoginRequest request) {
User user = userMapper.findByUsername(request.getUsername());
if (user == null || !encoder.matches(request.getPassword(), user.getPassword())) {
throw new ApiException(ErrorCode.UNAUTHORIZED, "用户名或密码错误");
}
if (!"ACTIVE".equals(user.getStatus())) {
throw new ApiException(ErrorCode.FORBIDDEN, "账号已禁用");
}
return user;
}
public User getById(Long id) {
return userMapper.findById(id);
}
public User updateProfile(User input) {
User db = userMapper.findById(input.getId());
if (db == null) {
throw new ApiException(ErrorCode.NOT_FOUND, "用户不存在");
}
db.setPhone(input.getPhone());
db.setEmail(input.getEmail());
userMapper.update(db);
db.setPassword(null);
return db;
}
public User submitRealName(Long userId, RealNameRequest request) {
User db = userMapper.findById(userId);
if (db == null) {
throw new ApiException(ErrorCode.NOT_FOUND, "用户不存在");
}
db.setRealName(request.getRealName());
db.setIdNumber(request.getIdNumber());
db.setIdFront(request.getIdFront());
db.setIdBack(request.getIdBack());
db.setRealNameStatus("PENDING");
userMapper.update(db);
db.setPassword(null);
return db;
}
public User reviewRealName(Long userId, boolean approved) {
User db = userMapper.findById(userId);
if (db == null) {
throw new ApiException(ErrorCode.NOT_FOUND, "用户不存在");
}
db.setRealNameStatus(approved ? "APPROVED" : "REJECTED");
userMapper.update(db);
db.setPassword(null);
return db;
}
public User changeStatus(Long userId, String status) {
User db = userMapper.findById(userId);
if (db == null) {
throw new ApiException(ErrorCode.NOT_FOUND, "用户不存在");
}
db.setStatus(status);
userMapper.update(db);
db.setPassword(null);
return db;
}
public User addBalance(Long userId, BalanceRequest request) {
if (request.getAmount() <= 0) {
throw new ApiException(ErrorCode.BAD_REQUEST, "金额必须大于0");
}
User db = userMapper.findById(userId);
if (db == null) {
throw new ApiException(ErrorCode.NOT_FOUND, "用户不存在");
}
db.setBalance(db.getBalance() + request.getAmount());
userMapper.update(db);
db.setPassword(null);
return db;
}
public List<User> listUsers(String keyword) {
return userMapper.findAll(keyword);
}
}

View File

@@ -0,0 +1,9 @@
package com.car.util;
import cn.dev33.satoken.stp.StpUtil;
public class AuthUtil {
public static Long getUserId() {
return Long.valueOf(StpUtil.getLoginId().toString());
}
}

View File

@@ -0,0 +1,26 @@
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/car_rental?useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: root
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
mybatis:
mapper-locations: classpath:mappers/*.xml
configuration:
map-underscore-to-camel-case: true
sa-token:
token-name: Authorization
token-style: jwt
timeout: 86400
active-timeout: -1
is-concurrent: true
is-share: true
token-prefix: Bearer
is-log: false

View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.car.mapper.CarMapper">
<resultMap id="CarMap" type="com.car.entity.Car">
<id column="id" property="id"/>
<result column="brand" property="brand"/>
<result column="model" property="model"/>
<result column="plate_no" property="plateNo"/>
<result column="price_per_day" property="pricePerDay"/>
<result column="deposit" property="deposit"/>
<result column="status" property="status"/>
<result column="is_special" property="isSpecial"/>
<result column="images" property="images"/>
<result column="description" property="description"/>
<result column="seats" property="seats"/>
<result column="transmission" property="transmission"/>
<result column="fuel_type" property="fuelType"/>
<result column="mileage" property="mileage"/>
<result column="created_at" property="createdAt"/>
<result column="updated_at" property="updatedAt"/>
</resultMap>
<select id="findById" resultMap="CarMap">
select * from cars where id = #{id}
</select>
<select id="findAll" resultMap="CarMap">
select * from cars
<where>
<if test="keyword != null and keyword != ''">
(brand like concat('%', #{keyword}, '%')
or model like concat('%', #{keyword}, '%')
or plate_no like concat('%', #{keyword}, '%'))
</if>
<if test="isSpecial != null">
and is_special = #{isSpecial}
</if>
</where>
order by id desc
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
insert into cars(brand, model, plate_no, price_per_day, deposit, status, is_special, images, description, seats, transmission, fuel_type, mileage, created_at, updated_at)
values (#{brand}, #{model}, #{plateNo}, #{pricePerDay}, #{deposit}, #{status}, #{isSpecial}, #{images}, #{description}, #{seats}, #{transmission}, #{fuelType}, #{mileage}, now(), now())
</insert>
<update id="update">
update cars
set brand = #{brand},
model = #{model},
plate_no = #{plateNo},
price_per_day = #{pricePerDay},
deposit = #{deposit},
status = #{status},
is_special = #{isSpecial},
images = #{images},
description = #{description},
seats = #{seats},
transmission = #{transmission},
fuel_type = #{fuelType},
mileage = #{mileage},
updated_at = now()
where id = #{id}
</update>
<delete id="delete">
delete from cars where id = #{id}
</delete>
</mapper>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.car.mapper.FavoriteMapper">
<resultMap id="FavoriteMap" type="com.car.entity.Favorite">
<id column="id" property="id"/>
<result column="user_id" property="userId"/>
<result column="car_id" property="carId"/>
<result column="created_at" property="createdAt"/>
</resultMap>
<select id="findByUserAndCar" resultMap="FavoriteMap">
select * from favorites where user_id = #{userId} and car_id = #{carId} limit 1
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
insert into favorites(user_id, car_id, created_at)
values (#{userId}, #{carId}, now())
</insert>
<delete id="delete">
delete from favorites where id = #{id}
</delete>
<select id="findByUserId" resultMap="FavoriteMap">
select * from favorites where user_id = #{userId} order by id desc
</select>
</mapper>

View File

@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.car.mapper.OrderMapper">
<resultMap id="OrderMap" type="com.car.entity.Order">
<id column="id" property="id"/>
<result column="order_no" property="orderNo"/>
<result column="user_id" property="userId"/>
<result column="car_id" property="carId"/>
<result column="start_date" property="startDate"/>
<result column="end_date" property="endDate"/>
<result column="days" property="days"/>
<result column="price_per_day" property="pricePerDay"/>
<result column="deposit" property="deposit"/>
<result column="total_amount" property="totalAmount"/>
<result column="status" property="status"/>
<result column="pay_type" property="payType"/>
<result column="paid_at" property="paidAt"/>
<result column="created_at" property="createdAt"/>
<result column="updated_at" property="updatedAt"/>
</resultMap>
<select id="findById" resultMap="OrderMap">
select * from orders where id = #{id}
</select>
<select id="findByOrderNo" resultMap="OrderMap">
select * from orders where order_no = #{orderNo}
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
insert into orders(order_no, user_id, car_id, start_date, end_date, days,
price_per_day, deposit, total_amount, status, pay_type, paid_at, created_at, updated_at)
values (#{orderNo}, #{userId}, #{carId}, #{startDate}, #{endDate}, #{days},
#{pricePerDay}, #{deposit}, #{totalAmount}, #{status}, #{payType}, #{paidAt}, now(), now())
</insert>
<update id="update">
update orders
set start_date = #{startDate},
end_date = #{endDate},
days = #{days},
price_per_day = #{pricePerDay},
deposit = #{deposit},
total_amount = #{totalAmount},
status = #{status},
pay_type = #{payType},
paid_at = #{paidAt},
updated_at = now()
where id = #{id}
</update>
<select id="findByUserId" resultMap="OrderMap">
select * from orders
<where>
user_id = #{userId}
<if test="status != null and status != ''">
and status = #{status}
</if>
</where>
order by id desc
</select>
<select id="findAll" resultMap="OrderMap">
select * from orders
<where>
<if test="status != null and status != ''">
status = #{status}
</if>
</where>
order by id desc
</select>
<select id="countOverlap" resultType="int">
select count(1) from orders
where car_id = #{carId}
and status in ('PAID', 'RENTING')
and not (end_date &lt; #{startDate} or start_date &gt; #{endDate})
</select>
</mapper>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.car.mapper.PaymentMapper">
<resultMap id="PaymentMap" type="com.car.entity.Payment">
<id column="id" property="id"/>
<result column="order_id" property="orderId"/>
<result column="user_id" property="userId"/>
<result column="amount" property="amount"/>
<result column="type" property="type"/>
<result column="created_at" property="createdAt"/>
</resultMap>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
insert into payments(order_id, user_id, amount, type, created_at)
values (#{orderId}, #{userId}, #{amount}, #{type}, now())
</insert>
<select id="findByUserId" resultMap="PaymentMap">
select * from payments where user_id = #{userId} order by id desc
</select>
</mapper>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.car.mapper.StatsMapper">
<select id="orderStats" resultType="map">
select count(1) as total,
sum(case when status = 'RENTING' then 1 else 0 end) as renting,
sum(case when status = 'RETURNED' then 1 else 0 end) as returned,
sum(case when status = 'PENDING_PAY' then 1 else 0 end) as pendingPay
from orders
</select>
<select id="financeStats" resultType="map">
select ifnull(sum(total_amount), 0) as income,
ifnull(sum(deposit), 0) as deposit,
count(1) as orderCount
from orders
where status in ('PAID', 'RENTING', 'RETURNED')
</select>
</mapper>

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.car.mapper.UserMapper">
<resultMap id="UserMap" type="com.car.entity.User">
<id column="id" property="id"/>
<result column="username" property="username"/>
<result column="password" property="password"/>
<result column="phone" property="phone"/>
<result column="email" property="email"/>
<result column="role" property="role"/>
<result column="status" property="status"/>
<result column="balance" property="balance"/>
<result column="real_name_status" property="realNameStatus"/>
<result column="real_name" property="realName"/>
<result column="id_number" property="idNumber"/>
<result column="id_front" property="idFront"/>
<result column="id_back" property="idBack"/>
<result column="created_at" property="createdAt"/>
<result column="updated_at" property="updatedAt"/>
</resultMap>
<select id="findByUsername" resultMap="UserMap">
select * from users where username = #{username} limit 1
</select>
<select id="findById" resultMap="UserMap">
select * from users where id = #{id}
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
insert into users(username, password, phone, email, role, status, balance, real_name_status, created_at, updated_at)
values (#{username}, #{password}, #{phone}, #{email}, #{role}, #{status}, #{balance}, #{realNameStatus}, now(), now())
</insert>
<update id="update">
update users
set password = #{password},
phone = #{phone},
email = #{email},
role = #{role},
status = #{status},
balance = #{balance},
real_name_status = #{realNameStatus},
real_name = #{realName},
id_number = #{idNumber},
id_front = #{idFront},
id_back = #{idBack},
updated_at = now()
where id = #{id}
</update>
<select id="findAll" resultMap="UserMap">
select * from users
<where>
<if test="keyword != null and keyword != ''">
(username like concat('%', #{keyword}, '%')
or phone like concat('%', #{keyword}, '%')
or email like concat('%', #{keyword}, '%'))
</if>
</where>
order by id desc
</select>
</mapper>

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
frontend/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1684
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
frontend/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@arco-design/web-vue": "^2.56.3",
"axios": "^1.7.9",
"dayjs": "^1.11.11",
"pinia": "^2.2.4",
"vue": "^3.5.24",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.4",
"vite": "^5.4.10"
}
}

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

82
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,82 @@
<template>
<a-layout class="layout">
<a-layout-header class="header">
<div class="logo">车智租车</div>
<a-menu mode="horizontal" :selected-keys="[active]">
<a-menu-item key="/">
<router-link to="/">租车大厅</router-link>
</a-menu-item>
<a-menu-item key="/favorites">
<router-link to="/favorites">收藏夹</router-link>
</a-menu-item>
<a-menu-item key="/orders">
<router-link to="/orders">我的订单</router-link>
</a-menu-item>
<a-menu-item key="/profile">
<router-link to="/profile">个人中心</router-link>
</a-menu-item>
<a-sub-menu v-if="auth.role === 'ADMIN'" key="admin" title="管理后台">
<a-menu-item key="/admin/cars">
<router-link to="/admin/cars">车辆管理</router-link>
</a-menu-item>
<a-menu-item key="/admin/users">
<router-link to="/admin/users">用户管理</router-link>
</a-menu-item>
<a-menu-item key="/admin/orders">
<router-link to="/admin/orders">订单管理</router-link>
</a-menu-item>
<a-menu-item key="/admin/stats">
<router-link to="/admin/stats">数据统计</router-link>
</a-menu-item>
</a-sub-menu>
</a-menu>
<div class="user-actions">
<template v-if="auth.token">
<a-tag color="arcoblue">{{ auth.user?.username || auth.role }}</a-tag>
<a-button type="text" @click="logout">退出</a-button>
</template>
<template v-else>
<router-link to="/login">登录</router-link>
<span class="divider">|</span>
<router-link to="/register">注册</router-link>
</template>
</div>
</a-layout-header>
<a-layout-content class="content">
<router-view />
</a-layout-content>
</a-layout>
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from './store/auth'
import http from './api/http'
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const active = computed(() => route.path)
const loadMe = async () => {
if (!auth.token) return
try {
const user = await http.get('/api/user/me')
auth.setUser(user)
} catch (e) {
auth.clear()
}
}
const logout = async () => {
try {
await http.post('/api/auth/logout')
} finally {
auth.clear()
router.push('/login')
}
}
onMounted(loadMe)
</script>

28
frontend/src/api/http.js Normal file
View File

@@ -0,0 +1,28 @@
import axios from 'axios'
import { useAuthStore } from '../store/auth'
const http = axios.create({
baseURL: 'http://localhost:8080',
timeout: 15000
})
http.interceptors.request.use((config) => {
const store = useAuthStore()
if (store.token) {
config.headers.Authorization = `Bearer ${store.token}`
}
return config
})
http.interceptors.response.use(
(response) => {
const res = response.data
if (res.code !== 0) {
return Promise.reject(new Error(res.message || '请求失败'))
}
return res.data
},
(error) => Promise.reject(error)
)
export default http

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,43 @@
<script setup>
import { ref } from 'vue'
defineProps({
msg: String,
})
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

13
frontend/src/main.js Normal file
View File

@@ -0,0 +1,13 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ArcoVue from '@arco-design/web-vue'
import '@arco-design/web-vue/dist/arco.css'
import './style.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ArcoVue)
app.mount('#app')

View File

@@ -0,0 +1,46 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../store/auth'
import Home from '../views/Home.vue'
import Login from '../views/Login.vue'
import Register from '../views/Register.vue'
import CarDetail from '../views/CarDetail.vue'
import Favorites from '../views/Favorites.vue'
import Orders from '../views/Orders.vue'
import Profile from '../views/Profile.vue'
import AdminCars from '../views/admin/AdminCars.vue'
import AdminUsers from '../views/admin/AdminUsers.vue'
import AdminOrders from '../views/admin/AdminOrders.vue'
import AdminStats from '../views/admin/AdminStats.vue'
const routes = [
{ path: '/', component: Home },
{ path: '/login', component: Login },
{ path: '/register', component: Register },
{ path: '/cars/:id', component: CarDetail },
{ path: '/favorites', component: Favorites },
{ path: '/orders', component: Orders },
{ path: '/profile', component: Profile },
{ path: '/admin/cars', component: AdminCars },
{ path: '/admin/users', component: AdminUsers },
{ path: '/admin/orders', component: AdminOrders },
{ path: '/admin/stats', component: AdminStats }
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach((to) => {
const store = useAuthStore()
const requiresAuth = ['/favorites', '/orders', '/profile', '/admin/cars', '/admin/users', '/admin/orders', '/admin/stats']
if (requiresAuth.includes(to.path) && !store.token) {
return '/login'
}
if (to.path.startsWith('/admin') && store.role !== 'ADMIN') {
return '/'
}
return true
})
export default router

View File

@@ -0,0 +1,27 @@
import { defineStore } from 'pinia'
export const useAuthStore = defineStore('auth', {
state: () => ({
token: localStorage.getItem('token') || '',
role: localStorage.getItem('role') || '',
user: null
}),
actions: {
setAuth(token, role) {
this.token = token
this.role = role
localStorage.setItem('token', token)
localStorage.setItem('role', role)
},
clear() {
this.token = ''
this.role = ''
this.user = null
localStorage.removeItem('token')
localStorage.removeItem('role')
},
setUser(user) {
this.user = user
}
}
})

60
frontend/src/style.css Normal file
View File

@@ -0,0 +1,60 @@
body {
margin: 0;
background: #f5f7fa;
color: #1d2129;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "PingFang SC", "Microsoft YaHei", sans-serif;
}
a {
color: inherit;
text-decoration: none;
}
.layout {
min-height: 100vh;
}
.header {
display: flex;
align-items: center;
gap: 24px;
background: #ffffff;
border-bottom: 1px solid #e5e6eb;
}
.logo {
font-weight: 600;
font-size: 18px;
padding: 0 16px;
}
.user-actions {
margin-left: auto;
padding-right: 16px;
display: flex;
align-items: center;
gap: 12px;
}
.divider {
color: #c9cdd4;
}
.content {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
}
.section-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
}

View File

@@ -0,0 +1,97 @@
<template>
<div>
<a-card v-if="car" :title="car.brand + ' ' + car.model">
<p>车牌{{ car.plateNo }}</p>
<p>日租金¥{{ car.pricePerDay }}</p>
<p>押金¥{{ car.deposit }}</p>
<p>座位{{ car.seats }} | 变速箱{{ car.transmission }} | 油耗类型{{ car.fuelType }}</p>
<p>里程{{ car.mileage }} km</p>
<p>{{ car.description }}</p>
<a-tag v-if="car.isSpecial" color="red">特价</a-tag>
<a-tag v-if="car.status !== 'AVAILABLE'" color="orange">不可租</a-tag>
<div style="margin-top: 12px; display: flex; gap: 12px;">
<a-button type="primary" @click="toggleFavorite">
{{ isFavorite ? '取消收藏' : '加入收藏' }}
</a-button>
</div>
</a-card>
<div style="height: 16px"></div>
<a-card>
<div class="section-title">租车下单</div>
<a-form :model="orderForm" layout="inline">
<a-form-item label="租期">
<a-range-picker v-model="orderForm.range" />
</a-form-item>
<a-form-item>
<a-button type="primary" @click="createOrder">提交订单</a-button>
</a-form-item>
</a-form>
</a-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Message } from '@arco-design/web-vue'
import http from '../api/http'
import { useAuthStore } from '../store/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const car = ref(null)
const isFavorite = ref(false)
const orderForm = ref({ range: [] })
const loadCar = async () => {
car.value = await http.get(`/api/cars/${route.params.id}`)
}
const loadFavorite = async () => {
if (!auth.token) return
const favorites = await http.get('/api/user/favorite')
isFavorite.value = favorites.some((item) => item.carId === Number(route.params.id))
}
const toggleFavorite = async () => {
if (!auth.token) {
router.push('/login')
return
}
if (isFavorite.value) {
await http.delete(`/api/user/favorite/${route.params.id}`)
isFavorite.value = false
Message.success('已取消收藏')
} else {
await http.post(`/api/user/favorite/${route.params.id}`)
isFavorite.value = true
Message.success('已收藏')
}
}
const createOrder = async () => {
if (!auth.token) {
router.push('/login')
return
}
if (!orderForm.value.range || orderForm.value.range.length !== 2) {
Message.warning('请选择租期')
return
}
const [startDate, endDate] = orderForm.value.range
await http.post('/api/user/order', {
carId: car.value.id,
startDate,
endDate
})
Message.success('订单创建成功,请前往我的订单支付')
router.push('/orders')
}
onMounted(() => {
loadCar()
loadFavorite()
})
</script>

View File

@@ -0,0 +1,45 @@
<template>
<div>
<div class="section-title">我的收藏</div>
<div class="card-grid">
<a-card v-for="item in favorites" :key="item.id" :title="item.car.brand + ' ' + item.car.model">
<p>车牌{{ item.car.plateNo }}</p>
<p>日租金¥{{ item.car.pricePerDay }}</p>
<a-button type="primary" @click="goDetail(item.car.id)">查看详情</a-button>
<a-button type="text" status="danger" @click="remove(item.car.id)">移除</a-button>
</a-card>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Message } from '@arco-design/web-vue'
import http from '../api/http'
const router = useRouter()
const favorites = ref([])
const load = async () => {
const list = await http.get('/api/user/favorite')
const cars = await http.get('/api/cars')
const map = new Map(cars.map((car) => [car.id, car]))
favorites.value = list.map((fav) => ({
...fav,
car: map.get(fav.carId) || {}
}))
}
const remove = async (carId) => {
await http.delete(`/api/user/favorite/${carId}`)
Message.success('已移除')
load()
}
const goDetail = (id) => {
router.push(`/cars/${id}`)
}
onMounted(load)
</script>

View File

@@ -0,0 +1,58 @@
<template>
<div>
<div class="section-title">可租车辆</div>
<a-card>
<a-form layout="inline" :model="query">
<a-form-item label="关键词">
<a-input v-model="query.keyword" placeholder="品牌/车型/车牌" allow-clear />
</a-form-item>
<a-form-item label="特价车">
<a-select v-model="query.isSpecial" style="width: 140px">
<a-option :value="null">全部</a-option>
<a-option :value="true"></a-option>
<a-option :value="false"></a-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="loadCars">查询</a-button>
</a-form-item>
</a-form>
</a-card>
<div style="height: 16px"></div>
<div class="card-grid">
<a-card v-for="car in cars" :key="car.id" :title="car.brand + ' ' + car.model">
<p>车牌{{ car.plateNo }}</p>
<p>日租金¥{{ car.pricePerDay }}</p>
<p>押金¥{{ car.deposit }}</p>
<a-tag v-if="car.isSpecial" color="red">特价</a-tag>
<a-tag v-if="car.status !== 'AVAILABLE'" color="orange">不可租</a-tag>
<div style="margin-top: 12px">
<router-link :to="`/cars/${car.id}`">
<a-button type="primary">查看详情</a-button>
</router-link>
</div>
</a-card>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import http from '../api/http'
const cars = ref([])
const query = ref({
keyword: '',
isSpecial: null
})
const loadCars = async () => {
const params = {}
if (query.value.keyword) params.keyword = query.value.keyword
if (query.value.isSpecial !== null) params.isSpecial = query.value.isSpecial
cars.value = await http.get('/api/cars', { params })
}
loadCars()
</script>

View File

@@ -0,0 +1,43 @@
<template>
<a-card title="用户登录" style="max-width: 420px; margin: 0 auto;">
<a-form :model="form">
<a-form-item label="用户名">
<a-input v-model="form.username" />
</a-form-item>
<a-form-item label="密码">
<a-input-password v-model="form.password" />
</a-form-item>
<a-form-item>
<a-button type="primary" @click="login" long>登录</a-button>
</a-form-item>
</a-form>
</a-card>
</template>
<script setup>
import { reactive } from 'vue'
import { useRouter } from 'vue-router'
import { Message } from '@arco-design/web-vue'
import http from '../api/http'
import { useAuthStore } from '../store/auth'
const router = useRouter()
const auth = useAuthStore()
const form = reactive({
username: '',
password: ''
})
const login = async () => {
try {
const res = await http.post('/api/auth/login', form)
auth.setAuth(res.token, res.role)
const user = await http.get('/api/user/me')
auth.setUser(user)
Message.success('登录成功')
router.push('/')
} catch (e) {
Message.error(e.message || '登录失败')
}
}
</script>

View File

@@ -0,0 +1,76 @@
<template>
<div>
<div class="section-title">我的订单</div>
<a-card>
<a-form layout="inline" :model="query">
<a-form-item label="状态">
<a-select v-model="query.status" style="width: 160px">
<a-option value="">全部</a-option>
<a-option value="PENDING_PAY">待支付</a-option>
<a-option value="RENTING">租用中</a-option>
<a-option value="RETURNED">已归还</a-option>
<a-option value="CANCELLED">已取消</a-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="loadOrders">查询</a-button>
</a-form-item>
</a-form>
</a-card>
<div style="height: 16px"></div>
<a-table :columns="columns" :data="orders" row-key="id">
<template #actions="{ record }">
<a-button v-if="record.status === 'PENDING_PAY'" type="primary" @click="pay(record)">余额支付</a-button>
<a-button v-if="record.status === 'PENDING_PAY'" type="text" status="danger" @click="cancel(record)">取消</a-button>
<a-button v-if="record.status === 'RENTING'" type="primary" @click="returnCar(record)">归还</a-button>
</template>
</a-table>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import http from '../api/http'
const orders = ref([])
const query = ref({ status: '' })
const columns = [
{ title: '订单号', dataIndex: 'orderNo' },
{ title: '车辆ID', dataIndex: 'carId' },
{ title: '起始日期', dataIndex: 'startDate' },
{ title: '结束日期', dataIndex: 'endDate' },
{ title: '天数', dataIndex: 'days' },
{ title: '总金额', dataIndex: 'totalAmount' },
{ title: '状态', dataIndex: 'status' },
{ title: '操作', slotName: 'actions' }
]
const loadOrders = async () => {
const params = {}
if (query.value.status) params.status = query.value.status
orders.value = await http.get('/api/user/order', { params })
}
const pay = async (record) => {
await http.post('/api/user/order/pay', { orderId: record.id, payType: 'BALANCE' })
Message.success('支付成功')
loadOrders()
}
const cancel = async (record) => {
await http.post(`/api/user/order/${record.id}/cancel`)
Message.success('已取消')
loadOrders()
}
const returnCar = async (record) => {
await http.post(`/api/user/order/${record.id}/return`)
Message.success('已归还')
loadOrders()
}
loadOrders()
</script>

View File

@@ -0,0 +1,91 @@
<template>
<div>
<a-card>
<div class="section-title">个人信息</div>
<a-descriptions :data="desc" bordered />
</a-card>
<div style="height: 16px"></div>
<a-card>
<div class="section-title">余额充值模拟</div>
<a-form layout="inline">
<a-form-item label="充值金额">
<a-input-number v-model="balance" :min="1" />
</a-form-item>
<a-form-item>
<a-button type="primary" @click="addBalance">充值</a-button>
</a-form-item>
</a-form>
</a-card>
<div style="height: 16px"></div>
<a-card>
<div class="section-title">实名认证</div>
<a-form :model="realName">
<a-form-item label="真实姓名">
<a-input v-model="realName.realName" />
</a-form-item>
<a-form-item label="身份证号">
<a-input v-model="realName.idNumber" />
</a-form-item>
<a-form-item label="身份证正面">
<a-input v-model="realName.idFront" placeholder="图片URL" />
</a-form-item>
<a-form-item label="身份证反面">
<a-input v-model="realName.idBack" placeholder="图片URL" />
</a-form-item>
<a-form-item>
<a-button type="primary" @click="submitRealName">提交审核</a-button>
</a-form-item>
</a-form>
</a-card>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import http from '../api/http'
import { useAuthStore } from '../store/auth'
const auth = useAuthStore()
const user = ref(null)
const balance = ref(100)
const realName = ref({
realName: '',
idNumber: '',
idFront: '',
idBack: ''
})
const desc = computed(() => {
if (!user.value) return []
return [
{ label: '用户名', value: user.value.username },
{ label: '手机号', value: user.value.phone || '-' },
{ label: '邮箱', value: user.value.email || '-' },
{ label: '余额', value: `¥${user.value.balance}` },
{ label: '实名认证状态', value: user.value.realNameStatus }
]
})
const load = async () => {
const res = await http.get('/api/user/me')
user.value = res
auth.setUser(res)
}
const addBalance = async () => {
await http.post('/api/user/balance', { amount: balance.value })
Message.success('充值成功')
load()
}
const submitRealName = async () => {
await http.post('/api/user/real-name', realName.value)
Message.success('已提交审核')
load()
}
onMounted(load)
</script>

View File

@@ -0,0 +1,46 @@
<template>
<a-card title="用户注册" style="max-width: 420px; margin: 0 auto;">
<a-form :model="form">
<a-form-item label="用户名">
<a-input v-model="form.username" />
</a-form-item>
<a-form-item label="密码">
<a-input-password v-model="form.password" />
</a-form-item>
<a-form-item label="手机号">
<a-input v-model="form.phone" />
</a-form-item>
<a-form-item label="邮箱">
<a-input v-model="form.email" />
</a-form-item>
<a-form-item>
<a-button type="primary" @click="register" long>注册</a-button>
</a-form-item>
</a-form>
</a-card>
</template>
<script setup>
import { reactive } from 'vue'
import { useRouter } from 'vue-router'
import { Message } from '@arco-design/web-vue'
import http from '../api/http'
const router = useRouter()
const form = reactive({
username: '',
password: '',
phone: '',
email: ''
})
const register = async () => {
try {
await http.post('/api/auth/register', form)
Message.success('注册成功,请登录')
router.push('/login')
} catch (e) {
Message.error(e.message || '注册失败')
}
}
</script>

View File

@@ -0,0 +1,133 @@
<template>
<div>
<div class="section-title">车辆管理</div>
<a-card>
<a-form layout="inline" :model="query">
<a-form-item label="关键词">
<a-input v-model="query.keyword" allow-clear />
</a-form-item>
<a-form-item label="特价">
<a-select v-model="query.isSpecial" style="width: 140px">
<a-option :value="null">全部</a-option>
<a-option :value="true"></a-option>
<a-option :value="false"></a-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="loadCars">查询</a-button>
</a-form-item>
<a-form-item>
<a-button type="primary" status="success" @click="openEdit()">新增</a-button>
</a-form-item>
</a-form>
</a-card>
<div style="height: 16px"></div>
<a-table :columns="columns" :data="cars" row-key="id">
<template #actions="{ record }">
<a-button type="text" @click="openEdit(record)">编辑</a-button>
<a-button type="text" status="danger" @click="remove(record.id)">删除</a-button>
</template>
</a-table>
<a-modal v-model:visible="visible" title="车辆信息" @ok="save">
<a-form :model="form">
<a-form-item label="品牌">
<a-input v-model="form.brand" />
</a-form-item>
<a-form-item label="车型">
<a-input v-model="form.model" />
</a-form-item>
<a-form-item label="车牌">
<a-input v-model="form.plateNo" />
</a-form-item>
<a-form-item label="日租金">
<a-input-number v-model="form.pricePerDay" :min="0" />
</a-form-item>
<a-form-item label="押金">
<a-input-number v-model="form.deposit" :min="0" />
</a-form-item>
<a-form-item label="座位">
<a-input-number v-model="form.seats" :min="2" />
</a-form-item>
<a-form-item label="变速箱">
<a-select v-model="form.transmission">
<a-option value="AT">AT</a-option>
<a-option value="MT">MT</a-option>
</a-select>
</a-form-item>
<a-form-item label="燃油">
<a-input v-model="form.fuelType" />
</a-form-item>
<a-form-item label="里程">
<a-input-number v-model="form.mileage" :min="0" />
</a-form-item>
<a-form-item label="特价">
<a-switch v-model="form.isSpecial" />
</a-form-item>
<a-form-item label="状态">
<a-select v-model="form.status">
<a-option value="AVAILABLE">可租</a-option>
<a-option value="RENTED">出租中</a-option>
<a-option value="MAINTENANCE">维护中</a-option>
</a-select>
</a-form-item>
<a-form-item label="描述">
<a-textarea v-model="form.description" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import http from '../../api/http'
const cars = ref([])
const query = ref({ keyword: '', isSpecial: null })
const visible = ref(false)
const form = ref({})
const columns = [
{ title: 'ID', dataIndex: 'id' },
{ title: '品牌', dataIndex: 'brand' },
{ title: '车型', dataIndex: 'model' },
{ title: '车牌', dataIndex: 'plateNo' },
{ title: '日租金', dataIndex: 'pricePerDay' },
{ title: '状态', dataIndex: 'status' },
{ title: '特价', dataIndex: 'isSpecial' },
{ title: '操作', slotName: 'actions' }
]
const loadCars = async () => {
const params = {}
if (query.value.keyword) params.keyword = query.value.keyword
if (query.value.isSpecial !== null) params.isSpecial = query.value.isSpecial
cars.value = await http.get('/api/admin/cars', { params })
}
const openEdit = (record) => {
form.value = record ? { ...record } : { status: 'AVAILABLE', isSpecial: false }
visible.value = true
}
const save = async () => {
if (form.value.id) {
await http.put('/api/admin/cars', form.value)
} else {
await http.post('/api/admin/cars', form.value)
}
Message.success('保存成功')
visible.value = false
loadCars()
}
const remove = async (id) => {
await http.delete(`/api/admin/cars/${id}`)
Message.success('已删除')
loadCars()
}
loadCars()
</script>

View File

@@ -0,0 +1,50 @@
<template>
<div>
<div class="section-title">订单管理</div>
<a-card>
<a-form layout="inline" :model="query">
<a-form-item label="状态">
<a-select v-model="query.status" style="width: 160px">
<a-option value="">全部</a-option>
<a-option value="PENDING_PAY">待支付</a-option>
<a-option value="RENTING">租用中</a-option>
<a-option value="RETURNED">已归还</a-option>
<a-option value="CANCELLED">已取消</a-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="loadOrders">查询</a-button>
</a-form-item>
</a-form>
</a-card>
<div style="height: 16px"></div>
<a-table :columns="columns" :data="orders" row-key="id" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import http from '../../api/http'
const orders = ref([])
const query = ref({ status: '' })
const columns = [
{ title: '订单号', dataIndex: 'orderNo' },
{ title: '用户ID', dataIndex: 'userId' },
{ title: '车辆ID', dataIndex: 'carId' },
{ title: '起始日期', dataIndex: 'startDate' },
{ title: '结束日期', dataIndex: 'endDate' },
{ title: '天数', dataIndex: 'days' },
{ title: '总金额', dataIndex: 'totalAmount' },
{ title: '状态', dataIndex: 'status' }
]
const loadOrders = async () => {
const params = {}
if (query.value.status) params.status = query.value.status
orders.value = await http.get('/api/admin/orders', { params })
}
loadOrders()
</script>

View File

@@ -0,0 +1,40 @@
<template>
<div>
<div class="section-title">数据统计</div>
<a-card>
<a-descriptions :data="orderStatsDesc" bordered />
</a-card>
<div style="height: 16px"></div>
<a-card>
<a-descriptions :data="financeDesc" bordered />
</a-card>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import http from '../../api/http'
const orderStats = ref({})
const financeStats = ref({})
const orderStatsDesc = computed(() => [
{ label: '订单总数', value: orderStats.value.total || 0 },
{ label: '租用中', value: orderStats.value.renting || 0 },
{ label: '已归还', value: orderStats.value.returned || 0 },
{ label: '待支付', value: orderStats.value.pendingPay || 0 }
])
const financeDesc = computed(() => [
{ label: '累计收入', value: `¥${financeStats.value.income || 0}` },
{ label: '累计押金', value: `¥${financeStats.value.deposit || 0}` },
{ label: '订单数', value: financeStats.value.orderCount || 0 }
])
const load = async () => {
orderStats.value = await http.get('/api/admin/stats/orders')
financeStats.value = await http.get('/api/admin/stats/finance')
}
onMounted(load)
</script>

View File

@@ -0,0 +1,76 @@
<template>
<div>
<div class="section-title">用户管理</div>
<a-card>
<a-form layout="inline" :model="query">
<a-form-item label="关键词">
<a-input v-model="query.keyword" allow-clear />
</a-form-item>
<a-form-item>
<a-button type="primary" @click="loadUsers">查询</a-button>
</a-form-item>
</a-form>
</a-card>
<div style="height: 16px"></div>
<a-table :columns="columns" :data="users" row-key="id">
<template #actions="{ record }">
<a-button v-if="record.realNameStatus === 'PENDING'" type="primary" @click="approve(record.id)">通过实名</a-button>
<a-button v-if="record.realNameStatus === 'PENDING'" type="text" status="danger" @click="reject(record.id)">驳回实名</a-button>
<a-button v-if="record.status === 'ACTIVE'" type="text" status="danger" @click="disable(record.id)">禁用</a-button>
<a-button v-else type="text" @click="enable(record.id)">启用</a-button>
</template>
</a-table>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import http from '../../api/http'
const users = ref([])
const query = ref({ keyword: '' })
const columns = [
{ title: 'ID', dataIndex: 'id' },
{ title: '用户名', dataIndex: 'username' },
{ title: '手机号', dataIndex: 'phone' },
{ title: '邮箱', dataIndex: 'email' },
{ title: '角色', dataIndex: 'role' },
{ title: '状态', dataIndex: 'status' },
{ title: '实名状态', dataIndex: 'realNameStatus' },
{ title: '操作', slotName: 'actions' }
]
const loadUsers = async () => {
const params = {}
if (query.value.keyword) params.keyword = query.value.keyword
users.value = await http.get('/api/admin/users', { params })
}
const approve = async (id) => {
await http.post(`/api/admin/real-name/${id}/approve`)
Message.success('已通过')
loadUsers()
}
const reject = async (id) => {
await http.post(`/api/admin/real-name/${id}/reject`)
Message.success('已驳回')
loadUsers()
}
const disable = async (id) => {
await http.post(`/api/admin/users/${id}/status`, null, { params: { status: 'DISABLED' } })
Message.success('已禁用')
loadUsers()
}
const enable = async (id) => {
await http.post(`/api/admin/users/${id}/status`, null, { params: { status: 'ACTIVE' } })
Message.success('已启用')
loadUsers()
}
loadUsers()
</script>

7
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
})

View File

@@ -0,0 +1,141 @@
# 毕业设计(论文)任务书
论文题目:车智车辆租赁平台的设计与实现
# 1. 任务和目标
本课题的总体任务是设计并实现一个基于B/S架构的“车智车辆租赁平台”。该系统旨在利用现代Web技术(前端采用Vue.js框架,后端使用Java语言结合SpringBoot框架),构建一个连接车辆租赁公司与租户用户的数字化桥梁。通过提供车辆信息展示、在线选车、租期预定、订单管理等核心功能,解决传统租车流程中信息不透明、手续繁琐、地域限制等问题,为用户提供便捷、高效、透明的现代化租车服务体验。
车智车辆租赁平台通过数字化手段连接车辆租赁服务商与消费者,打破了传统租车行业的信息壁垒和地域限制,为租车服务提供了更广阔的市场空间。这有助于提升车辆使用率,增加租赁公司收入,从而激发汽车租赁市场的活力,促进交通出行服务的多元化发展。
汽车租赁作为共享经济的重要环节,车智车辆租赁平台很好地体现了这一经济模式的核心理念。依托平台的高效运营,不仅能够增强社会车辆资源的配置效率,还有效减少了资源闲置现象。同时,这一实践对汽车产业链的上下游产生了积极的带动作用,进
一步推动了绿色出行理念的落地及可持续交通体系的构建,为实现更加环保与高效的出
行方式提供了可能性。
车智车辆租赁平台的设计与实现主要任务如下:
(1) 根据系统的需求和毕设要求,完成字数不少于 2500 字的开题报告。
(2) 根据项目的时间和实习的时间合理安排,拟定毕业设计进度计划表。
(3) 确定题目的思路以及具体实现所需的技术。
(4本设计要求开发一个车辆租赁平台。
顾客用户的功能模块划分为:登录注册模块,可租车辆模块,车辆详情模块,收藏夹模块,租车下单模块,归还车辆模块,实名认证模块,余额支付模块,个人中心模块,我的订单模块。
管理员用户的功能模块划分为:管理员登录模块,特价车辆模块,车辆管理模块,订单管理模块,实名审核模块,用户管理模块,订单管理模块,数据统计模块,财务报表模块。
(5) 开发车智车辆租赁平台,功能保证完善。
(6) 撰写车智车辆租赁平台的论文。
# 2.基本要求
(1) 结合自己实习情况安排进度,填写进度计划表交给指导教师审核并严格执行;
(2) 查阅和收集资料,与指导老师进行评审论文课题项目的需求和功能设计;
(3) 完成外文资料翻译,要求语言准确、流畅,译文至少 3500 字;
(4) 论文要求 10000 字以上, 包括绪论、系统总体设计、系统实现和结论等内容;
(5) 保证按照毕业指导进度稳定推进,服从指导教师安排的相关任务;
(6) 制作答辩 PPT、准备答辩;
(7) 根据答辩教师提出的要求修改论文;
(8) 完成论文和相关资料的存档和备份;
# 3.参考文献
[1]粟梁.基于SSM框架的汽车租赁管理系统设计与实现[J].电脑编程技巧与维护,2024,32(1):43-45,52.
[2] 王睿. 互联网模式下汽车租赁行业财务管理策略研究[J]. 活力, 2024, (21):133-135.
[3]粟梁.基于SSM框架的汽车租赁管理系统设计与实现[J].电脑编程技巧与维护2024,32(1):43-45,52.
[4] 肖安琪.汽车租赁系统的设计与实现[J].山西大同大学学报自然科学版2024,38(2):54-58.
[5] 韩鑫.租车自驾游消费新需求[N].人民日报, 2025-10-10(007).
[6]黑马程序员.Spring Boot企业级开发教程[M].人民邮电出版社,2024.
[7] 张建臣,陈承欢. JavaScript 程序设计基础与实战[M].人民邮电出版社, 2024.
[8] 杨玉, 刘杰举. 基 Spring Boot 与 Vue 的物业管理系统设计与实现[J]. 鞋类工艺与设计,
2025,(14):114-116.
[9] Wenjuan Shao, Kun Liu. Design and Implementation of Online Ordering System Based on SpringBoot[J]. Journal of Big Data and Computing, 2024, 2(3).
[10] 徐凯鑫.汽车租赁赋能个性化用车需求[N].汽车特刊,2023-07-28(007).
指导教师签字: 年月日
教研室审查意见:
指导教师签字: 年月日