Files
vmall/apps/mall/pages/user/index.vue
T
james 9904696e76 feat: wave 2 migration (P3, P5, P7 openspec changes)
Implements, verifies, and archives the three remaining Wave 2 changes from
openspec/MIGRATION-PLAN.md.

- add-wallet-settlement (P3): demo recharge, guarded withdrawal freeze and
  one-time admin review, paginated own fund entries, idempotent per-shop
  weekly/monthly settlement statements with commission rate and one-time
  payout confirmation.
- add-merchant-onboarding (P5): personal/enterprise applications with one live
  application per user, guarded review with mandatory rejection reason, and
  transactional shop + owner provisioning returning one-time credentials;
  mall onboarding/status pages and an admin review console.
- add-membership-messaging (P7): platform member levels, append-only growth
  accrual on order completion with guarded one-way leveling, order/shipment/
  refund system messages with unread/read state and soft deletion, plus the
  mall header unread badge.

Backend: migrations 0019-0023, new wallet, settlement, merchant_onboarding,
membership and messaging modules, event hooks in order/fulfillment/aftersale,
and integration suites for each. Shared contract extended and all three
frontends updated; code indexes, domain docs, backend guidelines and the
migration tracker synced.

Verification: cargo test -p vmall-api green twice consecutively; mall, admin
and shop-admin builds pass; browser smoke on every new surface; openspec
validate --all --strict green (33 passed).

The three changes share the @vmall/shared contract, the mall mock adapter and
per-app locale/nav files, so they are committed together to keep every commit
buildable.
2026-09-25 15:25:29 +00:00

235 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 class="space-y-4">
<VCard class="pb-0">
<h1 class="text-text mb-4 text-xl font-bold">{{ t("user.dashboard") }}</h1>
<div class="mb-5 grid gap-3 sm:grid-cols-3">
<div class="border-border bg-primary-soft border p-[18px]">
<span class="text-muted block text-xs">{{ t("user.statsBalance") }}</span
><strong class="text-primary mt-2.5 block text-xl"
><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 bg-primary-soft border p-[18px]">
<span class="text-muted block text-xs">{{ t("user.statsPoints") }}</span
><strong class="text-primary mt-2.5 block text-xl">{{
stats ? stats.points : t("common.loading")
}}</strong>
</div>
<div class="border-border bg-primary-soft border p-[18px]">
<span class="text-muted block text-xs">{{ t("user.statsFrozen") }}</span
><strong class="text-primary mt-2.5 block text-xl"
><PriceText
v-if="stats"
:amount-minor="stats.frozen_minor"
:currency="stats.currency"
/><template v-else>{{ t("common.loading") }}</template></strong
>
</div>
</div>
<div class="border-border grid grid-cols-3 border-t sm:grid-cols-5">
<NuxtLink
v-for="item in statusLinks"
:key="item.key"
:to="item.to"
class="text-muted hover:text-primary sm:border-border flex flex-col items-center gap-2 px-2 py-4 text-xs no-underline sm:border-l first:sm:border-l-0"
><span>{{ item.label }}</span
><b class="text-primary text-lg font-semibold">{{ item.count }}</b></NuxtLink
>
</div>
<nav class="border-border flex flex-wrap items-center gap-x-6 gap-y-2 border-t py-3">
<NuxtLink
class="text-primary hover:text-primary-hover text-sm no-underline"
to="/user/wallet"
>{{ t("wallet.title") }} ›</NuxtLink
>
<NuxtLink
class="text-primary hover:text-primary-hover text-sm no-underline"
to="/user/membership"
>{{ t("membership.title") }} ›</NuxtLink
>
<NuxtLink
class="text-primary hover:text-primary-hover text-sm no-underline"
to="/user/messages"
>{{ t("messaging.title") }} ›</NuxtLink
>
</nav>
</VCard>
<VCard>
<h2 class="text-text mb-4 flex items-center justify-between text-lg font-semibold">
{{ t("user.recentOrders") }}
<NuxtLink class="text-primary text-sm font-normal no-underline" to="/user/orders"
>{{ t("user.viewAll") }} ›</NuxtLink
>
</h2>
<div v-if="loading" class="text-muted py-5">{{ 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="bg-bg text-muted flex items-center justify-between gap-3 px-3 py-2.5 text-xs"
>
<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="border-border h-11 w-11 border object-contain"
:src="item.image || '/mock/product-1.svg'"
:alt="pick(item.product_name, locale)"
/>
</div>
<div class="text-muted flex-1 text-xs">
{{ t("user.createdAt") }} {{ order.created_at.slice(0, 10) }}
</div>
<div class="text-right text-xs">
<span class="text-muted mb-1 block">{{ t("user.orderTotal") }}</span
><PriceText
class="text-primary font-semibold"
:amount-minor="order.total_minor"
:currency="order.currency"
/>
</div>
<NuxtLink
class="border-border bg-surface text-text inline-flex items-center rounded-md border px-2.5 py-1 text-[13px] font-medium no-underline"
:to="`/user/orders/${order.id}`"
>{{ t("user.viewDetails") }}</NuxtLink
>
</div>
</article>
</div>
</VCard>
<VCard>
<h2 class="text-text mb-4 text-lg font-semibold">
{{ 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="border-border text-text hover:border-primary block border p-2.5 no-underline"
>
<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="text-primary mt-2 block text-sm font-semibold"
:amount-minor="item.product.price_minor"
:currency="item.product.currency"
/>
</NuxtLink>
</div>
</VCard>
</div>
</template>