mirror of
https://gitee.com/beijing_hongye_huicheng/lilishop-ui.git
synced 2026-11-10 02:49:03 +08:00
feat(seller): 调整商家菜单布局与商品列表展示
This commit is contained in:
47
seller/scripts/test-seller-navigation.mjs
Normal file
47
seller/scripts/test-seller-navigation.mjs
Normal 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);
|
||||
});
|
||||
@@ -31,6 +31,6 @@ module.exports = {
|
||||
*/
|
||||
mainColor: "#ff5c58", //主题色
|
||||
setting: {
|
||||
isUseTabsRouter: true, //多标签Tab页模式
|
||||
isUseTabsRouter: false, // 多标签页内嵌模式默认关闭,用户可在页面配置中开启。
|
||||
},
|
||||
};
|
||||
|
||||
55
seller/src/libs/seller-navigation.js
Normal file
55
seller/src/libs/seller-navigation.js
Normal 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));
|
||||
});
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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" }];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
92
seller/src/views/main-components/menu-search.vue
Normal file
92
seller/src/views/main-components/menu-search.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user