- 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
295 lines
8.2 KiB
Vue
295 lines
8.2 KiB
Vue
<script setup lang="ts">
|
|
import type { InvoiceKind, Order, Shipment } from "@vmall/shared";
|
|
import { t as pick } from "@vmall/shared";
|
|
|
|
definePageMeta({ middleware: "auth" });
|
|
|
|
const { locale, t } = useI18n();
|
|
const { $api } = useNuxtApp();
|
|
const route = useRoute();
|
|
|
|
const order = ref<Order | null>(null);
|
|
const shipments = ref<Shipment[]>([]);
|
|
const loading = ref(true);
|
|
const confirmingShipmentId = ref("");
|
|
const invoiceWorking = ref(false);
|
|
const invoiceSuccess = ref(false);
|
|
const invoiceErrorKey = ref("");
|
|
|
|
const invoiceTitle = ref("");
|
|
const invoiceTaxNo = ref("");
|
|
const invoiceKind = ref<InvoiceKind>("personal");
|
|
|
|
const orderId = computed(() => String(route.params.id ?? ""));
|
|
|
|
const orderShipments = computed(() => shipments.value.filter((shipment) => shipment.order_id === order.value?.id));
|
|
|
|
async function load(): Promise<void> {
|
|
loading.value = true;
|
|
try {
|
|
const [o, allShipments] = await Promise.all([$api.getOrder(orderId.value), $api.listMyShipments()]);
|
|
order.value = o;
|
|
shipments.value = allShipments;
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function confirmDelivery(shipment: Shipment): Promise<void> {
|
|
confirmingShipmentId.value = shipment.id;
|
|
try {
|
|
await $api.confirmDelivered(shipment.id);
|
|
await load();
|
|
} finally {
|
|
confirmingShipmentId.value = "";
|
|
}
|
|
}
|
|
|
|
async function submitInvoice(): Promise<void> {
|
|
invoiceErrorKey.value = "";
|
|
if (!order.value || !invoiceTitle.value.trim()) {
|
|
invoiceErrorKey.value = "user.validationRequired";
|
|
return;
|
|
}
|
|
invoiceWorking.value = true;
|
|
try {
|
|
await $api.requestInvoice(
|
|
order.value.id,
|
|
invoiceTitle.value.trim(),
|
|
invoiceTaxNo.value.trim() || null,
|
|
invoiceKind.value,
|
|
);
|
|
invoiceSuccess.value = true;
|
|
} catch {
|
|
invoiceErrorKey.value = "user.loadFailed";
|
|
} finally {
|
|
invoiceWorking.value = false;
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
void load();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<section class="mpanel order-detail-panel">
|
|
<h1 class="mpanel-title">{{ t("user.orderDetail") }}</h1>
|
|
<div v-if="loading" class="muted">{{ t("common.loading") }}</div>
|
|
<UiEmptyState v-else-if="!order" :text="t('user.noOrders')" />
|
|
<div v-else>
|
|
<header class="summary">
|
|
<div>
|
|
<strong>{{ t("user.orderNo") }} {{ order.order_no }}</strong>
|
|
<p>{{ t("user.createdAt") }} {{ order.created_at.slice(0, 10) }}</p>
|
|
</div>
|
|
<StatusBadge :status="order.status" kind="order" />
|
|
</header>
|
|
|
|
<article class="mpanel block">
|
|
<h2 class="block-title">{{ t("user.shippingAddress") }}</h2>
|
|
<p>{{ order.shipping_address.recipient }} · {{ order.shipping_address.phone }}</p>
|
|
<p>
|
|
{{ order.shipping_address.country }} {{ order.shipping_address.region }} {{ order.shipping_address.city }}
|
|
{{ order.shipping_address.line1 }} {{ order.shipping_address.postal_code }}
|
|
</p>
|
|
</article>
|
|
|
|
<article class="mpanel block">
|
|
<h2 class="block-title">{{ t("user.orderItems") }}</h2>
|
|
<table class="mtable">
|
|
<thead>
|
|
<tr>
|
|
<th>{{ t("user.product") }}</th>
|
|
<th>{{ t("common.price") }}</th>
|
|
<th>{{ t("common.qty") }}</th>
|
|
<th>{{ t("common.total") }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="item in order.items" :key="item.id">
|
|
<td class="prod-col">
|
|
<img :src="item.image || '/mock/product-1.svg'" :alt="pick(item.product_name, locale)" />
|
|
<span>{{ pick(item.product_name, locale) }}</span>
|
|
</td>
|
|
<td><PriceText :amount-minor="item.unit_price_minor" :currency="order.currency" /></td>
|
|
<td>{{ item.qty }}</td>
|
|
<td><PriceText :amount-minor="item.unit_price_minor * item.qty" :currency="order.currency" /></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p class="order-total">{{ t("user.total") }}: <PriceText :amount-minor="order.total_minor" :currency="order.currency" /></p>
|
|
</article>
|
|
|
|
<article class="mpanel block">
|
|
<h2 class="block-title">{{ t("user.shipments") }}</h2>
|
|
<UiEmptyState v-if="orderShipments.length === 0" :text="t('user.noShipments')" />
|
|
<div v-else>
|
|
<div v-for="shipment in orderShipments" :key="shipment.id" class="shipment-row">
|
|
<div>
|
|
<strong>{{ t("user.shipmentNo") }} {{ shipment.shipment_no }}</strong>
|
|
<p>{{ t("user.carrier") }}: {{ shipment.carrier }}</p>
|
|
<p>{{ t("user.trackingNo") }}: {{ shipment.tracking_no }}</p>
|
|
</div>
|
|
<div class="shipment-right">
|
|
<StatusBadge :status="shipment.status" kind="shipment" />
|
|
<button
|
|
v-if="shipment.status === 'shipped'"
|
|
class="mbtn red"
|
|
type="button"
|
|
:disabled="confirmingShipmentId === shipment.id"
|
|
@click="confirmDelivery(shipment)"
|
|
>
|
|
{{ t("user.confirmDelivery") }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
|
|
<article class="mpanel block">
|
|
<h2 class="block-title">{{ t("user.invoiceRequest") }}</h2>
|
|
<div v-if="invoiceSuccess" class="invoice-success">{{ t("user.invoiceSuccess") }}</div>
|
|
<form v-else class="invoice-form" @submit.prevent="submitInvoice">
|
|
<label>
|
|
<span>{{ t("user.invoiceTitle") }}</span>
|
|
<input v-model.trim="invoiceTitle" class="minput" type="text" />
|
|
</label>
|
|
<label>
|
|
<span>{{ t("user.taxNo") }}</span>
|
|
<input v-model.trim="invoiceTaxNo" class="minput" type="text" />
|
|
</label>
|
|
<fieldset>
|
|
<legend>{{ t("user.invoiceKind") }}</legend>
|
|
<label><input v-model="invoiceKind" type="radio" value="personal" /> {{ t("user.personal") }}</label>
|
|
<label><input v-model="invoiceKind" type="radio" value="company" /> {{ t("user.company") }}</label>
|
|
</fieldset>
|
|
<p v-if="invoiceErrorKey" class="error">{{ t(invoiceErrorKey) }}</p>
|
|
<button class="mbtn red" type="submit" :disabled="invoiceWorking">{{ t("user.submitInvoice") }}</button>
|
|
</form>
|
|
</article>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.order-detail-panel {
|
|
min-height: 560px;
|
|
}
|
|
.muted {
|
|
color: var(--mall-faint);
|
|
padding: 20px 0;
|
|
}
|
|
.summary {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 14px;
|
|
border: 1px solid var(--mall-line);
|
|
background: #fafafa;
|
|
}
|
|
.summary strong {
|
|
color: var(--mall-ink);
|
|
font-size: 15px;
|
|
}
|
|
.summary p {
|
|
margin: 4px 0 0;
|
|
color: var(--mall-faint);
|
|
font-size: 12px;
|
|
}
|
|
.block {
|
|
margin-top: 16px;
|
|
margin-bottom: 0;
|
|
border: 1px solid var(--mall-line);
|
|
}
|
|
.block-title {
|
|
margin: 0 0 14px;
|
|
color: var(--mall-ink);
|
|
font-size: 15px;
|
|
}
|
|
.prod-col {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
.prod-col img {
|
|
width: 44px;
|
|
height: 44px;
|
|
border: 1px solid var(--mall-line);
|
|
object-fit: contain;
|
|
}
|
|
.order-total {
|
|
margin: 16px 0 0;
|
|
text-align: right;
|
|
font-size: 13px;
|
|
color: var(--mall-muted);
|
|
}
|
|
.order-total :deep(.price) {
|
|
color: var(--mall-red);
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
}
|
|
.shipment-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 12px;
|
|
border: 1px solid var(--mall-line);
|
|
margin-bottom: 10px;
|
|
}
|
|
.shipment-row:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
.shipment-row p {
|
|
margin: 5px 0 0;
|
|
font-size: 12px;
|
|
color: var(--mall-muted);
|
|
}
|
|
.shipment-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.invoice-form {
|
|
display: grid;
|
|
gap: 12px;
|
|
max-width: 520px;
|
|
}
|
|
.invoice-form label > span,
|
|
.invoice-form legend {
|
|
display: block;
|
|
margin-bottom: 6px;
|
|
color: var(--mall-muted);
|
|
font-size: 13px;
|
|
}
|
|
.invoice-form fieldset {
|
|
margin: 0;
|
|
padding: 0;
|
|
border: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
}
|
|
.invoice-form fieldset label {
|
|
font-size: 13px;
|
|
color: var(--mall-ink);
|
|
}
|
|
.error {
|
|
margin: 0;
|
|
color: var(--mall-red);
|
|
font-size: 12px;
|
|
}
|
|
.invoice-success {
|
|
color: var(--mall-green);
|
|
font-size: 14px;
|
|
}
|
|
@media (max-width: 760px) {
|
|
.summary,
|
|
.shipment-row {
|
|
align-items: flex-start;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
}
|
|
}
|
|
</style>
|