feat(seller): 调整商家菜单布局与商品列表展示

This commit is contained in:
pikachu1995@126.com
2026-11-09 10:32:19 +08:00
parent 2497f33cb4
commit 0232ddeb27
11 changed files with 421 additions and 246 deletions

View File

@@ -1,166 +1,99 @@
<!--
商家单侧栏导航:栏目纵向展开,功能入口按两列排列,沿用当前账号权限与路由定位。
@author Bulbasaur
@since 2026-11-08
-->
<template>
<div class="shrinkable-menu" :class="{ 'no-sub-menu': !menuList.length }">
<el-menu
ref="sideMenu"
class="nav-menu-dark"
:default-active="currNav"
@select="selectNav"
>
<el-menu-item v-for="(item, i) in navList" :key="i" :index="item.name">
{{ item.title }}
</el-menu-item>
</el-menu>
<el-menu
v-if="menuList.length"
ref="childrenMenu"
:key="currNav"
class="sub-menu"
:default-active="$route.name"
@select="changeMenu"
>
<template v-for="item in menuList" :key="item.id">
<el-menu-item-group :title="item.title">
<el-menu-item
v-for="menu in item.children"
:key="menu.name"
:index="menu.name"
>
{{ menu.title }}
</el-menu-item>
</el-menu-item-group>
</template>
</el-menu>
</div>
<nav class="seller-navigation" aria-label="商家功能菜单">
<button type="button" class="nav-heading home-link" :class="{ 'is-active': route.name === 'home_index' }"
:aria-current="route.name === 'home_index' ? 'page' : undefined" @click="router.push({ name: 'home_index' })">
<el-icon :size="20"><House /></el-icon><span>首页</span>
</button>
<section v-for="section in sections" :key="section.name" class="nav-section">
<button type="button" class="nav-heading" :class="{ 'is-current': activeItem?.topName === section.name }"
:aria-label="section.title" :aria-expanded="!closedSections.includes(section.name)"
:aria-controls="`seller-nav-${section.name}`" @click="toggleSection(section.name)">
<el-icon :size="19"><component :is="navIcon(section)" /></el-icon>
<span>{{ section.title }}</span>
<el-icon class="section-arrow" :class="{ 'is-open': !closedSections.includes(section.name) }" :size="14"><ArrowDown /></el-icon>
</button>
<div v-show="!closedSections.includes(section.name)" :id="`seller-nav-${section.name}`" class="nav-grid">
<button v-for="menu in section.items" :key="menu.name" type="button" class="nav-link"
:class="{ 'is-active': activeItem?.name === menu.name }"
:aria-current="activeItem?.name === menu.name ? 'page' : undefined"
:title="`${menu.groupTitle} / ${menu.title}`" @click="router.push({ name: menu.name })">
{{ menu.title }}
</button>
</div>
</section>
</nav>
</template>
<script setup>
import { computed, ref, watch, onMounted } from "vue";
import { computed, nextTick, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import util, { HOME_NAV_NAME } from "@/libs/util.js";
import { House, Goods, Tickets, Wallet, Present, TrendCharts, Shop, Bell, Grid, ArrowDown, User, Box } from "@element-plus/icons-vue";
import { HOME_NAV_NAME } from "@/libs/util.js";
import { flattenNavigation, findNavigationItem } from "@/libs/seller-navigation";
import { setStore } from "@/libs/storage";
import { useAppStore } from "@/stores/app";
defineOptions({ name: "shrinkableMenu" });
const appStore = useAppStore();
const route = useRoute();
const router = useRouter();
const closedSections = ref([]);
const allItems = computed(() => flattenNavigation(appStore.navigationMenus).filter((item) => router.hasRoute(item.name)));
const activeItem = computed(() => findNavigationItem(allItems.value, route.name));
const sections = computed(() => appStore.navigationMenus.map((top) => ({
...top, items: allItems.value.filter((item) => item.topName === top.name),
})).filter((top) => top.items.length));
const childrenMenu = ref(null);
const menuList = computed(() => appStore.menuList);
const navList = computed(() => appStore.navList);
const currNav = computed(() => appStore.currNav);
watch(
() => route.name,
() => {
syncRouteMenu(route);
}
);
function syncRouteMenu(route) {
if (!route?.name) {
return;
}
if (route.name === "home_index") {
activateHomeNav();
return;
}
const firstRouterName =
route.meta?.firstRouterName || util.getFirstRouterName(route.name);
if (firstRouterName && firstRouterName !== currNav.value) {
selectNav(firstRouterName);
}
/** 使用业务栏目图标,新增的未知栏目显示通用图标。 */
function navIcon(item) {
return ({ 商品: Goods, 订单: Tickets, 财务: Wallet, 营销: Present, 统计: TrendCharts,
数据: TrendCharts, 设置: Shop, 店铺: Shop, 消息: Bell, 会员: User, 采购: Box })[item.title] || Grid;
}
function activateHomeNav() {
appStore.setCurrNav(HOME_NAV_NAME);
setStore("currNav", HOME_NAV_NAME);
appStore.updateMenulist([]);
/** 各栏目独立展开收起,可同时查看多个栏目的功能,不改变当前业务页面。 */
function toggleSection(name) {
closedSections.value = closedSections.value.includes(name)
? closedSections.value.filter((item) => item !== name) : [...closedSections.value, name];
}
function changeMenu(name) {
if (!name) return;
router.push({ name });
}
function selectNav(name) {
appStore.childrenMenu(childrenMenu.value);
/** 刷新、搜索和详情跳转后展开所属栏目;仅定位授权入口,不改变菜单顺序和权限。 */
async function syncRouteMenu() {
const name = activeItem.value?.topName || HOME_NAV_NAME;
appStore.setCurrNav(name);
appStore.updateMenulist(appStore.navigationMenus.find((top) => top.name === name)?.children || []);
setStore("currNav", name);
if (name === HOME_NAV_NAME) {
appStore.updateMenulist([]);
if (route.name !== "home_index") {
router.push({ name: "home_index" });
}
return;
}
util.initRouter();
closedSections.value = closedSections.value.filter((item) => item !== name);
await nextTick();
document.querySelector('.seller-navigation .nav-link.is-active')?.scrollIntoView({ block: "nearest" });
}
onMounted(() => {
syncRouteMenu(route);
});
watch(() => appStore.navigationMenus, () => { closedSections.value = []; });
watch([() => route.fullPath, () => appStore.navigationMenus], syncRouteMenu, { immediate: true });
</script>
<style lang="scss" scoped>
.shrinkable-menu {
height: calc(100% - 60px);
width: 180px;
display: flex;
transition: width 0.3s;
&.no-sub-menu {
width: 80px;
}
// 隐藏滚动条但保留滚轮、触控和键盘滚动,确保下方栏目仍可访问。
.seller-navigation { height: calc(100% - 64px); box-sizing: border-box; padding: 18px 14px 32px; overflow-y: auto; overflow-x: hidden; scrollbar-width: none; background: #fff;
&::-webkit-scrollbar { display: none; width: 0; height: 0; }
}
.nav-menu-dark {
width: 80px;
background-color: #191a23;
border-right: none;
overflow-y: auto;
:deep(.el-menu-item) {
color: rgba(255, 255, 255, 0.7);
justify-content: center;
padding: 0 8px !important;
text-align: center;
line-height: 1.3;
height: auto;
min-height: 56px;
white-space: normal;
transition: background-color 0.2s ease, color 0.2s ease;
}
:deep(.el-menu-item:not(.is-active):hover),
:deep(.el-menu-item:not(.is-active):focus) {
background-color: #43444d !important;
color: #fff !important;
}
:deep(.el-menu-item.is-active) {
background-color: #fff !important;
color: var(--el-color-primary) !important;
}
:deep(.el-menu-item.is-active:hover),
:deep(.el-menu-item.is-active:focus) {
background-color: #fff !important;
color: var(--el-color-primary) !important;
}
.nav-heading { width: 100%; display: flex; align-items: center; gap: 13px; min-height: 46px; padding: 11px 14px; border: 0; border-radius: 6px; background: transparent; color: #242832; text-align: left; font: inherit; font-size: 15px; font-weight: 600; cursor: pointer;
&:hover { background: #f7f8fc; }
&:focus-visible { outline: 2px solid #315efb; outline-offset: -2px; }
&.is-active { color: #315efb; background: #f0f4ff; }
}
.sub-menu {
width: 100px;
overflow-y: auto;
border-right: none;
:deep(.el-menu-item-group__title) {
height: 40px;
line-height: 40px;
padding-left: 20px;
}
.home-link { margin-bottom: 16px; }
.nav-section { margin-bottom: 22px; }
.section-arrow { margin-left: auto; color: #b8bdc7; transition: transform .15s; &.is-open { transform: rotate(180deg); } }
.nav-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px 8px; padding: 2px 5px 0 43px; }
.nav-link { min-width: 0; min-height: 36px; padding: 7px 5px; border: 0; border-radius: 5px; background: transparent; color: #62666f; text-align: left; font: inherit; font-size: 13px; line-height: 22px; overflow-wrap: anywhere; cursor: pointer;
&:hover { background: #f5f7fc; color: #315efb; }
&:focus-visible { outline: 2px solid #315efb; outline-offset: -2px; }
&.is-active { color: #315efb; background: #eef3ff; font-weight: 600; }
}
@media (prefers-reduced-motion: reduce) { .section-arrow { transition: none; } }
</style>