Files
vmall/apps/mall/pages/stores/index.vue
T
Chengdong Zhang 21e99bb52b feat(mall): classic B2B2C PC storefront with fixed mock API layer
- 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
2026-09-17 19:13:29 +08:00

214 lines
5.0 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 { t as pick } from "@vmall/shared";
import { MOCK_STORES } from "~/mock/data";
const { locale, t } = useI18n();
const sortMode = ref<"default" | "distance">("default");
const stores = computed(() => {
const list = [...MOCK_STORES];
if (sortMode.value === "distance") list.sort((a, b) => a.distanceKm - b.distanceKm);
return list;
});
const breadcrumb = computed(() => [
{ label: t("stores.breadcrumbHome"), to: "/" },
{ label: t("stores.directory") },
]);
</script>
<template>
<div class="stores-page">
<UiBreadcrumb :items="breadcrumb" />
<section class="w1200 store-directory">
<header class="sort-row">
<h1>{{ t("stores.directory") }}</h1>
<div class="sort-options" role="tablist">
<button
type="button"
:class="{ active: sortMode === 'default' }"
role="tab"
:aria-selected="sortMode === 'default'"
@click="sortMode = 'default'"
>{{ t("stores.sortDefault") }}</button>
<button
type="button"
:class="{ active: sortMode === 'distance' }"
role="tab"
:aria-selected="sortMode === 'distance'"
@click="sortMode = 'distance'"
>{{ t("stores.sortDistance") }}</button>
</div>
</header>
<div v-if="stores.length" class="store-list">
<article v-for="store in stores" :key="store.id" class="store-row hover-lift">
<NuxtLink :to="`/stores/${store.slug}`" class="store-logo-link">
<img class="store-logo" :src="store.logo" :alt="pick(store.name, locale)" loading="lazy" />
</NuxtLink>
<div class="store-main">
<NuxtLink :to="`/stores/${store.slug}`" class="store-name">{{ pick(store.name, locale) }}</NuxtLink>
<p class="store-company">{{ store.company }}</p>
<p class="store-location">
<span>{{ t("stores.region") }}{{ store.region }}</span>
<span>{{ t("stores.address") }}{{ pick(store.address, locale) }}</span>
</p>
</div>
<div class="store-rating">{{ t("stores.positiveRate") }}</div>
<div class="store-actions">
<span class="distance">{{ t("stores.distance", { n: store.distanceKm.toFixed(1) }) }}</span>
<NuxtLink :to="`/stores/${store.slug}`" class="mbtn red">{{ t("stores.visitStore") }}</NuxtLink>
</div>
</article>
</div>
<UiEmptyState v-else :text="t('common.empty')" />
</section>
</div>
</template>
<style scoped>
.stores-page {
padding-bottom: 60px;
}
.store-directory {
background: #fff;
}
.sort-row {
display: flex;
align-items: center;
justify-content: space-between;
border: 1px solid var(--mall-line);
background: #fafafa;
padding: 13px 18px;
}
h1 {
margin: 0;
border-left: 3px solid var(--mall-red);
padding-left: 10px;
font-size: 16px;
font-weight: 500;
}
.sort-options {
display: flex;
}
.sort-options button {
border: 0;
border-right: 1px solid var(--mall-line-dark);
background: transparent;
color: var(--mall-muted);
padding: 2px 15px;
font-size: 13px;
cursor: pointer;
}
.sort-options button:last-child {
border-right: 0;
padding-right: 0;
}
.sort-options button.active {
color: var(--mall-red);
font-weight: 700;
}
.store-list {
border: 1px solid var(--mall-line);
border-top: 0;
}
.store-row {
display: flex;
align-items: center;
min-height: 126px;
padding: 20px 22px;
border-bottom: 1px solid var(--mall-line);
background: #fff;
}
.store-row:last-child {
border-bottom: 0;
}
.store-logo-link {
display: flex;
width: 80px;
height: 80px;
align-items: center;
justify-content: center;
flex: 0 0 80px;
border: 1px solid var(--mall-line);
background: #fff;
}
.store-logo {
width: 72px;
height: 72px;
object-fit: contain;
}
.store-main {
min-width: 0;
flex: 1;
padding: 0 28px;
}
.store-name {
display: block;
color: var(--mall-ink);
font-size: 16px;
font-weight: 700;
text-decoration: none;
}
.store-name:hover {
color: var(--mall-red);
}
.store-company,
.store-location {
margin: 8px 0 0;
color: var(--mall-muted);
font-size: 13px;
}
.store-location {
display: flex;
gap: 24px;
color: var(--mall-faint);
font-size: 12px;
}
.store-rating {
width: 130px;
color: var(--mall-red);
font-size: 13px;
text-align: center;
}
.store-actions {
display: flex;
width: 145px;
flex-direction: column;
align-items: flex-end;
gap: 10px;
}
.distance {
color: var(--mall-muted);
font-size: 12px;
}
@media (max-width: 1240px) {
.w1200 {
width: calc(100% - 32px);
}
}
@media (max-width: 720px) {
.store-row {
align-items: flex-start;
flex-wrap: wrap;
gap: 12px;
}
.store-main {
width: calc(100% - 100px);
padding: 0;
}
.store-location {
flex-direction: column;
gap: 4px;
}
.store-rating {
width: auto;
margin-left: 100px;
}
.store-actions {
width: auto;
margin-left: auto;
}
}
</style>