front/src/views/h5/customer-center/components/H5AddressSheet.vue
zzh 33f696bc42
All checks were successful
Build and Deploy Front / deploy (push) Successful in 7m24s
feat: 抽取 PublishDiff 公共组件,完善发布/订单/渠道与多模块体验
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 23:34:54 +08:00

755 lines
18 KiB
Vue

<template>
<a-drawer
:visible="visible"
placement="bottom"
height="auto"
:footer="false"
:header="false"
unmount-on-close
:mask-closable="!saving"
:drawer-style="drawerStyle"
popup-container="body"
class="h5-addr-drawer"
@cancel="close"
>
<div class="h5-addr">
<div class="h5-addr__handle" aria-hidden="true" />
<header class="h5-addr__head">
<button
v-if="mode === 'form'"
type="button"
class="h5-addr__back"
:disabled="saving"
@click="backToList"
>
<icon-left />
</button>
<h3>{{ headTitle }}</h3>
<button type="button" class="h5-addr__close" :disabled="saving" @click="close">
<icon-close />
</button>
</header>
<!-- 列表 -->
<template v-if="mode === 'list'">
<div class="h5-addr__tabs">
<button
v-for="tab in typeTabs"
:key="tab"
type="button"
class="h5-addr__tab"
:class="{ 'is-active': activeType === tab }"
@click="switchType(tab)"
>
{{ t(`h5.address.type.${tab}`) }}
</button>
</div>
<a-spin :loading="loading" class="h5-addr__body">
<H5EmptyState
v-if="!rows.length && !loading"
:icon="IconLocation"
:text="t('h5.address.empty')"
/>
<article v-for="row in rows" :key="row.id" class="addr-card">
<div class="addr-card__main" @click="openEdit(row)">
<div class="addr-card__top">
<strong>{{ row.senderName || '--' }}</strong>
<span v-if="row.isDefault" class="addr-card__badge">{{ t('h5.address.default') }}</span>
</div>
<p class="addr-card__phone">{{ row.phone || '--' }}</p>
<p class="addr-card__line">
{{ [row.province, row.city, row.streetAddress1].filter(Boolean).join(' ') || '--' }}
</p>
<p v-if="row.addressName" class="addr-card__alias">{{ row.addressName }}</p>
</div>
<div class="addr-card__actions">
<button
v-if="!row.isDefault"
type="button"
class="addr-card__act"
@click="setDefault(row)"
>
{{ t('h5.address.setDefault') }}
</button>
<button type="button" class="addr-card__act" @click="openEdit(row)">
{{ t('h5.address.edit') }}
</button>
<button type="button" class="addr-card__act is-danger" @click="remove(row)">
{{ t('h5.address.delete') }}
</button>
</div>
</article>
</a-spin>
<footer class="h5-addr__foot">
<button type="button" class="h5-addr__btn" @click="openCreate">
{{ t('h5.address.add') }}
</button>
</footer>
</template>
<!-- 表单 -->
<template v-else>
<div class="h5-addr__body">
<section class="h5-addr__card">
<label class="h5-addr__label">{{ t('h5.address.typeLabel') }}</label>
<div class="h5-addr__type-seg">
<button
v-for="tab in typeTabs"
:key="tab"
type="button"
class="h5-addr__seg"
:class="{ 'is-active': form.addressType === tab }"
@click="form.addressType = tab"
>
{{ t(`h5.address.type.${tab}`) }}
</button>
</div>
<label class="h5-addr__label">{{ t('h5.address.senderName') }}</label>
<a-input v-model="form.senderName" allow-clear :placeholder="t('h5.address.senderNamePh')" />
<label class="h5-addr__label">{{ t('h5.address.phone') }}</label>
<a-input v-model="form.phone" allow-clear />
<label class="h5-addr__label">{{ t('h5.address.addressName') }}</label>
<a-input v-model="form.addressName" allow-clear :placeholder="t('h5.address.addressNamePh')" />
<label class="h5-addr__label">{{ t('h5.address.countryCode') }}</label>
<a-input v-model="form.countryCode" allow-clear />
<div class="h5-addr__two">
<div>
<label class="h5-addr__label">{{ t('h5.address.province') }}</label>
<a-input v-model="form.province" allow-clear />
</div>
<div>
<label class="h5-addr__label">{{ t('h5.address.city') }}</label>
<a-input v-model="form.city" allow-clear />
</div>
</div>
<label class="h5-addr__label">{{ t('h5.address.street') }}</label>
<a-textarea
v-model="form.streetAddress1"
:auto-size="{ minRows: 2, maxRows: 4 }"
:placeholder="t('h5.address.streetPh')"
/>
<label class="h5-addr__label">{{ t('h5.address.zipCode') }}</label>
<a-input v-model="form.zipCode" allow-clear />
<button
type="button"
class="h5-addr__default-toggle"
:class="{ 'is-on': form.isDefault }"
@click="form.isDefault = !form.isDefault"
>
<span class="dot" aria-hidden="true" />
{{ t('h5.address.setDefaultTip') }}
</button>
</section>
</div>
<footer class="h5-addr__foot">
<button type="button" class="h5-addr__btn" :disabled="saving" @click="save">
<span v-if="saving" class="h5-addr__loading" aria-hidden="true" />
{{ t('h5.me.save') }}
</button>
</footer>
</template>
</div>
</a-drawer>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Message, Modal } from '@arco-design/web-vue';
import { IconClose, IconLeft, IconLocation } from '@arco-design/web-vue/es/icon';
import {
createSenderAddress,
deleteSenderAddress,
getSenderAddressList,
setDefaultSenderAddress,
updateSenderAddress,
} from '@/api/order/senderAddress';
import { pickField } from '@/utils/warehouseListMapper';
import H5EmptyState from './H5EmptyState.vue';
defineOptions({ name: 'H5AddressSheet' });
type AddressType = 'sender' | 'receiver' | 'return' | 'pickup';
type AddressRow = {
id: number;
addressType: AddressType;
addressName: string;
senderName: string;
phone: string;
countryCode: string;
province: string;
city: string;
zipCode: string;
streetAddress1: string;
isDefault: boolean;
_raw: Record<string, unknown>;
};
const props = defineProps<{
visible: boolean;
}>();
const emit = defineEmits<{
'update:visible': [value: boolean];
}>();
const { t } = useI18n();
const typeTabs: AddressType[] = ['sender', 'pickup', 'return', 'receiver'];
const activeType = ref<AddressType>('sender');
const mode = ref<'list' | 'form'>('list');
const loading = ref(false);
const saving = ref(false);
const rows = ref<AddressRow[]>([]);
const editingId = ref(0);
const form = reactive({
addressType: 'sender' as AddressType,
addressName: '',
senderName: '',
phone: '',
countryCode: 'CN',
province: '',
city: '',
zipCode: '',
streetAddress1: '',
isDefault: false,
});
const drawerStyle = {
borderRadius: '20px 20px 0 0',
overflow: 'hidden',
maxHeight: 'min(92vh, 780px)',
};
const headTitle = computed(() => {
if (mode.value === 'list') return t('h5.address.title');
return editingId.value ? t('h5.address.editTitle') : t('h5.address.addTitle');
});
function close() {
if (saving.value) return;
emit('update:visible', false);
}
function backToList() {
if (saving.value) return;
mode.value = 'list';
}
function resetForm(type: AddressType = activeType.value) {
editingId.value = 0;
Object.assign(form, {
addressType: type,
addressName: '',
senderName: '',
phone: '',
countryCode: 'CN',
province: '',
city: '',
zipCode: '',
streetAddress1: '',
isDefault: rows.value.length === 0,
});
}
function mapRow(raw: Record<string, unknown>): AddressRow | null {
const id = Number(pickField(raw, 'id', 'Id'));
if (!Number.isFinite(id) || id <= 0) return null;
return {
id,
addressType: String(pickField(raw, 'addressType', 'AddressType') || 'sender') as AddressType,
addressName: String(pickField(raw, 'addressName', 'AddressName') || ''),
senderName: String(pickField(raw, 'senderName', 'SenderName') || ''),
phone: String(pickField(raw, 'phone', 'Phone') || ''),
countryCode: String(pickField(raw, 'countryCode', 'CountryCode') || 'CN'),
province: String(pickField(raw, 'province', 'Province') || ''),
city: String(pickField(raw, 'city', 'City') || ''),
zipCode: String(pickField(raw, 'zipCode', 'ZipCode') || ''),
streetAddress1: String(pickField(raw, 'streetAddress1', 'StreetAddress1') || ''),
isDefault: Boolean(pickField(raw, 'isDefault', 'IsDefault')),
_raw: raw,
};
}
async function load() {
loading.value = true;
try {
const res = await getSenderAddressList({
page: 1,
pageSize: 100,
addressType: activeType.value,
});
rows.value = (res.items || [])
.map((raw: any) => mapRow(raw))
.filter(Boolean) as AddressRow[];
} catch {
rows.value = [];
Message.error(t('h5.address.loadFailed'));
} finally {
loading.value = false;
}
}
function switchType(tab: AddressType) {
activeType.value = tab;
void load();
}
function openCreate() {
resetForm(activeType.value);
mode.value = 'form';
}
function openEdit(row: AddressRow) {
editingId.value = row.id;
Object.assign(form, {
addressType: row.addressType || activeType.value,
addressName: row.addressName,
senderName: row.senderName,
phone: row.phone,
countryCode: row.countryCode || 'CN',
province: row.province,
city: row.city,
zipCode: row.zipCode,
streetAddress1: row.streetAddress1,
isDefault: row.isDefault,
});
mode.value = 'form';
}
async function save() {
if (!form.senderName.trim()) {
Message.warning(t('h5.address.nameRequired'));
return;
}
if (!form.streetAddress1.trim()) {
Message.warning(t('h5.address.streetRequired'));
return;
}
saving.value = true;
try {
const body = {
id: editingId.value || undefined,
addressType: form.addressType,
addressName: form.addressName.trim() || form.senderName.trim(),
senderName: form.senderName.trim(),
phone: form.phone.trim(),
countryCode: (form.countryCode || 'CN').trim().toUpperCase(),
province: form.province.trim(),
city: form.city.trim(),
zipCode: form.zipCode.trim(),
streetAddress1: form.streetAddress1.trim(),
isDefault: form.isDefault,
};
if (editingId.value) {
await updateSenderAddress({ ...body, id: editingId.value });
} else {
await createSenderAddress(body);
}
Message.success(t('h5.me.updateSuccess'));
activeType.value = form.addressType;
mode.value = 'list';
await load();
} catch (e: any) {
Message.error(e?.message || t('h5.address.saveFailed'));
} finally {
saving.value = false;
}
}
async function setDefault(row: AddressRow) {
try {
await setDefaultSenderAddress(row.id);
Message.success(t('h5.address.setDefaultOk'));
await load();
} catch (e: any) {
Message.error(e?.message || t('h5.address.saveFailed'));
}
}
function remove(row: AddressRow) {
Modal.confirm({
title: t('h5.address.delete'),
content: row.senderName || t('h5.address.title'),
onOk: async () => {
await deleteSenderAddress(row.id);
Message.success(t('h5.address.deleteOk'));
await load();
},
});
}
watch(
() => props.visible,
(visible) => {
if (!visible) return;
mode.value = 'list';
activeType.value = 'sender';
void load();
},
{ immediate: true },
);
</script>
<style lang="less">
.h5-addr-drawer {
.arco-drawer-content {
border-radius: 20px 20px 0 0;
overflow: hidden;
max-height: min(92vh, 780px);
}
.arco-drawer-body {
padding: 0;
display: flex;
flex-direction: column;
max-height: min(92vh, 780px);
}
}
</style>
<style lang="less" scoped>
.h5-addr {
display: flex;
flex-direction: column;
max-height: min(92vh, 780px);
background: #f5f7f8;
}
.h5-addr__handle {
width: 36px;
height: 4px;
margin: 10px auto 0;
border-radius: 999px;
background: #c9cdd4;
}
.h5-addr__head {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 48px;
flex-shrink: 0;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #1d2129;
}
}
.h5-addr__back,
.h5-addr__close {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
border: none;
border-radius: 10px;
background: transparent;
color: #86909c;
font-size: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
&:disabled {
opacity: 0.5;
}
}
.h5-addr__back {
left: 8px;
color: #1d2129;
}
.h5-addr__close {
right: 12px;
}
.h5-addr__tabs {
display: flex;
gap: 4px;
margin: 0 16px 12px;
padding: 3px;
border-radius: 12px;
background: #eef0f3;
flex-shrink: 0;
}
.h5-addr__tab {
flex: 1;
height: 32px;
border: none;
border-radius: 10px;
background: transparent;
font-size: 13px;
font-weight: 500;
color: #86909c;
cursor: pointer;
&.is-active {
background: #fff;
color: #1d2129;
font-weight: 600;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
}
}
.h5-addr__body {
flex: 1;
min-height: 220px;
overflow: auto;
display: block;
width: 100%;
padding: 0 16px 12px;
box-sizing: border-box;
}
.addr-card {
background: #fff;
border-radius: 16px;
padding: 14px;
margin-bottom: 10px;
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.04);
}
.addr-card__main {
cursor: pointer;
}
.addr-card__top {
display: flex;
align-items: center;
gap: 8px;
strong {
font-size: 15px;
font-weight: 600;
color: #1d2129;
}
}
.addr-card__badge {
height: 22px;
padding: 0 8px;
border-radius: 8px;
display: inline-flex;
align-items: center;
font-size: 11px;
font-weight: 600;
color: var(--app-primary-color);
background: color-mix(in srgb, var(--app-primary-color) 12%, #fff);
}
.addr-card__phone {
margin: 6px 0 0;
font-size: 13px;
color: #4e5969;
}
.addr-card__line {
margin: 4px 0 0;
font-size: 13px;
color: #1d2129;
line-height: 1.45;
}
.addr-card__alias {
margin: 6px 0 0;
font-size: 12px;
color: #86909c;
}
.addr-card__actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.addr-card__act {
height: 30px;
padding: 0 12px;
border: none;
border-radius: 8px;
background: #f2f3f5;
color: #4e5969;
font-size: 12px;
font-weight: 600;
cursor: pointer;
&.is-danger {
color: #f53f3f;
background: color-mix(in srgb, #f53f3f 10%, #fff);
}
}
.h5-addr__card {
background: #fff;
border-radius: 16px;
padding: 16px;
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.04);
:deep(.arco-input-wrapper),
:deep(.arco-textarea-wrapper) {
background: #f7f8fa !important;
border: none !important;
box-shadow: none !important;
border-radius: 12px;
min-height: 44px;
}
:deep(.arco-input-wrapper.arco-input-focus),
:deep(.arco-textarea-wrapper.arco-textarea-focus) {
background: color-mix(in srgb, var(--app-primary-color) 8%, #fff) !important;
}
}
.h5-addr__label {
display: block;
margin: 14px 0 8px;
font-size: 13px;
color: #4e5969;
&:first-child {
margin-top: 0;
}
}
.h5-addr__type-seg {
display: flex;
gap: 4px;
padding: 3px;
border-radius: 12px;
background: #eef0f3;
}
.h5-addr__seg {
flex: 1;
height: 34px;
border: none;
border-radius: 10px;
background: transparent;
font-size: 13px;
font-weight: 500;
color: #86909c;
cursor: pointer;
&.is-active {
background: #fff;
color: #1d2129;
font-weight: 600;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
}
}
.h5-addr__two {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.h5-addr__default-toggle {
width: 100%;
margin-top: 16px;
height: 44px;
border: none;
border-radius: 12px;
background: #f7f8fa;
color: #4e5969;
font-size: 13px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 0 14px;
cursor: pointer;
.dot {
width: 18px;
height: 18px;
border-radius: 50%;
background: #e5e6eb;
position: relative;
&::after {
content: '';
position: absolute;
inset: 4px;
border-radius: 50%;
background: #fff;
}
}
&.is-on {
background: color-mix(in srgb, var(--app-primary-color) 10%, #fff);
color: var(--app-primary-color);
.dot {
background: var(--app-primary-color);
}
}
}
.h5-addr__foot {
flex-shrink: 0;
padding: 12px 16px calc(14px + env(safe-area-inset-bottom, 0px));
background: #fff;
box-shadow: 0 -6px 20px rgba(15, 23, 42, 0.04);
}
.h5-addr__btn {
width: 100%;
height: 48px;
border: none;
border-radius: 12px;
background: var(--app-primary-color);
color: #fff;
font-size: 15px;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
cursor: pointer;
box-shadow: 0 8px 18px color-mix(in srgb, var(--app-primary-color) 28%, transparent);
&:disabled {
opacity: 0.7;
box-shadow: none;
}
}
.h5-addr__loading {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: #fff;
border-radius: 50%;
animation: h5-addr-spin 0.7s linear infinite;
}
@keyframes h5-addr-spin {
to {
transform: rotate(360deg);
}
}
</style>