Files
vmall/apps/mall/components/shell/SiteHeader.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

207 lines
7.2 KiB
Vue

<script setup lang="ts">
const { t, locale, locales, setLocale } = useI18n();
const { currency } = usePrefs();
const cart = useCartStore();
const session = useSessionStore();
const { unread, refresh: refreshUnread, setCount } = useUnreadMessages();
const route = useRoute();
const router = useRouter();
const keywords = ref(typeof route.query.q === "string" ? route.query.q : "");
watch(
() => route.query.q,
(q) => {
keywords.value = typeof q === "string" ? q : "";
},
);
function search(): void {
const q = keywords.value.trim();
void router.push({ path: "/search", query: q ? { q } : {} });
}
const currencies = ref<string[]>(["USD"]);
const { $api } = useNuxtApp();
onMounted(async () => {
try {
const list = await $api.listCurrencies();
if (list.length > 0) currencies.value = list.filter((c) => c.enabled).map((c) => c.code);
} catch {
/* keep default */
}
void cart.refresh();
if (session.isLoggedIn) void refreshUnread();
});
// The session is restored from localStorage after hydration.
watch(
() => session.isLoggedIn,
(loggedIn) => {
if (loggedIn) void refreshUnread();
else setCount(0);
},
);
// The shell outlives route changes, so page entry is what re-reads the count.
watch(
() => route.fullPath,
() => {
if (session.isLoggedIn) void refreshUnread();
},
);
const menuOpen = ref(false);
const isHome = computed(() => route.path === "/");
// The home page pins the category menu in the hero row instead, so the header
// dropdown is hover-only and only on non-home pages.
const showMenu = computed(() => menuOpen.value && !isHome.value);
</script>
<template>
<header class="bg-surface">
<div class="max-w-mall mx-auto flex items-center gap-10 px-4 py-6">
<NuxtLink
to="/"
class="text-primary text-[34px] leading-none font-extrabold tracking-[1px] no-underline"
>VMall</NuxtLink
>
<div class="flex min-w-0 flex-1 items-center">
<div class="flex min-w-0 flex-1 items-center">
<input
v-model="keywords"
class="border-border bg-surface text-text focus:border-primary h-[38px] min-w-0 flex-1 border border-r-0 px-3 text-[12px] outline-none"
type="text"
:placeholder="t('shell.searchPlaceholder')"
@keyup.enter="search"
/>
<button
class="border-border bg-surface text-muted hover:bg-primary flex h-[38px] w-[38px] items-center justify-center border transition-colors hover:text-white"
type="button"
:aria-label="t('common.search')"
@click="search"
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path
d="M15.5 14h-.79l-.28-.27a6.5 6.5 0 10-.7.7l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0A4.5 4.5 0 1114 9.5 4.5 4.5 0 019.5 14z"
/>
</svg>
</button>
</div>
<div class="border-border ml-7 flex h-[38px] items-center gap-5 border px-5 text-[12px]">
<NuxtLink
to="/user"
class="text-muted hover:text-primary relative no-underline transition-colors"
>{{ t("shell.myMall") }}</NuxtLink
>
<NuxtLink
to="/cart"
class="text-muted hover:text-primary relative no-underline transition-colors"
>
{{ t("shell.myCart") }}
<span
v-if="cart.count > 0"
class="bg-primary absolute -top-3 -right-3 rounded-sm px-1 text-[11px] leading-4 text-white"
>{{ cart.count }}</span
>
</NuxtLink>
<!-- Anonymous shoppers have no message center, so this entry is
client-only until the localStorage session is restored. -->
<ClientOnly>
<NuxtLink
v-if="session.isLoggedIn"
to="/user/messages"
class="text-muted hover:text-primary relative no-underline transition-colors"
:aria-label="t('messaging.unreadBadge', { count: unread })"
>
{{ t("messaging.title") }}
<span
v-if="unread > 0"
class="bg-danger absolute -top-3 -right-3 rounded-sm px-1 text-[11px] leading-4 text-white"
>{{ unread }}</span
>
</NuxtLink>
</ClientOnly>
</div>
</div>
<div class="flex gap-2">
<select
:value="currency"
:aria-label="t('common.currency')"
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 h-[30px] min-w-[76px] rounded-md border px-2 text-[12px] focus:outline-2"
@change="currency = ($event.target as HTMLSelectElement).value"
>
<option v-for="c in currencies" :key="c" :value="c">{{ c }}</option>
</select>
<select
:value="locale"
:aria-label="t('common.language')"
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 h-[30px] min-w-[76px] rounded-md border px-2 text-[12px] focus:outline-2"
@change="setLocale(($event.target as HTMLSelectElement).value as 'en' | 'zh')"
>
<option v-for="l in locales" :key="l.code" :value="l.code">{{ l.name }}</option>
</select>
</div>
</div>
<nav class="h-10 bg-[#2d2d2d] text-sm leading-10 text-white">
<div class="max-w-mall mx-auto flex px-4">
<div
class="bg-primary relative w-[240px] cursor-pointer pl-[15px] font-medium"
@mouseenter="menuOpen = true"
@mouseleave="menuOpen = false"
>
{{ t("shell.allCategories") }}
<ShellCategoryMenu v-show="showMenu" />
</div>
<ul class="flex pl-5">
<li>
<NuxtLink
to="/"
class="block px-[30px] text-white no-underline transition-colors hover:bg-black"
>{{ t("shell.nav.home") }}</NuxtLink
>
</li>
<li>
<NuxtLink
to="/stores"
class="block px-[30px] text-white no-underline transition-colors hover:bg-black"
>{{ t("shell.nav.stores") }}</NuxtLink
>
</li>
<li>
<NuxtLink
to="/seckill"
class="block px-[30px] text-white no-underline transition-colors hover:bg-black"
>{{ t("shell.nav.seckill") }}</NuxtLink
>
</li>
<li>
<NuxtLink
to="/collective"
class="block px-[30px] text-white no-underline transition-colors hover:bg-black"
>{{ t("shell.nav.collective") }}</NuxtLink
>
</li>
<li>
<NuxtLink
to="/integral"
class="block px-[30px] text-white no-underline transition-colors hover:bg-black"
>{{ t("shell.nav.integral") }}</NuxtLink
>
</li>
<li>
<NuxtLink
to="/user"
class="block px-[30px] text-white no-underline transition-colors hover:bg-black"
>{{ t("shell.nav.help") }}</NuxtLink
>
</li>
</ul>
</div>
</nav>
</header>
</template>