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

@@ -0,0 +1,47 @@
/**
* 商家导航回归:覆盖权限裁剪、详情归属和跨分组检索,使用 Node 内置测试运行器。
* @author Bulbasaur
* @since 2026-11-08
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { flattenNavigation, findNavigationItem, searchNavigation } from '../src/libs/seller-navigation.js';
const component = () => {};
const menus = [
{ name: 'goods', title: '商品', children: [{ title: '商品管理', children: [
{ name: 'goods', title: '商品列表', component },
{ name: 'disabled', title: '不可用页面', component: null },
] }] },
{ name: 'order', title: '订单', children: [{ title: '订单管理', children: [
{ name: 'orderList', title: '商品订单', component },
] }] },
];
const items = flattenNavigation(menus);
test('搜索不补出未授权入口,也不展示无法加载的菜单', () => {
assert.equal(searchNavigation(items, '优惠券').length, 0);
assert.equal(searchNavigation(items, '不可用页面').length, 0);
});
test('详情页刷新可定位已授权业务入口', () => {
assert.equal(findNavigationItem(items, 'goods-operation-edit')?.topName, 'goods');
assert.equal(findNavigationItem(items, 'order-detail')?.name, 'orderList');
});
test('详情页对应入口被撤权后不能沿用旧账号菜单', () => {
const restricted = flattenNavigation(menus.slice(0, 1));
assert.equal(findNavigationItem(restricted, 'order-detail'), null);
assert.equal(findNavigationItem([], 'goods-operation-edit'), null);
});
test('详情本身是授权菜单时优先使用精确入口', () => {
const exact = { name: 'order-detail', topName: 'custom' };
assert.equal(findNavigationItem([...items, exact], 'order-detail'), exact);
});
test('关键词同时匹配栏目及名称,避免同名商品功能混淆', () => {
assert.deepEqual(searchNavigation(items, ' 订单 商品 ').map(item => item.name), ['orderList']);
assert.deepEqual(searchNavigation(items, '商品 列表').map(item => item.name), ['goods']);
});
test('搜索清空与空权限账号正确处理', () => {
assert.equal(searchNavigation(items, ' ').length, 2);
assert.deepEqual(searchNavigation(flattenNavigation([]), ''), []);
assert.equal(findNavigationItem(items, 'unknown-detail'), null);
});

View File

@@ -31,6 +31,6 @@ module.exports = {
*/
mainColor: "#ff5c58", //主题色
setting: {
isUseTabsRouter: true, //多标签Tab页模式
isUseTabsRouter: false, // 多标签页内嵌模式默认关闭,用户可在页面配置中开启。
},
};

View File

@@ -0,0 +1,55 @@
/**
* 商家导航的权限菜单检索及详情页归属;仅在已授权菜单中定位,不增加访问权限。
* @author Bulbasaur
* @since 2026-11-08
*/
// 非菜单页面回到对应业务入口;多个候选项按实际已授权入口匹配。
const DETAIL_MENUS = {
"goods-operation-edit": ["goods"],
"goods-template-operation-edit": ["template-goods"],
"goods-draft-operation-edit": ["draftGoods", "draft-goods", "goods"],
"card-key-pool": ["goods"],
"add-coupon": ["coupon"],
"coupon-receive": ["coupon"],
"add-live": ["live"],
"bill-detail": ["storeBill", "accountStatementBill"],
"order-detail": ["orderList", "virtualOrderList"],
"export-order-deliver": ["orderList"],
"order-complaint-detail": ["orderComplaint"],
"return-goods-order-detail": ["returnGoodsOrder", "returnMoneyOrder"],
"full-discount-detail": ["full-discount", "full-cut"],
"seckill-goods": ["seckill"],
"pintuan-goods": ["pintuan"],
"pintuan-edit": ["pintuan"],
main: ["wapList"],
renovation: ["pcList"],
"appointment-system-form-designer": ["appointment-system-forms"],
"procurement-purchase-order-operation": ["procurementPurchaseOrderList"],
"procurement-inbound-operation": ["procurementInboundList"],
"procurement-inventory-count-detail": ["procurementInventoryCountList"],
"procurement-damage-report-operation": ["procurementDamageReportList"],
};
/** 将权限树展开为带栏目与分组信息的可跳转菜单,用于搜索和路由定位。 */
export function flattenNavigation(menus) {
return menus.flatMap((top) => (top.children || []).flatMap((group) =>
(group.children || []).filter((leaf) => leaf.name && typeof leaf.component === "function")
.map((leaf) => ({ ...leaf, topName: top.name, topTitle: top.title, groupTitle: group.title }))
));
}
/** 优先精确匹配菜单,再匹配详情页业务入口;无授权入口时返回空,不沿用旧账号栏目。 */
export function findNavigationItem(items, routeName) {
return items.find((item) => item.name === routeName) ||
(DETAIL_MENUS[routeName] || []).map((name) => items.find((item) => item.name === name)).find(Boolean) || null;
}
/** 按菜单、分组和栏目检索,空格分隔的关键词须全部匹配。 */
export function searchNavigation(items, keyword) {
const terms = keyword.trim().toLowerCase().split(/\s+/).filter(Boolean);
return items.filter((item) => {
const text = `${item.topTitle} ${item.groupTitle} ${item.title}`.toLowerCase();
return terms.every((term) => text.includes(term));
});
}

