2025-11-17 22:00:25 +08:00

618 lines
14 KiB
Vue
Raw Permalink 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.

<template>
<view class="page">
<view class="page-header">
<text class="title">收货地址</text>
<text class="subtitle">管理常用地址支持设置默认地址</text>
</view>
<view v-if="loading" class="state-card">
<view class="loading-circle"></view>
<text class="state-text">正在加载地址</text>
</view>
<view v-else-if="!addressList.length" class="empty-card">
<text class="empty-icon">📭</text>
<text class="empty-title">还没有添加任何地址</text>
<text class="empty-subtitle">新增地址后即可在下单时快速选择</text>
<button class="btn primary" @click="handleAdd">新增收货地址</button>
</view>
<view v-else scroll-y class="address-list">
<view class="address-card" v-for="item in addressList" :key="getAddressId(item)">
<view class="card-header">
<view class="card-title">
<text class="receiver">{{ item.receiver_name }}</text>
<text class="phone">{{ item.phone }}</text>
</view>
<view class="tag default" v-if="Number(item.is_default) === 1">默认</view>
</view>
<view class="card-body">
<text class="region">{{ formatRegion(item) }}</text>
<text class="detail">{{ item.detail_address }}</text>
<text class="postal" v-if="item.postal_code">邮编{{ item.postal_code }}</text>
<text class="timestamp">更新于 {{ item.updated_at || item.created_at || '--' }}</text>
</view>
<view class="card-actions">
<view class="action" @click="handleEdit(item)">编辑</view>
<view class="action danger" @click="handleDelete(item)">删除</view>
<view class="action text" v-if="Number(item.is_default) !== 1" @click="handleSetDefault(item)">设为默认</view>
</view>
</view>
</view>
<view class="page-actions" v-if="addressList.length">
<button class="btn primary" @click="handleAdd">新增收货地址</button>
</view>
<view class="drawer-mask" v-if="formVisible" @click="closeForm" @touchmove.stop.prevent>
<view class="drawer" @click.stop="noop">
<view class="drawer-header">
<text class="drawer-title">{{ formTitle }}</text>
<text class="drawer-close" @click="closeForm">×</text>
</view>
<scroll-view scroll-y class="form">
<view class="form-item">
<text class="form-label">收货人</text>
<input class="form-input" type="text" v-model.trim="formData.receiver_name" placeholder="请输入收货人姓名" />
</view>
<view class="form-item">
<text class="form-label">手机号</text>
<input class="form-input" type="number" maxlength="11" v-model.trim="formData.phone" placeholder="请输入联系电话" />
</view>
<view class="form-item region-item">
<text class="form-label">所在地区</text>
<picker mode="region" :value="regionValue" @change="handleRegionChange">
<view class="form-picker">
<text :class="{ 'form-placeholder': !regionText }">
{{ regionText || '请选择省市区' }}
</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="form-item">
<text class="form-label">邮编</text>
<input class="form-input" type="number" maxlength="6" v-model.trim="formData.postal_code" placeholder="可选" />
</view>
<view class="form-item">
<text class="form-label">详细地址</text>
<textarea class="form-textarea" v-model.trim="formData.detail_address" placeholder="如张江路88号香氛公馆5楼"
auto-height />
</view>
<view class="form-item switch-row">
<view>
<text class="form-label">设为默认地址</text>
<text class="form-desc">下单时将优先选择该地址</text>
</view>
<switch :checked="Number(formData.is_default) === 1" @change="handleDefaultSwitch" color="#8b40ff" />
</view>
</scroll-view>
<view class="drawer-actions">
<button class="btn ghost" @click="closeForm">取消</button>
<button class="btn primary" :loading="submitting" @click="handleSubmit">
{{ submitting ? '提交中...' : '保存' }}
</button>
</view>
</view>
</view>
</view>
</template>
<script>
import {
fetchAddressList,
createAddress,
updateAddress,
deleteAddress
} from '@/api/address.js';
export default {
data() {
return {
loading: false,
submitting: false,
addressList: [],
formVisible: false,
formMode: 'create',
formData: this.getDefaultForm(),
regionValue: ['', '', ''],
phonePattern: /^(?:(?:\+|00)86)?1[3-9]\d{9}$/
};
},
computed: {
formTitle() {
return this.formMode === 'create' ? '新增收货地址' : '编辑收货地址';
},
regionText() {
return this.regionValue.filter(Boolean).join(' / ');
}
},
onShow() {
this.loadAddresses();
},
onPullDownRefresh() {
this.loadAddresses();
},
methods: {
normalizeDefaultFlag(value) {
return Number(value) === 1 ? 1 : 2;
},
buildPayload(source) {
return {
receiver_name: source.receiver_name || '',
phone: source.phone || '',
province: source.province || '',
city: source.city || '',
district: source.district || '',
detail_address: source.detail_address || '',
postal_code: source.postal_code || '',
is_default: this.normalizeDefaultFlag(source.is_default)
};
},
getDefaultForm() {
return {
id: '',
receiver_name: '',
phone: '',
province: '',
city: '',
district: '',
detail_address: '',
postal_code: '',
is_default: 2
};
},
async loadAddresses() {
this.loading = true;
try {
const list = await fetchAddressList();
this.addressList = list;
} catch (error) {
console.error('获取地址失败', error);
this.showToast('地址加载失败,请稍后重试');
} finally {
this.loading = false;
uni.stopPullDownRefresh();
}
},
getAddressId(item) {
return item?.id ?? item?.address_id ?? item?.addressId ?? '';
},
formatRegion(item) {
return [item.province, item.city, item.district].filter(Boolean).join(' · ');
},
handleAdd() {
this.formMode = 'create';
this.formData = this.getDefaultForm();
this.regionValue = ['', '', ''];
this.formVisible = true;
},
handleEdit(item) {
this.formMode = 'edit';
this.formData = {
...this.getDefaultForm(),
...item,
id: this.getAddressId(item),
is_default: this.normalizeDefaultFlag(item.is_default)
};
this.regionValue = [
this.formData.province || '',
this.formData.city || '',
this.formData.district || ''
];
this.formVisible = true;
},
handleRegionChange(e) {
const value = e?.detail?.value || ['', '', ''];
this.regionValue = value;
[this.formData.province, this.formData.city, this.formData.district] = value;
},
handleDefaultSwitch(e) {
this.formData.is_default = e.detail.value ? 1 : 2;
},
closeForm() {
if (this.submitting) return;
this.formVisible = false;
},
noop() {},
validateForm() {
if (!this.formData.receiver_name.trim()) {
this.showToast('请填写收货人姓名');
return false;
}
if (!this.formData.phone.trim() || !this.phonePattern.test(this.formData.phone.trim())) {
this.showToast('请填写有效的手机号');
return false;
}
if (!this.formData.province.trim() || !this.formData.city.trim() || !this.formData.district.trim()) {
this.showToast('请完善所在地区');
return false;
}
if (!this.formData.detail_address.trim()) {
this.showToast('请填写详细地址');
return false;
}
return true;
},
async handleSubmit() {
if (!this.validateForm()) return;
this.submitting = true;
const payload = this.buildPayload(this.formData);
try {
if (this.formMode === 'create') {
await createAddress(payload);
this.showToast('新增地址成功', 'success');
} else {
const id = this.formData.id;
if (!id) {
this.showToast('缺少地址ID无法保存');
return;
}
await updateAddress(id, payload);
this.showToast('地址更新成功', 'success');
}
this.formVisible = false;
this.loadAddresses();
} catch (error) {
console.error('保存地址失败', error);
this.showToast('保存失败,请稍后重试');
} finally {
this.submitting = false;
}
},
handleDelete(item) {
const id = this.getAddressId(item);
if (!id) return this.showToast('缺少地址ID');
uni.showModal({
title: '删除确认',
content: '删除后不可恢复,确认要删除该地址吗?',
success: async ({ confirm }) => {
if (!confirm) return;
try {
await deleteAddress(id);
this.showToast('删除成功', 'success');
this.loadAddresses();
} catch (error) {
console.error('删除地址失败', error);
this.showToast('删除失败,请稍后重试');
}
}
});
},
async handleSetDefault(item) {
const id = this.getAddressId(item);
if (!id) return this.showToast('缺少地址ID');
try {
const payload = this.buildPayload({ ...item, is_default: 1 });
await updateAddress(id, payload);
this.showToast('已设为默认地址', 'success');
this.loadAddresses();
} catch (error) {
console.error('设置默认地址失败', error);
this.showToast('设置失败,请稍后重试');
}
},
showToast(title, icon = 'none') {
uni.showToast({
title,
icon,
duration: 2000
});
}
}
};
</script>
<style lang="scss" scoped>
.page {
// height: 100vh;
// background: #f7f8fa;
padding: 32rpx 32rpx;
// display: flex;
// flex-direction: column;
// gap: 24rpx;
position: relative;
}
.page-header {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.title {
font-size: 40rpx;
font-weight: 600;
color: #1a1a1a;
}
.subtitle {
font-size: 26rpx;
color: #8c8c8c;
}
.state-card,
.empty-card {
background: #ffffff;
border-radius: 24rpx;
padding: 48rpx 32rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
box-shadow: 0 10rpx 30rpx rgba(16, 30, 54, 0.08);
}
.loading-circle {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
border: 8rpx solid rgba(139, 64, 255, 0.15);
border-top-color: #8b40ff;
animation: spin 1.2s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.state-text {
font-size: 26rpx;
color: #5f5f5f;
}
.empty-icon {
font-size: 72rpx;
}
.empty-title {
font-size: 32rpx;
color: #1a1a1a;
font-weight: 600;
}
.empty-subtitle {
font-size: 26rpx;
color: #8c8c8c;
}
.address-list {
flex: 1;
display: flex;
flex-direction: column;
gap: 24rpx;
max-height: 100%;
}
.address-card {
background: #ffffff;
border-radius: 28rpx;
padding: 32rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
box-shadow: 0 12rpx 32rpx rgba(79, 116, 255, 0.08);
// margin-bottom: 24rpx;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-title {
display: flex;
align-items: baseline;
gap: 16rpx;
}
.receiver {
font-size: 32rpx;
font-weight: 600;
color: #1a1a1a;
}
.phone {
font-size: 28rpx;
color: #4f5f7a;
}
.tag.default {
padding: 6rpx 18rpx;
border-radius: 999rpx;
background: rgba(139, 64, 255, 0.12);
color: #8b40ff;
font-size: 24rpx;
}
.card-body {
display: flex;
flex-direction: column;
gap: 8rpx;
font-size: 26rpx;
color: #4f5f7a;
}
.detail {
font-size: 28rpx;
color: #1f2533;
font-weight: 500;
}
.timestamp {
font-size: 24rpx;
color: #b0b3ba;
margin-top: 8rpx;
}
.card-actions {
display: flex;
gap: 16rpx;
flex-wrap: wrap;
}
.action {
padding: 14rpx 30rpx;
border-radius: 999rpx;
font-size: 26rpx;
color: #4f5f7a;
background: #f2f5ff;
}
.action.danger {
color: #ff4d6d;
background: rgba(255, 77, 109, 0.15);
}
.action.text {
background: transparent;
color: #8b40ff;
}
.page-actions {
margin-top: 40rpx;
position: sticky;
bottom: 0;
padding-bottom: env(safe-area-inset-bottom);
}
.btn {
width: 100%;
height: 100rpx;
border-radius: 999rpx;
font-size: 32rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
border: none;
}
.btn.primary {
color: #ffffff;
background: linear-gradient(135deg, #8b40ff 0%, #ff3c8d 100%);
}
.btn.ghost {
color: #4f5f7a;
background: rgba(79, 95, 122, 0.1);
}
.drawer-mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 99;
padding: 0;
}
.drawer {
width: 100%;
border-radius: 32rpx 32rpx 0 0;
background: #ffffff;
padding: 32rpx;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 12rpx;
}
.drawer-title {
font-size: 34rpx;
font-weight: 600;
color: #1a1a1a;
}
.drawer-close {
font-size: 40rpx;
color: #8c8c8c;
}
.form {
flex: 1;
padding-top: 16rpx;
}
.form-item {
display: flex;
flex-direction: column;
gap: 12rpx;
margin-bottom: 24rpx;
}
.form-inline {
display: flex;
gap: 24rpx;
.form-item {
flex: 1;
margin-bottom: 0;
}
}
.form-label {
font-size: 26rpx;
color: #1a1a1a;
font-weight: 500;
}
.form-input,
.form-textarea {
width: 100%;
background: #f5f7fb;
border-radius: 18rpx;
padding: 0 28rpx;
height: 88rpx;
line-height: 88rpx;
font-size: 28rpx;
color: #1a1a1a;
box-sizing: border-box;
}
.form-textarea {
padding-top: 24rpx;
padding-bottom: 24rpx;
min-height: 150rpx;
line-height: 1.6;
}
.switch-row {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.form-desc {
font-size: 24rpx;
color: #8c8c8c;
margin-top: 8rpx;
display: block;
}
.drawer-actions {
display: flex;
gap: 24rpx;
padding-top: 8rpx;
}
</style>