From abba469a20d8bd18593f5322c9999d8303600ba4 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Tue, 10 Mar 2026 23:21:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=89=8D=E7=AB=AF=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E8=AE=BE=E6=96=BD=E6=9B=B4=E6=96=B0=20-=20API=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E3=80=81=E8=B7=AF=E7=94=B1=E3=80=81=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=92=8C=E5=B7=A5=E5=85=B7=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 address/admin/favorite/review API 模块 - 更新已有 API 模块适配后端接口变更 - 新增 admin 类型定义和工具函数 - 添加静态资源文件 - 更新路由配置和守卫逻辑 - 更新 Vite 配置和依赖锁文件 --- flash-sale-frontend/package-lock.json | 22 +- .../src/api/modules/address.ts | 52 ++++ flash-sale-frontend/src/api/modules/admin.ts | 138 +++++++++ flash-sale-frontend/src/api/modules/cart.ts | 23 +- .../src/api/modules/favorite.ts | 31 ++ .../src/api/modules/flashsale.ts | 130 +++++++- flash-sale-frontend/src/api/modules/order.ts | 143 +++++++-- .../src/api/modules/product.ts | 33 +- flash-sale-frontend/src/api/modules/review.ts | 30 ++ flash-sale-frontend/src/api/modules/user.ts | 46 ++- flash-sale-frontend/src/api/request.ts | 32 +- .../src/assets/default-product.svg | 7 + flash-sale-frontend/src/router/guards.ts | 8 +- flash-sale-frontend/src/router/index.ts | 44 ++- flash-sale-frontend/src/stores/user.ts | 23 +- flash-sale-frontend/src/types/admin.ts | 163 ++++++++++ flash-sale-frontend/src/utils/image.ts | 42 +++ flash-sale-frontend/src/utils/normalizers.ts | 287 ++++++++++++++++++ flash-sale-frontend/vite.config.ts | 25 +- 19 files changed, 1202 insertions(+), 77 deletions(-) create mode 100644 flash-sale-frontend/src/api/modules/address.ts create mode 100644 flash-sale-frontend/src/api/modules/admin.ts create mode 100644 flash-sale-frontend/src/api/modules/favorite.ts create mode 100644 flash-sale-frontend/src/api/modules/review.ts create mode 100644 flash-sale-frontend/src/assets/default-product.svg create mode 100644 flash-sale-frontend/src/types/admin.ts create mode 100644 flash-sale-frontend/src/utils/image.ts create mode 100644 flash-sale-frontend/src/utils/normalizers.ts diff --git a/flash-sale-frontend/package-lock.json b/flash-sale-frontend/package-lock.json index d1d9493..a59a75b 100644 --- a/flash-sale-frontend/package-lock.json +++ b/flash-sale-frontend/package-lock.json @@ -1464,6 +1464,7 @@ "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/lodash": "*" } @@ -1474,6 +1475,7 @@ "integrity": "sha512-iAFpG6DokED3roLSP0K+ybeDdIX6Bc0Vd3mLW5uDqThPWtNos3E+EqOM11mPQHKzfWHqEBuLjIlsBQQ8CsISmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1533,6 +1535,7 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -1877,6 +1880,7 @@ "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.18.tgz", "integrity": "sha512-DUpHa1HpeOQEt6+3nheUfqVXRog2kivkXHUhoqJiKR33SO4x+a5uNOMkV487WPerQkL0vUuRvq/7JhRgLW3S+w==", "license": "MIT", + "peer": true, "dependencies": { "@vue/reactivity": "3.5.18", "@vue/shared": "3.5.18" @@ -1955,6 +1959,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2200,6 +2205,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -2551,6 +2557,7 @@ "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz", "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "2.3.0", "zrender": "5.6.1" @@ -2770,6 +2777,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2826,6 +2834,7 @@ "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -2870,6 +2879,7 @@ "integrity": "sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "globals": "^13.24.0", @@ -3744,13 +3754,15 @@ "version": "4.17.21", "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash-es": { "version": "4.17.21", "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz", "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash-unified": { "version": "1.0.3", @@ -4228,6 +4240,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4374,6 +4387,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -4593,6 +4607,7 @@ "integrity": "sha512-9GUyuksjw70uNpb1MTYWsH9MQHOHY6kwfnkafC24+7aOMZn9+rVMBxRbLvw756mrBFbIsFg6Xw9IkR2Fnn3k+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -5090,6 +5105,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5159,6 +5175,7 @@ "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -5218,6 +5235,7 @@ "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz", "integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.18", "@vue/compiler-sfc": "3.5.18", diff --git a/flash-sale-frontend/src/api/modules/address.ts b/flash-sale-frontend/src/api/modules/address.ts new file mode 100644 index 0000000..bfbde87 --- /dev/null +++ b/flash-sale-frontend/src/api/modules/address.ts @@ -0,0 +1,52 @@ +import { request } from '../request' +import type { ApiResponse } from '@/types/api' + +export interface AddressItem { + id: number + userId?: number + name: string + phone: string + province: string + city: string + district: string + address: string + isDefault: boolean + createdAt?: string + updatedAt?: string +} + +export interface SaveAddressParams { + name: string + phone: string + province: string + city: string + district: string + address: string + isDefault?: boolean +} + +export const addressApi = { + getList(): Promise> { + return request.get('/api/address') + }, + + getDefault(): Promise> { + return request.get('/api/address/default') + }, + + create(data: SaveAddressParams): Promise> { + return request.post('/api/address', data) + }, + + update(id: number, data: SaveAddressParams): Promise> { + return request.put(`/api/address/${id}`, data) + }, + + setDefault(id: number): Promise> { + return request.post(`/api/address/${id}/default`) + }, + + delete(id: number): Promise { + return request.delete(`/api/address/${id}`) + }, +} diff --git a/flash-sale-frontend/src/api/modules/admin.ts b/flash-sale-frontend/src/api/modules/admin.ts new file mode 100644 index 0000000..cd80f23 --- /dev/null +++ b/flash-sale-frontend/src/api/modules/admin.ts @@ -0,0 +1,138 @@ +import { request } from '../request' +import type { ApiResponse } from '@/types/api' +import type { + AdminDashboardStats, + AdminFavoriteRow, + AdminFavoriteStats, + AdminFlashSaleStats, + AdminHotProductRow, + AdminOrderRow, + AdminOrderStats, + AdminProductRow, + AdminProductStats, + AdminRecentOrderRow, + AdminReviewRow, + AdminReviewStats, + AdminUserRow, + AdminUserStats, + MonitorSystemStatus, + RedisNodeStatus, +} from '@/types/admin' +import { + normalizeAdminHotProduct, + normalizeAdminOrder, + normalizeAdminProduct, + normalizeAdminRecentOrder, + normalizeAdminUser, +} from '@/utils/normalizers' + +export const adminApi = { + getDashboardStats(): Promise> { + return request.get('/api/admin/dashboard/stats') + }, + getUserStats(): Promise> { + return request.get('/api/admin/users/stats') + }, + getOrderStats(): Promise> { + return request.get('/api/admin/orders/stats') + }, + getProductStats(): Promise> { + return request.get('/api/admin/products/stats') + }, + getFlashSaleStats(): Promise> { + return request.get('/api/admin/flashsales/stats') + }, + getRecentOrders(limit = 10): Promise> { + return request.get>('/api/admin/orders/recent', { limit }).then((res) => ({ + ...res, + data: Array.isArray(res.data) ? res.data.map((item) => normalizeAdminRecentOrder(item)) : [], + })) + }, + getHotProducts(limit = 5): Promise> { + return request.get>('/api/admin/products/hot', { limit }).then((res) => ({ + ...res, + data: Array.isArray(res.data) ? res.data.map((item) => normalizeAdminHotProduct(item)) : [], + })) + }, + getUsers(params: { page: number; size: number; keyword?: string; status?: number | '' }): Promise> { + const query = { page: params.page, size: params.size, keyword: params.keyword, status: params.status === '' ? undefined : params.status } + return request.get>>('/api/admin/users', query).then((res) => ({ + ...res, + data: { + users: Array.isArray(res.data.users) ? res.data.users.map((item) => normalizeAdminUser(item)) : [], + total: Number(res.data.total || 0), + totalPages: Number(res.data.totalPages || 0), + currentPage: Number(res.data.currentPage || params.page), + size: Number(res.data.size || params.size), + }, + })) + }, + getOrders(params: { page: number; size: number; keyword?: string; status?: string | '' }): Promise> { + const query = { page: params.page, size: params.size, keyword: params.keyword, status: params.status === '' ? undefined : params.status } + return request.get>>('/api/admin/orders', query).then((res) => ({ + ...res, + data: { + orders: Array.isArray(res.data.orders) ? res.data.orders.map((item) => normalizeAdminOrder(item)) : [], + total: Number(res.data.total || 0), + totalPages: Number(res.data.totalPages || 0), + currentPage: Number(res.data.currentPage || params.page), + size: Number(res.data.size || params.size), + }, + })) + }, + getProducts(params: { page: number; size: number; keyword?: string; category?: string; status?: number | '' }): Promise> { + const query = { page: params.page, size: params.size, keyword: params.keyword, category: params.category, status: params.status === '' ? undefined : params.status } + return request.get>>('/api/admin/products', query).then((res) => ({ + ...res, + data: { + products: Array.isArray(res.data.products) ? res.data.products.map((item) => normalizeAdminProduct(item)) : [], + total: Number(res.data.total || 0), + totalPages: Number(res.data.totalPages || 0), + currentPage: Number(res.data.currentPage || params.page), + size: Number(res.data.size || params.size), + }, + })) + }, + getSystemStatus(): Promise> { return request.get('/api/admin/monitor/system') }, + getRedisStatus(): Promise> { return request.get('/api/admin/monitor/redis') }, + getProduct(id: number): Promise> { + return request.get>(`/api/admin/products/${id}`).then((res) => ({ ...res, data: normalizeAdminProduct(res.data) })) + }, + createProduct(data: Record): Promise> { + return request.post>('/api/admin/products', data).then((res) => ({ ...res, data: normalizeAdminProduct(res.data) })) + }, + updateProduct(id: number, data: Record): Promise> { + return request.put>(`/api/admin/products/${id}`, data).then((res) => ({ ...res, data: normalizeAdminProduct(res.data) })) + }, + deleteProduct(id: number): Promise { return request.delete(`/api/admin/products/${id}`) }, + getReviewStats(): Promise> { return request.get('/api/admin/reviews/stats') }, + getFavoriteStats(): Promise> { return request.get('/api/admin/favorites/stats') }, + getReviews(params: { page: number; size: number; keyword?: string }): Promise> { + return request.get>>('/api/admin/reviews', params).then((res) => ({ + ...res, + data: { + reviews: Array.isArray(res.data.reviews) ? (res.data.reviews as AdminReviewRow[]) : [], + total: Number(res.data.total || 0), + totalPages: Number(res.data.totalPages || 0), + currentPage: Number(res.data.currentPage || params.page), + size: Number(res.data.size || params.size), + }, + })) + }, + updateReview(id: number, data: { status?: number; adminReply?: string }): Promise { return request.put(`/api/admin/reviews/${id}`, data) }, + deleteReview(id: number): Promise { return request.delete(`/api/admin/reviews/${id}`) }, + getFavorites(params: { page: number; size: number; keyword?: string }): Promise> { + return request.get>>('/api/admin/favorites', params).then((res) => ({ + ...res, + data: { + favorites: Array.isArray(res.data.favorites) ? (res.data.favorites as AdminFavoriteRow[]) : [], + total: Number(res.data.total || 0), + totalPages: Number(res.data.totalPages || 0), + currentPage: Number(res.data.currentPage || params.page), + size: Number(res.data.size || params.size), + }, + })) + }, + deleteFavorite(id: number): Promise { return request.delete(`/api/admin/favorites/${id}`) }, + migrateLegacyOrderItems(): Promise> { return request.post('/api/admin/orders/migrate-items') }, +} diff --git a/flash-sale-frontend/src/api/modules/cart.ts b/flash-sale-frontend/src/api/modules/cart.ts index 053ed33..63b6328 100644 --- a/flash-sale-frontend/src/api/modules/cart.ts +++ b/flash-sale-frontend/src/api/modules/cart.ts @@ -1,10 +1,14 @@ import { request } from '../request' import type { ApiResponse, CartItem } from '@/types/api' +import { normalizeCartItems, normalizeOrder } from '@/utils/normalizers' export const cartApi = { // 获取购物车 getCart(): Promise> { - return request.get('/api/cart') + return request.get>('/api/cart').then((res) => ({ + ...res, + data: normalizeCartItems(res.data), + })) }, // 添加到购物车 @@ -17,17 +21,17 @@ export const cartApi = { // 更新数量 updateQuantity(itemId: string, quantity: number): Promise { - return request.put(`/api/cart/item/${itemId}`, { quantity }) + return request.put('/api/cart/update', { productId: Number(itemId), quantity }) }, // 删除商品 removeItem(itemId: string): Promise { - return request.delete(`/api/cart/item/${itemId}`) + return request.delete('/api/cart/remove', { productId: Number(itemId) }) }, // 批量删除 batchRemove(ids: string[]): Promise { - return request.post('/api/cart/batch-remove', { ids }) + return request.delete('/api/cart/batch-remove', { productIds: ids.map(Number) }) }, // 清空购物车 @@ -39,4 +43,13 @@ export const cartApi = { getCount(): Promise> { return request.get('/api/cart/count') }, -} \ No newline at end of file + + checkout(ids?: string[]): Promise> { + return request.post>('/api/cart/checkout', { + productIds: ids?.map(Number), + }).then((res) => ({ + ...res, + data: normalizeOrder(res.data), + })) + }, +} diff --git a/flash-sale-frontend/src/api/modules/favorite.ts b/flash-sale-frontend/src/api/modules/favorite.ts new file mode 100644 index 0000000..bf7da30 --- /dev/null +++ b/flash-sale-frontend/src/api/modules/favorite.ts @@ -0,0 +1,31 @@ +import { request } from '../request' +import type { ApiResponse } from '@/types/api' + +export interface FavoriteItem { + id: number + userId: number + productId: number + productName: string + productImageUrl: string + productPrice: number + productCategory: string + createdAt: string +} + +export const favoriteApi = { + getList(): Promise> { + return request.get('/api/favorite') + }, + + getCount(): Promise> { + return request.get('/api/favorite/count') + }, + + check(productId: number): Promise> { + return request.get('/api/favorite/check', { productId }) + }, + + toggle(productId: number): Promise> { + return request.post('/api/favorite/toggle', { productId }) + }, +} diff --git a/flash-sale-frontend/src/api/modules/flashsale.ts b/flash-sale-frontend/src/api/modules/flashsale.ts index 1565a28..e0b6070 100644 --- a/flash-sale-frontend/src/api/modules/flashsale.ts +++ b/flash-sale-frontend/src/api/modules/flashsale.ts @@ -1,20 +1,51 @@ import { request } from '../request' import type { ApiResponse, FlashSale, PageParams, PageResponse } from '@/types/api' +import { mapOrderStatus, normalizeFlashSale, normalizePage } from '@/utils/normalizers' + +const flashSaleStatusToCode = (status?: string) => { + if (status === 'UPCOMING') return 1 + if (status === 'ACTIVE') return 2 + if (status === 'ENDED') return 3 + return undefined +} + +const flashSaleSortField = (sort?: string) => { + if (sort === 'flashPrice') return 'flashPrice' + if (sort === 'endTime') return 'endTime' + return 'startTime' +} export const flashsaleApi = { // 获取秒杀活动列表 getList(params?: PageParams & { status?: string }): Promise>> { - return request.get('/api/flashsale/list', params) + return request.post>>('/api/flashsale/list', { + status: flashSaleStatusToCode(params?.status), + page: params?.page ?? 0, + size: params?.size ?? 10, + sortBy: flashSaleSortField(params?.sort), + sortDirection: params?.order || 'asc', + }).then((res) => ({ + ...res, + data: normalizePage(res.data, normalizeFlashSale), + })) }, // 获取正在进行的秒杀活动 getActive(limit?: number): Promise> { - return request.get('/api/flashsale/active', { limit }) + return request.get>('/api/flashsale/active').then((res) => ({ + ...res, + data: (Array.isArray(res.data) ? res.data : []) + .map((item) => normalizeFlashSale(item)) + .slice(0, limit ?? Number.MAX_SAFE_INTEGER), + })) }, // 获取秒杀活动详情 getDetail(id: number): Promise> { - return request.get(`/api/flashsale/${id}`) + return request.get>(`/api/flashsale/${id}`).then((res) => ({ + ...res, + data: normalizeFlashSale(res.data), + })) }, // 参与秒杀 @@ -23,12 +54,33 @@ export const flashsaleApi = { quantity: number; timestamp?: number; }): Promise> { - return request.post('/api/flashsale/participate', data) + return request.post>('/api/flashsale/participate', data).then((res) => ({ + ...res, + data: { + orderId: Number(res.data?.orderId || res.data?.id || 0), + }, + })) }, // 获取用户参与记录 getUserRecords(): Promise> { - return request.get('/api/flashsale/user-records') + return request.post>>('/api/order/my-orders', { + orderType: 2, + page: 0, + size: 100, + sortBy: 'createdAt', + sortDirection: 'desc', + }).then((res) => { + const content = Array.isArray(res.data?.content) ? res.data.content : [] + return { + ...res, + data: content.map((item: Record) => ({ + id: item.id, + success: mapOrderStatus(item.status) !== 'CANCELLED', + status: item.status, + })), + } + }) }, // 检查用户是否可以参与 @@ -37,6 +89,70 @@ export const flashsaleApi = { reason?: string; remainingQuota?: number; }>> { - return request.get(`/api/flashsale/${flashSaleId}/check-eligibility`) + return this.getDetail(flashSaleId).then((res) => { + const eligible = res.data.status === 'ACTIVE' && res.data.remainingStock > 0 + return { + code: 0, + success: true, + message: '检查成功', + data: { + eligible, + reason: eligible ? '' : '活动未开始、已结束或库存不足', + remainingQuota: res.data.limitPerUser, + }, + } + }) }, -} \ No newline at end of file + + create(data: { + productId: number + flashPrice: number + flashStock: number + startTime: string + endTime: string + }): Promise> { + return request.post>('/api/flashsale/create', data).then((res) => ({ + ...res, + data: normalizeFlashSale(res.data), + })) + }, + + update(id: number, data: Record): Promise> { + return request.put>(`/api/flashsale/${id}`, data).then((res) => ({ + ...res, + data: normalizeFlashSale(res.data), + })) + }, + + delete(id: number): Promise { + return request.delete(`/api/flashsale/${id}`) + }, + + publish(id: number): Promise> { + return request.post>(`/api/flashsale/${id}/publish`).then((res) => ({ + ...res, + data: normalizeFlashSale(res.data), + })) + }, + + pause(id: number): Promise> { + return request.post>(`/api/flashsale/${id}/pause`).then((res) => ({ + ...res, + data: normalizeFlashSale(res.data), + })) + }, + + resume(id: number): Promise> { + return request.post>(`/api/flashsale/${id}/resume`).then((res) => ({ + ...res, + data: normalizeFlashSale(res.data), + })) + }, + + end(id: number): Promise> { + return request.post>(`/api/flashsale/${id}/end`).then((res) => ({ + ...res, + data: normalizeFlashSale(res.data), + })) + }, +} diff --git a/flash-sale-frontend/src/api/modules/order.ts b/flash-sale-frontend/src/api/modules/order.ts index 0247065..e9c8752 100644 --- a/flash-sale-frontend/src/api/modules/order.ts +++ b/flash-sale-frontend/src/api/modules/order.ts @@ -1,57 +1,138 @@ import { request } from '../request' import type { ApiResponse, Order, PageParams, PageResponse } from '@/types/api' +import { normalizeOrder } from '@/utils/normalizers' + +const orderStatusToCode = (status?: string) => { + if (status === 'PENDING') return 1 + if (status === 'PAID') return 2 + if (status === 'SHIPPED') return 3 + if (status === 'COMPLETED') return 4 + if (status === 'CANCELLED') return 5 + return undefined +} + +const aggregateOrders = (rawOrders: Array>): Order[] => { + const groups = new Map>>() + + rawOrders.forEach((item) => { + const key = item.groupNo || item.orderNo || String(item.id) + if (!groups.has(key)) { + groups.set(key, []) + } + groups.get(key)!.push(item) + }) + + return Array.from(groups.values()).map((group) => { + const [anchor] = group + const normalizedAnchor = normalizeOrder(anchor) + + const items = group.flatMap((item) => normalizeOrder(item).items) + + const totalAmount = items.reduce((sum, item) => sum + item.subtotal, 0) + + return { + ...normalizedAnchor, + orderNo: anchor.groupNo || normalizedAnchor.orderNo, + totalAmount, + paymentAmount: totalAmount, + items, + } + }) +} export const orderApi = { - // 创建订单 create(data: { items: Array<{ productId: number; quantity: number }> addressId?: number remark?: string }): Promise> { - return request.post('/api/order/create', data) + const [firstItem] = data.items + return request.post>('/api/order/create', { + productId: firstItem.productId, + quantity: firstItem.quantity, + remark: data.remark, + }).then((res) => ({ + ...res, + data: normalizeOrder(res.data), + })) }, - - // 获取订单列表 - getList(params?: PageParams & { - status?: string - }): Promise>> { - return request.get('/api/order/list', params) + + getList(params?: PageParams & { status?: string }): Promise>> { + return request.post>>('/api/order/my-orders', { + status: orderStatusToCode(params?.status), + page: params?.page ?? 0, + size: params?.size ?? 10, + sortBy: params?.sort || 'createdAt', + sortDirection: params?.order || 'desc', + }).then((res) => { + const rawContent = Array.isArray(res.data.content) ? res.data.content : [] + const content = aggregateOrders(rawContent) + return { + ...res, + data: { + content, + totalElements: Number(res.data.totalElements || content.length), + totalPages: Number(res.data.totalPages || 1), + size: res.data.size || content.length, + number: res.data.currentPage || 0, + first: true, + last: true, + }, + } + }) }, - - // 获取订单详情 + getDetail(id: number): Promise> { - return request.get(`/api/order/${id}`) + return request.get>(`/api/order/${id}`).then(async (res) => { + if (res.data.groupNo) { + const groupRes = await request.get>(`/api/order/group/${res.data.groupNo}`) + return { + ...res, + data: aggregateOrders(groupRes.data)[0], + } + } + return { + ...res, + data: normalizeOrder(res.data), + } + }) }, - - // 取消订单 + cancel(id: number): Promise { return request.post(`/api/order/${id}/cancel`) }, - - // 支付订单 + pay(id: number, paymentMethod: string): Promise { return request.post(`/api/order/${id}/pay`, { paymentMethod }) }, - - // 确认收货 + + ship(id: number): Promise { + return request.post(`/api/order/${id}/ship`) + }, + + updateStatus(id: number, status: number, remark?: string): Promise { + return request.put('/api/order/status', { orderId: id, status, remark }) + }, + confirm(id: number): Promise { return request.post(`/api/order/${id}/confirm`) }, - - // 删除订单 + delete(id: number): Promise { return request.delete(`/api/order/${id}`) }, - - // 获取订单统计 - getStatistics(): Promise> { - return request.get('/api/order/statistics') + + getStatistics(): Promise> { + return request.get>('/api/order/statistics').then((res) => ({ + ...res, + data: { + total: Number(res.data.totalOrders || 0), + pending: Number(res.data.pendingPaymentOrders || 0), + paid: Number(res.data.paidOrders || 0), + shipped: Number(res.data.shippedOrders || 0), + completed: Number(res.data.completedOrders || 0), + cancelled: Number(res.data.cancelledOrders || 0), + }, + })) }, -} \ No newline at end of file +} diff --git a/flash-sale-frontend/src/api/modules/product.ts b/flash-sale-frontend/src/api/modules/product.ts index 0e52095..2e96c8b 100644 --- a/flash-sale-frontend/src/api/modules/product.ts +++ b/flash-sale-frontend/src/api/modules/product.ts @@ -1,5 +1,6 @@ import { request } from '../request' import type { ApiResponse, Product, PageParams, PageResponse } from '@/types/api' +import { normalizePage, normalizeProduct } from '@/utils/normalizers' export const productApi = { // 获取商品列表 @@ -9,26 +10,48 @@ export const productApi = { minPrice?: number; maxPrice?: number; }): Promise>> { - return request.get('/api/product/list', params) + return request.get>>('/api/product/list', { + page: params?.page ?? 0, + size: params?.size ?? 10, + keyword: params?.keyword, + category: params?.category, + minPrice: params?.minPrice, + maxPrice: params?.maxPrice, + sortBy: params?.sort || 'id', + sortDirection: params?.order || 'desc', + status: 1, + }).then((res) => ({ + ...res, + data: normalizePage(res.data, normalizeProduct), + })) }, // 获取热门商品 getHot(limit = 8): Promise> { - return request.get('/api/product/hot', { limit }) + return request.get>('/api/product/hot', { limit }).then((res) => ({ + ...res, + data: Array.isArray(res.data) ? res.data.map((item) => normalizeProduct(item)) : [], + })) }, // 获取商品详情 getDetail(id: number): Promise> { - return request.get(`/api/product/${id}`) + return request.get>(`/api/product/${id}`).then((res) => ({ + ...res, + data: normalizeProduct(res.data), + })) }, // 搜索商品 search(keyword: string): Promise> { - return request.get('/api/product/search', { keyword }) + return this.getList({ keyword, page: 0, size: 50 }).then((res) => ({ + ...res, + data: res.data.content, + })) }, // 获取商品分类 getCategories(): Promise> { return request.get('/api/product/categories') }, -} \ No newline at end of file +} diff --git a/flash-sale-frontend/src/api/modules/review.ts b/flash-sale-frontend/src/api/modules/review.ts new file mode 100644 index 0000000..1484c22 --- /dev/null +++ b/flash-sale-frontend/src/api/modules/review.ts @@ -0,0 +1,30 @@ +import { request } from '../request' +import type { ApiResponse } from '@/types/api' + +export interface ReviewItem { + id: number + productId: number + userId: number + orderId: number + username: string + rating: number + content: string + createdAt: string + updatedAt?: string +} + +export interface ReviewSummary { + averageRating: number + totalReviews: number + reviews: ReviewItem[] +} + +export const reviewApi = { + getProductReviews(productId: number): Promise> { + return request.get(`/api/review/product/${productId}`) + }, + + create(data: { orderId: number; productId: number; rating: number; content: string }): Promise> { + return request.post('/api/review', data) + }, +} diff --git a/flash-sale-frontend/src/api/modules/user.ts b/flash-sale-frontend/src/api/modules/user.ts index 2ad1dad..de6a1c1 100644 --- a/flash-sale-frontend/src/api/modules/user.ts +++ b/flash-sale-frontend/src/api/modules/user.ts @@ -1,34 +1,64 @@ import { request } from '../request' import type { ApiResponse, User, LoginParams, RegisterParams } from '@/types/api' +import { normalizeUser } from '@/utils/normalizers' export const userApi = { // 登录 login(params: LoginParams): Promise> { - return request.post('/api/auth/login', params) + return request.post>('/api/user/login', params).then((res) => ({ + ...res, + data: { + token: res.data.token, + user: normalizeUser(res.data.user), + }, + })) }, // 注册 register(params: RegisterParams): Promise> { - return request.post('/api/auth/register', params) + return request.post>('/api/user/register', { + ...params, + confirmPassword: params.password, + }).then((res) => ({ + ...res, + data: normalizeUser(res.data), + })) }, // 退出登录 logout(): Promise { - return request.post('/api/auth/logout') + return request.post('/api/user/logout') }, // 获取用户信息 getInfo(): Promise> { - return request.get('/api/user/info') + return request.get>('/api/user/current').then((res) => ({ + ...res, + data: normalizeUser(res.data), + })) }, // 更新用户信息 updateInfo(data: Partial): Promise> { - return request.put('/api/user/info', data) + return request.put>('/api/user/update', { + email: data.email, + phone: data.phone, + avatar: data.avatar, + }).then((res) => ({ + ...res, + data: normalizeUser(res.data), + })) }, // 修改密码 - changePassword(data: { oldPassword: string; newPassword: string }): Promise { - return request.post('/api/user/change-password', data) + changePassword(data: { oldPassword: string; newPassword: string; confirmPassword?: string }): Promise { + return request.post('/api/user/change-password', { + ...data, + confirmPassword: data.confirmPassword || data.newPassword, + }) }, -} \ No newline at end of file + + getProfileStats(): Promise> { + return request.get('/api/user/profile-stats') + }, +} diff --git a/flash-sale-frontend/src/api/request.ts b/flash-sale-frontend/src/api/request.ts index 93cff7e..ce021f6 100644 --- a/flash-sale-frontend/src/api/request.ts +++ b/flash-sale-frontend/src/api/request.ts @@ -1,4 +1,9 @@ -import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' +import axios, { + AxiosInstance, + AxiosRequestConfig, + AxiosResponse, + InternalAxiosRequestConfig, +} from 'axios' import { ElMessage, ElMessageBox } from 'element-plus' import { useUserStore } from '@/stores/user' import router from '@/router' @@ -14,7 +19,7 @@ const service: AxiosInstance = axios.create({ // 请求拦截器 service.interceptors.request.use( - (config: AxiosRequestConfig) => { + (config: InternalAxiosRequestConfig) => { const userStore = useUserStore() // 添加token @@ -35,6 +40,20 @@ service.interceptors.request.use( service.interceptors.response.use( (response: AxiosResponse) => { const res = response.data + + if (typeof res?.success === 'boolean') { + if (!res.success) { + if (response.status === 401) { + ElMessage.error(res.message || '登录状态已失效') + } else { + ElMessage.error(res.message || '请求失败') + } + + return Promise.reject(new Error(res.message || '请求失败')) + } + + return res + } // 自定义状态码处理 if (res.code !== 200 && res.code !== 0) { @@ -110,9 +129,12 @@ export const request = { return service.put(url, data) }, - delete(url: string, params?: any): Promise { - return service.delete(url, { params }) + delete(url: string, data?: any, config?: AxiosRequestConfig): Promise { + return service.delete(url, { + ...config, + data, + }) }, } -export default service \ No newline at end of file +export default service diff --git a/flash-sale-frontend/src/assets/default-product.svg b/flash-sale-frontend/src/assets/default-product.svg new file mode 100644 index 0000000..4607430 --- /dev/null +++ b/flash-sale-frontend/src/assets/default-product.svg @@ -0,0 +1,7 @@ + + + 商品 + 图片 + 暂无图片 + + diff --git a/flash-sale-frontend/src/router/guards.ts b/flash-sale-frontend/src/router/guards.ts index 3a176c9..06b60b5 100644 --- a/flash-sale-frontend/src/router/guards.ts +++ b/flash-sale-frontend/src/router/guards.ts @@ -6,6 +6,10 @@ export function setupGuards(router: Router) { // 路由前置守卫 router.beforeEach(async (to, from, next) => { const userStore = useUserStore() + + if (userStore.token && !userStore.user) { + await userStore.getUserInfo() + } // 设置页面标题 document.title = `${to.meta.title || '秒杀系统'} - 高并发电商抢购平台` @@ -21,7 +25,7 @@ export function setupGuards(router: Router) { } // 需要管理员权限的页面 - if (to.meta.requiresAdmin && userStore.user?.role !== 'ADMIN') { + if (to.meta.requiresAdmin && !userStore.isAdmin) { ElMessage.error('无权访问') next('/') return @@ -41,4 +45,4 @@ export function setupGuards(router: Router) { // 页面切换后滚动到顶部 window.scrollTo(0, 0) }) -} \ No newline at end of file +} diff --git a/flash-sale-frontend/src/router/index.ts b/flash-sale-frontend/src/router/index.ts index 52039ab..8caf1f2 100644 --- a/flash-sale-frontend/src/router/index.ts +++ b/flash-sale-frontend/src/router/index.ts @@ -20,6 +20,10 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/pages/flashsale/index.vue'), meta: { title: '秒杀活动' } }, + { + path: 'flashsales', + redirect: '/flashsale' + }, { path: 'flashsale/:id', name: 'FlashSaleDetail', @@ -32,6 +36,14 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/pages/product/index.vue'), meta: { title: '商品列表' } }, + { + path: 'search', + redirect: (to) => ({ path: '/products', query: to.query }) + }, + { + path: 'category/:category', + redirect: (to) => ({ path: '/products', query: { category: String(to.params.category || '') } }) + }, { path: 'product/:id', name: 'ProductDetail', @@ -61,6 +73,18 @@ const routes: RouteRecordRaw[] = [ name: 'Profile', component: () => import('@/pages/user/profile.vue'), meta: { title: '个人中心', requiresAuth: true } + }, + { + path: 'favorites', + name: 'Favorites', + component: () => import('@/pages/user/favorites.vue'), + meta: { title: '我的收藏', requiresAuth: true } + }, + { + path: 'addresses', + name: 'Addresses', + component: () => import('@/pages/user/profile.vue'), + meta: { title: '地址管理', requiresAuth: true } } ] }, @@ -110,6 +134,24 @@ const routes: RouteRecordRaw[] = [ name: 'AdminUsers', component: () => import('@/pages/admin/users.vue'), meta: { title: '用户管理' } + }, + { + path: 'reviews', + name: 'AdminReviews', + component: () => import('@/pages/admin/reviews.vue'), + meta: { title: '评价管理' } + }, + { + path: 'favorites', + name: 'AdminFavorites', + component: () => import('@/pages/admin/favorites.vue'), + meta: { title: '收藏管理' } + }, + { + path: 'monitor', + name: 'AdminMonitor', + component: () => import('@/pages/admin/monitor.vue'), + meta: { title: '系统监控' } } ] }, @@ -136,4 +178,4 @@ const router = createRouter({ // 设置路由守卫 setupGuards(router) -export default router \ No newline at end of file +export default router diff --git a/flash-sale-frontend/src/stores/user.ts b/flash-sale-frontend/src/stores/user.ts index c17e552..b2f23d6 100644 --- a/flash-sale-frontend/src/stores/user.ts +++ b/flash-sale-frontend/src/stores/user.ts @@ -12,7 +12,7 @@ export const useUserStore = defineStore('user', () => { // 计算属性 const isLoggedIn = computed(() => !!token.value) - const isAdmin = computed(() => user.value?.role === 'ADMIN') + const isAdmin = computed(() => user.value?.role === 'ADMIN' || user.value?.username === 'admin') const username = computed(() => user.value?.username || '') // 从localStorage恢复登录状态 @@ -74,7 +74,15 @@ export const useUserStore = defineStore('user', () => { } // 退出登录 - const logout = () => { + const logout = async () => { + try { + if (token.value) { + await userApi.logout() + } + } catch (error) { + console.error('退出登录失败:', error) + } + user.value = null token.value = '' @@ -94,11 +102,18 @@ export const useUserStore = defineStore('user', () => { const res = await userApi.getInfo() if (res.success) { - user.value = res.data + user.value = { + ...res.data, + avatar: res.data.avatar || user.value?.avatar || '', + } localStorage.setItem('user', JSON.stringify(user.value)) } } catch (error) { console.error('获取用户信息失败:', error) + user.value = null + token.value = '' + localStorage.removeItem('token') + localStorage.removeItem('user') } } @@ -125,4 +140,4 @@ export const useUserStore = defineStore('user', () => { getUserInfo, updateUserInfo, } -}) \ No newline at end of file +}) diff --git a/flash-sale-frontend/src/types/admin.ts b/flash-sale-frontend/src/types/admin.ts new file mode 100644 index 0000000..f59c128 --- /dev/null +++ b/flash-sale-frontend/src/types/admin.ts @@ -0,0 +1,163 @@ +export interface AdminDashboardStats { + totalUsers: number + totalProducts: number + totalOrders: number + totalAmount: number + todayOrders: number + paidOrders: number + pendingOrders: number + activeFlashSales: number +} + +export interface AdminUserStats { + totalUsers: number + activeUsers: number + newUsers: number + onlineUsers: number +} + +export interface AdminOrderStats { + totalOrders: number + paidOrders: number + pendingOrders: number + completedOrders: number + cancelledOrders: number + totalAmount: number +} + +export interface AdminProductStats { + totalProducts: number + activeProducts: number + inactiveProducts: number + lowStockProducts: number +} + +export interface AdminFlashSaleStats { + totalFlashSales: number + activeFlashSales: number + upcomingFlashSales: number + endedFlashSales: number +} + +export interface AdminRecentOrderRow { + id: number + orderNo: string + username: string + productName: string + quantity: number + totalAmount: number + status: string + createdAt: string + isFlashSale: boolean +} + +export interface AdminHotProductRow { + id: number + name: string + price: number + stock: number + sales: number +} + +export interface AdminUserRow { + id: number + username: string + email: string + phone: string + status: number + statusText: string + role: 'USER' | 'ADMIN' + isOnline: boolean + createdAt: string + lastLogin?: string +} + +export interface AdminOrderRow { + id: number + orderNo: string + username: string + productName: string + productId?: number + quantity: number + totalAmount: number + status: string + createdAt: string + isFlashSale: boolean +} + +export interface AdminProductRow { + id: number + name: string + description: string + category: string + price: number + stock: number + status: number + imageUrl: string + createdAt: string + updatedAt?: string + totalSales?: number + totalRevenue?: number + viewCount?: number + rating?: number +} + +export interface MonitorSystemStatus { + status: string + cpuUsage: number + memoryUsage: number + diskUsage: number + availableProcessors?: number + totalMemory?: string + usedMemory?: string + dbStatus?: string + redisStatus?: string + requestCountToday?: number +} + +export interface RedisNodeStatus { + node: string + status: string + memory: string + connections: number +} + +export interface AdminReviewStats { + totalReviews: number + todayReviews: number + averageRating: number + fiveStarReviews: number +} + +export interface AdminFavoriteStats { + totalFavorites: number + favoriteUsers: number + favoriteProducts: number + todayFavorites: number +} + +export interface AdminReviewRow { + id: number + productId: number + userId: number + orderId: number + productName: string + username: string + rating: number + content: string + status: number + statusText: string + adminReply?: string + repliedAt?: string + createdAt: string +} + +export interface AdminFavoriteRow { + id: number + userId: number + productId: number + productName: string + productCategory: string + username: string + createdAt: string +} diff --git a/flash-sale-frontend/src/utils/image.ts b/flash-sale-frontend/src/utils/image.ts new file mode 100644 index 0000000..5c0927d --- /dev/null +++ b/flash-sale-frontend/src/utils/image.ts @@ -0,0 +1,42 @@ +import defaultProductImage from '@/assets/default-product.svg' + +export const DEFAULT_PRODUCT_IMAGE = defaultProductImage + +const ABSOLUTE_URL_PATTERN = /^(https?:)?\/\//i +const SPECIAL_URL_PATTERN = /^(data:|blob:)/i + +const normalizeBaseUrl = (value?: string) => { + if (!value) return '' + return value.endsWith('/') ? value.slice(0, -1) : value +} + +export const resolveImageUrl = (value?: string | null) => { + if (!value || !String(value).trim()) { + return DEFAULT_PRODUCT_IMAGE + } + + const imageUrl = String(value).trim() + if (ABSOLUTE_URL_PATTERN.test(imageUrl) || SPECIAL_URL_PATTERN.test(imageUrl)) { + return imageUrl + } + + const baseUrl = normalizeBaseUrl(import.meta.env.VITE_API_BASE_URL) + if (!baseUrl) { + return imageUrl.startsWith('/') ? imageUrl : `/${imageUrl}` + } + + return imageUrl.startsWith('/') ? `${baseUrl}${imageUrl}` : `${baseUrl}/${imageUrl}` +} + +export const applyFallbackImage = (event: Event) => { + const target = event.target as HTMLImageElement | null + if (!target) return + + if (target.dataset.fallbackApplied === 'true') { + return + } + + target.dataset.fallbackApplied = 'true' + target.onerror = null + target.src = DEFAULT_PRODUCT_IMAGE +} diff --git a/flash-sale-frontend/src/utils/normalizers.ts b/flash-sale-frontend/src/utils/normalizers.ts new file mode 100644 index 0000000..fdb4d51 --- /dev/null +++ b/flash-sale-frontend/src/utils/normalizers.ts @@ -0,0 +1,287 @@ +import type { + CartItem, + FlashSale, + Order, + OrderAddress, + PageResponse, + Product, + User, +} from '@/types/api' +import type { + AdminHotProductRow, + AdminOrderRow, + AdminProductRow, + AdminRecentOrderRow, + AdminUserRow, +} from '@/types/admin' +import { DEFAULT_PRODUCT_IMAGE, resolveImageUrl } from '@/utils/image' + +const toNumber = (value: unknown, fallback = 0) => { + const result = Number(value) + return Number.isFinite(result) ? result : fallback +} + +const toString = (value: unknown, fallback = '') => { + if (value === null || value === undefined) { + return fallback + } + return String(value) +} + +const toIsoLikeString = (value: unknown) => { + const raw = toString(value) + return raw || new Date().toISOString() +} + +export const buildOrderNo = (id: number | string) => { + const numericId = toString(id).padStart(6, '0') + return `FS${numericId}` +} + +export const mapUserStatusText = (status: number) => { + return status === 1 ? '正常' : '禁用' +} + +export const mapOrderStatus = (status: number | string): Order['status'] => { + const value = typeof status === 'string' ? status : toNumber(status) + if (value === 'PENDING' || value === 1) return 'PENDING' + if (value === 'PAID' || value === 2) return 'PAID' + if (value === 'SHIPPED' || value === 3) return 'SHIPPED' + if (value === 'COMPLETED' || value === 4) return 'COMPLETED' + if (value === 'CANCELLED' || value === 5) return 'CANCELLED' + return 'PENDING' +} + +export const mapFlashSaleStatus = (status: number | string): FlashSale['status'] => { + const value = typeof status === 'string' ? status : toNumber(status) + if (value === 'UPCOMING' || value === 1) return 'UPCOMING' + if (value === 'ACTIVE' || value === 2) return 'ACTIVE' + if (value === 'ENDED' || value === 3) return 'ENDED' + return 'UPCOMING' +} + +export const mapProductStatus = (status: number | string, stock = 0): Product['status'] => { + const value = typeof status === 'string' ? status : toNumber(status) + if (stock <= 0) return 'SOLD_OUT' + if (value === 'OFF_SALE' || value === 0) return 'OFF_SALE' + return 'ON_SALE' +} + +export const normalizeUser = (user: Record): User => { + const username = toString(user.username) + return { + id: toNumber(user.id), + username, + email: toString(user.email), + phone: toString(user.phone), + avatar: resolveImageUrl(toString(user.avatar, '')), + role: toString(user.role).toUpperCase() === 'ADMIN' ? 'ADMIN' : username === 'admin' ? 'ADMIN' : 'USER', + status: toNumber(user.status, 1) === 1 ? 'ACTIVE' : 'BANNED', + createdAt: toIsoLikeString(user.createdAt), + updatedAt: toIsoLikeString(user.updatedAt || user.createdAt), + } +} + +export const normalizeProduct = (product: Record): Product => { + const stock = toNumber(product.stock) + const imageUrl = resolveImageUrl(toString(product.imageUrl, '')) + return { + id: toNumber(product.id), + name: toString(product.name), + description: toString(product.description), + price: toNumber(product.price), + stock, + imageUrl, + images: imageUrl ? [imageUrl] : [DEFAULT_PRODUCT_IMAGE], + category: toString(product.category, '默认分类'), + status: mapProductStatus(product.status, stock), + sales: toNumber(product.sales), + views: toNumber(product.viewCount ?? product.views), + createdAt: toIsoLikeString(product.createdAt), + updatedAt: toIsoLikeString(product.updatedAt || product.createdAt), + } +} + +export const normalizeFlashSale = (flashSale: Record): FlashSale => { + const flashStock = toNumber(flashSale.flashStock) + const remainingStock = toNumber(flashSale.remainingStock, flashStock) + return { + id: toNumber(flashSale.id), + productId: toNumber(flashSale.productId), + productName: toString(flashSale.productName), + productImageUrl: resolveImageUrl(toString(flashSale.productImageUrl, '')), + originalPrice: toNumber(flashSale.originalPrice), + flashPrice: toNumber(flashSale.flashPrice), + flashStock, + remainingStock, + startTime: toIsoLikeString(flashSale.startTime), + endTime: toIsoLikeString(flashSale.endTime), + status: mapFlashSaleStatus(flashSale.status), + limitPerUser: toNumber(flashSale.limitPerUser, 1), + description: toString(flashSale.description || flashSale.statusDescription), + createdAt: toIsoLikeString(flashSale.createdAt), + updatedAt: toIsoLikeString(flashSale.updatedAt || flashSale.createdAt), + } +} + +const buildOrderAddress = (order: Record): OrderAddress | undefined => { + const name = toString(order.receiverName) + const phone = toString(order.receiverPhone) + const address = toString(order.receiverAddress) + if (!name && !phone && !address) { + return undefined + } + return { + name, + phone, + province: '', + city: '', + district: '', + address, + } +} + +export const normalizeOrder = (order: Record): Order => { + const totalAmount = toNumber(order.totalAmount ?? order.totalPrice) + const quantity = toNumber(order.quantity, 1) + const status = mapOrderStatus(order.status) + const createdAt = toIsoLikeString(order.createdAt) + const updatedAt = toIsoLikeString(order.updatedAt || order.createdAt) + const productImage = resolveImageUrl(toString(order.productImageUrl, '')) + + const fallbackItem = { + id: toNumber(order.productId || order.id), + productId: toNumber(order.productId), + productName: toString(order.productName, '未知商品'), + productImage, + price: quantity > 0 ? Number((totalAmount / quantity).toFixed(2)) : totalAmount, + quantity, + subtotal: totalAmount, + } + + const items = Array.isArray(order.items) && order.items.length > 0 + ? order.items.map((item: Record) => ({ + id: toNumber(item.id || item.productId), + productId: toNumber(item.productId), + productName: toString(item.productName, '未知商品'), + productImage: resolveImageUrl(toString(item.productImageUrl || item.productImage, '')), + price: toNumber(item.price), + quantity: toNumber(item.quantity, 1), + subtotal: toNumber(item.subtotal ?? item.price), + })) + : [fallbackItem] + + return { + id: toNumber(order.id), + orderNo: toString(order.orderNo, buildOrderNo(order.id)), + userId: toNumber(order.userId), + username: toString(order.username), + totalAmount, + paymentAmount: totalAmount, + paymentMethod: toString(order.paymentMethod) || (status === 'PENDING' ? undefined : 'ONLINE'), + status, + items, + address: buildOrderAddress(order), + remark: toString(order.remark), + createdAt, + updatedAt, + paidAt: order.paidAt ? toIsoLikeString(order.paidAt) : (status === 'PAID' || status === 'SHIPPED' || status === 'COMPLETED' ? updatedAt : undefined), + shippedAt: order.shippedAt ? toIsoLikeString(order.shippedAt) : (status === 'SHIPPED' || status === 'COMPLETED' ? updatedAt : undefined), + completedAt: order.completedAt ? toIsoLikeString(order.completedAt) : (status === 'COMPLETED' ? updatedAt : undefined), + } +} + +export const normalizeCartItems = (cart: Record | undefined): CartItem[] => { + const items = Array.isArray(cart?.items) ? cart.items : [] + return items.map((item: Record) => ({ + id: toString(item.productId), + productId: toNumber(item.productId), + productName: toString(item.productName), + productImage: resolveImageUrl(toString(item.productImageUrl || item.productImage, '')), + price: toNumber(item.productPrice), + quantity: toNumber(item.quantity, 1), + stock: toNumber(item.stock), + selected: true, + createdAt: new Date().toISOString(), + })) +} + +export const normalizePage = (payload: Record, mapper: (item: Record) => T): PageResponse => { + const content = Array.isArray(payload.content) ? payload.content.map((item: Record) => mapper(item)) : [] + const size = toNumber(payload.size, content.length || 10) + const pageNumber = toNumber(payload.currentPage ?? payload.number) + const totalElements = toNumber(payload.totalElements, content.length) + const totalPages = toNumber(payload.totalPages, size > 0 ? Math.ceil(totalElements / size) : 1) + return { + content, + totalElements, + totalPages, + size, + number: pageNumber, + first: pageNumber <= 0, + last: totalPages === 0 ? true : pageNumber >= totalPages - 1, + } +} + +export const normalizeAdminRecentOrder = (order: Record): AdminRecentOrderRow => ({ + id: toNumber(order.id), + orderNo: buildOrderNo(order.id), + username: toString(order.username), + productName: toString(order.productName), + quantity: toNumber(order.quantity, 1), + totalAmount: toNumber(order.totalAmount ?? order.totalPrice), + status: mapOrderStatus(order.status), + createdAt: toIsoLikeString(order.createdAt), + isFlashSale: Boolean(order.isFlashSale), +}) + +export const normalizeAdminHotProduct = (product: Record): AdminHotProductRow => ({ + id: toNumber(product.id), + name: toString(product.name), + price: toNumber(product.price), + stock: toNumber(product.stock), + sales: toNumber(product.sales), +}) + +export const normalizeAdminUser = (user: Record): AdminUserRow => ({ + id: toNumber(user.id), + username: toString(user.username), + email: toString(user.email), + phone: toString(user.phone), + status: toNumber(user.status, 1), + statusText: mapUserStatusText(toNumber(user.status, 1)), + role: toString(user.role).toUpperCase() === 'ADMIN' || toString(user.username) === 'admin' ? 'ADMIN' : 'USER', + isOnline: Boolean(user.isOnline), + createdAt: toIsoLikeString(user.createdAt), + lastLogin: user.lastLogin ? toIsoLikeString(user.lastLogin) : undefined, +}) + +export const normalizeAdminOrder = (order: Record): AdminOrderRow => ({ + id: toNumber(order.id), + orderNo: buildOrderNo(order.id), + username: toString(order.username), + productName: toString(order.productName), + productId: toNumber(order.productId), + quantity: toNumber(order.quantity, 1), + totalAmount: toNumber(order.totalAmount), + status: mapOrderStatus(order.status), + createdAt: toIsoLikeString(order.createdAt), + isFlashSale: Boolean(order.isFlashSale), +}) + +export const normalizeAdminProduct = (product: Record): AdminProductRow => ({ + id: toNumber(product.id), + name: toString(product.name), + description: toString(product.description), + category: toString(product.category, '默认分类'), + price: toNumber(product.price), + stock: toNumber(product.stock), + status: toNumber(product.status, 1), + imageUrl: resolveImageUrl(toString(product.imageUrl, '')), + createdAt: toIsoLikeString(product.createdAt), + updatedAt: product.updatedAt ? toIsoLikeString(product.updatedAt) : undefined, + totalSales: toNumber(product.totalSales), + totalRevenue: toNumber(product.totalRevenue), + viewCount: toNumber(product.viewCount), + rating: toNumber(product.rating), +}) diff --git a/flash-sale-frontend/vite.config.ts b/flash-sale-frontend/vite.config.ts index 9f969bf..d941e0b 100644 --- a/flash-sale-frontend/vite.config.ts +++ b/flash-sale-frontend/vite.config.ts @@ -2,7 +2,6 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import path from 'path' -// https://vitejs.dev/config/ export default defineConfig({ plugins: [vue()], resolve: { @@ -16,7 +15,19 @@ export default defineConfig({ '/api': { target: 'http://localhost:8080', changeOrigin: true, - rewrite: (path) => path.replace(/^\/api/, '/api'), + rewrite: (proxyPath) => proxyPath.replace(/^\/api/, '/api'), + }, + '/images': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + '/uploads': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + '/static': { + target: 'http://localhost:8080', + changeOrigin: true, }, }, }, @@ -24,12 +35,12 @@ export default defineConfig({ rollupOptions: { output: { manualChunks: { - 'vendor': ['vue', 'vue-router', 'pinia'], - 'element': ['element-plus', '@element-plus/icons-vue'], - 'utils': ['axios', 'dayjs', '@vueuse/core'], - 'charts': ['echarts', 'vue-echarts'], + vendor: ['vue', 'vue-router', 'pinia'], + element: ['element-plus', '@element-plus/icons-vue'], + utils: ['axios', 'dayjs', '@vueuse/core'], + charts: ['echarts', 'vue-echarts'], }, }, }, }, -}) \ No newline at end of file +})