feat: three nuxt frontends, demo seed, rounding + money-exponent + rate-cast fixes, archived specs

This commit is contained in:
Chengdong Zhang
2026-09-17 13:34:38 +08:00
parent dc9fd31c5e
commit 653f13161a
85 changed files with 4327 additions and 106 deletions
+143
View File
@@ -0,0 +1,143 @@
<script setup lang="ts">
import type { Product, Sku } from "@vmall/shared";
import { t as localized } from "@vmall/shared";
const route = useRoute();
const { locale } = useI18n();
const { $api } = useNuxtApp();
const session = useSessionStore();
const product = ref<Product | null>(null);
const selectedSkuId = ref("");
const quantity = ref(1);
const mainImage = ref("");
const loading = ref(true);
const submitting = ref(false);
const error = ref("");
const skus = computed(() => (product.value?.skus ?? []).filter((sku) => sku.active));
const selectedSku = computed<Sku | null>(() => skus.value.find((sku) => sku.id === selectedSkuId.value) ?? skus.value[0] ?? null);
function errorMessage(value: unknown): string {
return value instanceof Error ? value.message : $t("mall.loadFailed");
}
async function loadProduct(): Promise<void> {
loading.value = true;
error.value = "";
try {
product.value = await $api.getProduct(String(route.params.id));
mainImage.value = product.value.images[0] ?? "";
selectedSkuId.value = skus.value[0]?.id ?? "";
} catch (value) {
error.value = errorMessage(value);
} finally {
loading.value = false;
}
}
async function addToCart(): Promise<void> {
const sku = selectedSku.value;
if (!sku || sku.stock < 1) return;
if (!localStorage.getItem("vmall.token") || session.user?.role !== "customer") {
await navigateTo("/login");
return;
}
const qty = Math.max(1, Math.min(sku.stock, Math.trunc(quantity.value)));
submitting.value = true;
error.value = "";
try {
await $api.addCartItem(sku.id, qty);
await navigateTo("/cart");
} catch (value) {
error.value = errorMessage(value);
} finally {
submitting.value = false;
}
}
watch(selectedSku, (sku) => {
if (sku && quantity.value > sku.stock) quantity.value = Math.max(1, sku.stock);
});
onMounted(async () => {
session.hydrate();
await loadProduct();
});
</script>
<template>
<section>
<NuxtLink to="/" class="muted"> {{ $t("common.back") }}</NuxtLink>
<p v-if="loading" class="muted mt">{{ $t("common.loading") }}</p>
<p v-else-if="error" class="error-text" role="alert">{{ error }}</p>
<div v-else-if="product" class="detail-layout mt">
<div>
<div class="main-image">
<img v-if="mainImage" :src="mainImage" :alt="localized(product.name, locale)">
<div v-else class="image-placeholder">{{ $t("mall.noImage") }}</div>
</div>
<div v-if="product.images.length > 1" class="thumbs">
<button v-for="image in product.images" :key="image" class="thumb" :class="{ selected: image === mainImage }" type="button" @click="mainImage = image">
<img :src="image" :alt="localized(product.name, locale)">
</button>
</div>
</div>
<div class="card product-info">
<h1 class="page-title">{{ localized(product.name, locale) }}</h1>
<p class="muted">{{ localized(product.description, locale) }}</p>
<div class="field">
<label>{{ $t("mall.chooseSku") }}</label>
<div v-if="skus.length" class="sku-list">
<label v-for="sku in skus" :key="sku.id" class="sku-option" :class="{ selected: sku.id === selectedSkuId }">
<input v-model="selectedSkuId" type="radio" name="sku" :value="sku.id" :disabled="sku.stock < 1">
<span>
<strong>{{ sku.sku_code }}</strong>
<span v-for="(value, key) in sku.attributes" :key="key" class="attribute">{{ key }}: {{ value }}</span>
</span>
<span class="sku-meta">
<PriceText :amount-minor="sku.price_minor" :currency="sku.currency" />
<span class="muted">{{ sku.stock }} {{ $t("mall.available") }}</span>
</span>
</label>
</div>
<p v-else class="muted">{{ $t("mall.unavailable") }}</p>
</div>
<div v-if="selectedSku" class="purchase-row">
<div class="field quantity-field">
<label for="product-quantity">{{ $t("mall.quantity") }}</label>
<input id="product-quantity" v-model.number="quantity" type="number" min="1" :max="selectedSku.stock">
</div>
<div class="selected-price"><PriceText :amount-minor="selectedSku.price_minor" :currency="selectedSku.currency" /></div>
</div>
<button class="btn primary" :disabled="submitting || !selectedSku || selectedSku.stock < 1" @click="addToCart">
{{ submitting ? $t("common.loading") : $t("product.addToCart") }}
</button>
</div>
</div>
</section>
</template>
<style scoped>
.detail-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, 1fr); gap: 24px; align-items: start; }
.main-image { overflow: hidden; border-radius: var(--radius); background: #eef1f5; }
.main-image img, .image-placeholder { display: block; width: 100%; aspect-ratio: 1; object-fit: cover; }
.image-placeholder { display: grid; place-items: center; color: var(--muted); }
.thumbs { display: flex; gap: 8px; margin-top: 10px; }
.thumb { padding: 0; border: 2px solid transparent; background: none; cursor: pointer; border-radius: var(--radius); overflow: hidden; }
.thumb.selected { border-color: var(--primary); }
.thumb img { width: 64px; height: 64px; display: block; object-fit: cover; }
.product-info .page-title { margin-bottom: 8px; }
.sku-list { display: grid; gap: 8px; }
.sku-option { display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: start; border: 1px solid var(--border); border-radius: var(--radius); padding: 10px; cursor: pointer; }
.sku-option.selected { border-color: var(--primary); background: #f7f9ff; }
.sku-option input { width: auto; margin-top: 3px; }
.attribute { display: inline-block; margin-left: 10px; color: var(--muted); font-size: 12px; }
.sku-meta { display: grid; justify-items: end; gap: 2px; color: var(--primary); font-weight: 600; }
.sku-meta .muted { font-weight: 400; font-size: 12px; }
.purchase-row { display: flex; align-items: end; gap: 16px; margin: 18px 0; }
.quantity-field { width: 120px; margin: 0; }
.selected-price { color: var(--primary); font-size: 20px; font-weight: 700; padding-bottom: 8px; }
@media (max-width: 760px) { .detail-layout { grid-template-columns: 1fr; } }
</style>