One balance row per (user, kind, currency): available and frozen carry the platform base currency, points carries none. Monetary and points rows use separate partial unique indexes because a plain UNIQUE lets NULL repeat. Balance changes go through transactional primitives that debits guard with a conditional update, credits add atomically, and freeze/release move both sides in one transaction after locking rows by primary key. Every change appends an immutable entry holding its resulting balance. Registration and a migration backfill create the zero rows; GET /api/me/stats is the only public surface and no endpoint mutates a balance. The mall buyer center and points page drop the USER_STATS fixture for the shared contract; the fixture stays exported so the fixed-data adapter can still serve the account domain as a rollback path. Implements openspec change add-customer-accounts.
292 lines
8.2 KiB
Vue
292 lines
8.2 KiB
Vue
<script setup lang="ts">
|
||
import type { AccountSummary, Order, Product } from "@vmall/shared";
|
||
import { t as pick } from "@vmall/shared";
|
||
import { MOCK_FAVORITES, lowestSku, productById } from "~/mock/data";
|
||
|
||
definePageMeta({ middleware: "auth" });
|
||
|
||
const { locale, t } = useI18n();
|
||
const { $api } = useNuxtApp();
|
||
const orders = ref<Order[]>([]);
|
||
const stats = ref<AccountSummary | null>(null);
|
||
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;
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
void loadOrders();
|
||
void loadStats();
|
||
});
|
||
|
||
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" },
|
||
]);
|
||
|
||
const favoriteProducts = computed(() =>
|
||
MOCK_FAVORITES.filter((favorite) => favorite.kind === "product")
|
||
.map((favorite) => productById(favorite.refId))
|
||
.filter((product): product is Product => product !== null)
|
||
.map((product) => {
|
||
const sku = lowestSku(product);
|
||
return {
|
||
product,
|
||
amountMinor: sku?.price_minor ?? null,
|
||
currency: sku?.currency ?? null,
|
||
};
|
||
}),
|
||
);
|
||
</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") }}</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.product.id" :to="`/goods/${item.product.slug}`" class="favorite-card hover-lift">
|
||
<img :src="item.product.images[0] || '/mock/product-1.svg'" :alt="pick(item.product.name, locale)" />
|
||
<span>{{ pick(item.product.name, locale) }}</span>
|
||
<PriceText v-if="item.amountMinor !== null && item.currency" :amount-minor="item.amountMinor" :currency="item.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>
|