This commit is contained in:
王子琦
2026-01-16 13:26:57 +08:00
parent f006ed4c89
commit 0446cc184b
17 changed files with 276 additions and 24 deletions

View File

@@ -1,11 +1,18 @@
package com.car.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final String uploadDir;
public WebConfig(org.springframework.core.env.Environment environment) {
this.uploadDir = environment.getProperty("file.upload-dir", "");
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
@@ -14,4 +21,12 @@ public class WebConfig implements WebMvcConfigurer {
.allowedHeaders("*")
.allowCredentials(true);
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
if (!uploadDir.isBlank()) {
String location = "file:" + (uploadDir.endsWith("/") ? uploadDir : uploadDir + "/");
registry.addResourceHandler("/uploads/**").addResourceLocations(location);
}
}
}

View File

@@ -0,0 +1,38 @@
package com.car.controller;
import com.car.common.ApiResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.util.UUID;
@RestController
@RequestMapping("/api")
public class UploadController {
@Value("${file.upload-dir}")
private String uploadDir;
@PostMapping("/upload")
public ApiResponse<String> upload(@RequestParam("file") MultipartFile file) throws IOException {
if (file.isEmpty()) {
return new ApiResponse<>(400, "文件不能为空", null);
}
File dir = new File(uploadDir);
if (!dir.exists() && !dir.mkdirs()) {
return new ApiResponse<>(500, "上传目录创建失败", null);
}
String ext = StringUtils.getFilenameExtension(file.getOriginalFilename());
String filename = UUID.randomUUID().toString().replace("-", "");
if (ext != null && !ext.isBlank()) {
filename += "." + ext;
}
File dest = new File(dir, filename);
file.transferTo(dest);
return ApiResponse.ok("/uploads/" + filename);
}
}

View File

