初始化美若彩妆销售平台项目

This commit is contained in:
王子琦
2026-02-10 10:45:23 +08:00
commit f48acbe97b
75 changed files with 6133 additions and 0 deletions

View 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>

View 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"
}
}

View 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>

View 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' }
})
}
}

View 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>

View 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>

View 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')

View 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

View 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
}
})

View 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;
}

View 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>

View 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 像素支持 jpgpng 格式大小不超过 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>

View 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>

View 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>

View 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>

View 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">支持 jpgpng 格式大小不超过 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
}
}
}
})