feat: 添加地址管理功能,创建地址列表和添加地址页面,集成表单验证和状态管理

This commit is contained in:
2026-01-18 03:03:46 +07:00
parent c2f6af8625
commit 46c198c101
4 changed files with 580 additions and 0 deletions

6
components.d.ts vendored
View File

@@ -16,6 +16,7 @@ declare module 'vue' {
Empty: typeof import('./src/components/empty.vue')['default'] Empty: typeof import('./src/components/empty.vue')['default']
IonApp: typeof import('@ionic/vue')['IonApp'] IonApp: typeof import('@ionic/vue')['IonApp']
IonAvatar: typeof import('@ionic/vue')['IonAvatar'] IonAvatar: typeof import('@ionic/vue')['IonAvatar']
IonBadge: typeof import('@ionic/vue')['IonBadge']
IonButton: typeof import('@ionic/vue')['IonButton'] IonButton: typeof import('@ionic/vue')['IonButton']
IonButtons: typeof import('@ionic/vue')['IonButtons'] IonButtons: typeof import('@ionic/vue')['IonButtons']
IonCheckbox: typeof import('@ionic/vue')['IonCheckbox'] IonCheckbox: typeof import('@ionic/vue')['IonCheckbox']
@@ -35,7 +36,9 @@ declare module 'vue' {
IonTabBar: typeof import('@ionic/vue')['IonTabBar'] IonTabBar: typeof import('@ionic/vue')['IonTabBar']
IonTabButton: typeof import('@ionic/vue')['IonTabButton'] IonTabButton: typeof import('@ionic/vue')['IonTabButton']
IonTabs: typeof import('@ionic/vue')['IonTabs'] IonTabs: typeof import('@ionic/vue')['IonTabs']
IonTextarea: typeof import('@ionic/vue')['IonTextarea']
IonTitle: typeof import('@ionic/vue')['IonTitle'] IonTitle: typeof import('@ionic/vue')['IonTitle']
IonToggle: typeof import('@ionic/vue')['IonToggle']
IonToolbar: typeof import('@ionic/vue')['IonToolbar'] IonToolbar: typeof import('@ionic/vue')['IonToolbar']
LayoutDefault: typeof import('./src/components/layout/default.vue')['default'] LayoutDefault: typeof import('./src/components/layout/default.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
@@ -49,6 +52,7 @@ declare global {
const Empty: typeof import('./src/components/empty.vue')['default'] const Empty: typeof import('./src/components/empty.vue')['default']
const IonApp: typeof import('@ionic/vue')['IonApp'] const IonApp: typeof import('@ionic/vue')['IonApp']
const IonAvatar: typeof import('@ionic/vue')['IonAvatar'] const IonAvatar: typeof import('@ionic/vue')['IonAvatar']
const IonBadge: typeof import('@ionic/vue')['IonBadge']
const IonButton: typeof import('@ionic/vue')['IonButton'] const IonButton: typeof import('@ionic/vue')['IonButton']
const IonButtons: typeof import('@ionic/vue')['IonButtons'] const IonButtons: typeof import('@ionic/vue')['IonButtons']
const IonCheckbox: typeof import('@ionic/vue')['IonCheckbox'] const IonCheckbox: typeof import('@ionic/vue')['IonCheckbox']
@@ -68,7 +72,9 @@ declare global {
const IonTabBar: typeof import('@ionic/vue')['IonTabBar'] const IonTabBar: typeof import('@ionic/vue')['IonTabBar']
const IonTabButton: typeof import('@ionic/vue')['IonTabButton'] const IonTabButton: typeof import('@ionic/vue')['IonTabButton']
const IonTabs: typeof import('@ionic/vue')['IonTabs'] const IonTabs: typeof import('@ionic/vue')['IonTabs']
const IonTextarea: typeof import('@ionic/vue')['IonTextarea']
const IonTitle: typeof import('@ionic/vue')['IonTitle'] const IonTitle: typeof import('@ionic/vue')['IonTitle']
const IonToggle: typeof import('@ionic/vue')['IonToggle']
const IonToolbar: typeof import('@ionic/vue')['IonToolbar'] const IonToolbar: typeof import('@ionic/vue')['IonToolbar']
const LayoutDefault: typeof import('./src/components/layout/default.vue')['default'] const LayoutDefault: typeof import('./src/components/layout/default.vue')['default']
const RouterLink: typeof import('vue-router')['RouterLink'] const RouterLink: typeof import('vue-router')['RouterLink']

View File

@@ -59,6 +59,16 @@ const routes: Array<RouteRecordRaw> = [
component: () => import("@/views/real_name/index.vue"), component: () => import("@/views/real_name/index.vue"),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{
path: "/address",
component: () => import("@/views/address/index.vue"),
meta: { requiresAuth: true },
},
{
path: "/address/add",
component: () => import("@/views/address/add.vue"),
meta: { requiresAuth: true },
},
]; ];
const router = createRouter({ const router = createRouter({

265
src/views/address/add.vue Normal file
View File

@@ -0,0 +1,265 @@
<script lang='ts' setup>
import { toastController } from "@ionic/vue";
import { callOutline, locationOutline, personOutline } from "ionicons/icons";
import zod from "zod";
const router = useRouter();
const route = useRoute();
const formData = ref({
name: "",
phone: "",
address: "",
isDefault: false,
});
const isSubmitting = ref(false);
const isEditMode = computed(() => !!route.query.id);
// 表单验证 Schema
const AddressSchema = zod.object({
name: zod
.string()
.min(2, "请输入收货人姓名")
.max(20, "姓名长度不能超过20个字符"),
phone: zod
.string()
.min(1, "请输入手机号")
.regex(/^1[3-9]\d{9}$/, "请输入正确的手机号码"),
address: zod
.string()
.min(5, "请输入详细地址")
.max(200, "地址长度不能超过200个字符"),
});
async function showToast(message: string, color: "success" | "danger" | "warning" = "danger") {
const toast = await toastController.create({
message,
duration: 2000,
position: "top",
color,
});
await toast.present();
}
// 如果是编辑模式,加载地址数据
onMounted(() => {
if (isEditMode.value) {
// TODO: 根据 route.query.id 加载地址数据
// 模拟数据
formData.value = {
name: "张三",
phone: "13800138000",
address: "北京市朝阳区建国路88号SOHO现代城A座1001室",
isDefault: true,
};
}
});
async function handleSubmit() {
const result = AddressSchema.safeParse(formData.value);
if (!result.success) {
const first = result.error.issues[0];
await showToast(first.message);
return;
}
isSubmitting.value = true;
try {
// TODO: 调用添加/编辑地址 API
// if (isEditMode.value) {
// const { data } = await safeClient(client.api.address[route.query.id].put(formData.value));
// } else {
// const { data } = await safeClient(client.api.address.post(formData.value));
// }
// 模拟 API 调用
await new Promise(resolve => setTimeout(resolve, 1000));
await showToast(isEditMode.value ? "地址修改成功" : "地址添加成功", "success");
router.back();
}
catch (error) {
await showToast("操作失败,请重试", "danger");
}
finally {
isSubmitting.value = false;
}
}
</script>
<template>
<ion-page>
<ion-header class="ion-no-border">
<ion-toolbar class="ion-toolbar">
<ion-buttons slot="start">
<back-button />
</ion-buttons>
<ion-title>{{ isEditMode ? '编辑地址' : '添加地址' }}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="space-y-5">
<!-- 提示信息 -->
<div class="info-card">
<ion-icon :icon="locationOutline" class="text-2xl text-[#c41e3a]" />
<div class="text-sm text-[#666] leading-relaxed">
请填写准确的收货地址信息以便我们为您提供更好的配送服务
</div>
</div>
<!-- 表单卡片 -->
<div class="form-card">
<div class="space-y-4">
<!-- 收货人姓名 -->
<div class="form-item">
<div class="flex items-center gap-2 mb-2">
<ion-icon :icon="personOutline" class="text-lg text-primary" />
<label class="form-label">收货人姓名</label>
</div>
<ion-item lines="none" class="input-item">
<ion-input
v-model="formData.name"
type="text"
placeholder="请输入收货人姓名"
class="custom-input"
:maxlength="20"
/>
</ion-item>
</div>
<!-- 手机号码 -->
<div class="form-item">
<div class="flex items-center gap-2 mb-2">
<ion-icon :icon="callOutline" class="text-lg text-primary" />
<label class="form-label">手机号码</label>
</div>
<ion-item lines="none" class="input-item">
<ion-input
v-model="formData.phone"
type="tel"
placeholder="请输入手机号码"
class="custom-input"
:maxlength="11"
/>
</ion-item>
</div>
<!-- 详细地址 -->
<div class="form-item">
<div class="flex items-center gap-2 mb-2">
<ion-icon :icon="locationOutline" class="text-lg text-primary" />
<label class="form-label">详细地址</label>
</div>
<ion-item lines="none" class="input-item textarea-item">
<ion-textarea
v-model="formData.address"
placeholder="请输入详细地址(省市区街道门牌号等)"
class="custom-textarea"
:rows="4"
:maxlength="200"
auto-grow
/>
</ion-item>
</div>
<!-- 设为默认地址 -->
<div class="form-item">
<div class="flex items-center justify-between">
<label class="form-label">设为默认地址</label>
<ion-toggle v-model="formData.isDefault" color="danger" />
</div>
</div>
</div>
</div>
<!-- 提交按钮 -->
<ion-button
expand="block"
class="submit-button"
:disabled="isSubmitting"
@click="handleSubmit"
>
<ion-spinner v-if="isSubmitting" name="crescent" />
<span v-else>{{ isEditMode ? '保存修改' : '保存地址' }}</span>
</ion-button>
</div>
</ion-content>
</ion-page>
</template>
<style lang='css' scoped>
.info-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
background: linear-gradient(135deg, #fff5f5 0%, #ffffff 100%);
border-radius: 12px;
border: 1px solid #ffe0e0;
}
.form-card {
background: white;
border-radius: 16px;
padding: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.form-item {
margin-bottom: 20px;
}
.form-item:last-child {
margin-bottom: 0;
}
.form-label {
font-size: 15px;
font-weight: 600;
color: #333;
}
.input-item {
--background: #f7f8fa;
--border-radius: 12px;
--padding-start: 16px;
--padding-end: 16px;
--min-height: 48px;
}
.textarea-item {
--min-height: auto;
--padding-top: 12px;
--padding-bottom: 12px;
}
.custom-input {
--placeholder-color: #999;
--placeholder-opacity: 1;
font-size: 15px;
}
.custom-textarea {
--placeholder-color: #999;
--placeholder-opacity: 1;
font-size: 15px;
min-height: 100px;
}
.submit-button {
--background: linear-gradient(135deg, #c41e3a 0%, #8b1a2e 100%);
--background-activated: linear-gradient(135deg, #8b1a2e 0%, #c41e3a 100%);
--border-radius: 12px;
--padding-top: 14px;
--padding-bottom: 14px;
font-weight: 600;
font-size: 16px;
margin-top: 8px;
text-transform: none;
letter-spacing: 0.5px;
}
</style>

299
src/views/address/index.vue Normal file
View File

@@ -0,0 +1,299 @@
<script lang='ts' setup>
import { alertController, toastController } from "@ionic/vue";
import { addOutline, callOutline, checkmarkCircleOutline, createOutline, locationOutline, trashOutline } from "ionicons/icons";
const router = useRouter();
interface Address {
id: number;
name: string;
phone: string;
address: string;
isDefault: boolean;
}
const addresses = ref<Address[]>([
{
id: 1,
name: "张三",
phone: "13800138000",
address: "北京市朝阳区建国路88号SOHO现代城A座1001室",
isDefault: true,
},
{
id: 2,
name: "李四",
phone: "13900139000",
address: "上海市浦东新区世纪大道1号东方明珠广播电视塔",
isDefault: false,
},
]);
async function showToast(message: string, color: "success" | "danger" | "warning" = "success") {
const toast = await toastController.create({
message,
duration: 2000,
position: "top",
color,
});
await toast.present();
}
function handleAdd() {
router.push("/address/add");
}
function handleEdit(address: Address) {
// TODO: 跳转到编辑页面并传递地址ID
router.push(`/address/add?id=${address.id}`);
}
async function handleSetDefault(address: Address) {
if (address.isDefault) {
return;
}
// 取消所有默认地址
addresses.value.forEach((item) => {
item.isDefault = false;
});
// 设置当前地址为默认
address.isDefault = true;
// TODO: 调用 API 更新默认地址
await showToast("已设为默认地址");
}
async function handleDelete(address: Address) {
const alert = await alertController.create({
header: "确认删除",
message: `确定要删除"${address.name}"的收货地址吗?`,
buttons: [
{
text: "取消",
role: "cancel",
},
{
text: "删除",
role: "destructive",
handler: async () => {
// 如果删除的是默认地址,需要先设置其他地址为默认
if (address.isDefault && addresses.value.length > 1) {
const otherAddress = addresses.value.find(item => item.id !== address.id);
if (otherAddress) {
otherAddress.isDefault = true;
}
}
// 删除地址
const index = addresses.value.findIndex(item => item.id === address.id);
if (index > -1) {
addresses.value.splice(index, 1);
}
// TODO: 调用 API 删除地址
await showToast("删除成功");
},
},
],
});
await alert.present();
}
</script>
<template>
<ion-page>
<ion-header class="ion-no-border">
<ion-toolbar class="ion-toolbar">
<ion-buttons slot="start">
<back-button />
</ion-buttons>
<ion-title>收货地址</ion-title>
<ion-buttons slot="end">
<ion-button @click="handleAdd">
<ion-icon slot="icon-only" :icon="addOutline" />
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<div v-if="addresses.length > 0" class="ion-padding">
<div class="space-y-3">
<div
v-for="address in addresses"
:key="address.id"
class="address-card"
>
<!-- 地址信息 -->
<div class="address-info">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<ion-icon :icon="locationOutline" class="text-xl text-primary" />
<span class="name">{{ address.name }}</span>
<span class="phone">{{ address.phone }}</span>
</div>
<ion-badge v-if="address.isDefault" color="danger" class="default-badge">
默认
</ion-badge>
</div>
<div class="address-text">
{{ address.address }}
</div>
</div>
<!-- 操作按钮 -->
<div class="address-actions">
<ion-button
fill="clear"
size="small"
@click="handleSetDefault(address)"
>
<ion-icon slot="start" :icon="checkmarkCircleOutline" />
{{ address.isDefault ? '默认地址' : '设为默认' }}
</ion-button>
<div class="flex gap-2">
<ion-button
fill="clear"
size="small"
color="medium"
@click="handleEdit(address)"
>
<ion-icon slot="start" :icon="createOutline" />
编辑
</ion-button>
<ion-button
fill="clear"
size="small"
color="danger"
@click="handleDelete(address)"
>
<ion-icon slot="start" :icon="trashOutline" />
删除
</ion-button>
</div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-else class="empty-state">
<empty title="暂无收货地址">
<template #icon>
<ion-icon :icon="locationOutline" class="empty-icon" />
</template>
<template #extra>
<ion-button class="add-button" @click="handleAdd">
<ion-icon slot="start" :icon="addOutline" />
添加收货地址
</ion-button>
</template>
</empty>
</div>
<!-- 底部添加按钮 -->
<div v-if="addresses.length > 0" class="fixed-bottom">
<ion-button expand="block" class="add-button" @click="handleAdd">
<ion-icon slot="start" :icon="addOutline" />
添加新地址
</ion-button>
</div>
</ion-content>
</ion-page>
</template>
<style lang='css' scoped>
.address-card {
background: white;
border-radius: 16px;
padding: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.address-info {
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 12px;
}
.name {
font-size: 16px;
font-weight: 600;
color: #333;
}
.phone {
font-size: 14px;
color: #666;
}
.default-badge {
font-size: 11px;
padding: 2px 8px;
}
.address-text {
font-size: 14px;
color: #666;
line-height: 1.6;
margin-top: 8px;
}
.address-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
.address-actions ion-button {
--padding-start: 8px;
--padding-end: 8px;
font-size: 13px;
text-transform: none;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
padding: 40px 20px;
}
.empty-icon {
font-size: 80px;
color: #ddd;
}
.add-button {
--background: linear-gradient(135deg, #c41e3a 0%, #8b1a2e 100%);
--background-activated: linear-gradient(135deg, #8b1a2e 0%, #c41e3a 100%);
--border-radius: 12px;
--padding-top: 14px;
--padding-bottom: 14px;
font-weight: 600;
font-size: 15px;
text-transform: none;
letter-spacing: 0.5px;
margin-top: 12px;
}
.fixed-bottom {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 12px 16px;
background: white;
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.08);
z-index: 10;
padding-bottom: calc(12px + var(--ion-safe-area-bottom));
}
</style>