@@ -74,6 +74,12 @@ public class UserService {
if (db == null) {
throw new ApiException(ErrorCode.NOT_FOUND, "用户不存在");
}
if ("PENDING".equals(db.getRealNameStatus())) {
throw new ApiException(ErrorCode.CONFLICT, "实名认证正在审核中");
}
if ("APPROVED".equals(db.getRealNameStatus())) {
throw new ApiException(ErrorCode.CONFLICT, "实名认证已通过");
}
db.setRealName(request.getRealName());
db.setIdNumber(request.getIdNumber());
db.setIdFront(request.getIdFront());

View File

@@ -3,9 +3,9 @@ server:
spring:
datasource:
url: jdbc:mysql://localhost:3306/car_rental?useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai
url: jdbc:mysql://localhost:3307/car_rental?useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: root
password: qq5211314
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
@@ -24,3 +24,6 @@ sa-token:
is-share: true
token-prefix: Bearer
is-log: false
file:
upload-dir: ${user.dir}/uploads

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,4 +1,5 @@
import axios from 'axios'
import { Message } from '@arco-design/web-vue'
import { useAuthStore } from '../store/auth'
const http = axios.create({
@@ -18,11 +19,16 @@ http.interceptors.response.use(
(response) => {
const res = response.data
if (res.code !== 0) {
return Promise.reject(new Error(res.message || '请求失败'))
Message.error(res.message || 'Request failed')
return Promise.reject(new Error(res.message || 'Request failed'))
}
return res.data
},
(error) => Promise.reject(error)
(error) => {
const message = error?.response?.data?.message || error.message || 'Request failed'
Message.error(message)
return Promise.reject(error)
}
)
export default http

View File

@@ -20,12 +20,14 @@ a {
gap: 24px;
background: #ffffff;
border-bottom: 1px solid #e5e6eb;
flex-wrap: nowrap;
}
.logo {
font-weight: 600;
font-size: 18px;
padding: 0 16px;
white-space: nowrap;
}
.user-actions {
@@ -34,6 +36,17 @@ a {
display: flex;
align-items: center;
gap: 12px;
white-space: nowrap;
}
.header .arco-menu {
flex: 1;
min-width: 0;
}
.header .arco-menu-horizontal {
display: flex;
flex-wrap: nowrap;
}
.divider {

View File

@@ -0,0 +1,31 @@
export const roleMap = {
ADMIN: '管理员',
USER: '用户'
}
export const userStatusMap = {
ACTIVE: '启用',
DISABLED: '禁用'
}
export const realNameStatusMap = {
NONE: '未提交',
PENDING: '审核中',
APPROVED: '已通过',
REJECTED: '已驳回'
}
export const orderStatusMap = {
PENDING_PAY: '待支付',
RENTING: '租用中',
RETURNED: '已归还',
CANCELLED: '已取消'
}
export const carStatusMap = {
AVAILABLE: '可租',
RENTED: '出租中',
MAINTENANCE: '维护中'
}
export const mapText = (map, value) => map[value] || value || '-'

View File

@@ -8,7 +8,7 @@
<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>
<a-tag v-if="car.status !== 'AVAILABLE'" color="orange">{{ mapText(carStatusMap, car.status) }}</a-tag>
<div style="margin-top: 12px; display: flex; gap: 12px;">
<a-button type="primary" @click="toggleFavorite">
{{ isFavorite ? '取消收藏' : '加入收藏' }}
@@ -37,6 +37,7 @@ import { useRoute, useRouter } from 'vue-router'
import { Message } from '@arco-design/web-vue'
import http from '../api/http'
import { useAuthStore } from '../store/auth'
import { carStatusMap, mapText } from '../utils/format'
const route = useRoute()
const router = useRouter()

View File

@@ -26,7 +26,7 @@
<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>
<a-tag v-if="car.status !== 'AVAILABLE'" color="orange">{{ mapText(carStatusMap, car.status) }}</a-tag>
<div style="margin-top: 12px">
<router-link :to="`/cars/${car.id}`">
<a-button type="primary">查看详情</a-button>
@@ -40,6 +40,7 @@
<script setup>
import { ref } from 'vue'
import http from '../api/http'
import { carStatusMap, mapText } from '../utils/format'
const cars = ref([])
const query = ref({

View File

@@ -33,25 +33,38 @@
import { ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import http from '../api/http'
import { orderStatusMap, mapText } from '../utils/format'
const orders = ref([])
const query = ref({ status: '' })
const columns = [
{ title: '订单号', dataIndex: 'orderNo' },
{ title: '车辆ID', dataIndex: 'carId' },
{ title: '车辆信息', dataIndex: 'carInfo' },
{ title: '起始日期', dataIndex: 'startDate' },
{ title: '结束日期', dataIndex: 'endDate' },
{ title: '天数', dataIndex: 'days' },
{ title: '总金额', dataIndex: 'totalAmount' },
{ title: '状态', dataIndex: 'status' },
{ title: '状态', dataIndex: 'statusText' },
{ 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 [orderList, cars] = await Promise.all([
http.get('/api/user/order', { params }),
http.get('/api/cars')
])
const carMap = new Map(cars.map((car) => [car.id, car]))
orders.value = orderList.map((order) => {
const car = carMap.get(order.carId) || {}
return {
...order,
carInfo: car.id ? `${car.brand || ''} ${car.model || ''} (${car.plateNo || ''})` : '未知车辆',
statusText: mapText(orderStatusMap, order.status)
}
})
}
const pay = async (record) => {

View File

@@ -21,6 +21,8 @@
<div style="height: 16px"></div>
<a-card>
<div class="section-title">实名认证</div>
<a-descriptions v-if="user" :data="idDesc" bordered />
<div style="height: 12px"></div>
<a-form :model="realName">
<a-form-item label="真实姓名">
<a-input v-model="realName.realName" />
@@ -29,13 +31,41 @@
<a-input v-model="realName.idNumber" />
</a-form-item>
<a-form-item label="身份证正面">
<a-input v-model="realName.idFront" placeholder="图片URL" />
<a-upload
:action="uploadUrl"
:headers="uploadHeaders"
:show-file-list="false"
@success="onFrontSuccess"
>
<a-button>上传正面</a-button>
</a-upload>
<a-tag v-if="realName.idFront" color="arcoblue">已上传</a-tag>
<a-image
v-if="realName.idFront"
:src="fileBase + realName.idFront"
width="120"
style="margin-left: 12px"
/>
</a-form-item>
<a-form-item label="身份证反面">
<a-input v-model="realName.idBack" placeholder="图片URL" />
<a-upload
:action="uploadUrl"
:headers="uploadHeaders"
:show-file-list="false"
@success="onBackSuccess"
>
<a-button>上传反面</a-button>
</a-upload>
<a-tag v-if="realName.idBack" color="arcoblue">已上传</a-tag>
<a-image
v-if="realName.idBack"
:src="fileBase + realName.idBack"
width="120"
style="margin-left: 12px"
/>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="submitRealName">提交审核</a-button>
<a-button type="primary" :disabled="submitDisabled" @click="submitRealName">提交审核</a-button>
</a-form-item>
</a-form>
</a-card>
@@ -47,6 +77,7 @@ import { ref, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import http from '../api/http'
import { useAuthStore } from '../store/auth'
import { realNameStatusMap, mapText } from '../utils/format'
const auth = useAuthStore()
const user = ref(null)
@@ -58,6 +89,13 @@ const realName = ref({
idBack: ''
})
const fileBase = 'http://localhost:8080'
const uploadUrl = `${fileBase}/api/upload`
const uploadHeaders = computed(() => {
if (!auth.token) return {}
return { Authorization: `Bearer ${auth.token}` }
})
const desc = computed(() => {
if (!user.value) return []
return [
@@ -65,14 +103,35 @@ const desc = computed(() => {
{ label: '手机号', value: user.value.phone || '-' },
{ label: '邮箱', value: user.value.email || '-' },
{ label: '余额', value: `¥${user.value.balance}` },
{ label: '实名认证状态', value: user.value.realNameStatus }
{ label: '实名认证状态', value: mapText(realNameStatusMap, user.value.realNameStatus) }
]
})
const idDesc = computed(() => {
if (!user.value) return []
return [
{ label: '真实姓名', value: user.value.realName || '-' },
{ label: '身份证号', value: user.value.idNumber || '-' },
{ label: '身份证正面', value: user.value.idFront ? '已上传' : '-' },
{ label: '身份证反面', value: user.value.idBack ? '已上传' : '-' }
]
})
const submitDisabled = computed(() => {
if (!user.value) return false
return ['PENDING', 'APPROVED'].includes(user.value.realNameStatus)
})
const load = async () => {
const res = await http.get('/api/user/me')
user.value = res
auth.setUser(res)
if (user.value.realNameStatus !== 'REJECTED' && user.value.realNameStatus !== 'NONE') {
realName.value.realName = user.value.realName || ''
realName.value.idNumber = user.value.idNumber || ''
realName.value.idFront = user.value.idFront || ''
realName.value.idBack = user.value.idBack || ''
}
}
const addBalance = async () => {
@@ -87,5 +146,25 @@ const submitRealName = async () => {
load()
}
const onFrontSuccess = (file) => {
const res = file?.response
if (!res || res.code !== 0) {
Message.error(res?.message || '上传失败')
return
}
realName.value.idFront = res.data
Message.success('正面上传成功')
}
const onBackSuccess = (file) => {
const res = file?.response
if (!res || res.code !== 0) {
Message.error(res?.message || '上传失败')
return
}
realName.value.idBack = res.data
Message.success('反面上传成功')
}
onMounted(load)
</script>

View File

@@ -83,6 +83,7 @@
import { ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import http from '../../api/http'
import { carStatusMap, mapText } from '../../utils/format'
const cars = ref([])
const query = ref({ keyword: '', isSpecial: null })
@@ -95,8 +96,8 @@ const columns = [
{ title: '车型', dataIndex: 'model' },
{ title: '车牌', dataIndex: 'plateNo' },
{ title: '日租金', dataIndex: 'pricePerDay' },
{ title: '状态', dataIndex: 'status' },
{ title: '特价', dataIndex: 'isSpecial' },
{ title: '状态', dataIndex: 'statusText' },
{ title: '特价', dataIndex: 'isSpecialText' },
{ title: '操作', slotName: 'actions' }
]
@@ -104,7 +105,12 @@ 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 list = await http.get('/api/admin/cars', { params })
cars.value = list.map((car) => ({
...car,
statusText: mapText(carStatusMap, car.status),
isSpecialText: car.isSpecial ? '是' : '否'
}))
}
const openEdit = (record) => {

View File

@@ -25,25 +25,42 @@
<script setup>
import { ref } from 'vue'
import http from '../../api/http'
import { orderStatusMap, mapText } from '../../utils/format'
const orders = ref([])
const query = ref({ status: '' })
const columns = [
{ title: '订单号', dataIndex: 'orderNo' },
{ title: '用户ID', dataIndex: 'userId' },
{ title: '车辆ID', dataIndex: 'carId' },
{ title: '用户信息', dataIndex: 'userInfo' },
{ title: '车辆信息', dataIndex: 'carInfo' },
{ title: '起始日期', dataIndex: 'startDate' },
{ title: '结束日期', dataIndex: 'endDate' },
{ title: '天数', dataIndex: 'days' },
{ title: '总金额', dataIndex: 'totalAmount' },
{ title: '状态', dataIndex: 'status' }
{ title: '状态', dataIndex: 'statusText' }
]
const loadOrders = async () => {
const params = {}
if (query.value.status) params.status = query.value.status
orders.value = await http.get('/api/admin/orders', { params })
const [orderList, users, cars] = await Promise.all([
http.get('/api/admin/orders', { params }),
http.get('/api/admin/users'),
http.get('/api/admin/cars')
])
const userMap = new Map(users.map((user) => [user.id, user]))
const carMap = new Map(cars.map((car) => [car.id, car]))
orders.value = orderList.map((order) => {
const user = userMap.get(order.userId) || {}
const car = carMap.get(order.carId) || {}
return {
...order,
userInfo: user.id ? `${user.username || ''} ${user.phone || ''}`.trim() : '未知用户',
carInfo: car.id ? `${car.brand || ''} ${car.model || ''} (${car.plateNo || ''})` : '未知车辆',
statusText: mapText(orderStatusMap, order.status)
}
})
}
loadOrders()

View File

@@ -13,6 +13,14 @@
</a-card>
<div style="height: 16px"></div>
<a-table :columns="columns" :data="users" row-key="id">
<template #idFront="{ record }">
<a-image v-if="record.idFront" :src="fileBase + record.idFront" width="80" />
<span v-else>-</span>
</template>
<template #idBack="{ record }">
<a-image v-if="record.idBack" :src="fileBase + record.idBack" width="80" />
<span v-else>-</span>
</template>
<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>
@@ -27,6 +35,7 @@
import { ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import http from '../../api/http'
import { roleMap, userStatusMap, realNameStatusMap, mapText } from '../../utils/format'
const users = ref([])
const query = ref({ keyword: '' })
@@ -36,16 +45,26 @@ const columns = [
{ title: '用户名', dataIndex: 'username' },
{ title: '手机号', dataIndex: 'phone' },
{ title: '邮箱', dataIndex: 'email' },
{ title: '角色', dataIndex: 'role' },
{ title: '状态', dataIndex: 'status' },
{ title: '实名状态', dataIndex: 'realNameStatus' },
{ title: '角色', dataIndex: 'roleText' },
{ title: '状态', dataIndex: 'statusText' },
{ title: '实名状态', dataIndex: 'realNameStatusText' },
{ title: '身份证正面', dataIndex: 'idFront', slotName: 'idFront' },
{ title: '身份证反面', dataIndex: 'idBack', slotName: 'idBack' },
{ title: '操作', slotName: 'actions' }
]
const fileBase = 'http://localhost:8080'
const loadUsers = async () => {
const params = {}
if (query.value.keyword) params.keyword = query.value.keyword
users.value = await http.get('/api/admin/users', { params })
const list = await http.get('/api/admin/users', { params })
users.value = list.map((user) => ({
...user,
roleText: mapText(roleMap, user.role),
statusText: mapText(userStatusMap, user.status),
realNameStatusText: mapText(realNameStatusMap, user.realNameStatus)
}))
}
const approve = async (id) => {

View File

@@ -4,4 +4,8 @@ import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
// 添加 allowedHosts 配置
allowedHosts: ['cloud.neuz.cn']
}
})