198 lines
8.8 KiB
Vue
198 lines
8.8 KiB
Vue
<script setup lang="ts">
|
|
import type { Invoice, InvoiceKind, Order, Shipment } from "@vmall/shared";
|
|
import { t as localized } from "@vmall/shared";
|
|
|
|
const route = useRoute();
|
|
const { locale } = useI18n();
|
|
const { $api } = useNuxtApp();
|
|
const { formatAmount } = usePrice();
|
|
|
|
const order = ref<Order | null>(null);
|
|
const shipments = ref<Shipment[]>([]);
|
|
const invoice = ref<Invoice | null>(null);
|
|
const totalDisplay = ref("");
|
|
const loading = ref(true);
|
|
const error = ref("");
|
|
const action = ref("");
|
|
const invoiceKind = ref<InvoiceKind>("personal");
|
|
const invoiceTitle = ref("");
|
|
const taxNo = ref("");
|
|
const invoiceSubmitting = ref(false);
|
|
|
|
function errorMessage(value: unknown): string {
|
|
return value instanceof Error ? value.message : $t("mall.loadFailed");
|
|
}
|
|
|
|
async function loadOrder(): Promise<void> {
|
|
loading.value = true;
|
|
error.value = "";
|
|
try {
|
|
const orderId = String(route.params.id);
|
|
const [loadedOrder, loadedShipments, loadedInvoices] = await Promise.all([
|
|
$api.getOrder(orderId),
|
|
$api.listMyShipments(),
|
|
$api.listMyInvoices(),
|
|
]);
|
|
order.value = loadedOrder;
|
|
shipments.value = loadedShipments.filter((shipment) => shipment.order_id === loadedOrder.id);
|
|
invoice.value = loadedInvoices.find((item) => item.order_id === loadedOrder.id) ?? null;
|
|
totalDisplay.value = await formatAmount(loadedOrder.total_minor, loadedOrder.currency);
|
|
} catch (value) {
|
|
error.value = errorMessage(value);
|
|
order.value = null;
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function runAction(name: "pay" | "cancel"): Promise<void> {
|
|
if (!order.value) return;
|
|
action.value = name;
|
|
error.value = "";
|
|
try {
|
|
if (name === "pay") await $api.payOrder(order.value.id);
|
|
else await $api.cancelOrder(order.value.id);
|
|
await loadOrder();
|
|
} catch (value) {
|
|
error.value = errorMessage(value);
|
|
} finally {
|
|
action.value = "";
|
|
}
|
|
}
|
|
|
|
async function confirmDelivery(shipment: Shipment): Promise<void> {
|
|
action.value = shipment.id;
|
|
error.value = "";
|
|
try {
|
|
await $api.confirmDelivered(shipment.id);
|
|
await loadOrder();
|
|
} catch (value) {
|
|
error.value = errorMessage(value);
|
|
} finally {
|
|
action.value = "";
|
|
}
|
|
}
|
|
|
|
async function requestInvoice(): Promise<void> {
|
|
if (!order.value || !invoiceTitle.value.trim()) {
|
|
error.value = $t("common.required");
|
|
return;
|
|
}
|
|
if (invoiceKind.value === "company" && !taxNo.value.trim()) {
|
|
error.value = $t("mall.companyTaxRequired");
|
|
return;
|
|
}
|
|
invoiceSubmitting.value = true;
|
|
error.value = "";
|
|
try {
|
|
await $api.requestInvoice(order.value.id, invoiceTitle.value.trim(), invoiceKind.value === "company" ? taxNo.value.trim() : null, invoiceKind.value);
|
|
await loadOrder();
|
|
} catch (value) {
|
|
error.value = errorMessage(value);
|
|
} finally {
|
|
invoiceSubmitting.value = false;
|
|
}
|
|
}
|
|
|
|
watch(locale, () => {
|
|
if (order.value) void loadOrder();
|
|
});
|
|
onMounted(() => void loadOrder());
|
|
|
|
definePageMeta({ middleware: "auth" });
|
|
</script>
|
|
|
|
<template>
|
|
<section>
|
|
<NuxtLink to="/orders" class="muted">← {{ $t("common.back") }}</NuxtLink>
|
|
<p v-if="loading" class="muted mt">{{ $t("common.loading") }}</p>
|
|
<p v-if="error" class="error-text" role="alert">{{ error }}</p>
|
|
<template v-if="order">
|
|
<div class="page-head mt">
|
|
<h1 class="page-title">{{ order.order_no }}</h1>
|
|
<StatusBadge :status="order.status" kind="order" />
|
|
</div>
|
|
<div class="card order-summary">
|
|
<div><span class="muted">{{ $t("mall.created") }}</span><strong>{{ new Date(order.created_at).toLocaleString(locale === "zh" ? "zh-CN" : "en-US") }}</strong></div>
|
|
<div><span class="muted">{{ $t("common.total") }}</span><strong class="total">{{ totalDisplay }}</strong></div>
|
|
<div v-if="order.status === 'pending_payment'" class="row">
|
|
<button class="btn primary" :disabled="action !== ''" @click="runAction('pay')">{{ action === "pay" ? $t("common.loading") : $t("order.pay") }}</button>
|
|
<button class="btn danger" :disabled="action !== ''" @click="runAction('cancel')">{{ action === "cancel" ? $t("common.loading") : $t("order.cancelOrder") }}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card mt">
|
|
<h2>{{ $t("mall.cartItems") }}</h2>
|
|
<table class="table">
|
|
<thead><tr><th>{{ $t("product.detail") }}</th><th>{{ $t("common.price") }}</th><th>{{ $t("common.qty") }}</th><th>{{ $t("mall.lineTotal") }}</th></tr></thead>
|
|
<tbody>
|
|
<tr v-for="item in order.items" :key="item.id">
|
|
<td><div>{{ localized(item.product_name, locale) }}</div><span class="muted">{{ item.sku_code }}</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>
|
|
</div>
|
|
|
|
<div class="card mt">
|
|
<h2>{{ $t("order.shippingAddress") }}</h2>
|
|
<div class="address-grid">
|
|
<div><span class="muted">{{ $t("order.recipient") }}</span>{{ order.shipping_address.recipient }}</div>
|
|
<div><span class="muted">{{ $t("order.phone") }}</span>{{ order.shipping_address.phone }}</div>
|
|
<div><span class="muted">{{ $t("order.country") }}</span>{{ order.shipping_address.country }}</div>
|
|
<div><span class="muted">{{ $t("order.region") }}</span>{{ order.shipping_address.region || $t("mall.notAvailable") }}</div>
|
|
<div><span class="muted">{{ $t("order.city") }}</span>{{ order.shipping_address.city }}</div>
|
|
<div><span class="muted">{{ $t("order.postalCode") }}</span>{{ order.shipping_address.postal_code }}</div>
|
|
<div class="wide"><span class="muted">{{ $t("order.line1") }}</span>{{ order.shipping_address.line1 }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card mt">
|
|
<h2>{{ $t("mall.shipments") }}</h2>
|
|
<div v-if="shipments.length === 0" class="muted">{{ $t("common.empty") }}</div>
|
|
<table v-else class="table">
|
|
<thead><tr><th>{{ $t("shipment.shipmentNo") }}</th><th>{{ $t("shipment.carrier") }}</th><th>{{ $t("shipment.trackingNo") }}</th><th>{{ $t("common.status") }}</th><th>{{ $t("common.actions") }}</th></tr></thead>
|
|
<tbody>
|
|
<tr v-for="shipment in shipments" :key="shipment.id">
|
|
<td>{{ shipment.shipment_no }}</td><td>{{ shipment.carrier }}</td><td>{{ shipment.tracking_no }}</td><td><StatusBadge :status="shipment.status" kind="shipment" /></td>
|
|
<td><button v-if="shipment.status === 'shipped'" class="btn sm" :disabled="action !== ''" @click="confirmDelivery(shipment)">{{ $t("order.confirmDelivery") }}</button><span v-else class="muted">{{ $t("mall.notAvailable") }}</span></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="card mt">
|
|
<h2>{{ $t("mall.invoices") }}</h2>
|
|
<div v-if="invoice" class="invoice-existing row between">
|
|
<div><strong>{{ invoice.invoice_no || $t("mall.notAvailable") }}</strong><span class="muted">{{ invoice.title }} · {{ $t(`invoice.${invoice.kind}`) }}</span></div>
|
|
<StatusBadge :status="invoice.status" kind="invoice" />
|
|
</div>
|
|
<div v-else>
|
|
<p class="muted">{{ $t("mall.noInvoice") }}</p>
|
|
<form class="invoice-form" @submit.prevent="requestInvoice">
|
|
<div class="field"><label for="invoice-kind">{{ $t("invoice.kind") }}</label><select id="invoice-kind" v-model="invoiceKind"><option value="personal">{{ $t("invoice.personal") }}</option><option value="company">{{ $t("invoice.company") }}</option></select></div>
|
|
<div class="field"><label for="invoice-title">{{ $t("invoice.invoiceTitle") }}</label><input id="invoice-title" v-model="invoiceTitle" required></div>
|
|
<div v-if="invoiceKind === 'company'" class="field"><label for="tax-no">{{ $t("invoice.taxNo") }}</label><input id="tax-no" v-model="taxNo"></div>
|
|
<button class="btn primary" type="submit" :disabled="invoiceSubmitting">{{ invoiceSubmitting ? $t("common.loading") : $t("order.requestInvoice") }}</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.order-summary { display: flex; align-items: center; gap: 28px; flex-wrap: wrap; }
|
|
.order-summary > div:not(.row) { display: grid; gap: 3px; }
|
|
.total { color: var(--primary); font-size: 18px; }
|
|
.card h2 { font-size: 17px; margin: 0 0 14px; }
|
|
.address-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px 20px; }
|
|
.address-grid > div { display: grid; gap: 2px; }
|
|
.address-grid .wide { grid-column: 1 / -1; }
|
|
.invoice-existing > div { display: grid; gap: 3px; }
|
|
.invoice-form { max-width: 420px; }
|
|
@media (max-width: 640px) { .address-grid { grid-template-columns: 1fr; } .address-grid .wide { grid-column: auto; } .table { display: block; overflow-x: auto; white-space: nowrap; } }
|
|
</style>
|