feat(mall): classic B2B2C PC storefront with fixed mock API layer
- Mock adapter implementing @vmall/shared ApiClient (localStorage-persisted cart/orders/invoices), runtime switch via mockApi flag (default on) - Fixed bilingual mock catalog: 3-level categories, 24 products w/ SKUs, stores, brands, banners, floors, seckill/collective/integral, comments, coupons, addresses, seeded orders/shipments/invoices - B2B2C mall shell: top bar, logo/search/cart header, dark nav + category mega-menu, value-prop footer, back-to-top; 1200px grid, #ca151e theme - UI primitives replacing element-plus: carousel, pagination, breadcrumb, qty stepper, rating stars, modal, tabs, step bar, product card - Pages: home floors, search (filters/sort/paging), goods detail (SKU picker, store rail, review tabs), cart -> checkout -> pay -> success, auth pages, user center (dashboard/orders/addresses/favorites/coupons/ invoices), stores, seckill, collective, integral - i18n split into per-domain locale modules (en+zh) - OpenSpec change mall-pc-storefront-replica archived; all specs green
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
import type { MockAddress } from "~/mock/data";
|
||||
import { MOCK_ADDRESSES } from "~/mock/data";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const rows = ref<MockAddress[]>(MOCK_ADDRESSES.map((address) => ({ ...address })));
|
||||
const open = ref(false);
|
||||
const editingId = ref<string | null>(null);
|
||||
|
||||
const form = reactive<MockAddress>({
|
||||
id: "",
|
||||
recipient: "",
|
||||
phone: "",
|
||||
region: "",
|
||||
city: "",
|
||||
line1: "",
|
||||
postalCode: "",
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
const errorKey = ref("");
|
||||
|
||||
const modalTitle = computed(() => (editingId.value ? t("user.editAddress") : t("user.addAddress")));
|
||||
|
||||
function resetForm(): void {
|
||||
form.id = "";
|
||||
form.recipient = "";
|
||||
form.phone = "";
|
||||
form.region = "";
|
||||
form.city = "";
|
||||
form.line1 = "";
|
||||
form.postalCode = "";
|
||||
form.isDefault = false;
|
||||
errorKey.value = "";
|
||||
}
|
||||
|
||||
function openAdd(): void {
|
||||
editingId.value = null;
|
||||
resetForm();
|
||||
open.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row: MockAddress): void {
|
||||
editingId.value = row.id;
|
||||
form.id = row.id;
|
||||
form.recipient = row.recipient;
|
||||
form.phone = row.phone;
|
||||
form.region = row.region;
|
||||
form.city = row.city;
|
||||
form.line1 = row.line1;
|
||||
form.postalCode = row.postalCode;
|
||||
form.isDefault = row.isDefault;
|
||||
errorKey.value = "";
|
||||
open.value = true;
|
||||
}
|
||||
|
||||
function setDefault(id: string): void {
|
||||
rows.value = rows.value.map((row) => ({ ...row, isDefault: row.id === id }));
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
if (!form.recipient || !form.phone || !form.region || !form.city || !form.line1 || !form.postalCode) {
|
||||
errorKey.value = "user.validationRequired";
|
||||
return;
|
||||
}
|
||||
if (editingId.value) {
|
||||
rows.value = rows.value.map((row) =>
|
||||
row.id === editingId.value
|
||||
? {
|
||||
...row,
|
||||
recipient: form.recipient,
|
||||
phone: form.phone,
|
||||
region: form.region,
|
||||
city: form.city,
|
||||
line1: form.line1,
|
||||
postalCode: form.postalCode,
|
||||
isDefault: form.isDefault,
|
||||
}
|
||||
: form.isDefault
|
||||
? { ...row, isDefault: false }
|
||||
: row,
|
||||
);
|
||||
} else {
|
||||
const nextId = `a-local-${Date.now()}`;
|
||||
const next: MockAddress = {
|
||||
id: nextId,
|
||||
recipient: form.recipient,
|
||||
phone: form.phone,
|
||||
region: form.region,
|
||||
city: form.city,
|
||||
line1: form.line1,
|
||||
postalCode: form.postalCode,
|
||||
isDefault: form.isDefault,
|
||||
};
|
||||
rows.value = [next, ...rows.value.map((row) => ({ ...row, isDefault: form.isDefault ? false : row.isDefault }))];
|
||||
}
|
||||
open.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mpanel addresses-panel">
|
||||
<h1 class="mpanel-title">
|
||||
{{ t("user.addresses") }}
|
||||
<button class="mbtn red" type="button" @click="openAdd">{{ t("user.addAddress") }}</button>
|
||||
</h1>
|
||||
<UiEmptyState v-if="rows.length === 0" :text="t('user.noAddresses')" />
|
||||
<table v-else class="mtable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("user.recipient") }}</th>
|
||||
<th>{{ t("user.phone") }}</th>
|
||||
<th>{{ t("user.region") }}</th>
|
||||
<th>{{ t("user.city") }}</th>
|
||||
<th>{{ t("user.line1") }}</th>
|
||||
<th>{{ t("user.postalCode") }}</th>
|
||||
<th>{{ t("common.status") }}</th>
|
||||
<th>{{ t("common.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="row.id">
|
||||
<td>{{ row.recipient }}</td>
|
||||
<td>{{ row.phone }}</td>
|
||||
<td>{{ row.region }}</td>
|
||||
<td>{{ row.city }}</td>
|
||||
<td>{{ row.line1 }}</td>
|
||||
<td>{{ row.postalCode }}</td>
|
||||
<td>
|
||||
<span v-if="row.isDefault" class="default-tag">{{ t("user.defaultAddress") }}</span>
|
||||
<button v-else class="mbtn" type="button" @click="setDefault(row.id)">{{ t("user.setDefault") }}</button>
|
||||
</td>
|
||||
<td>
|
||||
<button class="mbtn" type="button" @click="openEdit(row)">{{ t("common.edit") }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<UiModal :open="open" :title="modalTitle" @close="open = false">
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
<span>{{ t("user.recipient") }}</span>
|
||||
<input v-model.trim="form.recipient" class="minput" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t("user.phone") }}</span>
|
||||
<input v-model.trim="form.phone" class="minput" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t("user.region") }}</span>
|
||||
<input v-model.trim="form.region" class="minput" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t("user.city") }}</span>
|
||||
<input v-model.trim="form.city" class="minput" type="text" />
|
||||
</label>
|
||||
<label class="full-line">
|
||||
<span>{{ t("user.line1") }}</span>
|
||||
<input v-model.trim="form.line1" class="minput" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t("user.postalCode") }}</span>
|
||||
<input v-model.trim="form.postalCode" class="minput" type="text" />
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input v-model="form.isDefault" type="checkbox" />
|
||||
<span>{{ t("user.defaultAddress") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="errorKey" class="error">{{ t(errorKey) }}</p>
|
||||
<template #footer>
|
||||
<button class="mbtn" type="button" @click="open = false">{{ t("common.cancel") }}</button>
|
||||
<button class="mbtn red" type="button" @click="save">{{ t("user.saveAddress") }}</button>
|
||||
</template>
|
||||
</UiModal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.addresses-panel {
|
||||
min-height: 560px;
|
||||
}
|
||||
.default-tag {
|
||||
color: var(--mall-red);
|
||||
font-size: 12px;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.form-grid label > span {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--mall-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.full-line {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.checkbox > span {
|
||||
margin: 0;
|
||||
}
|
||||
.error {
|
||||
margin-top: 8px;
|
||||
color: var(--mall-red);
|
||||
font-size: 12px;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user