View File

@@ -450,47 +450,29 @@ util.initAllMenuData = function (constRoutes, data) {
util.initMenuData = function (data) {
const appStore = useAppStore();
const menuRoutes = [];
let menuData = data;
// 全量导航与搜索只使用当前账号权限接口返回的菜单,沿用已下线菜单过滤规则。
appStore.navigationMenus = data.map((top) => {
const children = [];
util.initRouterNode(children, top.children || []);
return { ...top, children: children.filter((group) => group.children?.length) };
}).filter((top) => top.children.length);
let navList = [HOME_NAV_ITEM];
menuData.forEach((e) => {
appStore.navigationMenus.forEach((e) => {
navList.push({ name: e.name, title: e.title });
});
if (navList.length < 1) {
return;
}
appStore.setNavList(navList);
let currNav = window.localStorage.getItem("currNav");
if (!currNav) {
if (!navList.some((item) => item.name === currNav)) {
currNav = HOME_NAV_NAME;
}
appStore.setCurrNav(currNav);
if (currNav === HOME_NAV_NAME) {
appStore.updateMenulist([]);
return;
}
// 首页也必须准备全部标签入口;切换栏目只更新展示,不能依赖再次初始化路由补齐标签。
appStore.tagsList = appStore.routers.flatMap((item) => item.children || []);
for (let item of menuData) {
if (item.name == currNav) {
menuData = item.children;
break;
}
}
util.initRouterNode(menuRoutes, menuData);
appStore.updateMenulist(
menuRoutes.filter((item) => item.children.length > 0)
appStore.navigationMenus.find((item) => item.name === currNav)?.children || []
);
let tagsList = [];
appStore.routers.map((item) => {
if (item.children.length <= 1) {
tagsList.push(item.children[0]);
} else {
tagsList.push(...item.children);
}
});
appStore.setTagsList(tagsList);
};
util.initRouterNode = function (routers, data) {

View File

@@ -12,6 +12,7 @@ export const useAppStore = defineStore("app", {
loading: false,
added: false,
navList: [],
navigationMenus: [],
currNav: "",
lang: "",
openedSubmenuArr: [],
@@ -28,6 +29,11 @@ export const useAppStore = defineStore("app", {
refMenu: "",
}),
actions: {
/** 清理账号绑定的路由与标签目录,保留静态页面,防止切换账号后复用旧菜单。 */
resetNavigationRoutes() {
this.routers = [otherRouter];
this.tagsList = [...otherRouter.children];
},
childrenMenu(v) {
this.refMenu = v;
},

View File

@@ -1,13 +1,16 @@
import { defineStore } from "pinia";
import Cookies from "js-cookie";
import { removeStore } from "@/libs/storage";
import { useAppStore } from "@/stores/app";
// 退出登录时需要清除的会话/店铺数据
// 保留 uuid(设备标识)、setting(主题)、currNav、category(缓存)、saveLogin(记住登录状态)等无关键
// 保留 uuid(设备标识)、setting(主题)、category(缓存)、saveLogin(记住登录状态)等无关键
const LOGOUT_KEYS = [
"accessToken",
"refreshToken",
"menuData",
"currNav",
"storeOpenedList",
"seller_expiration_time",
"sellersiteName",
"sellerlogoImg",
@@ -20,6 +23,15 @@ export const useUserStore = defineStore("user", {
Cookies.remove("userInfoSeller");
// 精确清除会话相关键,不再 localStorage.clear() 误删无关数据
LOGOUT_KEYS.forEach((key) => removeStore(key));
// 导航和已打开标签属于当前账号,退出时同步清理内存,避免下一账号复用旧权限菜单。
const appStore = useAppStore();
appStore.navigationMenus = [];
appStore.setNavList([]);
appStore.updateMenulist([]);
appStore.setCurrNav("");
appStore.setAdded(false);
appStore.resetNavigationRoutes();
appStore.storeOpenedList = [{ title: "首页", path: "", name: "home_index" }];
},
},
});

View File

@@ -3,36 +3,44 @@
</style>
<template>
<div class="main" :class="{ 'menu-collapsed': !showSubMenu }">
<div class="main">
<div class="sidebar-menu-con menu-bar">
<div class="logo-con">
<img alt="店铺 Logo" :src="storeSideLogo" key="max-logo" />
<span class="brand-caption">商家工作台</span>
</div>
<shrinkable-menu />
</div>
<div class="main-header-con" :style="{ height: setting.isUseTabsRouter ? '106px' : '60px' }">
<div class="main-header">
<div class="header-avator-con">
<div></div>
<div class="header-page-info">
<span class="workspace-label">商家后台</span>
<span class="header-divider">/</span>
<strong>{{ currentPageTitle }}</strong>
</div>
<div class="user-dropdown-menu-con">
<el-row
justify="end"
align="middle"
class="user-dropdown-innercon"
>
<menu-search />
<store-qrcode />
<ul class="nav-list">
<li class="nav-item" @click="im">
<li class="nav-item">
<el-tooltip content="联系客服" placement="bottom">
<el-button type="warning" size="small" :loading="load">
<el-button type="warning" size="small" :loading="load" @click="im">
<el-icon><ChatDotRound /></el-icon>
客服
</el-button>
</el-tooltip>
</li>
<li class="nav-item" @click="handleClickSetting">
<li class="nav-item">
<el-tooltip content="设置" placement="bottom">
<el-icon :size="16"><Setting /></el-icon>
<button type="button" class="header-icon-button" aria-label="页面设置" @click="handleClickSetting">
<el-icon :size="17"><Setting /></el-icon>
</button>
</el-tooltip>
</li>
</ul>
@@ -45,6 +53,7 @@
>
<el-icon><UserFilled /></el-icon>
</el-avatar>
<span class="header-store-name">{{ userInfo.storeName || '我的店铺' }}</span>
</div>
<template #dropdown>
<el-dropdown-menu>
@@ -88,6 +97,7 @@ import tagsPageOpened from "./main-components/tags-page-opened.vue";
import circleLoading from "@/views/my-components/lili/circle-loading.vue";
import configDrawer from "@/views/main-components/config-drawer.vue";
import storeQrcode from "@/views/main-components/store-qrcode.vue";
import menuSearch from "@/views/main-components/menu-search.vue";
import Cookies from "js-cookie";
import util from "@/libs/util.js";
import { logout } from "@/api/index";
@@ -115,7 +125,7 @@ const load = ref(false);
const setting = computed(() => settingStore.setting);
const loading = computed(() => appStore.loading);
const pageTagsList = computed(() => appStore.storeOpenedList);
const showSubMenu = computed(() => appStore.menuList.length > 0);
const currentPageTitle = computed(() => route.meta.title || pageTagsList.value.find((item) => item.name === route.name)?.title || '工作台');
const lang = computed(() => appStore.lang);
function handleClickSetting() {
@@ -148,7 +158,7 @@ function init() {
appStore.addOpenSubmenu(pathArr[1].name);
}
storeSideLogo.value = localStorage.getItem("sellerlogoImg");
window.document.title = localStorage.getItem("sellersiteName");
util.title(currentPageTitle.value);
const link =
document.querySelector("link[rel*='icon']") ||
document.createElement("link");
@@ -203,6 +213,7 @@ watch(
appStore.addOpenSubmenu(pathArr[1].name);
}
checkTag(name);
util.title(currentPageTitle.value);
}
);
@@ -210,6 +221,12 @@ watch(lang, () => {
util.setCurrentPath(route.name);
});
// 刷新时权限菜单晚于布局挂载;目录到达后补齐当前标签和标题,无需再切换一次栏目。
watch(() => appStore.tagsList, () => {
checkTag(route.name);
util.title(currentPageTitle.value);
});
appStore.setOpenedList();
onMounted(() => {

View File

@@ -150,9 +150,9 @@
row-key="id"
@selection-change="onSelectionChange"
>
<el-table-column v-if="!scheduledOnly" type="selection" width="100" align="center" />
<el-table-column prop="id" label="SPU编码" width="200" show-overflow-tooltip />
<el-table-column label="商品图片" width="100" align="center">
<el-table-column v-if="!scheduledOnly" type="selection" width="48" align="center" />
<el-table-column prop="id" label="SPU编码" width="180" show-overflow-tooltip />
<el-table-column label="商品图片" width="80" align="center">
<template #default="{ row }">
<img alt="商品图片"
v-if="row && row.original"
@@ -172,60 +172,57 @@
</el-table-column>
<el-table-column v-if="currentStatus === 'SCHEDULED_UPPER'" prop="scheduledUpperTime" label="计划上架时间" width="190" />
<el-table-column v-if="currentStatus === 'SCHEDULED_DOWN'" prop="scheduledDownTime" label="计划下架时间" width="190" />
<el-table-column label="价格" width="120">
<el-table-column label="价格" width="110">
<template #default="{ row }">
<span :style="{ color: $mainColor }">{{ $filters.unitPrice(row.price, '¥') }}</span>
</template>
</el-table-column>
<el-table-column prop="buyCount" label="销量" width="90" />
<el-table-column prop="quantity" label="库存" width="90" />
<el-table-column label="商品类型" width="150">
<el-table-column prop="buyCount" label="销量" width="70" />
<el-table-column prop="quantity" label="库存" width="70" />
<el-table-column label="商品类型" width="100">
<template #default="{ row }">
<span v-if="row">{{ goodsTypeText(row.goodsType) }}</span>
</template>
</el-table-column>
<el-table-column label="商品分类" min-width="200" show-overflow-tooltip>
<el-table-column label="商品分类" min-width="240" class-name="goods-category-column">
<template #default="{ row }">
{{ platformCategoryText(row?.categoryPath) }}
</template>
</el-table-column>
<el-table-column v-if="!scheduledOnly" label="状态" width="150">
<el-table-column v-if="!scheduledOnly" label="状态" width="80">
<template #default="{ row }">
<span v-if="row">{{ marketEnableText(row.marketEnable) }}</span>
</template>
</el-table-column>
<el-table-column label="审核状态" width="150">
<el-table-column label="审核状态" width="90">
<template #default="{ row }">
<span v-if="row">{{ authFlagText(row.authFlag) }}</span>
</template>
</el-table-column>
<el-table-column v-if="!scheduledOnly" prop="createTime" label="创建时间" width="170" show-overflow-tooltip />
<el-table-column label="操作" :width="scheduledOnly ? 150 : 320" align="right" fixed="right">
<el-table-column label="操作" :width="scheduledOnly ? 100 : 190" align="right" fixed="right">
<template #default="{ row }">
<template v-if="scheduledOnly">
<a class="link-text" @click="emit('edit-schedule', row)">编辑</a>
<span class="op-split">|</span>
<a class="link-text" @click="emit('delete-schedule', row)">删除</a>
</template>
<template v-else>
<template v-if="row.marketEnable === 'DOWN'">
<a class="link-text" @click="upper(row)">上架</a>
<span class="op-split">|</span>
<a class="link-text" @click="editGoods(row)">编辑</a>
</template>
<template v-else>
<a class="link-text" @click="edit(row)">下架</a>
<span class="op-split">|</span>
<a class="link-text" @click="editGoods(row)">编辑</a>
</template>
<span class="op-split">|</span>
<a class="link-text" @click="copyGoods(row)">复制商品</a>
<!-- E_COUPON 专属:卡池管理入口(原型 P-03) -->
<template v-if="row.goodsType === 'E_COUPON'">
<span class="op-split">|</span>
<a class="link-text" @click="goCardKeyPool(row)">卡池管理</a>
</template>
</template>
<div class="goods-actions">
<template v-if="scheduledOnly">
<a class="link-text" @click="emit('edit-schedule', row)">编辑</a>
<a class="link-text" @click="emit('delete-schedule', row)">删除</a>
</template>
<template v-else>
<template v-if="row.marketEnable === 'DOWN'">
<a class="link-text" @click="upper(row)">上架</a>
<a class="link-text" @click="editGoods(row)">编辑</a>
</template>
<template v-else>
<a class="link-text" @click="edit(row)">下架</a>
<a class="link-text" @click="editGoods(row)">编辑</a>
</template>
<a class="link-text" @click="copyGoods(row)">复制商品</a>
<!-- E_COUPON 专属:卡池管理入口(原型 P-03) -->
<template v-if="row.goodsType === 'E_COUPON'">
<a class="link-text" @click="goCardKeyPool(row)">卡池管理</a>
</template>
</template>
</div>
</template>
</el-table-column>
</el-table>
@@ -753,6 +750,19 @@ onMounted(() => {
white-space: normal;
line-height: 1.5;
}
// 分类完整换行,不使用省略号;操作按钮保持可读,额外入口允许换行。
:deep(.goods-category-column .cell) {
white-space: normal;
overflow-wrap: anywhere;
line-height: 1.6;
}
.goods-actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 4px 12px;
.link-text { white-space: nowrap; }
}
.link-text {
color: #409eff;
cursor: pointer;

View File

@@ -0,0 +1,92 @@
<!--
商家功能搜索:仅检索当前账号已授权且已注册的菜单,支持键盘选择。
@author Bulbasaur
@since 2026-11-08
-->
<template>
<button type="button" class="menu-search-trigger" aria-label="搜索功能菜单" @click="openSearch">
<el-icon :size="17"><Search /></el-icon><span>搜索功能菜单</span><kbd>/</kbd>
</button>
<el-dialog v-model="visible" class="seller-menu-search" title="搜索功能" width="560px" align-center
@opened="focusInput" @closed="keyword = ''">
<el-input ref="inputRef" v-model="keyword" placeholder="搜索菜单,例如:商品、订单、优惠券" size="large" clearable
aria-label="搜索菜单" @keydown.down.prevent="moveSelection(1)" @keydown.up.prevent="moveSelection(-1)"
@keydown.enter.prevent="navigate(results[selected])">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<div class="search-summary">{{ keyword.trim() ? `找到 ${results.length} 个功能` : '全部可用功能' }}</div>
<div ref="resultsRef" class="search-results">
<button v-for="(item, index) in results" :key="`${item.topName}-${item.name}`" type="button"
class="search-result" :class="{ selected: selected === index }" @click="navigate(item)" @mouseenter="selected = index">
<div><strong>{{ item.title }}</strong><span>{{ item.topTitle }} / {{ item.groupTitle }}</span></div>
<el-icon><ArrowRight /></el-icon>
</button>
<el-empty v-if="!results.length" :image-size="72" description="没有匹配的功能,试试其他关键词" />
</div>
<template #footer><div class="search-help"><span>↑ ↓ 选择</span><span>Enter 打开</span><span>Esc 关闭</span></div></template>
</el-dialog>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { Search, ArrowRight } from "@element-plus/icons-vue";
import { useAppStore } from "@/stores/app";
import { flattenNavigation, searchNavigation } from "@/libs/seller-navigation";
const router = useRouter();
const appStore = useAppStore();
const visible = ref(false);
const keyword = ref("");
const selected = ref(0);
const inputRef = ref(null);
const resultsRef = ref(null);
const items = computed(() => flattenNavigation(appStore.navigationMenus).filter((item) => router.hasRoute(item.name)));
const results = computed(() => searchNavigation(items.value, keyword.value));
/** 每次打开清空上次关键词,避免残留搜索状态。 */
function openSearch() { keyword.value = ""; selected.value = 0; visible.value = true; }
/** 等弹层完成动画后聚焦输入框。 */
function focusInput() { inputRef.value?.focus(); }
/** 键盘移动候选项并滚动到可见区域;空列表不产生无效索引。 */
async function moveSelection(step) {
if (!results.value.length) return;
selected.value = (selected.value + step + results.value.length) % results.value.length;
await nextTick();
resultsRef.value?.children[selected.value]?.scrollIntoView({ block: "nearest" });
}
/** 只允许打开当前权限搜索结果;清空关闭的弹层避免遮挡业务页面。 */
function navigate(item) {
if (!item || !items.value.some((entry) => entry.name === item.name)) return;
visible.value = false;
router.push({ name: item.name });
}
/** 仅在非编辑区域按 / 唤起搜索,不截获输入框、富文本和其他弹窗内的按键。 */
function handleShortcut(event) {
if (event.key !== "/" || event.ctrlKey || event.metaKey || event.altKey || visible.value ||
event.target.closest?.('input, textarea, select, [contenteditable="true"], [role="dialog"]')) return;
event.preventDefault();
openSearch();
}
watch(results, () => { selected.value = 0; });
watch(() => appStore.navigationMenus, () => { visible.value = false; keyword.value = ""; });
onMounted(() => window.addEventListener("keydown", handleShortcut));
onBeforeUnmount(() => window.removeEventListener("keydown", handleShortcut));
</script>
<style lang="scss" scoped>
.menu-search-trigger { width: 240px; height: 34px; display: flex; align-items: center; gap: 9px; padding: 0 12px; border: 1px solid #eceef3; border-radius: 7px; background: #f7f8fb; color: #969cab; font: inherit; font-size: 12px; cursor: pointer;
&:hover { border-color: #c5d2ff; color: #315efb; }
&:focus-visible { outline: 2px solid #315efb; }
kbd { margin-left: auto; padding: 0 5px; border: 1px solid #e0e4ed; border-radius: 3px; background: #fff; font-family: inherit; }
}
.search-summary { padding: 18px 2px 8px; font-size: 12px; color: #969cab; }
.search-results { max-height: 380px; overflow-y: auto; }
.search-result { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 12px; margin: 3px 0; border: 0; border-radius: 7px; background: #fff; text-align: left; cursor: pointer; color: #858c9c;
strong { display: block; font-size: 14px; font-weight: 500; color: #303749; }
span { display: block; margin-top: 5px; font-size: 12px; }
&.selected, &:focus-visible { background: #eef2ff; strong { color: #315efb; } }
}
.search-help { display: flex; gap: 20px; color: #969cab; font-size: 12px; }
@media (max-width: 1100px) { .menu-search-trigger { width: 180px; } }
</style>

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>

View File

@@ -29,10 +29,11 @@
top: 0;
left: 0;
z-index: 21;
width: 180px;
width: 300px;
transition: width 0.3s; // background: rgb(73, 80, 96)
background: #fff;
box-shadow: rgba(0, 21, 41, 0.35) 2px 0px 6px;
border-right: 1px solid #e9ecf2;
box-shadow: none;
}
.layout-text {
@@ -53,11 +54,12 @@
box-sizing: border-box;
position: fixed;
display: block;
padding-left: 180px;
padding-left: 300px;
width: 100%;
height: 106px;
z-index: 20;
box-shadow: 0 2px 1px 1px rgba(100, 100, 100, 0.1);
border-bottom: 1px solid #e9ecf2;
box-shadow: none;
transition: padding 0.3s;
}
@@ -84,7 +86,7 @@
.tags-con {
height: 46px;
z-index: -1;
background: #f0f0f0;
background: #f7f8fa;
display: flex;
align-items: center;
@@ -123,10 +125,11 @@
}
&-header {
min-width: 740px;
min-width: 0;
height: 60px;
background: #fff;
box-shadow: 0 2px 1px 1px rgba(100, 100, 100, 0.1);
border-bottom: 1px solid #e9ecf2;
box-shadow: none;
position: relative;
z-index: 11;
@@ -153,7 +156,8 @@
display: flex;
align-items: center;
padding: 0 30px;
padding: 0 24px;
gap: 20px;
justify-content: space-between;
height: 100%;
@@ -227,7 +231,7 @@
.single-page-con {
position: absolute;
left: 180px;
left: 300px;
right: 0;
top: 106px;
bottom: 0;
@@ -235,7 +239,7 @@
min-width: 0;
overflow-x: hidden;
overflow-y: auto;
background-color: #f0f0f0;
background-color: #f7f8fa;
z-index: 1;
transition: left 0.3s;
box-sizing: border-box;
@@ -247,22 +251,6 @@
}
}
&.menu-collapsed {
.sidebar-menu-con {
width: 80px;
}
.main-header-con {
padding-left: 80px;
}
.single-page-con {
left: 80px;
right: 0;
width: auto;
}
}
&-copy {
text-align: center;
padding: 10px 0 20px;
@@ -276,19 +264,23 @@
.logo-con {
width: 100%;
height: 60px;
padding: 8px;
text-align: center;
border-bottom: 1px solid #eee;
height: 64px;
box-sizing: border-box;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 12px;
overflow: hidden;
border-bottom: 1px solid #e9ecf2;
img {
height: 44px;
width: auto;
max-height: 36px;
max-width: 48px;
object-fit: contain;
}
}
.menu-bar {
overflow: auto;
overflow-x: hidden;
overflow: visible;
}
.menu-bar::-webkit-scrollbar {
@@ -339,3 +331,32 @@
justify-content: center;
z-index: 999;
}
// 导航与顶部栏的样式局限于商家外层布局,不覆盖业务页面的组件主题。
.main {
.brand-caption { color: #252b3a; font-size: 15px; font-weight: 600; white-space: nowrap; }
.header-page-info { display: flex; align-items: center; gap: 12px; min-width: 0; font-size: 13px;
strong { color: #303749; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}
.workspace-label { color: #969cab; white-space: nowrap; }
.header-divider { color: #d9dde6; }
.user-dropdown-innercon { flex-wrap: nowrap; gap: 16px; }
.header-store-name { margin-left: 9px; max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; color: #525a6b; }
.header-avator-con .nav-item { margin: 0; width: auto; height: auto; border: 0; border-radius: 0; }
.header-icon-button { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; border: 1px solid #eceef3; border-radius: 6px; background: #fff; color: #626879; cursor: pointer;
&:hover { background: #f5f7fb; color: #315efb; }
&:focus-visible { outline: 2px solid #315efb; }
}
.single-page-con .single-page { margin: 16px; }
}
@media (max-width: 1200px) {
.main {
.header-avator-con { padding: 0 16px; gap: 12px; }
.user-dropdown-innercon { gap: 10px; }
.workspace-label, .header-divider, .header-store-name { display: none; }
.single-page-con .single-page { margin: 12px; }
}
}
@media (prefers-reduced-motion: reduce) {
.main .sidebar-menu-con, .main-header-con, .main .single-page-con { transition: none; }
}