Files
vmall/apps/mall/pages/user/orders/[id].vue
T
Chengdong Zhang 0d0e10b97b feat(ui): adopt Tailwind v4 design system and archive change
- Add tailwindcss v4 + @tailwindcss/vite to mall, shop-admin, admin
- Add @vmall/shared/theme.css tokens with html[data-accent] presets
- Add @vmall/ui kit (VBtn/VBadge/VField/VInput/VCard/VPanel/VTable/VPage,
  VAccentSwatch, useAccent) as a Nuxt module
- Convert all three apps to kit + utilities; delete ui.css/mall.css and
  every <style scoped>; consoles get accent presets, mall locked to red
- Fix VCard boolean prop default (padding) and PDP/store stale
  useAsyncData keys on param navigation
- Archive adopt-tailwind-design-system; new frontend-ui capability spec
2026-09-22 18:22:50 +08:00

135 lines
6.9 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>
<VCard class="min-h-[560px]">
<h1 class="mb-4 text-xl font-bold text-text">{{ t("user.orderDetail") }}</h1>
<div v-if="loading" class="py-5 text-muted">{{ t("common.loading") }}</div>
<UiEmptyState v-else-if="!order" :text="t('user.noOrders')" />
<div v-else class="space-y-4">
<header class="flex items-center justify-between border border-border bg-bg p-3.5 max-sm:flex-col max-sm:items-start max-sm:gap-2.5">
<div><strong class="text-[15px] text-text">{{ t("user.orderNo") }} {{ order.order_no }}</strong><p class="mt-1 text-xs text-muted">{{ t("user.createdAt") }} {{ order.created_at.slice(0, 10) }}</p></div>
<StatusBadge :status="order.status" kind="order" />
</header>
<VCard :padded="false" class="p-5">
<h2 class="mb-3.5 text-[15px] font-semibold text-text">{{ t("user.shippingAddress") }}</h2>
<p class="m-0 text-sm text-text">{{ order.shipping_address.recipient }} · {{ order.shipping_address.phone }}</p>
<p class="mt-1 text-sm text-text">{{ order.shipping_address.country }} {{ order.shipping_address.region }} {{ order.shipping_address.city }} {{ order.shipping_address.line1 }} {{ order.shipping_address.postal_code }}</p>
</VCard>
<VCard :padded="false" class="p-5">
<h2 class="mb-3.5 text-[15px] font-semibold text-text">{{ t("user.orderItems") }}</h2>
<VTable>
<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><div class="flex items-center gap-2.5"><img class="h-11 w-11 border border-border object-contain" :src="item.image || '/mock/product-1.svg'" :alt="pick(item.product_name, locale)" /><span>{{ pick(item.product_name, locale) }}</span><VBadge v-if="item.flash_sale_item_id" tone="red">{{ t("marketing.flashTag") }}</VBadge></div></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>
</VTable>
<p v-if="order.discount_minor > 0" class="mt-4 text-right text-[13px] text-primary">{{ t("checkout.couponDiscount") }}: <PriceText :amount-minor="order.discount_minor" :currency="order.currency" /></p>
<p class="mt-4 text-right text-[13px] text-muted">{{ t("user.total") }}: <PriceText class="text-lg font-semibold text-primary" :amount-minor="order.total_minor" :currency="order.currency" /></p>
</VCard>
<VCard :padded="false" class="p-5">
<h2 class="mb-3.5 text-[15px] font-semibold text-text">{{ t("user.shipments") }}</h2>
<UiEmptyState v-if="orderShipments.length === 0" :text="t('user.noShipments')" />
<div v-else class="space-y-2.5">
<div v-for="shipment in orderShipments" :key="shipment.id" class="flex items-center justify-between border border-border p-3 max-sm:flex-col max-sm:items-start max-sm:gap-2.5">
<div><strong class="text-sm text-text">{{ t("user.shipmentNo") }} {{ shipment.shipment_no }}</strong><p class="mt-1 text-xs text-muted">{{ t("user.carrier") }}: {{ shipment.carrier }}</p><p class="mt-1 text-xs text-muted">{{ t("user.trackingNo") }}: {{ shipment.tracking_no }}</p></div>
<div class="flex items-center gap-2"><StatusBadge :status="shipment.status" kind="shipment" /><VBtn v-if="shipment.status === 'shipped'" variant="primary" size="sm" type="button" :disabled="confirmingShipmentId === shipment.id" @click="confirmDelivery(shipment)">{{ t("user.confirmDelivery") }}</VBtn></div>
</div>
</div>
</VCard>
<VCard :padded="false" class="p-5">
<h2 class="mb-3.5 text-[15px] font-semibold text-text">{{ t("user.invoiceRequest") }}</h2>
<div v-if="invoiceSuccess" class="text-sm text-success">{{ t("user.invoiceSuccess") }}</div>
<form v-else class="grid max-w-[520px] gap-3" @submit.prevent="submitInvoice">
<VField :label="t('user.invoiceTitle')"><VInput v-model="invoiceTitle" /></VField>
<VField :label="t('user.taxNo')"><VInput v-model="invoiceTaxNo" /></VField>
<fieldset class="flex items-center gap-4 border-0 p-0"><legend class="mb-1 block text-[13px] text-muted">{{ t("user.invoiceKind") }}</legend><label class="text-[13px] text-text"><input v-model="invoiceKind" class="mr-1 accent-primary" type="radio" value="personal" /> {{ t("user.personal") }}</label><label class="text-[13px] text-text"><input v-model="invoiceKind" class="mr-1 accent-primary" type="radio" value="company" /> {{ t("user.company") }}</label></fieldset>
<p v-if="invoiceErrorKey" class="m-0 text-xs text-danger">{{ t(invoiceErrorKey) }}</p>
<VBtn class="w-fit" variant="primary" type="submit" :disabled="invoiceWorking">{{ t("user.submitInvoice") }}</VBtn>
</form>
</VCard>
</div>
</VCard>
</template>