- Mock adapter implementing @vmall/shared ApiClient (localStorage-persisted cart/orders/invoices), runtime switch via mockApi flag (default on) - Fixed bilingual mock catalog: 3-level categories, 24 products w/ SKUs, stores, brands, banners, floors, seckill/collective/integral, comments, coupons, addresses, seeded orders/shipments/invoices - B2B2C mall shell: top bar, logo/search/cart header, dark nav + category mega-menu, value-prop footer, back-to-top; 1200px grid, #ca151e theme - UI primitives replacing element-plus: carousel, pagination, breadcrumb, qty stepper, rating stars, modal, tabs, step bar, product card - Pages: home floors, search (filters/sort/paging), goods detail (SKU picker, store rail, review tabs), cart -> checkout -> pay -> success, auth pages, user center (dashboard/orders/addresses/favorites/coupons/ invoices), stores, seckill, collective, integral - i18n split into per-domain locale modules (en+zh) - OpenSpec change mall-pc-storefront-replica archived; all specs green
224 lines
5.5 KiB
Vue
224 lines
5.5 KiB
Vue
<script setup lang="ts">
|
|
const { t, locale, locales, setLocale } = useI18n();
|
|
const { currency } = usePrefs();
|
|
const cart = useCartStore();
|
|
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();
|
|
});
|
|
|
|
const menuOpen = ref(false);
|
|
const isHome = computed(() => route.path === "/");
|
|
const collapsed = ref(false);
|
|
onMounted(() => {
|
|
const onScroll = (): void => {
|
|
collapsed.value = window.scrollY > 200;
|
|
};
|
|
window.addEventListener("scroll", onScroll, { passive: true });
|
|
});
|
|
const showMenu = computed(() => menuOpen.value || (isHome.value && !collapsed.value));
|
|
</script>
|
|
|
|
<template>
|
|
<header class="hdr">
|
|
<div v-show="!collapsed" class="w1200 hdr-top">
|
|
<NuxtLink to="/" class="logo">VMall</NuxtLink>
|
|
<div class="search-box">
|
|
<input
|
|
v-model="keywords"
|
|
class="search-input"
|
|
type="text"
|
|
:placeholder="t('shell.searchPlaceholder')"
|
|
@keyup.enter="search"
|
|
/>
|
|
<button class="search-btn" 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 class="cart-links">
|
|
<NuxtLink to="/user" class="cart-link">{{ t("shell.myMall") }}</NuxtLink>
|
|
<NuxtLink to="/cart" class="cart-link">
|
|
{{ t("shell.myCart") }}
|
|
<span v-if="cart.count > 0" class="dot">{{ cart.count }}</span>
|
|
</NuxtLink>
|
|
</div>
|
|
</div>
|
|
<div class="prefs">
|
|
<select :value="currency" :aria-label="t('common.currency')" @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')" @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="nav">
|
|
<div class="w1200 nav-in">
|
|
<div
|
|
class="nav-cats"
|
|
@mouseenter="menuOpen = true"
|
|
@mouseleave="menuOpen = false"
|
|
>
|
|
{{ t("shell.allCategories") }}
|
|
<ShellCategoryMenu v-show="showMenu" />
|
|
</div>
|
|
<ul class="nav-links">
|
|
<li><NuxtLink to="/">{{ t("shell.nav.home") }}</NuxtLink></li>
|
|
<li><NuxtLink to="/stores">{{ t("shell.nav.stores") }}</NuxtLink></li>
|
|
<li><NuxtLink to="/seckill">{{ t("shell.nav.seckill") }}</NuxtLink></li>
|
|
<li><NuxtLink to="/collective">{{ t("shell.nav.collective") }}</NuxtLink></li>
|
|
<li><NuxtLink to="/integral">{{ t("shell.nav.integral") }}</NuxtLink></li>
|
|
<li><NuxtLink to="/user">{{ t("shell.nav.help") }}</NuxtLink></li>
|
|
</ul>
|
|
</div>
|
|
</nav>
|
|
</header>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.hdr {
|
|
background: #fff;
|
|
}
|
|
.hdr-top {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 40px;
|
|
padding: 24px 0;
|
|
}
|
|
.logo {
|
|
font-size: 34px;
|
|
font-weight: 800;
|
|
color: var(--mall-red);
|
|
text-decoration: none;
|
|
letter-spacing: 1px;
|
|
}
|
|
.search-box {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
.search-input {
|
|
width: 434px;
|
|
height: 38px;
|
|
padding: 10px;
|
|
outline: 0;
|
|
border: 1px solid var(--mall-line);
|
|
border-right: none;
|
|
box-sizing: border-box;
|
|
font-size: 12px;
|
|
}
|
|
.search-btn {
|
|
border: 1px solid var(--mall-line);
|
|
width: 38px;
|
|
height: 38px;
|
|
background: #fff;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.search-btn:hover {
|
|
color: #fff;
|
|
background: var(--mall-nav);
|
|
}
|
|
.cart-links {
|
|
display: flex;
|
|
height: 38px;
|
|
border: 1px solid var(--mall-line);
|
|
margin-left: 30px;
|
|
box-sizing: border-box;
|
|
font-size: 12px;
|
|
line-height: 38px;
|
|
padding: 0 20px;
|
|
gap: 20px;
|
|
}
|
|
.cart-link {
|
|
position: relative;
|
|
color: inherit;
|
|
text-decoration: none;
|
|
}
|
|
.cart-link:hover {
|
|
color: var(--mall-red);
|
|
}
|
|
.dot {
|
|
position: absolute;
|
|
top: -12px;
|
|
right: -14px;
|
|
line-height: 16px;
|
|
background: var(--mall-red);
|
|
padding: 0 4px;
|
|
color: #fff;
|
|
border-radius: 4px;
|
|
font-size: 11px;
|
|
}
|
|
.prefs {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
.prefs select {
|
|
height: 30px;
|
|
min-width: 76px;
|
|
padding: 0 6px;
|
|
border: 1px solid var(--mall-line);
|
|
background: #fff;
|
|
font-size: 12px;
|
|
}
|
|
.nav {
|
|
height: 40px;
|
|
background: var(--mall-nav);
|
|
font-size: 14px;
|
|
line-height: 40px;
|
|
color: #fff;
|
|
}
|
|
.nav-in {
|
|
display: flex;
|
|
}
|
|
.nav-cats {
|
|
position: relative;
|
|
cursor: pointer;
|
|
width: 240px;
|
|
background: var(--mall-red);
|
|
box-sizing: border-box;
|
|
padding-left: 15px;
|
|
}
|
|
.nav-links {
|
|
display: flex;
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0 0 0 20px;
|
|
}
|
|
.nav-links a {
|
|
line-height: 40px;
|
|
padding: 0 30px;
|
|
color: #fff;
|
|
display: block;
|
|
text-decoration: none;
|
|
}
|
|
.nav-links a:hover {
|
|
background: #000;
|
|
}
|
|
</style>
|