This commit is contained in:
王子琦
2026-01-14 14:27:39 +08:00
parent f567e733d3
commit 2343001168
29 changed files with 262 additions and 30 deletions

View File

@@ -39,7 +39,7 @@ public class SecurityConfig {
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/auth/**", "/api/public/**", "/error").permitAll()
.requestMatchers("/api/auth/**", "/api/public/**", "/files/**", "/error").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

View File

@@ -1,4 +1,4 @@
package com.toyshop.config;
package com.toyshop.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;

View File

@@ -81,7 +81,14 @@ public class AdminController {
@GetMapping("/products")
public ApiResponse<List<Product>> products() {
return ApiResponse.ok(productRepository.findAll());
List<Product> products = productRepository.findAll();
for (Product product : products) {
var img = productService.getFirstImage(product);
if (img != null) {
product.setImageUrl(img.getUrl());
}
}
return ApiResponse.ok(products);
}
@PostMapping("/products")
@@ -148,6 +155,16 @@ public class AdminController {
return ApiResponse.ok("删除成功", null);
}
@PutMapping("/products/{id}/image")
public ApiResponse<?> replaceProductImage(@PathVariable Long id, @RequestBody ProductImage body) {
Product product = productRepository.findById(id).orElseThrow();
if (body.getUrl() == null || body.getUrl().isBlank()) {
return ApiResponse.fail("图片地址不能为空");
}
productService.replaceProductImage(product, body.getUrl());
return ApiResponse.ok("更新成功", null);
}
@GetMapping("/orders")
public ApiResponse<List<Order>> orders() {
return ApiResponse.ok(orderRepository.findAll());

View File

@@ -1,4 +1,4 @@
package com.toyshop.controller.admin;
package com.toyshop.controller.admin;
import com.toyshop.dto.ApiResponse;
import org.springframework.beans.factory.annotation.Value;

View File

@@ -1,5 +1,6 @@
package com.toyshop.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@@ -12,6 +13,7 @@ public class Address {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
@JsonIgnore
private User user;
@Column(nullable = false, length = 50)

View File

@@ -1,5 +1,7 @@
package com.toyshop.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.persistence.*;
@Entity
@@ -11,10 +13,12 @@ public class CartItem {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
@JsonIgnore
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "product_id", nullable = false)
@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})
private Product product;
@Column(nullable = false)

View File

@@ -1,5 +1,6 @@
package com.toyshop.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@@ -16,6 +17,7 @@ public class Order {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
@JsonIgnore
private User user;
@Enumerated(EnumType.STRING)

View File

@@ -1,5 +1,7 @@
package com.toyshop.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.persistence.*;
import java.math.BigDecimal;
@@ -12,10 +14,12 @@ public class OrderItem {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id", nullable = false)
@JsonIgnore
private Order order;
@ManyToOne(fetch = FetchType.LAZY)
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "product_id", nullable = false)
@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})
private Product product;
@Column(nullable = false)

View File

@@ -47,6 +47,9 @@ public class Product {
@Column(nullable = false)
private LocalDateTime updatedAt;
@Transient
private String imageUrl;
@PrePersist
public void prePersist() {
LocalDateTime now = LocalDateTime.now();
@@ -81,4 +84,6 @@ public class Product {
public void setOnSale(boolean onSale) { this.onSale = onSale; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public String getImageUrl() { return imageUrl; }
public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
}

View File

@@ -1,5 +1,6 @@
package com.toyshop.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
@Entity
@@ -11,6 +12,7 @@ public class ProductImage {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id", nullable = false)
@JsonIgnore
private Product product;
@Column(nullable = false, length = 255)

View File

@@ -4,7 +4,10 @@ import com.toyshop.entity.ProductImage;
import com.toyshop.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface ProductImageRepository extends JpaRepository<ProductImage, Long> {
List<ProductImage> findByProduct(Product product);
Optional<ProductImage> findTopByProductOrderBySortOrderAsc(Product product);
void deleteByProduct(Product product);
}

View File

@@ -3,6 +3,7 @@ package com.toyshop.service;
import com.toyshop.entity.*;
import com.toyshop.repository.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
@@ -23,6 +24,7 @@ public class OrderService {
this.addressRepository = addressRepository;
}
@Transactional
public Order createOrder(User user, Long addressId) {
Address address = addressRepository.findById(addressId).orElseThrow();
if (!address.getUser().getId().equals(user.getId())) {

View File

@@ -5,6 +5,7 @@ import com.toyshop.repository.*;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import jakarta.persistence.criteria.Predicate;
import java.util.ArrayList;
@@ -59,4 +60,18 @@ public class ProductService {
public List<Category> allCategories() {
return categoryRepository.findAll();
}
public ProductImage getFirstImage(Product product) {
return productImageRepository.findTopByProductOrderBySortOrderAsc(product).orElse(null);
}
@Transactional
public void replaceProductImage(Product product, String url) {
productImageRepository.deleteByProduct(product);
ProductImage image = new ProductImage();
image.setProduct(product);
image.setUrl(url);
image.setSortOrder(0);
productImageRepository.save(image);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

View File

@@ -1,4 +1,5 @@
import axios from 'axios'
import router from '../router'
const api = axios.create({
baseURL: 'http://localhost:8080',
@@ -16,6 +17,14 @@ api.interceptors.request.use((config) => {
api.interceptors.response.use(
(res) => res.data,
(err) => {
const status = err?.response?.status
if (status === 401 || status === 403) {
const role = localStorage.getItem('role')
const target = role === 'ADMIN' ? '/admin/login' : '/login'
if (router.currentRoute.value.path !== target) {
router.push(target)
}
}
const message = err?.response?.data?.message || '请求失败'
return Promise.reject(new Error(message))
}

View File

@@ -13,11 +13,15 @@
</a-form>
<a-table :dataSource="list" :columns="columns" rowKey="id" style="margin-top: 16px">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'image'">
<img :src="previewUrl(record.imageUrl)" class="thumb" />
</template>
<template v-if="column.key === 'action'">
<a-button type="link" danger @click="remove(record)">删除</a-button>
</template>
</template>
</a-table>
<img v-if="form.imageUrl" :src="previewUrl(form.imageUrl)" class="preview" />
</a-card>
</template>
@@ -27,10 +31,11 @@ import api from '../../api'
const list = ref([])
const form = reactive({ imageUrl: '', linkUrl: '', sortOrder: 0 })
const uploadUrl = 'http://localhost:8080/api/admin/upload'
const baseUrl = 'http://localhost:8080'
const uploadUrl = `${baseUrl}/api/admin/upload`
const headers = { Authorization: `Bearer ${localStorage.getItem('token') || ''}` }
const columns = [
{ title: '图片', dataIndex: 'imageUrl' },
{ title: '图片', key: 'image' },
{ title: '链接', dataIndex: 'linkUrl' },
{ title: '排序', dataIndex: 'sortOrder' },
{ title: '操作', key: 'action' }
@@ -53,11 +58,19 @@ const onUpload = (info) => {
if (info.file.status === 'done') {
const res = info.file.response
if (res && res.success) {
form.imageUrl = res.data
form.imageUrl = normalizeUrl(res.data)
}
}
}
const normalizeUrl = (url) => {
if (!url) return ''
if (url.startsWith('http')) return url
return `${baseUrl}${url}`
}
const previewUrl = (url) => normalizeUrl(url)
const remove = async (record) => {
await api.delete(`/api/admin/carousels/${record.id}`)
load()
@@ -65,3 +78,18 @@ const remove = async (record) => {
onMounted(load)
</script>
<style scoped>
.thumb {
width: 120px;
height: 60px;
object-fit: cover;
}
.preview {
display: block;
width: 240px;
height: 120px;
object-fit: cover;
margin-top: 12px;
}
</style>

View File

@@ -13,18 +13,41 @@
</a-form>
<a-table :dataSource="list" :columns="columns" rowKey="id" style="margin-top: 16px">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'image'">
<img v-if="record.imageUrl" :src="previewUrl(record.imageUrl)" class="thumb" />
<span v-else>-</span>
</template>
<template v-if="column.key === 'action'">
<a-button type="link" @click="addImage(record)">图片</a-button>
<a-button type="link" @click="edit(record)">编辑</a-button>
<a-button type="link" @click="addImage(record)">更换图片</a-button>
<a-button type="link" danger @click="remove(record)">删除</a-button>
</template>
</template>
</a-table>
<a-modal v-model:open="imgVisible" title="新增图片" @ok="saveImage">
<a-modal v-model:open="imgVisible" title="商品图片" @ok="saveImage">
<a-upload :action="uploadUrl" :headers="headers" @change="onUpload" :showUploadList="false">
<a-button>上传图片</a-button>
</a-upload>
<a-input v-model:value="imgForm.url" placeholder="图片URL" style="margin-top: 8px" />
<a-input-number v-model:value="imgForm.sortOrder" style="margin-top: 8px" />
<img v-if="imgForm.url" :src="previewUrl(imgForm.url)" class="preview" />
</a-modal>
<a-modal v-model:open="editVisible" title="编辑商品" @ok="saveEdit">
<a-form layout="vertical">
<a-form-item label="名称"><a-input v-model:value="editForm.name" /></a-form-item>
<a-form-item label="价格"><a-input-number v-model:value="editForm.price" style="width: 100%" /></a-form-item>
<a-form-item label="库存"><a-input-number v-model:value="editForm.stock" style="width: 100%" /></a-form-item>
<a-form-item label="分类">
<a-select v-model:value="editForm.categoryId" style="width: 100%" allowClear>
<a-select-option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="适龄"><a-input v-model:value="editForm.ageRange" /></a-form-item>
<a-form-item label="安全认证"><a-input v-model:value="editForm.safetyInfo" /></a-form-item>
<a-form-item label="描述"><a-textarea v-model:value="editForm.description" /></a-form-item>
<a-form-item label="上架">
<a-switch v-model:checked="editForm.onSale" />
</a-form-item>
</a-form>
</a-modal>
</a-card>
</template>
@@ -40,13 +63,27 @@ const columns = [
{ title: '名称', dataIndex: 'name' },
{ title: '价格', dataIndex: 'price' },
{ title: '库存', dataIndex: 'stock' },
{ title: '图片', key: 'image' },
{ title: '操作', key: 'action' }
]
const imgVisible = ref(false)
const imgForm = reactive({ productId: null, url: '', sortOrder: 0 })
const uploadUrl = 'http://localhost:8080/api/admin/upload'
const imgForm = reactive({ productId: null, url: '' })
const baseUrl = 'http://localhost:8080'
const uploadUrl = `${baseUrl}/api/admin/upload`
const headers = { Authorization: `Bearer ${localStorage.getItem('token') || ''}` }
const editVisible = ref(false)
const editForm = reactive({
id: null,
name: '',
price: 0,
stock: 0,
categoryId: null,
ageRange: '',
safetyInfo: '',
description: '',
onSale: true
})
const load = async () => {
const res = await api.get('/api/admin/products')
@@ -71,24 +108,67 @@ const remove = async (record) => {
const addImage = (record) => {
imgForm.productId = record.id
imgForm.url = ''
imgForm.sortOrder = 0
imgForm.url = record.imageUrl || ''
imgVisible.value = true
}
const saveImage = async () => {
await api.post(`/api/admin/products/${imgForm.productId}/images`, imgForm)
await api.put(`/api/admin/products/${imgForm.productId}/image`, { url: imgForm.url })
imgVisible.value = false
load()
}
const onUpload = (info) => {
if (info.file.status === 'done') {
const res = info.file.response
if (res && res.success) {
imgForm.url = res.data
imgForm.url = normalizeUrl(res.data)
}
}
}
const normalizeUrl = (url) => {
if (!url) return ''
if (url.startsWith('http')) return url
return `${baseUrl}${url}`
}
const previewUrl = (url) => normalizeUrl(url)
const edit = (record) => {
editForm.id = record.id
editForm.name = record.name
editForm.price = record.price
editForm.stock = record.stock
editForm.categoryId = record.category?.id || null
editForm.ageRange = record.ageRange || ''
editForm.safetyInfo = record.safetyInfo || ''
editForm.description = record.description || ''
editForm.onSale = record.onSale
editVisible.value = true
}
const saveEdit = async () => {
await api.put(`/api/admin/products/${editForm.id}`, editForm)
editVisible.value = false
load()
}
onMounted(load)
</script>
<style scoped>
.preview {
display: block;
width: 200px;
height: 120px;
object-fit: cover;
margin-top: 8px;
}
.thumb {
width: 60px;
height: 40px;
object-fit: cover;
}
</style>

View File

@@ -1,14 +1,16 @@
<template>
<a-card title="订单确认">
<a-list :data-source="addresses">
<template #renderItem="{ item }">
<a-list-item>
<a-radio v-model:checked="selected" :value="item.id">
{{ item.receiverName }} {{ item.receiverPhone }} {{ item.detail }}
</a-radio>
</a-list-item>
</template>
</a-list>
<a-radio-group v-model:value="selected">
<a-list :data-source="addresses">
<template #renderItem="{ item }">
<a-list-item>
<a-radio :value="item.id">
{{ item.receiverName }} {{ item.receiverPhone }} {{ item.detail }}
</a-radio>
</a-list-item>
</template>
</a-list>
</a-radio-group>
<a-button type="primary" @click="createOrder" :disabled="!selected">提交订单</a-button>
</a-card>
</template>

View File

@@ -3,7 +3,7 @@
<a-card title="轮播与公告">
<a-carousel autoplay>
<div v-for="item in home.carousels" :key="item.id" class="banner">
<img :src="item.imageUrl" alt="banner" />
<img :src="previewUrl(item.imageUrl)" alt="banner" />
</div>
</a-carousel>
<a-list :data-source="home.notices" style="margin-top: 16px">
@@ -46,6 +46,7 @@ const loading = ref(false)
const home = reactive({ carousels: [], notices: [], hot: [], newest: [] })
const router = useRouter()
const baseUrl = 'http://localhost:8080'
const load = async () => {
loading.value = true
try {
@@ -58,6 +59,12 @@ const load = async () => {
const goDetail = (id) => router.push(`/products/${id}`)
const previewUrl = (url) => {
if (!url) return ''
if (url.startsWith('http')) return url
return `${baseUrl}${url}`
}
onMounted(load)
</script>

View File

@@ -6,11 +6,38 @@
{{ statusMap[record.status] || record.status }}
</template>
<template v-else-if="column.key === 'action'">
<a-button type="link" @click="viewDetail(record)">详情</a-button>
<a-button type="link" v-if="record.status === 'PENDING_PAYMENT'" @click="pay(record)">去支付</a-button>
</template>
</template>
</a-table>
</a-card>
<a-modal v-model:open="detailVisible" title="订单详情" footer={null}>
<a-descriptions bordered :column="1">
<a-descriptions-item label="订单号">{{ detail.order?.orderNo }}</a-descriptions-item>
<a-descriptions-item label="金额">{{ detail.order?.totalAmount }}</a-descriptions-item>
<a-descriptions-item label="状态">{{ statusMap[detail.order?.status] }}</a-descriptions-item>
<a-descriptions-item label="收货人">{{ detail.order?.receiverName }}</a-descriptions-item>
<a-descriptions-item label="联系电话">{{ detail.order?.receiverPhone }}</a-descriptions-item>
<a-descriptions-item label="地址">{{ detail.order?.receiverAddress }}</a-descriptions-item>
<a-descriptions-item label="物流单号">{{ detail.order?.logisticsNo || '-' }}</a-descriptions-item>
</a-descriptions>
<a-table
:dataSource="detail.items"
:columns="itemColumns"
rowKey="id"
style="margin-top: 16px"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'product'">
{{ record.product.name }}
</template>
</template>
</a-table>
</a-modal>
</template>
<script setup>
@@ -18,6 +45,8 @@ import { onMounted, ref } from 'vue'
import api from '../../api'
const orders = ref([])
const detailVisible = ref(false)
const detail = ref({ order: null, items: [] })
const columns = [
{ title: '订单号', dataIndex: 'orderNo' },
{ title: '金额', dataIndex: 'totalAmount' },
@@ -25,6 +54,12 @@ const columns = [
{ title: '操作', key: 'action' }
]
const itemColumns = [
{ title: '商品', key: 'product' },
{ title: '数量', dataIndex: 'quantity' },
{ title: '单价', dataIndex: 'price' }
]
const statusMap = {
PENDING_PAYMENT: '待付款',
PENDING_SHIPMENT: '待发货',
@@ -42,5 +77,13 @@ const pay = async (record) => {
load()
}
const viewDetail = async (record) => {
const res = await api.get(`/api/user/orders/${record.id}`)
if (res.success) {
detail.value = res.data
detailVisible.value = true
}
}
onMounted(load)
</script>

View File

@@ -5,7 +5,7 @@
<a-col :span="10">
<a-carousel>
<div v-for="img in images" :key="img.id" class="banner">
<img :src="img.url" alt="img" />
<img :src="previewUrl(img.url)" alt="img" />
</div>
</a-carousel>
</a-col>
@@ -39,6 +39,7 @@ const product = ref({})
const images = ref([])
const quantity = ref(1)
const baseUrl = 'http://localhost:8080'
const load = async () => {
loading.value = true
try {
@@ -61,6 +62,12 @@ const addToCart = async () => {
message.success('已加入购物车')
}
const previewUrl = (url) => {
if (!url) return ''
if (url.startsWith('http')) return url
return `${baseUrl}${url}`
}
onMounted(load)
</script>