Files
vmall/apps/mall/pages/checkout/pay.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

273 lines
7.1 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 { Order } from "@vmall/shared";
import { storeById } from "~/mock/data";
definePageMeta({ middleware: "auth" });
const { $api } = useNuxtApp();
const { locale, t } = useI18n();
const { currency } = usePrefs();
const router = useRouter();
const pendingOrderIds = useState<string[]>("checkout-orders", () => []);
const orders = ref<Order[]>([]);
const paymentMethod = ref("balance");
const loading = ref(true);
const paying = 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 paymentOptions = computed(() => [
{ value: "balance", label: t("checkout.balance") },
{ value: "wechat", label: t("checkout.wechat") },
{ value: "alipay", label: t("checkout.alipay") },
]);
const grandTotalMinor = computed(() => orders.value.reduce((total, order) => total + order.total_minor, 0));
const totalCurrency = computed(() => orders.value[0]?.currency ?? currency.value);
function imageFor(image: string | null): string {
return image ?? "/mock/product-1.svg";
}
function storeName(shopId: string): string {
const store = storeById(shopId);
return store ? pick(store.name, locale.value) : t("checkout.shop");
}
async function loadOrders(): Promise<void> {
loading.value = true;
error.value = "";
if (pendingOrderIds.value.length === 0) {
loading.value = false;
return;
}
try {
orders.value = await Promise.all(pendingOrderIds.value.map((id) => $api.getOrder(id)));
} catch {
orders.value = [];
error.value = t("checkout.loadError");
} finally {
loading.value = false;
}
}
async function confirmPayment(): Promise<void> {
if (orders.value.length === 0) return;
paying.value = true;
error.value = "";
try {
for (const order of orders.value) await $api.payOrder(order.id);
pendingOrderIds.value = [];
await router.push("/checkout/success");
} catch {
error.value = t("checkout.payError");
} finally {
paying.value = false;
}
}
onMounted(() => void loadOrders());
</script>
<template>
<div class="transaction-page pay-page">
<div class="w1200">
<UiStepBar :steps="stepLabels" :active="2" />
<header class="page-heading">
<h1>{{ t("checkout.payTitle") }}</h1>
</header>
<p v-if="error" class="error-message">{{ error }}</p>
<template v-if="!loading && orders.length > 0">
<section class="order-cards">
<article v-for="order in orders" :key="order.id" class="mpanel order-card">
<header class="order-heading">
<span>{{ t("checkout.orderNo") }}: {{ order.order_no }}</span>
<span class="shop-name">{{ storeName(order.shop_id) }}</span>
</header>
<div v-for="item in order.items" :key="item.id" class="order-item">
<img :src="imageFor(item.image)" :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="order.currency" />
<span class="qty">× {{ item.qty }}</span>
<strong class="line-total">
<PriceText :amount-minor="item.unit_price_minor * item.qty" :currency="order.currency" />
</strong>
</div>
<footer class="order-total">
<span>{{ t("checkout.total") }}</span>
<strong><PriceText :amount-minor="order.total_minor" :currency="order.currency" /></strong>
</footer>
</article>
</section>
<section class="mpanel payment-panel">
<h2 class="section-title">{{ t("checkout.paymentTitle") }}</h2>
<label v-for="option in paymentOptions" :key="option.value" class="payment-option">
<input v-model="paymentMethod" type="radio" name="payment-method" :value="option.value" />
<span>{{ option.label }}</span>
</label>
</section>
<footer class="submit-bar mpanel">
<span class="total-label">{{ t("checkout.grandTotal") }}:</span>
<strong class="total-price"><PriceText :amount-minor="grandTotalMinor" :currency="totalCurrency" /></strong>
<button class="mbtn red" type="button" :disabled="paying" @click="confirmPayment">
{{ t("checkout.confirmPay") }}
</button>
</footer>
</template>
<div v-else-if="!loading" class="empty-wrap mpanel">
<UiEmptyState :text="t('checkout.noPendingOrders')" />
<NuxtLink class="mbtn gray" to="/">{{ t("checkout.backHome") }}</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;
}
.order-card {
padding: 0;
overflow: hidden;
}
.order-card + .order-card {
margin-top: 16px;
}
.order-heading {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--mall-line);
font-size: 13px;
}
.shop-name {
color: var(--mall-muted);
}
.order-item {
display: grid;
grid-template-columns: 56px minmax(0, 1fr) 120px 64px 130px;
align-items: center;
gap: 12px;
padding: 12px 18px;
border-bottom: 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;
}
.order-total {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 18px;
padding: 14px 18px;
color: var(--mall-muted);
font-size: 13px;
}
.order-total strong {
color: var(--mall-red);
font-size: 16px;
}
.payment-panel {
margin-top: 16px;
padding: 0 18px 16px;
}
.section-title {
margin: 0 -18px 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--mall-line);
font-size: 15px;
font-weight: 600;
}
.payment-option {
display: inline-flex;
align-items: center;
gap: 8px;
margin-right: 30px;
padding: 8px 0;
cursor: pointer;
}
.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>