初始化美若彩妆销售平台项目
This commit is contained in:
13
meiruo-frontend/index.html
Normal file
13
meiruo-frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<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>美若彩妆销售平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
25
meiruo-frontend/package.json
Normal file
25
meiruo-frontend/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "meiruo-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.2.5",
|
||||
"pinia": "^2.1.7",
|
||||
"axios": "^1.6.2",
|
||||
"sa-token": "^1.0.0",
|
||||
"element-plus": "^2.4.4",
|
||||
"@element-plus/icons-vue": "^2.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.5.2",
|
||||
"vite": "^5.0.10",
|
||||
"sass": "^1.69.5"
|
||||
}
|
||||
}
|
||||
12
meiruo-frontend/src/App.vue
Normal file
12
meiruo-frontend/src/App.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<el-config-provider :locale="locale">
|
||||
<router-view />
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
|
||||
const locale = ref(zhCn)
|
||||
</script>
|
||||
108
meiruo-frontend/src/api/index.js
Normal file
108
meiruo-frontend/src/api/index.js
Normal file
@@ -0,0 +1,108 @@
|
||||
import axios from 'axios'
|
||||
import router from '../router'
|
||||
import { useUserStore } from '../stores/user'
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
request.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
error => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
request.interceptors.response.use(
|
||||
response => {
|
||||
const res = response.data
|
||||
if (res.code === 200) {
|
||||
return res
|
||||
} else {
|
||||
ElMessage.error(res.msg || '请求失败')
|
||||
return Promise.reject(res)
|
||||
}
|
||||
},
|
||||
error => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('userInfo')
|
||||
router.push('/login')
|
||||
}
|
||||
ElMessage.error(error.response?.data?.msg || '请求失败')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
|
||||
export const userApi = {
|
||||
login: (data) => request.post('/user/login', data),
|
||||
register: (data) => request.post('/user/register', data),
|
||||
getInfo: () => request.get('/user/info'),
|
||||
updateInfo: (data) => request.put('/user/info', data),
|
||||
logout: () => request.post('/user/logout'),
|
||||
getList: (query) => request.get('/user/list', { params: { query } }),
|
||||
updateStatus: (id, status) => request.put(`/user/status/${id}`, null, { params: { status } })
|
||||
}
|
||||
|
||||
export const productApi = {
|
||||
getList: (params) => request.get('/product/list', { params }),
|
||||
getRecommend: () => request.get('/product/recommend'),
|
||||
getById: (id) => request.get(`/product/${id}`),
|
||||
add: (data) => request.post('/product', data),
|
||||
update: (data) => request.put('/product', data),
|
||||
delete: (id) => request.delete(`/product/${id}`)
|
||||
}
|
||||
|
||||
export const categoryApi = {
|
||||
getList: (status) => request.get('/category/list', { params: { status } }),
|
||||
getById: (id) => request.get(`/category/${id}`),
|
||||
add: (data) => request.post('/category', data),
|
||||
update: (data) => request.put('/category', data),
|
||||
delete: (id) => request.delete(`/category/${id}`)
|
||||
}
|
||||
|
||||
export const cartApi = {
|
||||
getList: () => request.get('/cart/list'),
|
||||
add: (productId, quantity) => request.post('/cart/add', null, { params: { productId, quantity } }),
|
||||
updateQuantity: (id, quantity) => request.put(`/cart/quantity/${id}`, null, { params: { quantity } }),
|
||||
delete: (id) => request.delete(`/cart/${id}`),
|
||||
clear: () => request.delete('/cart/clear')
|
||||
}
|
||||
|
||||
export const orderApi = {
|
||||
create: (data) => request.post('/order/create', data),
|
||||
getList: (status) => request.get('/order/list', { params: { status } }),
|
||||
adminList: (params) => request.get('/order/admin/list', { params }),
|
||||
getById: (id) => request.get(`/order/${id}`),
|
||||
updateStatus: (id, status) => request.put(`/order/status/${id}`, null, { params: { status } }),
|
||||
getRevenue: (type) => request.get(`/order/revenue/${type}`),
|
||||
getTopProducts: (limit) => request.get(`/order/top/${limit}`)
|
||||
}
|
||||
|
||||
export const bannerApi = {
|
||||
getList: (status) => request.get('/banner/list', { params: { status } }),
|
||||
getById: (id) => request.get(`/banner/${id}`),
|
||||
add: (data) => request.post('/banner', data),
|
||||
update: (data) => request.put('/banner', data),
|
||||
delete: (id) => request.delete(`/banner/${id}`),
|
||||
updateSort: (id, sort) => request.put(`/banner/sort/${id}`, null, { params: { sort } })
|
||||
}
|
||||
|
||||
export const uploadApi = {
|
||||
uploadImage: (file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return request.post('/upload/image', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
}
|
||||
18
meiruo-frontend/src/components/Footer.vue
Normal file
18
meiruo-frontend/src/components/Footer.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<el-footer class="footer">
|
||||
<p>© 2024 美若彩妆销售平台 版权所有</p>
|
||||
</el-footer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.footer {
|
||||
background: #333;
|
||||
color: #999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
160
meiruo-frontend/src/components/Header.vue
Normal file
160
meiruo-frontend/src/components/Header.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<el-header class="header">
|
||||
<div class="header-content">
|
||||
<div class="logo">
|
||||
<router-link to="/">美若彩妆</router-link>
|
||||
</div>
|
||||
<div class="nav">
|
||||
<router-link to="/" :class="{ active: $route.name === 'Home' }">首页</router-link>
|
||||
<router-link to="/product/1" :class="{ active: $route.name === 'ProductDetail' }">商品</router-link>
|
||||
</div>
|
||||
<div class="search">
|
||||
<el-input v-model="keyword" placeholder="搜索商品" @keyup.enter="handleSearch">
|
||||
<template #append>
|
||||
<el-button @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
<div class="user-actions">
|
||||
<template v-if="isLoggedIn">
|
||||
<el-dropdown>
|
||||
<span class="user-info">
|
||||
<el-avatar :size="32" :src="userInfo.avatar">{{ userInfo.nickname?.charAt(0) }}</el-avatar>
|
||||
<span>{{ userInfo.nickname || userInfo.username }}</span>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="$router.push('/user')">个人中心</el-dropdown-item>
|
||||
<el-dropdown-item @click="$router.push('/order')">我的订单</el-dropdown-item>
|
||||
<el-dropdown-item @click="$router.push('/admin')" v-if="isAdmin">管理后台</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="handleLogout">退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-link to="/login">登录</router-link>
|
||||
<router-link to="/register">注册</router-link>
|
||||
</template>
|
||||
<router-link to="/cart" class="cart-icon">
|
||||
<el-badge :value="cartCount" :hidden="cartCount === 0">
|
||||
<el-icon :size="24"><ShoppingCart /></el-icon>
|
||||
</el-badge>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</el-header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import { cartApi } from '../api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const keyword = ref('')
|
||||
const cartCount = ref(0)
|
||||
|
||||
const isLoggedIn = computed(() => userStore.isLoggedIn)
|
||||
const isAdmin = computed(() => userStore.isAdmin)
|
||||
const userInfo = computed(() => userStore.userInfo)
|
||||
|
||||
const handleSearch = () => {
|
||||
router.push({ name: 'Home', query: { keyword: keyword.value } })
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await userStore.logout()
|
||||
ElMessage.success('退出成功')
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const fetchCartCount = async () => {
|
||||
if (isLoggedIn.value) {
|
||||
try {
|
||||
const res = await cartApi.getList()
|
||||
if (res.code === 200) {
|
||||
cartCount.value = res.data.length
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchCartCount()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.header {
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.logo {
|
||||
a {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #e93b3d;
|
||||
}
|
||||
}
|
||||
|
||||
.nav {
|
||||
a {
|
||||
margin: 0 20px;
|
||||
color: #333;
|
||||
font-size: 16px;
|
||||
&:hover,
|
||||
&.active {
|
||||
color: #e93b3d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
|
||||
a {
|
||||
color: #333;
|
||||
&:hover {
|
||||
color: #e93b3d;
|
||||
}
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cart-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
20
meiruo-frontend/src/main.js
Normal file
20
meiruo-frontend/src/main.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
|
||||
app.mount('#app')
|
||||
120
meiruo-frontend/src/router/index.js
Normal file
120
meiruo-frontend/src/router/index.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useUserStore } from '../stores/user'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: () => import('../views/user/Home.vue'),
|
||||
meta: { title: '首页' }
|
||||
},
|
||||
{
|
||||
path: '/product/:id',
|
||||
name: 'ProductDetail',
|
||||
component: () => import('../views/user/ProductDetail.vue'),
|
||||
meta: { title: '商品详情' }
|
||||
},
|
||||
{
|
||||
path: '/cart',
|
||||
name: 'Cart',
|
||||
component: () => import('../views/user/Cart.vue'),
|
||||
meta: { title: '购物车', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/order',
|
||||
name: 'Order',
|
||||
component: () => import('../views/user/Order.vue'),
|
||||
meta: { title: '我的订单', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/checkout',
|
||||
name: 'Checkout',
|
||||
component: () => import('../views/user/Checkout.vue'),
|
||||
meta: { title: '结算', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('../views/user/Login.vue'),
|
||||
meta: { title: '登录' }
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('../views/user/Register.vue'),
|
||||
meta: { title: '注册' }
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
name: 'UserCenter',
|
||||
component: () => import('../views/user/UserCenter.vue'),
|
||||
meta: { title: '个人中心', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'Admin',
|
||||
component: () => import('../views/admin/Admin.vue'),
|
||||
meta: { title: '管理后台', requiresAuth: true, isAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('../views/admin/Dashboard.vue'),
|
||||
meta: { title: '数据统计' }
|
||||
},
|
||||
{
|
||||
path: 'product',
|
||||
name: 'ProductManage',
|
||||
component: () => import('../views/admin/ProductManage.vue'),
|
||||
meta: { title: '商品管理' }
|
||||
},
|
||||
{
|
||||
path: 'category',
|
||||
name: 'CategoryManage',
|
||||
component: () => import('../views/admin/CategoryManage.vue'),
|
||||
meta: { title: '分类管理' }
|
||||
},
|
||||
{
|
||||
path: 'order',
|
||||
name: 'OrderManage',
|
||||
component: () => import('../views/admin/OrderManage.vue'),
|
||||
meta: { title: '订单管理' }
|
||||
},
|
||||
{
|
||||
path: 'banner',
|
||||
name: 'BannerManage',
|
||||
component: () => import('../views/admin/BannerManage.vue'),
|
||||
meta: { title: '轮播图管理' }
|
||||
},
|
||||
{
|
||||
path: 'user',
|
||||
name: 'UserManage',
|
||||
component: () => import('../views/admin/UserManage.vue'),
|
||||
meta: { title: '用户管理' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
document.title = to.meta.title || '美若彩妆'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const isLoggedIn = userStore.isLoggedIn
|
||||
const isAdmin = userStore.userInfo.role === 1
|
||||
|
||||
if (to.meta.requiresAuth && !isLoggedIn) {
|
||||
next({ name: 'Login', query: { redirect: to.fullPath } })
|
||||
} else if (to.meta.isAdmin && !isAdmin) {
|
||||
next({ name: 'Home' })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
65
meiruo-frontend/src/stores/user.js
Normal file
65
meiruo-frontend/src/stores/user.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { userApi } from '../api'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const token = ref(localStorage.getItem('token') || '')
|
||||
const userInfo = ref(JSON.parse(localStorage.getItem('userInfo') || '{}'))
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const isAdmin = computed(() => userInfo.value.role === 1)
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await userApi.login({ username, password })
|
||||
if (res.code === 200) {
|
||||
token.value = res.data.token || 'token'
|
||||
userInfo.value = res.data
|
||||
localStorage.setItem('token', token.value)
|
||||
localStorage.setItem('userInfo', JSON.stringify(userInfo.value))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function register(data) {
|
||||
const res = await userApi.register(data)
|
||||
return res.code === 200
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await userApi.logout()
|
||||
token.value = ''
|
||||
userInfo.value = {}
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('userInfo')
|
||||
}
|
||||
|
||||
async function getInfo() {
|
||||
try {
|
||||
const res = await userApi.getInfo()
|
||||
if (res.code === 200) {
|
||||
userInfo.value = res.data
|
||||
localStorage.setItem('userInfo', JSON.stringify(userInfo.value))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
function updateUserInfo(info) {
|
||||
userInfo.value = { ...userInfo.value, ...info }
|
||||
localStorage.setItem('userInfo', JSON.stringify(userInfo.value))
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
userInfo,
|
||||
isLoggedIn,
|
||||
isAdmin,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
getInfo,
|
||||
updateUserInfo
|
||||
}
|
||||
})
|
||||
37
meiruo-frontend/src/style.css
Normal file
37
meiruo-frontend/src/style.css
Normal file
@@ -0,0 +1,37 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.el-container {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.el-header {
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.el-main {
|
||||
background-color: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.el-footer {
|
||||
background-color: #fff;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #999;
|
||||
}
|
||||
119
meiruo-frontend/src/views/admin/Admin.vue
Normal file
119
meiruo-frontend/src/views/admin/Admin.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div class="admin-layout">
|
||||
<el-container>
|
||||
<el-aside width="200px">
|
||||
<div class="logo">美若彩妆管理后台</div>
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
router
|
||||
background-color="#304156"
|
||||
text-color="#bfcbd9"
|
||||
active-text-color="#409EFF"
|
||||
>
|
||||
<el-menu-item index="/admin/dashboard">
|
||||
<el-icon><DataAnalysis /></el-icon>
|
||||
<span>数据统计</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/product">
|
||||
<el-icon><Goods /></el-icon>
|
||||
<span>商品管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/category">
|
||||
<el-icon><Grid /></el-icon>
|
||||
<span>分类管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/order">
|
||||
<el-icon><List /></el-icon>
|
||||
<span>订单管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/banner">
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>轮播图管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/user">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>用户管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/" divided>
|
||||
<el-icon><HomeFilled /></el-icon>
|
||||
<span>返回前台</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-container>
|
||||
<el-header>
|
||||
<div class="header-right">
|
||||
<span class="admin-name">管理员: {{ userInfo.nickname }}</span>
|
||||
<el-button type="text" @click="handleLogout">退出</el-button>
|
||||
</div>
|
||||
</el-header>
|
||||
<el-main>
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const activeMenu = ref('/admin/dashboard')
|
||||
|
||||
const userInfo = computed(() => userStore.userInfo)
|
||||
|
||||
const handleLogout = async () => {
|
||||
await userStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
activeMenu.value = router.currentRoute.value.path
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.admin-layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.el-aside {
|
||||
background: #304156;
|
||||
|
||||
.logo {
|
||||
height: 60px;
|
||||
line-height: 60px;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
background: #263445;
|
||||
}
|
||||
|
||||
.el-menu {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
.el-header {
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.admin-name {
|
||||
margin-right: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.el-main {
|
||||
background: #f0f2f5;
|
||||
}
|
||||
</style>
|
||||
366
meiruo-frontend/src/views/admin/BannerManage.vue
Normal file
366
meiruo-frontend/src/views/admin/BannerManage.vue
Normal file
@@ -0,0 +1,366 @@
|
||||
<template>
|
||||
<div class="banner-manage">
|
||||
<el-card class="main-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<el-icon class="header-icon"><Picture /></el-icon>
|
||||
<span>轮播图管理</span>
|
||||
</div>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加轮播图
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="bannerList" stripe style="width: 100%" :header-cell-style="{ background: '#f8f9fa' }">
|
||||
<el-table-column label="轮播图" width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="banner-cell">
|
||||
<img :src="row.image || '/images/default.png'" :alt="row.title" class="banner-thumb" />
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="标题" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<span class="banner-title">{{ row.title || '暂无标题' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="跳转链接" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.link" type="primary" :underline="false" class="link-text">
|
||||
{{ row.link }}
|
||||
</el-link>
|
||||
<span v-else class="no-link">暂无链接</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="排序" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="info" size="small">{{ row.sort }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 1 ? '显示' : '隐藏' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog :title="dialogTitle" v-model="dialogVisible" width="600px" destroy-on-close>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px" class="banner-form">
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input v-model="form.title" placeholder="请输入轮播图标题(选填)" maxlength="50" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="轮播图" prop="image">
|
||||
<el-upload
|
||||
class="banner-uploader"
|
||||
action="#"
|
||||
:show-file-list="false"
|
||||
:auto-upload="true"
|
||||
:http-request="handleImageUpload"
|
||||
>
|
||||
<img v-if="form.image" :src="form.image" class="upload-image" />
|
||||
<div v-else class="upload-placeholder">
|
||||
<el-icon class="upload-icon"><Plus /></el-icon>
|
||||
<span>上传图片</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div class="upload-tip">建议尺寸: 1920x450 像素,支持 jpg、png 格式,大小不超过 5MB</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="跳转链接">
|
||||
<el-input v-model="form.link" placeholder="请输入点击后的跳转链接(选填)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sort" :min="0" :max="999" controls-position="right" style="width: 100%" />
|
||||
<span class="form-tip">数字越小越靠前</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="显示状态">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio :label="1">
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
显示
|
||||
</el-radio>
|
||||
<el-radio :label="0">
|
||||
<el-icon><CircleClose /></el-icon>
|
||||
隐藏
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitting">
|
||||
<el-icon v-if="!submitting"><Check /></el-icon>
|
||||
确定保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { bannerApi, uploadApi } from '../../api'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Picture, Plus, Edit, Delete, CircleCheck, CircleClose, Check } from '@element-plus/icons-vue'
|
||||
|
||||
const bannerList = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('添加轮播图')
|
||||
const formRef = ref(null)
|
||||
const isEdit = ref(false)
|
||||
const submitting = ref(false)
|
||||
const uploadLoading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
id: null,
|
||||
title: '',
|
||||
image: '',
|
||||
link: '',
|
||||
sort: 0,
|
||||
status: 1
|
||||
})
|
||||
|
||||
const rules = {
|
||||
image: [{ required: true, message: '请上传轮播图', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const fetchBanners = async () => {
|
||||
try {
|
||||
const res = await bannerApi.getList()
|
||||
if (res.code === 200) {
|
||||
bannerList.value = res.data || []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageUpload = async (options) => {
|
||||
const file = options.file
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
ElMessage.error('图片大小不能超过 5MB')
|
||||
return
|
||||
}
|
||||
|
||||
const isImage = file.type === 'image/jpeg' || file.type === 'image/png'
|
||||
if (!isImage) {
|
||||
ElMessage.error('只能上传 JPG/PNG 格式的图片')
|
||||
return
|
||||
}
|
||||
|
||||
uploadLoading.value = true
|
||||
try {
|
||||
const res = await uploadApi.uploadImage(file)
|
||||
if (res.code === 200) {
|
||||
form.image = res.url
|
||||
ElMessage.success('图片上传成功')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '图片上传失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
ElMessage.error('图片上传失败')
|
||||
} finally {
|
||||
uploadLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.id = null
|
||||
form.title = ''
|
||||
form.image = ''
|
||||
form.link = ''
|
||||
form.sort = 0
|
||||
form.status = 1
|
||||
if (formRef.value) {
|
||||
formRef.value.resetFields()
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '添加轮播图'
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row) => {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = '编辑轮播图'
|
||||
Object.assign(form, row)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该轮播图吗?删除后无法恢复!', '确认删除', {
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
await bannerApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchBanners()
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitting.value = true
|
||||
if (isEdit.value) {
|
||||
await bannerApi.update(form)
|
||||
} else {
|
||||
await bannerApi.add(form)
|
||||
}
|
||||
ElMessage.success(isEdit.value ? '修改成功' : '添加成功')
|
||||
dialogVisible.value = false
|
||||
fetchBanners()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchBanners()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.banner-manage {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.main-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
|
||||
.header-icon {
|
||||
color: #e93b3d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.banner-cell {
|
||||
.banner-thumb {
|
||||
width: 180px;
|
||||
height: 100px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
}
|
||||
|
||||
.banner-title {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.link-text {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.no-link {
|
||||
color: #bbb;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
margin-left: 8px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.banner-uploader {
|
||||
width: 360px;
|
||||
|
||||
:deep(.el-upload) {
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
width: 360px;
|
||||
height: 100px;
|
||||
|
||||
&:hover {
|
||||
border-color: #e93b3d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.upload-image {
|
||||
width: 360px;
|
||||
height: 100px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #8c939d;
|
||||
background: #fafafa;
|
||||
|
||||
.upload-icon {
|
||||
font-size: 24px;
|
||||
color: #c0c4cc;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-tip {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
260
meiruo-frontend/src/views/admin/CategoryManage.vue
Normal file
260
meiruo-frontend/src/views/admin/CategoryManage.vue
Normal file
@@ -0,0 +1,260 @@
|
||||
<template>
|
||||
<div class="category-manage">
|
||||
<el-card class="main-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<el-icon class="header-icon"><Grid /></el-icon>
|
||||
<span>分类管理</span>
|
||||
</div>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加分类
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="categoryList" stripe style="width: 100%" :header-cell-style="{ background: '#f8f9fa' }">
|
||||
<el-table-column label="分类名称" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="category-cell">
|
||||
<div class="category-icon-wrapper">
|
||||
<el-icon><Grid /></el-icon>
|
||||
</div>
|
||||
<span class="category-name">{{ row.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="分类描述" min-width="250">
|
||||
<template #default="{ row }">
|
||||
<span class="description-text">{{ row.description || '暂无描述' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="排序" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="info" size="small">{{ row.sort }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 1 ? '正常' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog :title="dialogTitle" v-model="dialogVisible" width="500px" destroy-on-close>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px" class="category-form">
|
||||
<el-form-item label="分类名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="请输入分类名称" maxlength="50" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类描述">
|
||||
<el-input v-model="form.description" type="textarea" rows="3" placeholder="请输入分类描述(选填)" maxlength="200" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sort" :min="0" :max="999" controls-position="right" style="width: 100%" />
|
||||
<span class="form-tip">数字越小越靠前</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="分类状态">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio :label="1">
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
正常
|
||||
</el-radio>
|
||||
<el-radio :label="0">
|
||||
<el-icon><CircleClose /></el-icon>
|
||||
禁用
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitting">
|
||||
<el-icon v-if="!submitting"><Check /></el-icon>
|
||||
确定保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { categoryApi } from '../../api'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Grid, Plus, Edit, Delete, CircleCheck, CircleClose, Check } from '@element-plus/icons-vue'
|
||||
|
||||
const categoryList = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('添加分类')
|
||||
const formRef = ref(null)
|
||||
const isEdit = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
sort: 0,
|
||||
status: 1
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入分类名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const res = await categoryApi.getList()
|
||||
if (res.code === 200) {
|
||||
categoryList.value = res.data || []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.id = null
|
||||
form.name = ''
|
||||
form.description = ''
|
||||
form.sort = 0
|
||||
form.status = 1
|
||||
if (formRef.value) {
|
||||
formRef.value.resetFields()
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '添加分类'
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row) => {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = '编辑分类'
|
||||
Object.assign(form, row)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该分类吗?删除后无法恢复!', '确认删除', {
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
await categoryApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchCategories()
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitting.value = true
|
||||
if (isEdit.value) {
|
||||
await categoryApi.update(form)
|
||||
} else {
|
||||
await categoryApi.add(form)
|
||||
}
|
||||
ElMessage.success(isEdit.value ? '修改成功' : '添加成功')
|
||||
dialogVisible.value = false
|
||||
fetchCategories()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchCategories()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.category-manage {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.main-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
|
||||
.header-icon {
|
||||
color: #e93b3d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.category-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.category-icon-wrapper {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #fff5f5 0%, #ffe5e5 100%);
|
||||
border-radius: 10px;
|
||||
color: #e93b3d;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.description-text {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
margin-left: 8px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
129
meiruo-frontend/src/views/admin/Dashboard.vue
Normal file
129
meiruo-frontend/src/views/admin/Dashboard.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<div class="stat-cards">
|
||||
<el-card v-for="stat in stats" :key="stat.title" class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-title">{{ stat.title }}</div>
|
||||
<div class="stat-value">{{ stat.value }}</div>
|
||||
</div>
|
||||
<div class="stat-icon" :style="{ background: stat.color }">
|
||||
<el-icon :size="30"><component :is="stat.icon" /></el-icon>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-card class="chart-card">
|
||||
<template #header>
|
||||
<span>本周收入趋势</span>
|
||||
</template>
|
||||
<div class="chart-placeholder">
|
||||
<el-table :data="revenueData" stripe style="width: 100%">
|
||||
<el-table-column prop="date" label="日期" />
|
||||
<el-table-column prop="amount" label="收入金额" />
|
||||
<el-table-column prop="orderCount" label="订单数" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card class="chart-card">
|
||||
<template #header>
|
||||
<span>热销商品排行</span>
|
||||
</template>
|
||||
<div class="chart-placeholder">
|
||||
<el-table :data="topProducts" stripe style="width: 100%">
|
||||
<el-table-column prop="name" label="商品名称" />
|
||||
<el-table-column prop="totalSales" label="销量" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { orderApi } from '../../api'
|
||||
|
||||
const stats = ref([
|
||||
{ title: '今日订单', value: 0, icon: 'ShoppingCart', color: '#409EFF' },
|
||||
{ title: '今日收入', value: '¥0', icon: 'Money', color: '#67C23A' },
|
||||
{ title: '商品总数', value: 0, icon: 'Goods', color: '#E6A23C' },
|
||||
{ title: '用户总数', value: 0, icon: 'User', color: '#F56C6C' }
|
||||
])
|
||||
|
||||
const revenueData = ref([])
|
||||
const topProducts = ref([])
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const weekRes = await orderApi.getRevenue('week')
|
||||
if (weekRes.code === 200) {
|
||||
revenueData.value = weekRes.data?.list || []
|
||||
}
|
||||
|
||||
const topRes = await orderApi.getTopProducts(10)
|
||||
if (topRes.code === 200) {
|
||||
topProducts.value = topRes.data || []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dashboard {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.stat-cards {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
.stat-title {
|
||||
color: #999;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.chart-placeholder {
|
||||
height: 300px;
|
||||
}
|
||||
</style>
|
||||
119
meiruo-frontend/src/views/admin/OrderManage.vue
Normal file
119
meiruo-frontend/src/views/admin/OrderManage.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div class="order-manage">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>订单管理</span>
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索订单号/收货人"
|
||||
style="width: 250px"
|
||||
@keyup.enter="fetchOrders"
|
||||
>
|
||||
<template #append>
|
||||
<el-button @click="fetchOrders">
|
||||
<el-icon><Search /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="orderList" stripe style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="order_no" label="订单号" width="200" />
|
||||
<el-table-column prop="receiver_name" label="收货人" width="120" />
|
||||
<el-table-column prop="receiver_phone" label="联系电话" width="140" />
|
||||
<el-table-column prop="total_amount" label="订单金额">
|
||||
<template #default="{ row }">
|
||||
¥{{ parseFloat(row.total_amount).toFixed(2) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)">
|
||||
{{ getStatusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="create_time" label="下单时间" width="180" />
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" size="small" @click="viewDetail(row)">查看</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 1"
|
||||
type="success"
|
||||
size="small"
|
||||
@click="updateStatus(row.id, 2)"
|
||||
>
|
||||
发货
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { orderApi } from '../../api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const orderList = ref([])
|
||||
const keyword = ref('')
|
||||
|
||||
const statusMap = {
|
||||
1: '待付款',
|
||||
2: '已付款',
|
||||
3: '已发货',
|
||||
4: '已完成',
|
||||
5: '已取消'
|
||||
}
|
||||
|
||||
const getStatusText = (status) => statusMap[status] || '未知'
|
||||
const getStatusType = (status) => {
|
||||
const types = { 1: 'warning', 2: 'primary', 3: 'info', 4: 'success', 5: 'danger' }
|
||||
return types[status] || 'info'
|
||||
}
|
||||
|
||||
const fetchOrders = async () => {
|
||||
try {
|
||||
const res = await orderApi.adminList({ keyword: keyword.value })
|
||||
if (res.code === 200) {
|
||||
orderList.value = res.data || []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const viewDetail = (order) => {
|
||||
console.log('查看订单详情', order)
|
||||
}
|
||||
|
||||
const updateStatus = async (id, status) => {
|
||||
try {
|
||||
await orderApi.updateStatus(id, status)
|
||||
ElMessage.success('操作成功')
|
||||
fetchOrders()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchOrders()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.order-manage {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
442
meiruo-frontend/src/views/admin/ProductManage.vue
Normal file
442
meiruo-frontend/src/views/admin/ProductManage.vue
Normal file
@@ -0,0 +1,442 @@
|
||||
<template>
|
||||
<div class="product-manage">
|
||||
<el-card class="main-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<el-icon class="header-icon"><Goods /></el-icon>
|
||||
<span>商品管理</span>
|
||||
</div>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加商品
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="productList" stripe style="width: 100%" :header-cell-style="{ background: '#f8f9fa' }">
|
||||
<el-table-column label="商品信息" min-width="280">
|
||||
<template #default="{ row }">
|
||||
<div class="product-cell">
|
||||
<img :src="row.image || '/images/default.png'" :alt="row.name" class="product-thumb" />
|
||||
<div class="product-info">
|
||||
<span class="product-name">{{ row.name }}</span>
|
||||
<span class="product-desc">{{ row.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分类" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="info" size="small">{{ getCategoryName(row.categoryId) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="价格" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="price-text">¥{{ row.price.toFixed(2) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="库存" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ 'low-stock': row.stock < 10 }">{{ row.stock }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="销量" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.sales }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 1 ? '上架' : '下架' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog :title="dialogTitle" v-model="dialogVisible" width="680px" destroy-on-close>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px" class="product-form">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="24">
|
||||
<el-form-item label="商品名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="请输入商品名称" maxlength="100" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="商品描述" prop="description">
|
||||
<el-input v-model="form.description" type="textarea" rows="3" placeholder="请输入商品描述" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="商品价格" prop="price">
|
||||
<el-input-number v-model="form.price" :precision="2" :min="0" :max="999999" controls-position="right" style="width: 100%" />
|
||||
<span class="form-tip">元</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="商品库存" prop="stock">
|
||||
<el-input-number v-model="form.stock" :min="0" :max="999999" controls-position="right" style="width: 100%" />
|
||||
<span class="form-tip">件</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="商品分类" prop="categoryId">
|
||||
<el-select v-model="form.categoryId" placeholder="请选择分类" style="width: 100%">
|
||||
<el-option v-for="cat in categories" :key="cat.id" :label="cat.name" :value="cat.id">
|
||||
<span>{{ cat.name }}</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="商品状态" prop="status">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio :label="1">
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
上架
|
||||
</el-radio>
|
||||
<el-radio :label="0">
|
||||
<el-icon><CircleClose /></el-icon>
|
||||
下架
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="商品图片" prop="image">
|
||||
<el-upload
|
||||
class="image-uploader"
|
||||
action="#"
|
||||
:show-file-list="false"
|
||||
:auto-upload="true"
|
||||
:http-request="handleImageUpload"
|
||||
>
|
||||
<img v-if="form.image" :src="form.image" class="upload-image" />
|
||||
<div v-else class="upload-placeholder">
|
||||
<el-icon class="upload-icon"><Plus /></el-icon>
|
||||
<span>上传图片</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div class="upload-tip">支持 jpg、png 格式,大小不超过 2MB</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitting">
|
||||
<el-icon v-if="!submitting"><Check /></el-icon>
|
||||
确定保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { productApi, categoryApi, uploadApi } from '../../api'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Goods, Plus, Edit, Delete, CircleCheck, CircleClose, Check } from '@element-plus/icons-vue'
|
||||
|
||||
const productList = ref([])
|
||||
const categories = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('添加商品')
|
||||
const formRef = ref(null)
|
||||
const isEdit = ref(false)
|
||||
const submitting = ref(false)
|
||||
const uploadLoading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
price: 0,
|
||||
stock: 0,
|
||||
categoryId: null,
|
||||
image: '',
|
||||
status: 1
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
|
||||
price: [{ required: true, message: '请输入商品价格', trigger: 'blur' }],
|
||||
stock: [{ required: true, message: '请输入商品库存', trigger: 'blur' }],
|
||||
categoryId: [{ required: true, message: '请选择商品分类', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const getCategoryName = (categoryId) => {
|
||||
const cat = categories.value.find(c => c.id === categoryId)
|
||||
return cat ? cat.name : '-'
|
||||
}
|
||||
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
const res = await productApi.getList()
|
||||
if (res.code === 200) {
|
||||
productList.value = res.data || []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const res = await categoryApi.getList()
|
||||
if (res.code === 200) {
|
||||
categories.value = res.data || []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageUpload = async (options) => {
|
||||
const file = options.file
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
ElMessage.error('图片大小不能超过 2MB')
|
||||
return
|
||||
}
|
||||
|
||||
const isImage = file.type === 'image/jpeg' || file.type === 'image/png'
|
||||
if (!isImage) {
|
||||
ElMessage.error('只能上传 JPG/PNG 格式的图片')
|
||||
return
|
||||
}
|
||||
|
||||
uploadLoading.value = true
|
||||
try {
|
||||
const res = await uploadApi.uploadImage(file)
|
||||
if (res.code === 200) {
|
||||
form.image = res.url
|
||||
ElMessage.success('图片上传成功')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '图片上传失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
ElMessage.error('图片上传失败')
|
||||
} finally {
|
||||
uploadLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.id = null
|
||||
form.name = ''
|
||||
form.description = ''
|
||||
form.price = 0
|
||||
form.stock = 0
|
||||
form.categoryId = null
|
||||
form.image = ''
|
||||
form.status = 1
|
||||
if (formRef.value) {
|
||||
formRef.value.resetFields()
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '添加商品'
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row) => {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = '编辑商品'
|
||||
Object.assign(form, row)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该商品吗?删除后无法恢复!', '确认删除', {
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
await productApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchProducts()
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitting.value = true
|
||||
if (isEdit.value) {
|
||||
await productApi.update(form)
|
||||
} else {
|
||||
await productApi.add(form)
|
||||
}
|
||||
ElMessage.success(isEdit.value ? '修改成功' : '添加成功')
|
||||
dialogVisible.value = false
|
||||
fetchProducts()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchProducts()
|
||||
fetchCategories()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.product-manage {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.main-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
|
||||
.header-icon {
|
||||
color: #e93b3d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.product-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.product-thumb {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.product-name {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-desc {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.price-text {
|
||||
color: #e93b3d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.low-stock {
|
||||
color: #f56c6c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
margin-left: 8px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.image-uploader {
|
||||
width: 120px;
|
||||
|
||||
:deep(.el-upload) {
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
|
||||
&:hover {
|
||||
border-color: #e93b3d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.upload-image {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #8c939d;
|
||||
|
||||
.upload-icon {
|
||||
font-size: 28px;
|
||||
color: #c0c4cc;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-tip {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
110
meiruo-frontend/src/views/admin/UserManage.vue
Normal file
110
meiruo-frontend/src/views/admin/UserManage.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="user-manage">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>用户管理</span>
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索用户名/昵称/手机号"
|
||||
style="width: 250px"
|
||||
@keyup.enter="fetchUsers"
|
||||
>
|
||||
<template #append>
|
||||
<el-button @click="fetchUsers">
|
||||
<el-icon><Search /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="userList" stripe style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="username" label="用户名" width="150" />
|
||||
<el-table-column prop="nickname" label="昵称" width="150" />
|
||||
<el-table-column prop="phone" label="手机号" width="150" />
|
||||
<el-table-column prop="role" label="角色" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.role === 1 ? 'danger' : 'primary'">
|
||||
{{ row.role === 1 ? '管理员' : '普通用户' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
:model-value="row.status === 1"
|
||||
@change="updateStatus(row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="create_time" label="注册时间" width="180" />
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { userApi } from '../../api'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const userList = ref([])
|
||||
const keyword = ref('')
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const res = await userApi.getList(keyword.value)
|
||||
if (res.code === 200) {
|
||||
userList.value = res.data || []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const updateStatus = async (user) => {
|
||||
try {
|
||||
const newStatus = user.status === 1 ? 0 : 1
|
||||
await userApi.updateStatus(user.id, newStatus)
|
||||
user.status = newStatus
|
||||
ElMessage.success('操作成功')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (user) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该用户吗?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
|
||||
await userApi.delete(user.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchUsers()
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-manage {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
152
meiruo-frontend/src/views/user/Cart.vue
Normal file
152
meiruo-frontend/src/views/user/Cart.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div class="cart-page">
|
||||
<el-container>
|
||||
<el-header>
|
||||
<Header />
|
||||
</el-header>
|
||||
<el-main>
|
||||
<div class="cart-container">
|
||||
<h2>购物车</h2>
|
||||
<div class="cart-list" v-if="cartList.length > 0">
|
||||
<el-table :data="cartList" stripe style="width: 100%">
|
||||
<el-table-column width="180">
|
||||
<template #default="{ row }">
|
||||
<el-image :src="row.product_image || '/images/default.png'" fit="cover" style="width: 100px; height: 100px" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="product_name" label="商品名称" />
|
||||
<el-table-column label="单价">
|
||||
<template #default="{ row }">
|
||||
¥{{ parseFloat(row.product_price).toFixed(2) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数量" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.quantity" :min="1" @change="updateQuantity(row.id, row.quantity)" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="小计">
|
||||
<template #default="{ row }">
|
||||
¥{{ (parseFloat(row.product_price) * row.quantity).toFixed(2) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" size="small" @click="deleteItem(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="cart-footer">
|
||||
<div class="total">
|
||||
总计: <span>¥{{ totalPrice.toFixed(2) }}</span>
|
||||
</div>
|
||||
<el-button type="primary" size="large" @click="goToCheckout">去结算</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="购物车空空如也" />
|
||||
</div>
|
||||
</el-main>
|
||||
<el-footer>
|
||||
<Footer />
|
||||
</el-footer>
|
||||
</el-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Header from '../../components/Header.vue'
|
||||
import Footer from '../../components/Footer.vue'
|
||||
import { cartApi } from '../../api'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const cartList = ref([])
|
||||
|
||||
const totalPrice = computed(() => {
|
||||
return cartList.value.reduce((sum, item) => {
|
||||
return sum + parseFloat(item.product_price) * item.quantity
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const fetchCartList = async () => {
|
||||
try {
|
||||
const res = await cartApi.getList()
|
||||
if (res.code === 200) {
|
||||
cartList.value = res.data || []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const updateQuantity = async (id, quantity) => {
|
||||
try {
|
||||
await cartApi.updateQuantity(id, quantity)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteItem = async (id) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该商品吗?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
|
||||
await cartApi.delete(id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchCartList()
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const goToCheckout = () => {
|
||||
if (cartList.value.length === 0) {
|
||||
ElMessage.warning('请先添加商品到购物车')
|
||||
return
|
||||
}
|
||||
const cartIds = cartList.value.map(item => item.id)
|
||||
router.push({ path: '/checkout', query: { cartIds: JSON.stringify(cartIds) } })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchCartList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cart-container {
|
||||
max-width: 1200px;
|
||||
margin: 20px auto;
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
|
||||
h2 {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
}
|
||||
|
||||
.cart-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #eee;
|
||||
|
||||
.total {
|
||||
font-size: 18px;
|
||||
|
||||
span {
|
||||
font-size: 24px;
|
||||
color: #e93b3d;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
235
meiruo-frontend/src/views/user/Checkout.vue
Normal file
235
meiruo-frontend/src/views/user/Checkout.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<div class="checkout-page">
|
||||
<el-container>
|
||||
<el-header>
|
||||
<Header />
|
||||
</el-header>
|
||||
<el-main>
|
||||
<div class="checkout-container">
|
||||
<h2>订单结算</h2>
|
||||
<div class="checkout-content">
|
||||
<div class="address-section">
|
||||
<h3>收货信息</h3>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
|
||||
<el-form-item label="收货人" prop="receiverName">
|
||||
<el-input v-model="form.receiverName" placeholder="请输入收货人姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系电话" prop="receiverPhone">
|
||||
<el-input v-model="form.receiverPhone" placeholder="请输入联系电话" />
|
||||
</el-form-item>
|
||||
<el-form-item label="收货地址" prop="receiverAddress">
|
||||
<el-input v-model="form.receiverAddress" placeholder="请输入详细地址" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.remark" type="textarea" placeholder="选填,请输入备注信息" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="order-section">
|
||||
<h3>订单商品</h3>
|
||||
<div class="product-list">
|
||||
<div class="product-item" v-for="item in orderItems" :key="item.id">
|
||||
<img :src="item.product_image || '/images/default.png'" alt="" />
|
||||
<div class="info">
|
||||
<h4>{{ item.product_name }}</h4>
|
||||
<p>¥{{ parseFloat(item.product_price).toFixed(2) }} x {{ item.quantity }}</p>
|
||||
</div>
|
||||
<div class="subtotal">¥{{ (parseFloat(item.product_price) * item.quantity).toFixed(2) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="order-total">
|
||||
<span>订单总额:</span>
|
||||
<span class="amount">¥{{ totalAmount.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="submit-order">
|
||||
<el-button type="primary" size="large" @click="handleSubmit" :loading="submitting">提交订单</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-main>
|
||||
<el-footer>
|
||||
<Footer />
|
||||
</el-footer>
|
||||
</el-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Header from '../../components/Header.vue'
|
||||
import Footer from '../../components/Footer.vue'
|
||||
import { cartApi, orderApi, productApi } from '../../api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const formRef = ref(null)
|
||||
const submitting = ref(false)
|
||||
const orderItems = ref([])
|
||||
const form = ref({
|
||||
receiverName: '',
|
||||
receiverPhone: '',
|
||||
receiverAddress: '',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
receiverName: [{ required: true, message: '请输入收货人姓名', trigger: 'blur' }],
|
||||
receiverPhone: [{ required: true, message: '请输入联系电话', trigger: 'blur' }],
|
||||
receiverAddress: [{ required: true, message: '请输入收货地址', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const totalAmount = computed(() => {
|
||||
return orderItems.value.reduce((sum, item) => {
|
||||
return sum + parseFloat(item.product_price) * item.quantity
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const cartIds = JSON.parse(router.currentRoute.value.query.cartIds || '[]')
|
||||
const productId = router.currentRoute.value.query.productId
|
||||
|
||||
if (productId) {
|
||||
const res = await productApi.getById(productId)
|
||||
if (res.code === 200) {
|
||||
orderItems.value = [{
|
||||
id: 0,
|
||||
product_id: res.data.id,
|
||||
product_name: res.data.name,
|
||||
product_image: res.data.image,
|
||||
product_price: res.data.price,
|
||||
quantity: parseInt(router.currentRoute.value.query.quantity) || 1
|
||||
}]
|
||||
}
|
||||
} else if (cartIds.length > 0) {
|
||||
const res = await cartApi.getList()
|
||||
if (res.code === 200) {
|
||||
orderItems.value = res.data.filter(item => cartIds.includes(item.id))
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitting.value = true
|
||||
|
||||
const cartIds = orderItems.value.filter(item => item.id > 0).map(item => item.id)
|
||||
const res = await orderApi.create({
|
||||
cartIds,
|
||||
...form.value
|
||||
})
|
||||
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('下单成功')
|
||||
router.push('/order')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.checkout-container {
|
||||
max-width: 1200px;
|
||||
margin: 20px auto;
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
|
||||
h2 {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
}
|
||||
|
||||
.checkout-content {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.address-section {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.order-section {
|
||||
flex: 1;
|
||||
|
||||
h3 {
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
}
|
||||
|
||||
.product-list {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.product-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
|
||||
img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1;
|
||||
|
||||
h4 {
|
||||
font-size: 14px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.subtotal {
|
||||
font-weight: bold;
|
||||
color: #e93b3d;
|
||||
}
|
||||
}
|
||||
|
||||
.order-total {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #eee;
|
||||
|
||||
.amount {
|
||||
font-size: 24px;
|
||||
color: #e93b3d;
|
||||
font-weight: bold;
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.submit-order {
|
||||
margin-top: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
437
meiruo-frontend/src/views/user/Home.vue
Normal file
437
meiruo-frontend/src/views/user/Home.vue
Normal file
@@ -0,0 +1,437 @@
|
||||
<template>
|
||||
<div class="home">
|
||||
<el-container>
|
||||
<el-header>
|
||||
<Header />
|
||||
</el-header>
|
||||
<el-main>
|
||||
<div class="banner-section">
|
||||
<el-carousel :interval="5000" height="450px" indicator-position="outside" trigger="click">
|
||||
<el-carousel-item v-for="banner in banners" :key="banner.id">
|
||||
<div class="banner-item" @click="handleBannerClick(banner)">
|
||||
<img :src="banner.image || '/images/default.png'" :alt="banner.title" class="banner-img" />
|
||||
<div class="banner-overlay">
|
||||
<h3>{{ banner.title }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</el-carousel-item>
|
||||
</el-carousel>
|
||||
</div>
|
||||
|
||||
<div class="category-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<el-icon><Grid /></el-icon>
|
||||
商品分类
|
||||
</h2>
|
||||
</div>
|
||||
<div class="category-grid">
|
||||
<div
|
||||
class="category-card"
|
||||
:class="{ active: activeCategory === null }"
|
||||
@click="selectCategory(null)"
|
||||
>
|
||||
<div class="category-icon">
|
||||
<el-icon><Grid /></el-icon>
|
||||
</div>
|
||||
<span>全部</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="cat in categories"
|
||||
:key="cat.id"
|
||||
class="category-card"
|
||||
:class="{ active: activeCategory === cat.id }"
|
||||
@click="selectCategory(cat.id)"
|
||||
>
|
||||
<div class="category-icon">
|
||||
<el-icon><Goods /></el-icon>
|
||||
</div>
|
||||
<span>{{ cat.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<el-icon><ShoppingBag /></el-icon>
|
||||
{{ activeCategory ? categories.find(c => c.id === activeCategory)?.name : '全部商品' }}
|
||||
</h2>
|
||||
<div class="product-count">共 {{ productList.length }} 件商品</div>
|
||||
</div>
|
||||
<el-row :gutter="24" v-if="productList.length > 0">
|
||||
<el-col :xs="24" :sm="12" :md="8" :lg="6" v-for="product in productList" :key="product.id">
|
||||
<div class="product-card" @click="goToDetail(product.id)">
|
||||
<div class="product-image-wrapper">
|
||||
<img :src="product.image || '/images/default.png'" :alt="product.name" class="product-img" />
|
||||
<div class="product-badge" v-if="product.sales > 50">热销</div>
|
||||
</div>
|
||||
<div class="product-info">
|
||||
<h4 class="product-name">{{ product.name }}</h4>
|
||||
<p class="product-desc">{{ product.description }}</p>
|
||||
<div class="product-bottom">
|
||||
<div class="product-price">
|
||||
<span class="price-symbol">¥</span>
|
||||
<span class="price-value">{{ product.price.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="product-sales">已售 {{ product.sales }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div class="empty-state" v-else>
|
||||
<el-empty description="暂无该分类商品">
|
||||
<el-button type="primary" @click="selectCategory(null)">查看全部商品</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
</div>
|
||||
</el-main>
|
||||
<el-footer class="home-footer">
|
||||
<Footer />
|
||||
</el-footer>
|
||||
</el-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import Header from '../../components/Header.vue'
|
||||
import Footer from '../../components/Footer.vue'
|
||||
import { productApi, categoryApi, bannerApi } from '../../api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const banners = ref([])
|
||||
const categories = ref([])
|
||||
const productList = ref([])
|
||||
const activeCategory = ref(null)
|
||||
|
||||
const fetchBanners = async () => {
|
||||
try {
|
||||
const res = await bannerApi.getList(1)
|
||||
if (res.code === 200) {
|
||||
banners.value = res.data || []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const res = await categoryApi.getList(1)
|
||||
if (res.code === 200) {
|
||||
categories.value = res.data || []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
const res = await productApi.getList({
|
||||
categoryId: activeCategory.value,
|
||||
keyword: route.query.keyword
|
||||
})
|
||||
if (res.code === 200) {
|
||||
productList.value = res.data || []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const selectCategory = (id) => {
|
||||
activeCategory.value = id
|
||||
fetchProducts()
|
||||
}
|
||||
|
||||
const goToDetail = (id) => {
|
||||
router.push(`/product/${id}`)
|
||||
}
|
||||
|
||||
const handleBannerClick = (banner) => {
|
||||
if (banner.link) {
|
||||
router.push(banner.link)
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => route.query.keyword, () => {
|
||||
fetchProducts()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchBanners()
|
||||
fetchCategories()
|
||||
fetchProducts()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.home {
|
||||
background: linear-gradient(180deg, #f8f5f3 0%, #ffffff 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.banner-section {
|
||||
max-width: 1400px;
|
||||
margin: 20px auto 0;
|
||||
padding: 0 20px;
|
||||
|
||||
.banner-item {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
|
||||
.banner-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
|
||||
&:hover .banner-img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.banner-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 40px;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.6));
|
||||
color: #fff;
|
||||
|
||||
h3 {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-carousel__item--card) {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
:deep(.el-carousel__button) {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: rgba(233, 59, 61, 0.4);
|
||||
}
|
||||
|
||||
:deep(.el-carousel__button.is-active) {
|
||||
background: #e93b3d;
|
||||
}
|
||||
|
||||
.category-section {
|
||||
max-width: 1400px;
|
||||
margin: 40px auto 0;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
|
||||
.el-icon {
|
||||
color: #e93b3d;
|
||||
}
|
||||
}
|
||||
|
||||
.product-count {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.category-grid {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.category-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 32px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
border: 2px solid transparent;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #e93b3d;
|
||||
background: linear-gradient(135deg, #fff5f5 0%, #fff 100%);
|
||||
|
||||
.category-icon {
|
||||
background: linear-gradient(135deg, #e93b3d 0%, #ff6b6b 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #e93b3d;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f8f5f3;
|
||||
border-radius: 50%;
|
||||
color: #666;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
.el-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.product-section {
|
||||
max-width: 1400px;
|
||||
margin: 40px auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
|
||||
|
||||
.product-img {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.product-image-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
overflow: hidden;
|
||||
|
||||
.product-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
|
||||
.product-badge {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
padding: 6px 12px;
|
||||
background: linear-gradient(135deg, #e93b3d 0%, #ff6b6b 100%);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
border-radius: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.product-info {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-desc {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.product-price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
.price-symbol {
|
||||
font-size: 14px;
|
||||
color: #e93b3d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.price-value {
|
||||
font-size: 24px;
|
||||
color: #e93b3d;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.product-sales {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
.home-footer {
|
||||
background: #2c2c2c;
|
||||
}
|
||||
</style>
|
||||
100
meiruo-frontend/src/views/user/Login.vue
Normal file
100
meiruo-frontend/src/views/user/Login.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-container">
|
||||
<h2>用户登录</h2>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="0">
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock" @keyup.enter="handleLogin" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleLogin" :loading="loading" style="width: 100%">登录</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="login-footer">
|
||||
<router-link to="/register">还没有账号?去注册</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const formRef = ref(null)
|
||||
const loading = ref(false)
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
loading.value = true
|
||||
|
||||
const success = await userStore.login(form.username, form.password)
|
||||
if (success) {
|
||||
ElMessage.success('登录成功')
|
||||
const redirect = router.currentRoute.value.query.redirect || '/'
|
||||
router.push(redirect)
|
||||
} else {
|
||||
ElMessage.error('用户名或密码错误')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 400px;
|
||||
padding: 40px;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
|
||||
a {
|
||||
color: #667eea;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
178
meiruo-frontend/src/views/user/Order.vue
Normal file
178
meiruo-frontend/src/views/user/Order.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<div class="order-page">
|
||||
<el-container>
|
||||
<el-header>
|
||||
<Header />
|
||||
</el-header>
|
||||
<el-main>
|
||||
<div class="order-container">
|
||||
<h2>我的订单</h2>
|
||||
<el-tabs v-model="activeTab" @tab-change="fetchOrders">
|
||||
<el-tab-pane label="全部" name="all" />
|
||||
<el-tab-pane label="待付款" name="1" />
|
||||
<el-tab-pane label="已付款" name="2" />
|
||||
<el-tab-pane label="已发货" name="3" />
|
||||
<el-tab-pane label="已完成" name="4" />
|
||||
</el-tabs>
|
||||
<div class="order-list" v-if="orderList.length > 0">
|
||||
<div class="order-item" v-for="order in orderList" :key="order.id">
|
||||
<div class="order-header">
|
||||
<span class="order-no">订单号: {{ order.order_no }}</span>
|
||||
<span class="order-status">{{ getStatusText(order.status) }}</span>
|
||||
</div>
|
||||
<div class="order-body">
|
||||
<div class="order-info">
|
||||
<p>收货人: {{ order.receiver_name }}</p>
|
||||
<p>联系电话: {{ order.receiver_phone }}</p>
|
||||
<p>收货地址: {{ order.receiver_address }}</p>
|
||||
<p>下单时间: {{ order.create_time }}</p>
|
||||
</div>
|
||||
<div class="order-total">
|
||||
<span>订单总额:</span>
|
||||
<span class="amount">¥{{ parseFloat(order.total_amount).toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="order-footer">
|
||||
<el-button size="small" @click="viewDetail(order)">查看详情</el-button>
|
||||
<el-button
|
||||
v-if="order.status === 1"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="payOrder(order)"
|
||||
>
|
||||
去付款
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无订单" />
|
||||
</div>
|
||||
</el-main>
|
||||
<el-footer>
|
||||
<Footer />
|
||||
</el-footer>
|
||||
</el-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import Header from '../../components/Header.vue'
|
||||
import Footer from '../../components/Footer.vue'
|
||||
import { orderApi } from '../../api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const activeTab = ref('all')
|
||||
const orderList = ref([])
|
||||
|
||||
const statusMap = {
|
||||
1: '待付款',
|
||||
2: '已付款',
|
||||
3: '已发货',
|
||||
4: '已完成',
|
||||
5: '已取消'
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
return statusMap[status] || '未知'
|
||||
}
|
||||
|
||||
const fetchOrders = async () => {
|
||||
try {
|
||||
const status = activeTab.value === 'all' ? null : parseInt(activeTab.value)
|
||||
const res = await orderApi.getList(status)
|
||||
if (res.code === 200) {
|
||||
orderList.value = res.data || []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const viewDetail = (order) => {
|
||||
console.log('查看订单详情', order)
|
||||
}
|
||||
|
||||
const payOrder = async (order) => {
|
||||
try {
|
||||
await orderApi.updateStatus(order.id, 2)
|
||||
ElMessage.success('付款成功')
|
||||
fetchOrders()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchOrders()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.order-container {
|
||||
max-width: 1200px;
|
||||
margin: 20px auto;
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
|
||||
h2 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.order-item {
|
||||
border: 1px solid #eee;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.order-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
background: #f9f9f9;
|
||||
border-bottom: 1px solid #eee;
|
||||
|
||||
.order-no {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.order-status {
|
||||
color: #e93b3d;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.order-body {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.order-info {
|
||||
p {
|
||||
margin-bottom: 5px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.order-total {
|
||||
text-align: right;
|
||||
|
||||
.amount {
|
||||
font-size: 20px;
|
||||
color: #e93b3d;
|
||||
font-weight: bold;
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.order-footer {
|
||||
padding: 15px;
|
||||
text-align: right;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
</style>
|
||||
157
meiruo-frontend/src/views/user/ProductDetail.vue
Normal file
157
meiruo-frontend/src/views/user/ProductDetail.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div class="product-detail">
|
||||
<el-container>
|
||||
<el-header>
|
||||
<Header />
|
||||
</el-header>
|
||||
<el-main>
|
||||
<div class="detail-container" v-if="product">
|
||||
<div class="product-gallery">
|
||||
<el-image :src="product.image || '/images/default.png'" :preview-src-list="[product.image]" fit="cover" />
|
||||
</div>
|
||||
<div class="product-info">
|
||||
<h1>{{ product.name }}</h1>
|
||||
<p class="description">{{ product.description }}</p>
|
||||
<div class="price-section">
|
||||
<span class="price">¥{{ product.price.toFixed(2) }}</span>
|
||||
<span class="stock">库存: {{ product.stock }}</span>
|
||||
</div>
|
||||
<div class="quantity-section">
|
||||
<span>数量:</span>
|
||||
<el-input-number v-model="quantity" :min="1" :max="product.stock" size="large" />
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<el-button type="primary" size="large" @click="handleAddCart">加入购物车</el-button>
|
||||
<el-button type="warning" size="large" @click="handleBuyNow">立即购买</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="商品不存在" />
|
||||
</el-main>
|
||||
<el-footer>
|
||||
<Footer />
|
||||
</el-footer>
|
||||
</el-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import Header from '../../components/Header.vue'
|
||||
import Footer from '../../components/Footer.vue'
|
||||
import { productApi, cartApi } from '../../api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const product = ref(null)
|
||||
const quantity = ref(1)
|
||||
|
||||
const fetchProduct = async () => {
|
||||
try {
|
||||
const res = await productApi.getById(route.params.id)
|
||||
if (res.code === 200) {
|
||||
product.value = res.data
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddCart = async () => {
|
||||
if (!userStore.isLoggedIn) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await cartApi.add(product.value.id, quantity.value)
|
||||
ElMessage.success('添加成功')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBuyNow = async () => {
|
||||
if (!userStore.isLoggedIn) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
router.push({
|
||||
path: '/checkout',
|
||||
query: { productId: product.value.id, quantity: quantity.value }
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchProduct()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.detail-container {
|
||||
max-width: 1200px;
|
||||
margin: 20px auto;
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
background: #fff;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.product-gallery {
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
|
||||
.el-image {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.product-info {
|
||||
flex: 1;
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.price-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.price {
|
||||
font-size: 28px;
|
||||
color: #e93b3d;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.stock {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.quantity-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
127
meiruo-frontend/src/views/user/Register.vue
Normal file
127
meiruo-frontend/src/views/user/Register.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div class="register-page">
|
||||
<div class="register-container">
|
||||
<h2>用户注册</h2>
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="0">
|
||||
<el-form-item prop="username">
|
||||
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="confirmPassword">
|
||||
<el-input v-model="form.confirmPassword" type="password" placeholder="确认密码" prefix-icon="Lock" @keyup.enter="handleRegister" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="nickname">
|
||||
<el-input v-model="form.nickname" placeholder="昵称" prefix-icon="UserFilled" />
|
||||
</el-form-item>
|
||||
<el-form-item prop="phone">
|
||||
<el-input v-model="form.phone" placeholder="手机号" prefix-icon="Phone" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleRegister" :loading="loading" style="width: 100%">注册</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="register-footer">
|
||||
<router-link to="/login">已有账号?去登录</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const formRef = ref(null)
|
||||
const loading = ref(false)
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
nickname: '',
|
||||
phone: ''
|
||||
})
|
||||
|
||||
const validateConfirmPassword = (rule, value, callback) => {
|
||||
if (value !== form.password) {
|
||||
callback(new Error('两次密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
const rules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
confirmPassword: [{ required: true, validator: validateConfirmPassword, trigger: 'blur' }],
|
||||
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
loading.value = true
|
||||
|
||||
const success = await userStore.register({
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
nickname: form.nickname,
|
||||
phone: form.phone
|
||||
})
|
||||
|
||||
if (success) {
|
||||
ElMessage.success('注册成功,请登录')
|
||||
router.push('/login')
|
||||
} else {
|
||||
ElMessage.error('注册失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.register-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.register-container {
|
||||
width: 400px;
|
||||
padding: 40px;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.register-footer {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
|
||||
a {
|
||||
color: #667eea;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
161
meiruo-frontend/src/views/user/UserCenter.vue
Normal file
161
meiruo-frontend/src/views/user/UserCenter.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div class="user-center">
|
||||
<el-container>
|
||||
<el-header>
|
||||
<Header />
|
||||
</el-header>
|
||||
<el-main>
|
||||
<div class="user-container">
|
||||
<div class="user-sidebar">
|
||||
<div class="user-info">
|
||||
<el-avatar :size="80">{{ userInfo.nickname?.charAt(0) || userInfo.username?.charAt(0) }}</el-avatar>
|
||||
<p>{{ userInfo.nickname || userInfo.username }}</p>
|
||||
</div>
|
||||
<el-menu :default-active="activeMenu" @select="handleMenuSelect">
|
||||
<el-menu-item index="info">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>个人信息</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="orders">
|
||||
<el-icon><List /></el-icon>
|
||||
<span>我的订单</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</div>
|
||||
<div class="user-content">
|
||||
<div v-if="activeMenu === 'info'" class="info-section">
|
||||
<h3>个人信息</h3>
|
||||
<el-form :model="userForm" :rules="formRules" ref="formRef" label-width="100px">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="userForm.username" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="昵称" prop="nickname">
|
||||
<el-input v-model="userForm.nickname" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input v-model="userForm.phone" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="userForm.email" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSave">保存修改</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div v-if="activeMenu === 'orders'" class="orders-section">
|
||||
<h3>我的订单</h3>
|
||||
<Order />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-main>
|
||||
<el-footer>
|
||||
<Footer />
|
||||
</el-footer>
|
||||
</el-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Header from '../../components/Header.vue'
|
||||
import Footer from '../../components/Footer.vue'
|
||||
import Order from './Order.vue'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { userApi } from '../../api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const formRef = ref(null)
|
||||
const activeMenu = ref('info')
|
||||
const userForm = reactive({
|
||||
username: '',
|
||||
nickname: '',
|
||||
phone: '',
|
||||
email: ''
|
||||
})
|
||||
|
||||
const userInfo = computed(() => userStore.userInfo)
|
||||
|
||||
const formRules = {
|
||||
nickname: [{ required: true, message: '请输入昵称', trigger: 'blur' }],
|
||||
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const handleMenuSelect = (index) => {
|
||||
activeMenu.value = index
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
await userApi.updateInfo(userForm)
|
||||
userStore.updateUserInfo(userForm)
|
||||
ElMessage.success('保存成功')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
userForm.username = userInfo.value.username
|
||||
userForm.nickname = userInfo.value.nickname
|
||||
userForm.phone = userInfo.value.phone
|
||||
userForm.email = userInfo.value.email
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-container {
|
||||
max-width: 1200px;
|
||||
margin: 20px auto;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.user-sidebar {
|
||||
width: 250px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
text-align: center;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.el-avatar {
|
||||
background: #e93b3d;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.user-content {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.info-section,
|
||||
.orders-section {
|
||||
h3 {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
21
meiruo-frontend/vite.config.js
Normal file
21
meiruo-frontend/vite.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user