snack-mall/web-snack/src/views/user/index.vue

1660 lines
44 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
/**
* 个人中心
*
* 侧边栏 + 内容区布局,覆盖:
* - 个人资料(展示 + 编辑/改密占位表单,后端接口开发中)
* - 我的订单(按状态 Tab 筛选 + 支付/取消/确认收货)
* - 收货地址(增删改 / 设默认)
* - 我的收藏(列表 + 取消收藏 + 跳商品详情)
* - 领券中心(可领取优惠券列表 + 一键领取)
* - 我的优惠券(按状态筛选)
*/
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
User as UserIcon,
Goods,
Location,
Star,
Ticket,
Present,
SwitchButton,
Plus,
Edit,
Delete,
Check
} from '@element-plus/icons-vue'
import AppHeader from '@/components/layout/AppHeader.vue'
import AppFooter from '@/components/layout/AppFooter.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import { useUserStore } from '@/stores/modules/user'
import {
getOrderPageApi,
getOrderPayUrlApi,
cancelOrderApi,
receiveOrderApi
} from '@/api/order'
import {
getAddressListApi,
createAddressApi,
updateAddressApi,
deleteAddressApi,
setDefaultAddressApi
} from '@/api/address'
import { getFavoriteListApi, cancelFavoriteApi } from '@/api/favorite'
import { getReceivableCouponsApi, receiveCouponApi, getMyCouponsApi } from '@/api/coupon'
import type { PageResult } from '@/types/common'
import type { OrderDetail } from '@/types/order'
import type { Address, AddressSaveReq } from '@/types/address'
import type { FavoriteItem } from '@/types/favorite'
import type { ClaimableCoupon, UserCoupon } from '@/types/coupon'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
// ==================== 侧边栏 ====================
type MenuKey = 'profile' | 'orders' | 'addresses' | 'favorites' | 'claim' | 'coupons'
const activeMenu = ref<MenuKey>('profile')
const menuItems: { key: MenuKey; label: string; icon: typeof UserIcon }[] = [
{ key: 'profile', label: '个人资料', icon: UserIcon },
{ key: 'orders', label: '我的订单', icon: Goods },
{ key: 'addresses', label: '收货地址', icon: Location },
{ key: 'favorites', label: '我的收藏', icon: Star },
{ key: 'claim', label: '领券中心', icon: Present },
{ key: 'coupons', label: '我的优惠券', icon: Ticket }
]
const avatarText = computed(() => {
const name = userStore.displayName || '用户'
return name.slice(0, 1)
})
function switchMenu(key: MenuKey) {
activeMenu.value = key
if (key === 'orders' && !ordersLoaded.value) loadOrders()
if (key === 'favorites' && !favoritesLoaded.value) loadFavorites()
}
async function handleLogout() {
try {
await ElMessageBox.confirm('确定退出登录吗?', '提示', {
confirmButtonText: '退出',
cancelButtonText: '取消',
type: 'warning'
})
} catch {
return
}
userStore.logout()
ElMessage.success('已退出登录')
router.push('/')
}
// ==================== 个人资料 ====================
const profileDialog = ref(false)
const pwdDialog = ref(false)
const profileForm = reactive({
nickname: userStore.nickname,
phone: '',
gender: 0,
birthday: ''
})
const pwdForm = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
function openProfileDialog() {
profileForm.nickname = userStore.nickname
profileDialog.value = true
}
function submitProfile() {
// 后端暂无个人资料修改接口
ElMessage.info('该功能后端接口开发中,敬请期待')
profileDialog.value = false
}
function submitPassword() {
if (pwdForm.newPassword !== pwdForm.confirmPassword) {
ElMessage.warning('两次输入的密码不一致')
return
}
// 后端暂无用户端修改密码接口
ElMessage.info('该功能后端接口开发中,敬请期待')
pwdDialog.value = false
pwdForm.oldPassword = ''
pwdForm.newPassword = ''
pwdForm.confirmPassword = ''
}
// ==================== 我的订单 ====================
const ORDER_TABS = [
{ label: '全部', value: undefined },
{ label: '待付款', value: 0 },
{ label: '待发货', value: 1 },
{ label: '待收货', value: 2 },
{ label: '已完成', value: 3 },
{ label: '已取消', value: 4 },
{ label: '已退款', value: 5 }
] as const
const ordersLoaded = ref(false)
const orderLoading = ref(false)
const orderTab = ref<number | undefined>(undefined)
const orderPage = ref<PageResult<OrderDetail>>({
records: [],
total: 0,
current: 1,
size: 5,
pages: 0
})
const orderActingId = ref<number | null>(null)
async function loadOrders() {
orderLoading.value = true
try {
orderPage.value = await getOrderPageApi(
orderPage.value.current,
orderPage.value.size,
orderTab.value
)
ordersLoaded.value = true
} catch {
/* 拦截器已提示 */
} finally {
orderLoading.value = false
}
}
function onOrderTabChange(name: any) {
const val = typeof name === 'number' ? name : undefined
orderTab.value = val
orderPage.value.current = 1
loadOrders()
}
function onOrderPageChange(page: number) {
orderPage.value.current = page
loadOrders()
}
async function handlePayOrder(order: OrderDetail) {
try {
await ElMessageBox.confirm('将跳转到支付宝沙箱完成支付,是否继续?', '提示', {
confirmButtonText: '去支付',
cancelButtonText: '取消',
type: 'info'
})
} catch {
return
}
orderActingId.value = order.id
try {
const data = await getOrderPayUrlApi(order.id)
if (data?.payUrl) {
window.location.href = data.payUrl
return
}
ElMessage.warning('支付宝支付链接生成失败')
} catch {
/* 拦截器已提示 */
} finally {
orderActingId.value = null
}
}
async function handleCancelOrder(order: OrderDetail) {
try {
await ElMessageBox.confirm('确定取消该订单吗?', '提示', {
confirmButtonText: '取消订单',
cancelButtonText: '再想想',
type: 'warning'
})
} catch {
return
}
orderActingId.value = order.id
try {
await cancelOrderApi(order.id)
ElMessage.success('订单已取消')
loadOrders()
} catch {
/* 拦截器已提示 */
} finally {
orderActingId.value = null
}
}
async function handleReceiveOrder(order: OrderDetail) {
try {
await ElMessageBox.confirm('确认已收到货品吗?', '提示', {
confirmButtonText: '确认收货',
cancelButtonText: '取消',
type: 'info'
})
} catch {
return
}
orderActingId.value = order.id
try {
await receiveOrderApi(order.id)
ElMessage.success('已确认收货')
loadOrders()
} catch {
/* 拦截器已提示 */
} finally {
orderActingId.value = null
}
}
// ==================== 收货地址 ====================
const addressList = ref<Address[]>([])
const addressLoading = ref(false)
const addressDialog = ref(false)
const addressFormRef = ref()
const addressForm = reactive<AddressSaveReq>({
id: undefined,
receiver: '',
phone: '',
province: '',
city: '',
district: '',
detail: '',
tag: '',
isDefault: 0
})
const addressSaving = ref(false)
const addressRules = {
receiver: [{ required: true, message: '请输入收货人姓名', trigger: 'blur' }],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
],
detail: [{ required: true, message: '请输入详细地址', trigger: 'blur' }]
}
async function loadAddresses() {
addressLoading.value = true
try {
addressList.value = await getAddressListApi()
} catch {
/* 拦截器已提示 */
} finally {
addressLoading.value = false
}
}
function openAddressDialog(address?: Address) {
if (address) {
Object.assign(addressForm, {
id: address.id,
receiver: address.receiver,
phone: address.phone,
province: address.province,
city: address.city,
district: address.district,
detail: address.detail,
tag: address.tag,
isDefault: address.isDefault
})
} else {
Object.assign(addressForm, {
id: undefined,
receiver: '',
phone: '',
province: '',
city: '',
district: '',
detail: '',
tag: '',
isDefault: 0
})
}
addressDialog.value = true
}
async function submitAddress() {
if (!addressFormRef.value) return
await addressFormRef.value.validate(async (valid: boolean) => {
if (!valid) return
addressSaving.value = true
try {
if (addressForm.id) {
await updateAddressApi(addressForm)
ElMessage.success('地址已更新')
} else {
await createAddressApi(addressForm)
ElMessage.success('地址已添加')
}
addressDialog.value = false
loadAddresses()
} catch {
/* 拦截器已提示 */
} finally {
addressSaving.value = false
}
})
}
async function handleDeleteAddress(address: Address) {
try {
await ElMessageBox.confirm('确定删除该地址吗?', '提示', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning'
})
} catch {
return
}
try {
await deleteAddressApi(address.id)
ElMessage.success('地址已删除')
loadAddresses()
} catch {
/* 拦截器已提示 */
}
}
async function handleSetDefault(address: Address) {
try {
await setDefaultAddressApi(address.id)
ElMessage.success('已设为默认地址')
loadAddresses()
} catch {
/* 拦截器已提示 */
}
}
// ==================== 我的收藏 ====================
const favoritesLoaded = ref(false)
const favoriteLoading = ref(false)
const favoritePage = ref<PageResult<FavoriteItem>>({
records: [],
total: 0,
current: 1,
size: 8,
pages: 0
})
async function loadFavorites() {
favoriteLoading.value = true
try {
favoritePage.value = await getFavoriteListApi(
favoritePage.value.current,
favoritePage.value.size
)
favoritesLoaded.value = true
} catch {
/* 拦截器已提示 */
} finally {
favoriteLoading.value = false
}
}
function onFavoritePageChange(page: number) {
favoritePage.value.current = page
loadFavorites()
}
async function handleCancelFavorite(item: FavoriteItem) {
try {
await cancelFavoriteApi(item.id)
ElMessage.success('已取消收藏')
loadFavorites()
} catch {
/* 拦截器已提示 */
}
}
function goProduct(id: number) {
router.push(`/product/${id}`)
}
// ==================== 我的优惠券 ====================
const COUPON_TABS = [
{ label: '全部', value: undefined },
{ label: '未使用', value: 0 },
{ label: '已使用', value: 1 },
{ label: '已过期', value: 2 },
{ label: '已作废', value: 3 }
] as const
const couponList = ref<UserCoupon[]>([])
const couponLoading = ref(false)
const couponTab = ref<number | undefined>(undefined)
async function loadCoupons() {
couponLoading.value = true
try {
couponList.value = await getMyCouponsApi(couponTab.value)
} catch {
/* 拦截器已提示 */
} finally {
couponLoading.value = false
}
}
function onCouponTabChange(name: any) {
const val = typeof name === 'number' ? name : undefined
couponTab.value = val
loadCoupons()
}
// ==================== 领券中心 ====================
const claimList = ref<ClaimableCoupon[]>([])
const claimLoading = ref(false)
const claimingId = ref<number | null>(null)
async function loadClaimable() {
claimLoading.value = true
try {
claimList.value = await getReceivableCouponsApi()
} catch {
/* 拦截器已提示 */
} finally {
claimLoading.value = false
}
}
function claimMainText(c: ClaimableCoupon) {
if (c.type === 1) {
return `${Number((c.amount * 10).toFixed(1))}`
}
return `¥${c.amount}`
}
function claimConditionText(c: ClaimableCoupon) {
if (c.type === 2 || c.minAmount <= 0) return '无门槛'
return `满 ¥${c.minAmount} 可用`
}
function claimStockText(c: ClaimableCoupon) {
if (c.total === -1) return '不限量'
return `剩余 ${c.remain}`
}
function claimValidText(c: ClaimableCoupon) {
return c.validDays
? `领取后 ${c.validDays} 天内有效`
: `有效期至 ${(c.endTime || '').slice(0, 10)}`
}
function claimDisabled(c: ClaimableCoupon) {
return c.claimed || (c.total !== -1 && c.remain <= 0)
}
async function handleClaim(c: ClaimableCoupon) {
if (claimDisabled(c) || claimingId.value !== null) return
claimingId.value = c.id
try {
await receiveCouponApi(c.id)
ElMessage.success('领取成功,可在「我的优惠券」查看')
loadClaimable()
loadCoupons()
} catch {
/* 拦截器已提示 */
} finally {
claimingId.value = null
}
}
// ==================== 挂载 ====================
onMounted(() => {
// 支持 /user?menu=orders 等直达指定板块(如抢购成功后跳转)
const menuQuery = route.query.menu as string | undefined
if (menuQuery && menuItems.some((i) => i.key === menuQuery)) {
switchMenu(menuQuery as MenuKey)
}
loadAddresses()
loadCoupons()
loadClaimable()
// 支付宝支付完成后跳回个人中心,直接展示订单列表
if (route.query.pay === 'success') {
activeMenu.value = 'orders'
loadOrders()
ElMessage.success('支付成功')
}
})
</script>
<template>
<div class="user-center">
<AppHeader />
<main class="uc-main">
<div class="uc-container">
<!-- 侧边栏 -->
<aside class="uc-sidebar">
<div class="uc-user-card">
<div class="avatar">{{ avatarText }}</div>
<div class="nickname">{{ userStore.displayName }}</div>
<div class="uid">用户ID{{ userStore.userId || '-' }}</div>
</div>
<nav class="uc-nav">
<div
v-for="item in menuItems"
:key="item.key"
class="uc-nav-item"
:class="{ active: activeMenu === item.key }"
@click="switchMenu(item.key)"
>
<el-icon class="nav-icon"><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</div>
</nav>
<div class="uc-logout" @click="handleLogout">
<el-icon class="nav-icon"><SwitchButton /></el-icon>
<span>退出登录</span>
</div>
</aside>
<!-- 内容区 -->
<section class="uc-content">
<!-- ===== 个人资料 ===== -->
<div v-if="activeMenu === 'profile'" class="uc-panel">
<div class="panel-head">
<h3 class="panel-title">个人资料</h3>
<el-button :icon="Edit" round @click="openProfileDialog">
编辑资料
</el-button>
</div>
<div class="profile-card">
<div class="profile-avatar">{{ avatarText }}</div>
<div class="profile-info">
<div class="info-row">
<span class="label">昵称</span>
<span class="value">{{ userStore.nickname || '未设置' }}</span>
</div>
<div class="info-row">
<span class="label">用户ID</span>
<span class="value">{{ userStore.userId || '-' }}</span>
</div>
<div class="info-row">
<span class="label">手机号</span>
<span class="value">未绑定</span>
</div>
</div>
<el-button class="pwd-btn" text type="primary" @click="pwdDialog = true">
修改密码
</el-button>
</div>
</div>
<!-- ===== 我的订单 ===== -->
<div v-else-if="activeMenu === 'orders'" class="uc-panel">
<div class="panel-head">
<h3 class="panel-title">我的订单</h3>
</div>
<el-tabs v-model="orderTab" @tab-change="onOrderTabChange">
<el-tab-pane
v-for="tab in ORDER_TABS"
:key="tab.value"
:label="tab.label"
:name="tab.value as any"
/>
</el-tabs>
<div v-loading="orderLoading">
<template v-if="orderPage.records.length">
<div v-for="order in orderPage.records" :key="order.id" class="order-card">
<div class="order-head">
<span class="order-no">订单号:{{ order.orderNo }}</span>
<el-tag :type="(order.statusTagType as any) || 'info'" size="small">
{{ order.statusText }}
</el-tag>
</div>
<div class="order-body">
<div class="order-items">
<el-image
v-for="item in order.items.slice(0, 4)"
:key="item.id"
:src="item.productImage"
fit="cover"
class="order-thumb"
>
<template #error>
<div class="thumb-placeholder">🍪</div>
</template>
</el-image>
<span v-if="order.totalQuantity > 4" class="more-count">
+{{ order.totalQuantity - 4 }}
</span>
</div>
<div class="order-meta">
<span class="meta-time">{{ order.createTime }}</span>
<span class="meta-amount">
实付 <b>¥{{ Number(order.payAmount || 0).toFixed(2) }}</b>
</span>
</div>
</div>
<div class="order-foot">
<el-button
v-if="order.status === 0"
size="small"
type="primary"
round
:loading="orderActingId === order.id"
@click="handlePayOrder(order)"
>
立即支付
</el-button>
<el-button
v-if="order.status === 2"
size="small"
type="success"
round
:loading="orderActingId === order.id"
@click="handleReceiveOrder(order)"
>
确认收货
</el-button>
<el-button
v-if="order.status === 0"
size="small"
plain
round
@click="handleCancelOrder(order)"
>
取消订单
</el-button>
</div>
</div>
<el-pagination
v-if="orderPage.pages > 1"
class="uc-pagination"
background
layout="prev, pager, next, total"
:total="orderPage.total"
:current-page="orderPage.current"
:page-size="orderPage.size"
@current-change="onOrderPageChange"
/>
</template>
<EmptyState v-else description="暂无相关订单" />
</div>
</div>
<!-- ===== 收货地址 ===== -->
<div v-else-if="activeMenu === 'addresses'" class="uc-panel">
<div class="panel-head">
<h3 class="panel-title">收货地址</h3>
<el-button type="primary" :icon="Plus" round @click="openAddressDialog()">
新增地址
</el-button>
</div>
<div v-loading="addressLoading">
<template v-if="addressList.length">
<div class="address-grid">
<div
v-for="address in addressList"
:key="address.id"
class="address-card"
:class="{ 'is-default': address.isDefault === 1 }"
>
<div class="addr-head">
<span class="addr-receiver">{{ address.receiver }}</span>
<span class="addr-phone">{{ address.phone }}</span>
<el-tag
v-if="address.tag"
size="small"
effect="plain"
class="addr-tag"
>
{{ address.tag }}
</el-tag>
<el-tag v-if="address.isDefault === 1" size="small" type="danger">
默认
</el-tag>
</div>
<p class="addr-detail">{{ address.fullAddress || address.detail }}</p>
<div class="addr-foot">
<el-button
v-if="address.isDefault !== 1"
text
type="primary"
size="small"
:icon="Check"
@click="handleSetDefault(address)"
>
设为默认
</el-button>
<el-button
text
type="primary"
size="small"
:icon="Edit"
@click="openAddressDialog(address)"
>
编辑
</el-button>
<el-button
text
type="danger"
size="small"
:icon="Delete"
@click="handleDeleteAddress(address)"
>
删除
</el-button>
</div>
</div>
</div>
</template>
<EmptyState v-else description="还没有收货地址,点击右上角新增" />
</div>
</div>
<!-- ===== 我的收藏 ===== -->
<div v-else-if="activeMenu === 'favorites'" class="uc-panel">
<div class="panel-head">
<h3 class="panel-title">我的收藏</h3>
</div>
<div v-loading="favoriteLoading">
<template v-if="favoritePage.records.length">
<div class="favorite-grid">
<div
v-for="item in favoritePage.records"
:key="item.id"
class="favorite-card"
@click="goProduct(item.productId)"
>
<div class="fav-image">
<el-image :src="item.productMainImage" fit="cover" lazy>
<template #error>
<div class="fav-placeholder">🍪</div>
</template>
</el-image>
<div
class="fav-remove"
@click.stop="handleCancelFavorite(item)"
>
<el-icon><Delete /></el-icon>
</div>
</div>
<div class="fav-body">
<p class="fav-name">{{ item.productName }}</p>
<div class="fav-foot">
<span class="fav-price">
¥{{ Number(item.minPrice || 0).toFixed(2) }}
</span>
<span class="fav-time">{{ item.createTime }}</span>
</div>
</div>
</div>
</div>
<el-pagination
v-if="favoritePage.pages > 1"
class="uc-pagination"
background
layout="prev, pager, next, total"
:total="favoritePage.total"
:current-page="favoritePage.current"
:page-size="favoritePage.size"
@current-change="onFavoritePageChange"
/>
</template>
<EmptyState v-else description="暂无收藏的商品" />
</div>
</div>
<!-- ===== 领券中心 ===== -->
<div v-else-if="activeMenu === 'claim'" class="uc-panel">
<div class="panel-head">
<h3 class="panel-title">领券中心</h3>
</div>
<div v-loading="claimLoading">
<template v-if="claimList.length">
<div class="claim-grid">
<div
v-for="c in claimList"
:key="c.id"
class="claim-card"
>
<div class="claim-main">
<span class="claim-amount">{{ claimMainText(c) }}</span>
<span class="claim-type">{{ c.typeText }}</span>
</div>
<div class="claim-info">
<span class="claim-name">{{ c.name }}</span>
<span class="claim-condition">{{ claimConditionText(c) }}</span>
<span class="claim-meta">
{{ claimStockText(c) }} · {{ claimValidText(c) }}
</span>
<span v-if="c.description" class="claim-desc">{{ c.description }}</span>
</div>
<div class="claim-action">
<el-tag v-if="c.claimed" type="info">已领取</el-tag>
<el-tag v-else-if="c.total !== -1 && c.remain <= 0" type="danger">已抢完</el-tag>
<el-button
v-else
type="primary"
size="small"
round
:loading="claimingId === c.id"
@click="handleClaim(c)"
>立即领取</el-button>
</div>
</div>
</div>
</template>
<EmptyState v-else description="暂无可领取的优惠券" />
</div>
</div>
<!-- ===== 我的优惠券 ===== -->
<div v-else-if="activeMenu === 'coupons'" class="uc-panel">
<div class="panel-head">
<h3 class="panel-title">我的优惠券</h3>
</div>
<el-tabs v-model="couponTab" @tab-change="onCouponTabChange">
<el-tab-pane
v-for="tab in COUPON_TABS"
:key="tab.value"
:label="tab.label"
:name="tab.value as any"
/>
</el-tabs>
<div v-loading="couponLoading">
<template v-if="couponList.length">
<div class="coupon-grid">
<div
v-for="coupon in couponList"
:key="coupon.id"
class="coupon-card"
>
<div class="coupon-left">
<span class="coupon-name">{{ coupon.couponName }}</span>
<span class="coupon-type">
类型:{{ coupon.couponType === 0 ? '满减券' : '折扣券' }}
</span>
</div>
<div class="coupon-right">
<el-tag size="small" :type="coupon.status === 0 ? 'success' : 'info'">
{{ coupon.statusText }}
</el-tag>
<span class="coupon-expire">
有效期至 {{ (coupon.expireTime || '').slice(0, 10) }}
</span>
</div>
</div>
</div>
</template>
<EmptyState v-else description="暂无优惠券,去领券中心看看吧" />
</div>
</div>
</section>
</div>
</main>
<AppFooter />
<!-- 编辑资料弹窗(占位,后端接口开发中) -->
<el-dialog v-model="profileDialog" title="编辑资料" width="440px">
<el-form label-width="80px">
<el-form-item label="昵称">
<el-input v-model="profileForm.nickname" placeholder="请输入昵称" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="profileForm.phone" placeholder="请输入手机号" />
</el-form-item>
<el-form-item label="性别">
<el-radio-group v-model="profileForm.gender">
<el-radio :value="0">保密</el-radio>
<el-radio :value="1">男</el-radio>
<el-radio :value="2"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="生日">
<el-date-picker
v-model="profileForm.birthday"
type="date"
placeholder="选择生日"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="profileDialog = false">取消</el-button>
<el-button type="primary" @click="submitProfile">保存</el-button>
</template>
</el-dialog>
<!-- 修改密码弹窗(占位,后端接口开发中) -->
<el-dialog v-model="pwdDialog" title="修改密码" width="440px">
<el-form label-width="90px">
<el-form-item label="原密码">
<el-input
v-model="pwdForm.oldPassword"
type="password"
show-password
placeholder="请输入原密码"
/>
</el-form-item>
<el-form-item label="新密码">
<el-input
v-model="pwdForm.newPassword"
type="password"
show-password
placeholder="请输入新密码"
/>
</el-form-item>
<el-form-item label="确认密码">
<el-input
v-model="pwdForm.confirmPassword"
type="password"
show-password
placeholder="请再次输入新密码"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="pwdDialog = false">取消</el-button>
<el-button type="primary" @click="submitPassword">确认修改</el-button>
</template>
</el-dialog>
<!-- 地址编辑弹窗 -->
<el-dialog
v-model="addressDialog"
:title="addressForm.id ? '编辑地址' : '新增地址'"
width="520px"
>
<el-form
ref="addressFormRef"
:model="addressForm"
:rules="addressRules"
label-width="80px"
>
<el-form-item label="收货人" prop="receiver">
<el-input v-model="addressForm.receiver" placeholder="请输入收货人姓名" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="addressForm.phone" placeholder="请输入手机号" />
</el-form-item>
<el-form-item label="省">
<el-input v-model="addressForm.province" placeholder="如:广东省" />
</el-form-item>
<el-form-item label="城市">
<el-input v-model="addressForm.city" placeholder="如:深圳市" />
</el-form-item>
<el-form-item label="区/县">
<el-input v-model="addressForm.district" placeholder="如:南山区" />
</el-form-item>
<el-form-item label="详细地址" prop="detail">
<el-input
v-model="addressForm.detail"
type="textarea"
:rows="2"
placeholder="街道、楼栋、门牌号等"
/>
</el-form-item>
<el-form-item label="标签">
<el-select v-model="addressForm.tag" placeholder="请选择标签" clearable>
<el-option label="家" value="家" />
<el-option label="公司" value="公司" />
<el-option label="学校" value="学校" />
</el-select>
</el-form-item>
<el-form-item label="设为默认">
<el-switch v-model="addressForm.isDefault" :active-value="1" :inactive-value="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addressDialog = false">取消</el-button>
<el-button type="primary" :loading="addressSaving" @click="submitAddress">
保存
</el-button>
</template>
</el-dialog>
</div>
</template>
<style lang="scss" scoped>
.user-center {
min-height: 100vh;
display: flex;
flex-direction: column;
background: $bg-page;
}
.uc-main {
flex: 1;
width: 100%;
}
.uc-container {
max-width: $container-max;
margin: 0 auto;
padding: 24px 24px 0;
display: flex;
gap: 24px;
align-items: flex-start;
}
// ==================== 侧边栏 ====================
.uc-sidebar {
width: 220px;
flex-shrink: 0;
background: $bg-surface;
border: 2px solid $color-border;
border-radius: $radius-lg;
box-shadow: $clay-shadow-sm;
padding: 20px 12px;
position: sticky;
top: calc(#{$header-height} + 24px);
}
.uc-user-card {
text-align: center;
padding: 12px 8px 20px;
border-bottom: 2px solid $color-border;
margin-bottom: 12px;
.avatar {
width: 64px;
height: 64px;
margin: 0 auto 12px;
border-radius: $radius-full;
background: linear-gradient(135deg, $color-primary, $color-cta);
color: #fff;
font-size: 28px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
box-shadow: $clay-shadow-sm;
}
.nickname {
font-size: $font-size-lg;
font-weight: 600;
color: $color-text-primary;
margin-bottom: 4px;
}
.uid {
font-size: $font-size-xs;
color: $color-text-placeholder;
}
}
.uc-nav {
display: flex;
flex-direction: column;
gap: 4px;
.uc-nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 14px;
border-radius: $radius-sm;
font-size: $font-size-md;
color: $color-text-regular;
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
.nav-icon {
font-size: 18px;
}
&:hover {
background: $bg-hover;
color: $color-primary;
}
&.active {
background: $color-primary-light;
color: $color-primary;
font-weight: 600;
}
}
}
.uc-logout {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 14px;
margin-top: 12px;
border-top: 2px solid $color-border;
padding-top: 16px;
font-size: $font-size-md;
color: $color-danger;
cursor: pointer;
transition: background-color 0.2s;
.nav-icon {
font-size: 18px;
}
&:hover {
background: $bg-hover;
border-radius: $radius-sm;
}
}
// ==================== 内容区 ====================
.uc-content {
flex: 1;
min-width: 0;
background: $bg-surface;
border: 2px solid $color-border;
border-radius: $radius-lg;
box-shadow: $clay-shadow-sm;
padding: 24px;
min-height: 480px;
}
.uc-panel {
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
.panel-title {
margin: 0;
font-size: $font-size-xl;
font-weight: 600;
color: $color-text-primary;
}
}
}
.uc-pagination {
margin-top: 20px;
justify-content: center;
}
// ---- 个人资料 ----
.profile-card {
display: flex;
align-items: center;
gap: 24px;
background: $bg-page;
border: 2px solid $color-border;
border-radius: $radius-md;
padding: 28px;
.profile-avatar {
width: 80px;
height: 80px;
flex-shrink: 0;
border-radius: $radius-full;
background: linear-gradient(135deg, $color-primary, $color-cta);
color: #fff;
font-size: 34px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
box-shadow: $clay-shadow-sm;
}
.profile-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
.info-row {
display: flex;
gap: 16px;
.label {
width: 64px;
font-size: $font-size-sm;
color: $color-text-placeholder;
flex-shrink: 0;
}
.value {
font-size: $font-size-md;
color: $color-text-primary;
}
}
}
.pwd-btn {
align-self: flex-end;
}
}
// ---- 订单 ----
.order-card {
border: 2px solid $color-border;
border-radius: $radius-md;
margin-bottom: 16px;
overflow: hidden;
.order-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: $bg-page;
border-bottom: 2px solid $color-border;
.order-no {
font-size: $font-size-sm;
color: $color-text-secondary;
}
}
.order-body {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px;
flex-wrap: wrap;
.order-items {
display: flex;
align-items: center;
gap: 8px;
.order-thumb {
width: 56px;
height: 56px;
border-radius: $radius-sm;
border: 1px solid $color-border;
overflow: hidden;
}
.thumb-placeholder {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
background: $bg-page;
}
.more-count {
font-size: $font-size-sm;
color: $color-text-placeholder;
margin-left: 4px;
}
}
.order-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
.meta-time {
font-size: $font-size-xs;
color: $color-text-placeholder;
}
.meta-amount {
font-size: $font-size-sm;
color: $color-text-secondary;
b {
font-size: $font-size-lg;
color: $color-primary;
font-family: 'Fredoka', sans-serif;
}
}
}
}
.order-foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 2px solid $color-border;
background: $bg-surface;
}
}
// ---- 收货地址 ----
.address-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.address-card {
border: 2px solid $color-border;
border-radius: $radius-md;
padding: 16px;
transition: border-color 0.2s, box-shadow 0.2s;
&.is-default {
border-color: $color-primary;
box-shadow: $clay-shadow-sm;
}
.addr-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
.addr-receiver {
font-size: $font-size-lg;
font-weight: 600;
color: $color-text-primary;
}
.addr-phone {
font-size: $font-size-sm;
color: $color-text-secondary;
}
.addr-tag {
margin-left: auto;
}
}
.addr-detail {
margin: 0 0 12px;
font-size: $font-size-sm;
color: $color-text-regular;
line-height: 1.5;
min-height: 42px;
}
.addr-foot {
display: flex;
justify-content: flex-end;
gap: 4px;
border-top: 1px dashed $color-border;
padding-top: 10px;
}
}
// ---- 收藏 ----
.favorite-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
}
.favorite-card {
border: 2px solid $color-border;
border-radius: $radius-md;
overflow: hidden;
cursor: pointer;
transition: transform 0.25s ease, box-shadow 0.25s ease;
&:hover {
transform: translateY(-4px);
box-shadow: $clay-shadow-md;
}
.fav-image {
position: relative;
aspect-ratio: 1 / 1;
background: $bg-page;
.el-image,
.fav-placeholder {
width: 100%;
height: 100%;
}
.fav-placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 40px;
background: $bg-page;
}
.fav-remove {
position: absolute;
top: 8px;
right: 8px;
width: 30px;
height: 30px;
border-radius: $radius-full;
background: rgba(255, 255, 255, 0.92);
color: $color-danger;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(80, 40, 20, 0.2);
opacity: 0;
transition: opacity 0.2s;
&:hover {
background: $color-danger;
color: #fff;
}
}
}
&:hover .fav-remove {
opacity: 1;
}
.fav-body {
padding: 10px 12px 12px;
.fav-name {
margin: 0 0 8px;
font-size: $font-size-sm;
color: $color-text-primary;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fav-foot {
display: flex;
align-items: center;
justify-content: space-between;
.fav-price {
font-family: 'Fredoka', sans-serif;
font-size: $font-size-lg;
font-weight: 700;
color: $color-primary;
}
.fav-time {
font-size: $font-size-xs;
color: $color-text-placeholder;
}
}
}
}
// ---- 优惠券 ----
.coupon-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.coupon-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 20px;
border: 2px solid $color-border;
border-radius: $radius-md;
background: $bg-page;
.coupon-left {
display: flex;
flex-direction: column;
gap: 6px;
.coupon-name {
font-size: $font-size-lg;
font-weight: 600;
color: $color-text-primary;
}
.coupon-type {
font-size: $font-size-xs;
color: $color-text-placeholder;
}
}
.coupon-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
.coupon-expire {
font-size: $font-size-xs;
color: $color-text-placeholder;
}
}
}
// ---- 领券中心 ----
.claim-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.claim-card {
display: flex;
align-items: center;
gap: 20px;
padding: 18px 20px;
border: 2px solid $color-border;
border-radius: $radius-md;
background: $bg-surface;
box-shadow: $clay-shadow-sm;
.claim-main {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
width: 112px;
min-height: 78px;
border-radius: $radius-sm;
background: linear-gradient(135deg, $color-primary, $color-secondary);
color: #fff;
flex-shrink: 0;
.claim-amount {
font-size: $font-size-xl;
font-weight: 700;
line-height: 1.2;
}
.claim-type {
font-size: $font-size-xs;
opacity: 0.92;
}
}
.claim-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
.claim-name {
font-size: $font-size-lg;
font-weight: 600;
color: $color-text-primary;
}
.claim-condition {
font-size: $font-size-sm;
color: $color-danger;
}
.claim-meta {
font-size: $font-size-xs;
color: $color-text-placeholder;
}
.claim-desc {
font-size: $font-size-xs;
color: $color-text-secondary;
}
}
.claim-action {
width: 104px;
text-align: center;
flex-shrink: 0;
}
}
// ==================== 响应式 ====================
@media (max-width: 768px) {
.uc-container {
flex-direction: column;
padding: 16px 16px 0;
}
.uc-sidebar {
width: 100%;
position: static;
}
.uc-nav {
flex-direction: row;
flex-wrap: wrap;
.uc-nav-item {
flex: 1 1 40%;
}
}
.claim-card {
flex-direction: column;
align-items: stretch;
text-align: center;
.claim-main {
width: 100%;
}
.claim-action {
width: 100%;
}
}
.uc-logout {
justify-content: center;
}
}
</style>