Files
vmall/apps/mall/pages/checkout/index.vue
T
Chengdong Zhang 21e99bb52b 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
2026-09-17 19:13:29 +08:00

351 lines
9.4 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">
import { t as pick } from "@vmall/shared";
import type { Address, CartItem } from "@vmall/shared";
import { MOCK_ADDRESSES, productById, storeById } from "~/mock/data";
import type { MockAddress } from "~/mock/data";
type CheckoutGroup = {
shopId: string;
store: ReturnType<typeof storeById>;
items: CartItem[];
};
definePageMeta({ middleware: "auth" });
const { $api } = useNuxtApp();
const { locale, t } = useI18n();
const { currency } = usePrefs();
const cartStore = useCartStore();
const router = useRouter();
const pendingOrderIds = useState<string[]>("checkout-orders", () => []);
const items = ref<CartItem[]>([]);
const selectedAddressId = ref(MOCK_ADDRESSES.find((address) => address.isDefault)?.id ?? MOCK_ADDRESSES[0]?.id ?? "");
const remark = ref("");
const loading = ref(true);
const submitting = ref(false);
const error = ref("");
const stepLabels = computed(() => [
t("checkout.steps.cart"),
t("checkout.steps.order"),
t("checkout.steps.payment"),
t("checkout.steps.complete"),
]);
const selectedAddress = computed<MockAddress | null>(
() => MOCK_ADDRESSES.find((address) => address.id === selectedAddressId.value) ?? null,
);
const groups = computed<CheckoutGroup[]>(() => {
const grouped = new Map<string, CartItem[]>();
for (const item of items.value) {
const shopId = productById(item.product_id)?.shop_id ?? "unknown";
const groupItems = grouped.get(shopId) ?? [];
groupItems.push(item);
grouped.set(shopId, groupItems);
}
return Array.from(grouped, ([shopId, groupItems]) => ({
shopId,
store: shopId === "unknown" ? null : storeById(shopId),
items: groupItems,
}));
});
const sourceCurrency = computed(() => items.value[0]?.currency ?? currency.value);
const totalMinor = computed(() =>
items.value.reduce((total, item) => total + item.unit_price_minor * item.qty, 0),
);
function imageFor(item: CartItem): string {
return productById(item.product_id)?.images[0] ?? item.image ?? "/mock/product-1.svg";
}
function toAddress(address: MockAddress): Address {
return {
recipient: address.recipient,
phone: address.phone,
country: "US",
region: address.region,
city: address.city,
line1: address.line1,
postal_code: address.postalCode,
};
}
async function loadCart(): Promise<void> {
loading.value = true;
error.value = "";
try {
const snapshot = await $api.getCart();
items.value = snapshot.items;
if (items.value.length === 0) {
await router.replace("/cart");
}
} catch {
error.value = t("checkout.loadError");
} finally {
loading.value = false;
}
}
async function submitOrder(): Promise<void> {
if (!selectedAddress.value || items.value.length === 0) return;
submitting.value = true;
error.value = "";
try {
const orders = await $api.checkout(toAddress(selectedAddress.value), currency.value);
pendingOrderIds.value = orders.map((order) => order.id);
const snapshot = await $api.getCart();
items.value = snapshot.items;
await cartStore.refresh();
await router.push("/checkout/pay");
} catch {
error.value = t("checkout.submitError");
} finally {
submitting.value = false;
}
}
onMounted(() => void loadCart());
</script>
<template>
<div class="transaction-page checkout-page">
<div class="w1200">
<UiStepBar :steps="stepLabels" :active="1" />
<header class="page-heading">
<h1>{{ t("checkout.title") }}</h1>
</header>
<p v-if="error" class="error-message">{{ error }}</p>
<template v-if="!loading && items.length > 0">
<section class="mpanel address-panel">
<h2 class="section-title">{{ t("checkout.address") }}</h2>
<div class="address-list">
<label
v-for="address in MOCK_ADDRESSES"
:key="address.id"
class="address-option"
:class="{ selected: address.id === selectedAddressId }"
>
<input v-model="selectedAddressId" type="radio" name="shipping-address" :value="address.id" />
<span class="address-main">
<strong>{{ address.recipient }}</strong>
<span>{{ address.phone }}</span>
<span>{{ address.region }} {{ address.city }} {{ address.line1 }} {{ address.postalCode }}</span>
</span>
<span v-if="address.isDefault" class="default-tag">{{ t("checkout.defaultAddress") }}</span>
</label>
</div>
</section>
<section class="mpanel order-panel">
<h2 class="section-title">{{ t("checkout.orderPreview") }}</h2>
<div v-for="group in groups" :key="group.shopId" class="order-group">
<header class="shop-heading">
<img v-if="group.store" :src="group.store.logo" :alt="pick(group.store.name, locale)" />
<span>{{ group.store ? pick(group.store.name, locale) : t("checkout.shop") }}</span>
</header>
<div v-for="item in group.items" :key="item.sku_id" class="order-item">
<img :src="imageFor(item)" :alt="pick(item.product_name, locale)" />
<div class="item-info">
<strong>{{ pick(item.product_name, locale) }}</strong>
<span class="muted">{{ item.sku_code }}</span>
</div>
<PriceText :amount-minor="item.unit_price_minor" :currency="item.currency" />
<span class="qty">× {{ item.qty }}</span>
<strong class="line-total">
<PriceText :amount-minor="item.unit_price_minor * item.qty" :currency="item.currency" />
</strong>
</div>
</div>
</section>
<section class="mpanel remark-panel">
<label class="section-title" for="order-remark">{{ t("checkout.remark") }}</label>
<textarea id="order-remark" v-model="remark" class="remark-input" :placeholder="t('checkout.remarkPlaceholder')" rows="3" />
</section>
<footer class="submit-bar mpanel">
<span class="total-label">{{ t("checkout.total") }}:</span>
<strong class="total-price"><PriceText :amount-minor="totalMinor" :currency="sourceCurrency" /></strong>
<button class="mbtn red" type="button" :disabled="submitting" @click="submitOrder">
{{ t("checkout.submit") }}
</button>
</footer>
</template>
<div v-else-if="!loading" class="empty-wrap mpanel">
<UiEmptyState :text="t('checkout.noItems')" />
<NuxtLink class="mbtn gray" to="/cart">{{ t("checkout.backToCart") }}</NuxtLink>
</div>
<div v-else class="loading-state">{{ $t("common.loading") }}</div>
</div>
</div>
</template>
<style scoped>
.transaction-page {
padding: 24px 0 56px;
background: #f5f5f5;
min-height: 60vh;
}
.page-heading {
margin: 0 0 16px;
}
.page-heading h1 {
margin: 0;
font-size: 20px;
font-weight: 500;
}
.error-message {
margin: 0 0 16px;
padding: 10px 14px;
color: #b42318;
background: #fff1f0;
border: 1px solid #ffd6d2;
}
.mpanel {
margin-bottom: 16px;
}
.section-title {
display: block;
margin: 0;
padding: 16px 18px;
border-bottom: 1px solid var(--mall-line);
font-size: 15px;
font-weight: 600;
}
.address-list {
padding: 8px 18px 16px;
}
.address-option {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
padding: 13px 14px;
border: 1px solid transparent;
cursor: pointer;
line-height: 1.5;
}
.address-option:hover,
.address-option.selected {
border-color: var(--mall-red);
background: #fffafa;
}
.address-main {
display: flex;
align-items: center;
gap: 16px;
flex: 1;
min-width: 0;
}
.address-main span:last-child {
color: var(--mall-muted);
}
.default-tag {
color: var(--mall-red);
font-size: 12px;
}
.order-panel {
padding: 0;
overflow: hidden;
}
.order-group + .order-group {
border-top: 1px solid var(--mall-line);
}
.shop-heading {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 18px;
color: #444;
font-size: 13px;
}
.shop-heading img {
width: 28px;
height: 28px;
object-fit: cover;
border-radius: 50%;
}
.order-item {
display: grid;
grid-template-columns: 56px minmax(0, 1fr) 120px 64px 130px;
align-items: center;
gap: 12px;
padding: 12px 18px;
border-top: 1px solid #f5f5f5;
}
.order-item > img {
width: 56px;
height: 56px;
object-fit: cover;
border: 1px solid var(--mall-line);
}
.item-info {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
}
.item-info strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.muted,
.qty {
color: var(--mall-muted);
font-size: 12px;
}
.line-total {
color: var(--mall-red);
text-align: right;
}
.remark-panel {
padding: 0 0 16px;
}
.remark-input {
display: block;
box-sizing: border-box;
width: calc(100% - 36px);
margin: 14px 18px 0;
resize: vertical;
border: 1px solid var(--mall-line-dark);
padding: 10px 12px;
font: inherit;
outline: none;
}
.remark-input:focus {
border-color: var(--mall-red);
}
.submit-bar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 18px;
padding: 14px 18px;
}
.total-label {
color: var(--mall-muted);
font-size: 13px;
}
.total-price {
color: var(--mall-red);
font-size: 20px;
}
.empty-wrap {
padding-bottom: 28px;
text-align: center;
}
.empty-wrap :deep(.empty) {
padding-bottom: 20px;
}
.loading-state {
padding: 70px 0;
text-align: center;
color: var(--mall-muted);
}
</style>