feat: 前端页面和组件全面完善

- 优化通用组件:导航栏、页脚、图片上传、搜索
- 完善业务组件:商品卡片、秒杀卡片
- 更新用户端页面:首页、商品、秒杀、订单、购物车、个人中心
- 新增用户收藏页面
- 完善管理后台:仪表盘、商品/订单/用户/秒杀管理
- 新增管理后台:收藏管理、评价管理、系统监控页面
This commit is contained in:
2026-03-10 23:21:53 +08:00
parent abba469a20
commit c52d9c52e3
25 changed files with 3409 additions and 1467 deletions

View File

@@ -0,0 +1,106 @@
<template>
<div class="favorites-page">
<div class="container mx-auto px-4 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2 flex items-center">
<el-icon class="text-pink-500 mr-2"><StarFilled /></el-icon>
我的收藏
</h1>
<p class="text-gray-600">收藏你感兴趣的商品随时回来查看</p>
</div>
<div v-if="loading" class="text-center py-12">
<el-icon :size="40" class="animate-spin"><Loading /></el-icon>
<p class="mt-2 text-gray-500">加载中...</p>
</div>
<div v-else-if="favorites.length === 0" class="bg-white rounded-lg shadow-sm p-12">
<el-empty description="暂无收藏商品">
<el-button type="primary" @click="router.push('/products')">去逛逛</el-button>
</el-empty>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<div v-for="item in favorites" :key="item.id" class="bg-white rounded-lg shadow-sm overflow-hidden hover:shadow-md transition-shadow">
<SafeImage
:src="item.productImageUrl"
:alt="item.productName"
wrapper-class="w-full h-48 bg-gray-100"
img-class="w-full h-48 object-cover"
:clickable="true"
@click="router.push(`/product/${item.productId}`)"
/>
<div class="p-4">
<h3 class="font-semibold mb-2 line-clamp-1">{{ item.productName }}</h3>
<p class="text-sm text-gray-500 mb-3">{{ item.productCategory || '默认分类' }}</p>
<div class="flex justify-between items-center mb-4">
<span class="text-xl font-bold text-red-500">¥{{ item.productPrice }}</span>
<span class="text-xs text-gray-400">收藏于 {{ formatTime(item.createdAt) }}</span>
</div>
<div class="flex gap-2">
<el-button type="primary" class="flex-1" @click="addToCart(item.productId)">加入购物车</el-button>
<el-button @click="toggleFavorite(item.productId)">取消收藏</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import dayjs from 'dayjs'
import { ElMessage } from 'element-plus'
import { favoriteApi, type FavoriteItem } from '@/api/modules/favorite'
import { useCartStore } from '@/stores/cart'
import SafeImage from '@/components/common/SafeImage.vue'
const router = useRouter()
const cartStore = useCartStore()
const loading = ref(false)
const favorites = ref<FavoriteItem[]>([])
const formatTime = (value: string) => dayjs(value).format('YYYY-MM-DD')
const loadFavorites = async () => {
loading.value = true
try {
const res = await favoriteApi.getList()
if (res.success) favorites.value = res.data || []
} finally {
loading.value = false
}
}
const addToCart = async (productId: number) => {
await cartStore.addToCart(productId)
}
const toggleFavorite = async (productId: number) => {
const res = await favoriteApi.toggle(productId)
if (res.success) {
ElMessage.success(res.data.favorited ? '收藏成功' : '已取消收藏')
loadFavorites()
}
}
onMounted(() => {
loadFavorites()
})
</script>
<style scoped lang="scss">
.favorites-page {
min-height: calc(100vh - 60px);
background-color: #f5f5f5;
}
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -86,8 +86,8 @@
<div class="mt-4 p-4 bg-blue-50 rounded-lg">
<h3 class="font-semibold text-blue-900 mb-2">测试账号</h3>
<div class="text-sm text-blue-700">
<p>普通用户: user / 123456</p>
<p>管理员: admin / 123456</p>
<p>普通用户: demo1 / 123456</p>
<p>管理员: admin / admin123</p>
</div>
</div>
</div>
@@ -96,13 +96,10 @@
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElForm } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useUserStore } from '@/stores/user'
import type { LoginParams } from '@/types/api'
const router = useRouter()
const userStore = useUserStore()
const formRef = ref<FormInstance>()
@@ -147,11 +144,11 @@ const handleLogin = async () => {
// 快速登录
const quickLogin = (type: 'user' | 'admin') => {
if (type === 'user') {
loginForm.username = 'user'
loginForm.username = 'demo1'
loginForm.password = '123456'
} else {
loginForm.username = 'admin'
loginForm.password = '123456'
loginForm.password = 'admin123'
}
loginForm.rememberMe = true
handleLogin()
@@ -162,4 +159,4 @@ const quickLogin = (type: 'user' | 'admin') => {
.login-page {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
</style>
</style>

View File

@@ -1,303 +1,325 @@
<template>
<div class="profile-page">
<div class="container mx-auto px-4 py-8">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="stat-card blue">
<div class="stat-value">{{ profileStats.totalOrders }}</div>
<div class="stat-label">总订单数</div>
</div>
<div class="stat-card green">
<div class="stat-value">¥{{ Number(profileStats.totalAmount || 0).toFixed(2) }}</div>
<div class="stat-label">累计消费</div>
</div>
<div class="stat-card orange">
<div class="stat-value">{{ profileStats.flashSaleSuccess }}</div>
<div class="stat-label">秒杀成功</div>
</div>
<div class="stat-card purple">
<div class="stat-value">{{ profileStats.favoriteCount }}</div>
<div class="stat-label">收藏商品</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
<!-- 左侧菜单 -->
<div class="lg:col-span-1">
<div class="bg-white rounded-lg shadow-sm p-6">
<!-- 用户头像 -->
<div class="text-center mb-6">
<el-avatar :size="80" :src="userStore.user?.avatar">
{{ userStore.username[0] }}
<el-avatar :size="80" :src="infoForm.avatar">
{{ (userStore.username || 'U')[0] }}
</el-avatar>
<h3 class="mt-3 font-semibold">{{ userStore.username }}</h3>
<p class="text-sm text-gray-500">{{ userStore.user?.email }}</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="security">
<el-icon><Lock /></el-icon>
<span>账号安全</span>
</el-menu-item>
<el-menu-item index="address">
<el-icon><Location /></el-icon>
<span>收货地址</span>
</el-menu-item>
<el-menu-item index="orders">
<el-icon><List /></el-icon>
<span>我的订单</span>
</el-menu-item>
<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="security"><el-icon><Lock /></el-icon><span>账号安全</span></el-menu-item>
<el-menu-item index="address"><el-icon><Location /></el-icon><span>收货地址</span></el-menu-item>
<el-menu-item index="orders"><el-icon><List /></el-icon><span>我的订单</span></el-menu-item>
<el-menu-item index="favorites"><el-icon><Star /></el-icon><span>我的收藏</span></el-menu-item>
</el-menu>
</div>
</div>
<!-- 右侧内容 -->
<div class="lg:col-span-3">
<div class="bg-white rounded-lg shadow-sm p-6">
<!-- 基本信息 -->
<div v-if="activeMenu === 'info'">
<h2 class="text-xl font-semibold mb-6">基本信息</h2>
<el-form
ref="infoFormRef"
:model="infoForm"
:rules="infoRules"
label-width="100px"
>
<el-form-item label="用户名" prop="username">
<el-input v-model="infoForm.username" disabled />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="infoForm.email" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="infoForm.phone" />
</el-form-item>
<el-form ref="infoFormRef" :model="infoForm" :rules="infoRules" label-width="100px">
<el-form-item label="用户名"><el-input v-model="infoForm.username" disabled /></el-form-item>
<el-form-item label="邮箱" prop="email"><el-input v-model="infoForm.email" /></el-form-item>
<el-form-item label="手机号" prop="phone"><el-input v-model="infoForm.phone" /></el-form-item>
<el-form-item label="头像">
<div class="flex items-center gap-4">
<el-avatar :size="60" :src="infoForm.avatar">
{{ infoForm.username[0] }}
</el-avatar>
<el-button>更换头像</el-button>
<el-avatar :size="60" :src="infoForm.avatar">{{ (infoForm.username || 'U')[0] }}</el-avatar>
<el-button @click="handleUpdateAvatar">更换头像</el-button>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSaveInfo">
保存修改
</el-button>
</el-form-item>
<el-form-item><el-button type="primary" @click="handleSaveInfo">保存修改</el-button></el-form-item>
</el-form>
</div>
<!-- 账号安全 -->
<div v-else-if="activeMenu === 'security'">
<h2 class="text-xl font-semibold mb-6">账号安全</h2>
<el-form
ref="passwordFormRef"
:model="passwordForm"
:rules="passwordRules"
label-width="100px"
>
<el-form-item label="原密码" prop="oldPassword">
<el-input
v-model="passwordForm.oldPassword"
type="password"
show-password
/>
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input
v-model="passwordForm.newPassword"
type="password"
show-password
/>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input
v-model="passwordForm.confirmPassword"
type="password"
show-password
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleChangePassword">
修改密码
</el-button>
</el-form-item>
<el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-width="100px">
<el-form-item label="原密码" prop="oldPassword"><el-input v-model="passwordForm.oldPassword" type="password" show-password /></el-form-item>
<el-form-item label="新密码" prop="newPassword"><el-input v-model="passwordForm.newPassword" type="password" show-password /></el-form-item>
<el-form-item label="确认密码" prop="confirmPassword"><el-input v-model="passwordForm.confirmPassword" type="password" show-password /></el-form-item>
<el-form-item><el-button type="primary" @click="handleChangePassword">修改密码</el-button></el-form-item>
</el-form>
</div>
<!-- 收货地址 -->
<div v-else-if="activeMenu === 'address'">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-semibold">收货地址</h2>
<el-button type="primary" @click="handleAddAddress">
<el-icon class="mr-1"><Plus /></el-icon>
添加地址
</el-button>
<el-button type="primary" @click="openAddressDialog()"><el-icon class="mr-1"><Plus /></el-icon>添加地址</el-button>
</div>
<div class="space-y-4">
<div
v-for="addr in addresses"
:key="addr.id"
class="border rounded-lg p-4"
>
<div class="flex justify-between items-start">
<div v-for="addr in addresses" :key="addr.id" class="border rounded-lg p-4">
<div class="flex justify-between items-start gap-4">
<div>
<div class="flex items-center gap-2 mb-2">
<div class="flex items-center gap-2 mb-2 flex-wrap">
<span class="font-semibold">{{ addr.name }}</span>
<span class="text-gray-500">{{ addr.phone }}</span>
<el-tag v-if="addr.isDefault" type="primary" size="small">
默认
</el-tag>
<el-tag v-if="addr.isDefault" type="primary" size="small">默认</el-tag>
</div>
<p class="text-gray-600">
{{ addr.province }} {{ addr.city }} {{ addr.district }} {{ addr.address }}
</p>
<p class="text-gray-600">{{ addr.province }} {{ addr.city }} {{ addr.district }} {{ addr.address }}</p>
</div>
<div class="space-x-2">
<el-button text type="primary" size="small">编辑</el-button>
<el-button text type="danger" size="small">删除</el-button>
<div class="space-x-2 whitespace-nowrap">
<el-button v-if="!addr.isDefault" text type="success" size="small" @click="setDefaultAddress(addr.id)">设为默认</el-button>
<el-button text type="primary" size="small" @click="openAddressDialog(addr)">编辑</el-button>
<el-button text type="danger" size="small" @click="removeAddress(addr.id)">删除</el-button>
</div>
</div>
</div>
<el-empty v-if="addresses.length === 0" description="暂无收货地址" />
</div>
</div>
<!-- 我的订单 -->
<div v-else-if="activeMenu === 'orders'">
<h2 class="text-xl font-semibold mb-6">我的订单</h2>
<p class="text-gray-500">请访问 <router-link to="/orders" class="text-primary-500">订单页面</router-link> 查看详细订单信息</p>
<p class="text-gray-500">正在跳转到订单列表页...</p>
</div>
<div v-else-if="activeMenu === 'favorites'">
<h2 class="text-xl font-semibold mb-6">我的收藏</h2>
<p class="text-gray-500">正在跳转到收藏页...</p>
</div>
</div>
</div>
</div>
</div>
<el-dialog v-model="addressDialogVisible" :title="editingAddressId ? '编辑地址' : '新增地址'" width="620px">
<el-form ref="addressFormRef" :model="addressForm" :rules="addressRules" label-width="90px">
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="收货人" prop="name"><el-input v-model="addressForm.name" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="手机号" prop="phone"><el-input v-model="addressForm.phone" /></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8"><el-form-item label="省份" prop="province"><el-input v-model="addressForm.province" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="城市" prop="city"><el-input v-model="addressForm.city" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="区县" prop="district"><el-input v-model="addressForm.district" /></el-form-item></el-col>
</el-row>
<el-form-item label="详细地址" prop="address"><el-input v-model="addressForm.address" type="textarea" :rows="3" /></el-form-item>
<el-form-item><el-checkbox v-model="addressForm.isDefault">设为默认地址</el-checkbox></el-form-item>
</el-form>
<template #footer>
<el-button @click="addressDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveAddress">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElForm } from 'element-plus'
import { onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { userApi } from '@/api/modules/user'
import { addressApi, type AddressItem } from '@/api/modules/address'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const activeMenu = ref('info')
const infoFormRef = ref<FormInstance>()
const passwordFormRef = ref<FormInstance>()
const addressFormRef = ref<FormInstance>()
const addressDialogVisible = ref(false)
const editingAddressId = ref<number | null>(null)
const addresses = ref<AddressItem[]>([])
const profileStats = reactive({ totalOrders: 0, totalAmount: 0, flashSaleSuccess: 0, favoriteCount: 0 })
// 基本信息表单
const infoForm = reactive({
username: userStore.user?.username || '',
email: userStore.user?.email || '',
phone: userStore.user?.phone || '',
avatar: userStore.user?.avatar || ''
})
const infoForm = reactive({ username: '', email: '', phone: '', avatar: '' })
const passwordForm = reactive({ oldPassword: '', newPassword: '', confirmPassword: '' })
const addressForm = reactive<AddressItem>({ id: 0, name: '', phone: '', province: '', city: '', district: '', address: '', isDefault: false })
const infoRules: FormRules = {
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
],
phone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
]
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }, { type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }],
phone: [{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }],
}
// 修改密码表单
const passwordForm = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
const validatePassword = (rule: any, value: any, callback: any) => {
if (value === '') {
callback(new Error('请再次输入密码'))
} else if (value !== passwordForm.newPassword) {
callback(new Error('两次输入密码不一致'))
} else {
callback()
}
const validatePassword = (_rule: any, value: string, callback: (error?: Error) => void) => {
if (!value) callback(new Error('请再次输入密码'))
else if (value !== passwordForm.newPassword) callback(new Error('两次输入密码不一致'))
else callback()
}
const passwordRules: FormRules = {
oldPassword: [
{ required: true, message: '请输入原密码', trigger: 'blur' }
],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }
],
confirmPassword: [
{ required: true, validator: validatePassword, trigger: 'blur' }
]
oldPassword: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
newPassword: [{ required: true, message: '请输入新密码', trigger: 'blur' }, { min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }],
confirmPassword: [{ required: true, validator: validatePassword, trigger: 'blur' }],
}
// 收货地址
const addresses = ref<any[]>([])
const addressRules: FormRules = {
name: [{ required: true, message: '请输入收货人', trigger: 'blur' }],
phone: [{ required: true, pattern: /^1[3-9]\d{9}$/, message: '请输入正确手机号', trigger: 'blur' }],
province: [{ required: true, message: '请输入省份', trigger: 'blur' }],
city: [{ required: true, message: '请输入城市', trigger: 'blur' }],
district: [{ required: true, message: '请输入区县', trigger: 'blur' }],
address: [{ required: true, message: '请输入详细地址', trigger: 'blur' }],
}
// 菜单选择
const handleMenuSelect = (index: string) => {
activeMenu.value = index
if (index === 'orders') {
router.push('/orders')
const syncInfoForm = () => {
infoForm.username = userStore.user?.username || ''
infoForm.email = userStore.user?.email || ''
infoForm.phone = userStore.user?.phone || ''
infoForm.avatar = userStore.user?.avatar || ''
}
const syncActiveMenu = () => {
if (route.path.endsWith('/addresses')) activeMenu.value = 'address'
else activeMenu.value = (route.query.tab as string) || 'info'
}
const loadAddresses = async () => {
try {
const res = await addressApi.getList()
if (res.success) addresses.value = res.data || []
} catch (error) {
console.error('加载地址失败:', error)
}
}
// 保存基本信息
const loadProfileStats = async () => {
try {
const res = await userApi.getProfileStats()
if (res.success) Object.assign(profileStats, res.data)
} catch (error) {
console.error('加载个人中心统计失败:', error)
}
}
const resetAddressForm = () => {
editingAddressId.value = null
Object.assign(addressForm, { id: 0, name: '', phone: '', province: '', city: '', district: '', address: '', isDefault: false })
}
const handleMenuSelect = (index: string) => {
activeMenu.value = index
if (index === 'orders') router.push('/orders')
else if (index === 'favorites') router.push('/favorites')
else if (index === 'address') router.push('/addresses')
else router.push({ path: '/profile', query: { tab: index } })
}
const handleSaveInfo = async () => {
if (!infoFormRef.value) return
await infoFormRef.value.validate(async (valid) => {
if (valid) {
try {
const res = await userApi.updateInfo(infoForm)
if (res.success) {
userStore.updateUserInfo(infoForm)
ElMessage.success('保存成功')
}
} catch (error) {
console.error('保存失败:', error)
if (!valid) return
try {
const res = await userApi.updateInfo({ email: infoForm.email, phone: infoForm.phone, avatar: infoForm.avatar })
if (res.success) {
userStore.updateUserInfo(res.data)
ElMessage.success('保存成功')
}
} catch (error) {
console.error('保存失败:', error)
}
})
}
// 修改密码
const handleUpdateAvatar = async () => {
try {
const { value } = await ElMessageBox.prompt('请输入新的头像图片 URL', '更换头像', { inputValue: infoForm.avatar, confirmButtonText: '保存', cancelButtonText: '取消' })
infoForm.avatar = value || ''
userStore.updateUserInfo({ avatar: infoForm.avatar })
ElMessage.success('头像已更新')
} catch {}
}
const handleChangePassword = async () => {
if (!passwordFormRef.value) return
await passwordFormRef.value.validate(async (valid) => {
if (valid) {
try {
const res = await userApi.changePassword({
oldPassword: passwordForm.oldPassword,
newPassword: passwordForm.newPassword
})
if (res.success) {
ElMessage.success('密码修改成功,请重新登录')
userStore.logout()
}
} catch (error) {
console.error('修改密码失败:', error)
if (!valid) return
try {
const res = await userApi.changePassword({ oldPassword: passwordForm.oldPassword, newPassword: passwordForm.newPassword, confirmPassword: passwordForm.confirmPassword })
if (res.success) {
ElMessage.success('密码修改成功,请重新登录')
await userStore.logout()
}
} catch (error) {
console.error('修改密码失败:', error)
}
})
}
// 添加地址
const handleAddAddress = () => {
ElMessage.info('功能开发中...')
const openAddressDialog = (item?: AddressItem) => {
resetAddressForm()
if (item) {
editingAddressId.value = item.id
Object.assign(addressForm, item)
}
addressDialogVisible.value = true
}
onMounted(() => {
// 加载用户信息
userStore.getUserInfo()
const saveAddress = async () => {
if (!addressFormRef.value) return
await addressFormRef.value.validate(async (valid) => {
if (!valid) return
const payload = { name: addressForm.name, phone: addressForm.phone, province: addressForm.province, city: addressForm.city, district: addressForm.district, address: addressForm.address, isDefault: addressForm.isDefault }
try {
if (editingAddressId.value) {
await addressApi.update(editingAddressId.value, payload)
ElMessage.success('地址已更新')
} else {
await addressApi.create(payload)
ElMessage.success('地址已新增')
}
addressDialogVisible.value = false
loadAddresses()
} catch (error) {
console.error('保存地址失败:', error)
}
})
}
const setDefaultAddress = async (id: number) => {
await addressApi.setDefault(id)
ElMessage.success('默认地址已更新')
loadAddresses()
}
const removeAddress = async (id: number) => {
await ElMessageBox.confirm('确定删除这个地址吗?', '提示', { type: 'warning' })
await addressApi.delete(id)
ElMessage.success('地址已删除')
loadAddresses()
}
watch(() => userStore.user, syncInfoForm, { immediate: true })
watch(() => route.fullPath, syncActiveMenu, { immediate: true })
onMounted(async () => {
if (userStore.token) await userStore.getUserInfo()
syncInfoForm()
syncActiveMenu()
loadAddresses()
loadProfileStats()
})
</script>
@@ -306,4 +328,21 @@ onMounted(() => {
min-height: calc(100vh - 60px);
background-color: #f5f5f5;
}
</style>
.stat-card {
@apply rounded-lg p-5 text-white shadow-sm;
&.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
&.green { background: linear-gradient(135deg, #10b981, #059669); }
&.orange { background: linear-gradient(135deg, #f59e0b, #ea580c); }
&.purple { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
}
.stat-value {
@apply text-2xl font-bold;
}
.stat-label {
@apply text-sm mt-2 opacity-90;
}
</style>