Files
vmall/apps/mall/pages/user/index.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

115 lines
6.6 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 type { AccountSummary, Order, ProductFavorite } from "@vmall/shared";
import { t as pick } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const orders = ref<Order[]>([]);
const stats = ref<AccountSummary | null>(null);
const favoriteProducts = ref<ProductFavorite[]>([]);
const favoriteProductTotal = ref(0);
const loading = ref(true);
async function loadOrders(): Promise<void> {
loading.value = true;
try {
const result = await $api.listMyOrders();
orders.value = result.items;
} finally {
loading.value = false;
}
}
// Live balances; shows a placeholder rather than a fixture if the call fails.
async function loadStats(): Promise<void> {
try {
stats.value = await $api.getAccountSummary();
} catch {
stats.value = null;
}
}
async function loadFavorites(): Promise<void> {
try {
const page = await $api.listFavorites({ kind: "product", page: 1, per_page: 8 });
favoriteProducts.value = page.items.filter(
(row): row is ProductFavorite => row.kind === "product",
);
favoriteProductTotal.value = page.total;
} catch {
favoriteProducts.value = [];
favoriteProductTotal.value = 0;
}
}
onMounted(() => {
void loadOrders();
void loadStats();
void loadFavorites();
});
const counts = computed(() => ({
pendingPayment: orders.value.filter((o) => o.status === "pending_payment").length,
pendingShipment: orders.value.filter((o) => o.status === "paid").length,
pendingReceipt: orders.value.filter((o) => o.status === "shipped").length,
completed: orders.value.filter((o) => o.status === "completed").length,
afterSale: 0,
}));
const statusLinks = computed(() => [
{ key: "pendingPayment", label: t("user.pendingPayment"), count: counts.value.pendingPayment, to: "/user/orders?status=pending_payment" },
{ key: "pendingShipment", label: t("user.pendingShipment"), count: counts.value.pendingShipment, to: "/user/orders?status=paid" },
{ key: "pendingReceipt", label: t("user.pendingReceipt"), count: counts.value.pendingReceipt, to: "/user/orders?status=shipped" },
{ key: "completed", label: t("user.completed"), count: counts.value.completed, to: "/user/orders?status=completed" },
{ key: "afterSale", label: t("user.afterSale"), count: counts.value.afterSale, to: "/user/orders?status=after-sale" },
]);
</script>
<template>
<div class="space-y-4">
<VCard class="pb-0">
<h1 class="mb-4 text-xl font-bold text-text">{{ t("user.dashboard") }}</h1>
<div class="mb-5 grid gap-3 sm:grid-cols-3">
<div class="border border-border bg-primary-soft p-[18px]"><span class="block text-xs text-muted">{{ t("user.statsBalance") }}</span><strong class="mt-2.5 block text-xl text-primary"><PriceText v-if="stats" :amount-minor="stats.balance_minor" :currency="stats.currency" /><template v-else>{{ t("common.loading") }}</template></strong></div>
<div class="border border-border bg-primary-soft p-[18px]"><span class="block text-xs text-muted">{{ t("user.statsPoints") }}</span><strong class="mt-2.5 block text-xl text-primary">{{ stats ? stats.points : t("common.loading") }}</strong></div>
<div class="border border-border bg-primary-soft p-[18px]"><span class="block text-xs text-muted">{{ t("user.statsFrozen") }}</span><strong class="mt-2.5 block text-xl text-primary"><PriceText v-if="stats" :amount-minor="stats.frozen_minor" :currency="stats.currency" /><template v-else>{{ t("common.loading") }}</template></strong></div>
</div>
<div class="grid grid-cols-3 border-t border-border sm:grid-cols-5">
<NuxtLink v-for="item in statusLinks" :key="item.key" :to="item.to" class="flex flex-col items-center gap-2 px-2 py-4 text-xs text-muted no-underline hover:text-primary sm:border-l sm:border-border first:sm:border-l-0"><span>{{ item.label }}</span><b class="text-lg font-semibold text-primary">{{ item.count }}</b></NuxtLink>
</div>
</VCard>
<VCard>
<h2 class="mb-4 flex items-center justify-between text-lg font-semibold text-text">{{ t("user.recentOrders") }} <NuxtLink class="text-sm font-normal text-primary no-underline" to="/user/orders">{{ t("user.viewAll") }} </NuxtLink></h2>
<div v-if="loading" class="py-5 text-muted">{{ t("common.loading") }}</div>
<UiEmptyState v-else-if="orders.length === 0" :text="t('user.noOrders')" />
<div v-else class="space-y-3">
<article v-for="order in orders.slice(0, 3)" :key="order.id" class="border border-border">
<header class="flex items-center justify-between gap-3 bg-bg px-3 py-2.5 text-xs text-muted"><span>{{ t("user.orderNo") }} {{ order.order_no }}</span><StatusBadge :status="order.status" kind="order" /></header>
<div class="flex items-center gap-4 p-3 max-sm:flex-wrap">
<div class="flex min-w-[140px] gap-1"><img v-for="item in order.items" :key="item.id" class="h-11 w-11 border border-border object-contain" :src="item.image || '/mock/product-1.svg'" :alt="pick(item.product_name, locale)" /></div>
<div class="flex-1 text-xs text-muted">{{ t("user.createdAt") }} {{ order.created_at.slice(0, 10) }}</div>
<div class="text-right text-xs"><span class="mb-1 block text-muted">{{ t("user.orderTotal") }}</span><PriceText class="font-semibold text-primary" :amount-minor="order.total_minor" :currency="order.currency" /></div>
<NuxtLink class="inline-flex items-center rounded-md border border-border bg-surface px-2.5 py-1 text-[13px] font-medium text-text no-underline" :to="`/user/orders/${order.id}`">{{ t("user.viewDetails") }}</NuxtLink>
</div>
</article>
</div>
</VCard>
<VCard>
<h2 class="mb-4 text-lg font-semibold text-text">{{ t("user.favoriteProducts") }} ({{ favoriteProductTotal }})</h2>
<UiEmptyState v-if="favoriteProducts.length === 0" :text="t('user.noFavorites')" />
<div v-else class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<NuxtLink v-for="item in favoriteProducts" :key="item.id" :to="`/goods/${item.product.slug}`" class="block border border-border p-2.5 text-text no-underline hover:border-primary">
<img class="mb-2 block h-[120px] w-full object-contain" :src="item.product.image || '/mock/product-1.svg'" :alt="pick(item.product.name, locale)" />
<span class="block h-[34px] overflow-hidden text-xs leading-[17px]">{{ pick(item.product.name, locale) }}</span>
<PriceText v-if="item.product.price_minor !== null && item.product.currency" class="mt-2 block text-sm font-semibold text-primary" :amount-minor="item.product.price_minor" :currency="item.product.currency" />
</NuxtLink>
</div>
</VCard>
</div>
</template>