Files
vmall/apps/mall/pages/user/index.vue
T
Chengdong ZhangandCursor 6c1357ec4d feat: persist customer product and shop favorites through the live API
Replace mall fixture favorites with customer-scoped endpoints, and send signed-out shoppers back to the page they left after sign-in.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 18:51:45 +08:00

293 lines
8.3 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>
<section class="mpanel welcome-panel">
<h1 class="mpanel-title">{{ t("user.dashboard") }}</h1>
<div class="stats-grid">
<div class="stat-card">
<span>{{ t("user.statsBalance") }}</span>
<strong v-if="stats"><PriceText :amount-minor="stats.balance_minor" :currency="stats.currency" /></strong>
<strong v-else>{{ t("common.loading") }}</strong>
</div>
<div class="stat-card">
<span>{{ t("user.statsPoints") }}</span>
<strong>{{ stats ? stats.points : t("common.loading") }}</strong>
</div>
<div class="stat-card">
<span>{{ t("user.statsFrozen") }}</span>
<strong v-if="stats"><PriceText :amount-minor="stats.frozen_minor" :currency="stats.currency" /></strong>
<strong v-else>{{ t("common.loading") }}</strong>
</div>
</div>
<div class="status-links">
<NuxtLink v-for="item in statusLinks" :key="item.key" :to="item.to" class="status-link">
<span>{{ item.label }}</span>
<b>{{ item.count }}</b>
</NuxtLink>
</div>
</section>
<section class="mpanel">
<h2 class="mpanel-title">
{{ t("user.recentOrders") }}
<NuxtLink class="more" to="/user/orders">{{ t("user.viewAll") }} </NuxtLink>
</h2>
<div v-if="loading" class="muted">{{ t("common.loading") }}</div>
<UiEmptyState v-else-if="orders.length === 0" :text="t('user.noOrders')" />
<div v-else class="recent-orders">
<article v-for="order in orders.slice(0, 3)" :key="order.id" class="recent-order">
<header>
<span>{{ t("user.orderNo") }} {{ order.order_no }}</span>
<StatusBadge :status="order.status" kind="order" />
</header>
<div class="order-body">
<div class="item-previews">
<img v-for="item in order.items" :key="item.id" :src="item.image || '/mock/product-1.svg'" :alt="pick(item.product_name, locale)" />
</div>
<div class="order-date">{{ t("user.createdAt") }} {{ order.created_at.slice(0, 10) }}</div>
<div class="order-total">
<span>{{ t("user.orderTotal") }}</span>
<PriceText :amount-minor="order.total_minor" :currency="order.currency" />
</div>
<NuxtLink class="mbtn" :to="`/user/orders/${order.id}`">{{ t("user.viewDetails") }}</NuxtLink>
</div>
</article>
</div>
</section>
<section class="mpanel">
<h2 class="mpanel-title">{{ t("user.favoriteProducts") }} ({{ favoriteProductTotal }})</h2>
<UiEmptyState v-if="favoriteProducts.length === 0" :text="t('user.noFavorites')" />
<div v-else class="favorite-grid">
<NuxtLink v-for="item in favoriteProducts" :key="item.id" :to="`/goods/${item.product.slug}`" class="favorite-card hover-lift">
<img :src="item.product.image || '/mock/product-1.svg'" :alt="pick(item.product.name, locale)" />
<span>{{ pick(item.product.name, locale) }}</span>
<PriceText v-if="item.product.price_minor !== null && item.product.currency" :amount-minor="item.product.price_minor" :currency="item.product.currency" />
</NuxtLink>
</div>
</section>
</div>
</template>
<style scoped>
.welcome-panel {
padding-bottom: 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
padding: 18px;
border: 1px solid var(--mall-line);
background: #fffafa;
}
.stat-card span {
display: block;
color: var(--mall-muted);
font-size: 12px;
}
.stat-card strong {
display: block;
margin-top: 10px;
color: var(--mall-red);
font-size: 20px;
}
.status-links {
display: grid;
grid-template-columns: repeat(5, 1fr);
border-top: 1px solid var(--mall-line);
}
.status-link {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 8px;
color: var(--mall-muted);
font-size: 12px;
text-decoration: none;
}
.status-link + .status-link {
border-left: 1px solid var(--mall-line);
}
.status-link b {
color: var(--mall-red);
font-size: 18px;
font-weight: 600;
}
.muted {
color: var(--mall-faint);
padding: 20px 0;
}
.recent-order {
border: 1px solid var(--mall-line);
margin-bottom: 12px;
}
.recent-order:last-child {
margin-bottom: 0;
}
.recent-order header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: #fafafa;
color: var(--mall-muted);
font-size: 12px;
}
.order-body {
display: flex;
align-items: center;
gap: 16px;
padding: 12px;
}
.item-previews {
display: flex;
gap: 5px;
min-width: 140px;
}
.item-previews img {
width: 44px;
height: 44px;
object-fit: contain;
border: 1px solid var(--mall-line);
}
.order-date {
flex: 1;
color: var(--mall-faint);
font-size: 12px;
}
.order-total {
text-align: right;
font-size: 12px;
}
.order-total span {
display: block;
margin-bottom: 3px;
color: var(--mall-faint);
}
.order-total :deep(.price) {
color: var(--mall-red);
font-weight: 600;
}
.favorite-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.favorite-card {
display: block;
padding: 10px;
border: 1px solid var(--mall-line);
color: var(--mall-ink);
text-decoration: none;
}
.favorite-card img {
display: block;
width: 100%;
height: 120px;
object-fit: contain;
margin-bottom: 8px;
}
.favorite-card span {
display: block;
height: 34px;
overflow: hidden;
font-size: 12px;
line-height: 17px;
}
.favorite-card :deep(.price) {
display: block;
margin-top: 8px;
color: var(--mall-red);
font-size: 14px;
font-weight: 600;
}
@media (max-width: 760px) {
.status-links {
grid-template-columns: repeat(3, 1fr);
}
.order-body {
align-items: flex-start;
flex-wrap: wrap;
}
.order-date {
flex: 0 0 calc(100% - 156px);
}
.favorite-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>