feat: 客户门户可折叠侧栏导航,去掉页签并区分打印入口提示 #11

Merged
zzh merged 6 commits from test into master 2026-10-24 19:13:04 +08:00
18 changed files with 1892 additions and 48 deletions
Showing only changes of commit 88909258cc - Show all commits

View File

@ -27,19 +27,31 @@
```
a-layout (垂直)
├── TopNav(顶部导航,fixed)
├── TopNav(仅运营端,fixed)
├── a-layout-content
│ ├── paddingTop: 顶部导航高度 + Tab 栏高度(可选)
│ └── PageLayout(router-view + keep-alive)
│ ├── 运营端:paddingTop = 顶栏 + Tab;PageLayout
│ └── 客户门户:CustomerSideRail(全高)+ PageLayout(无顶栏、无多页签)
└── Footer(可选)
```
运营端布局 DOM/样式与改前一致;客户门户不挂载 TopNav / TabBar。
**约定:**
- 主内容背景色:`#f0f3f4`
- 页面容器使用 `[class$='-container']`,统一 `padding: 16px`
- 内容区域使用 `overflow: hidden`、`height: 100%` 保证高度正确
**客户门户 vs 运营端导航(差异约定):**
| | 运营端 | 客户门户 |
|---|---|---|
| 顶栏 | 一级模块 + hover 二级 + 右侧余额/打印/用户 | **无顶栏** |
| 页签 | TabBar 多页签 | **无多页签**(当前页仅由侧栏高亮表达) |
| 侧栏 | 无全局侧栏 | **全高可折叠左栏**(默认展开):顶 Logo;中按「常用 / 仓储 / 更多」浅分组;**资产与账户收进用户下拉**;底沉余额/打印(含下载)/消息/仓库;**语言收进用户菜单** |
| 高亮 | 路由最长路径匹配 | 侧栏最长路径匹配;账户类路由高亮底栏用户区 |
| 数据 | RBAC 菜单树原样 | 仍用同一菜单树,`flatMap` 二级即可,不改表结构 |
### 2.2 页面内容结构
单页内容推荐结构:

1
src/components.d.ts vendored
View File

@ -18,6 +18,7 @@ declare module 'vue' {
CodeMirror: typeof import('./components/CodeMirror/index.vue')['default']
CompleteContactModal: typeof import('./components/CompleteContactModal/index.vue')['default']
ContactService: typeof import('./components/ContactService/index.vue')['default']
CustomerSideRail: typeof import('./components/customer-side-rail/index.vue')['default']
CustomLeftMenu: typeof import('./components/CustomLeftMenu/index.vue')['default']
CustomList: typeof import('./components/CustomList/index.vue')['default']
DrawerPro: typeof import('./components/DrawerPro/index.vue')['default']

View File

@ -4,12 +4,18 @@ import { useI18n } from 'vue-i18n'
import { getWarehouseList } from '@/api/system/warehouse'
import { useCurrentWarehouseStore } from '@/store'
withDefaults(
const props = withDefaults(
defineProps<{
variant?: 'navbar' | 'login'
variant?: 'navbar' | 'login' | 'sider'
/** 下拉位置;侧栏沉底用 rt */
dropdownPosition?: 'br' | 'bl' | 'tr' | 'tl' | 'rt' | 'rb'
/** 侧栏收起:窄宽展示缩略文案(无图标) */
compact?: boolean
}>(),
{
variant: 'navbar',
dropdownPosition: 'br',
compact: false,
},
)
@ -22,6 +28,17 @@ const displayName = computed(
t('nav.contactService.selectWarehouse'),
)
/** 收起时只露一字,避免无图标时空块 */
const labelText = computed(() => {
const name = displayName.value
if (props.variant === 'sider' && props.compact) {
return name.slice(0, 1) || '仓'
}
return name
})
const useDropdown = computed(() => props.variant === 'navbar' || props.variant === 'sider')
async function loadServiceDataList() {
try {
const { items } = await getWarehouseList({
@ -51,15 +68,22 @@ onMounted(() => {
</script>
<template>
<div class="contact-service" :class="`contact-service--${variant}`">
<!-- 顶栏:与语言切换一致,悬停出菜单,无箭头/无 icon -->
<div
class="contact-service"
:class="[
`contact-service--${variant}`,
{ 'is-compact': compact },
]"
>
<a-dropdown
v-if="variant === 'navbar'"
v-if="useDropdown"
trigger="hover"
position="br"
:position="dropdownPosition"
:popup-max-height="320"
>
<span class="contact-service__label">{{ displayName }}</span>
<span class="contact-service__label" :title="displayName">
<span class="contact-service__text">{{ labelText }}</span>
</span>
<template #content>
<a-doption
v-for="opt in warehouseStore.options"
@ -73,7 +97,6 @@ onMounted(() => {
</template>
</a-dropdown>
<!-- 登录页等:保留选择器 -->
<a-select
v-else
:model-value="warehouseStore.warehouseId"
@ -98,11 +121,18 @@ onMounted(() => {
display: inline-flex;
align-items: center;
flex-shrink: 0;
min-width: 0;
&__select {
min-width: 120px;
}
&__text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&--navbar {
color: #fff;
@ -128,6 +158,38 @@ onMounted(() => {
}
}
&--sider {
width: 100%;
.contact-service__label {
display: inline-flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 32px;
padding: 0 8px;
border-radius: 8px;
font-size: 12px;
color: var(--color-text-2);
cursor: pointer;
transition: background-color 0.14s ease, color 0.14s ease;
&:hover {
color: var(--color-text-1);
background: rgba(255, 255, 255, 0.72);
}
}
&.is-compact .contact-service__label {
justify-content: center;
padding: 0 2px;
min-width: 28px;
min-height: 28px;
font-size: 12px;
font-weight: 600;
}
}
&--login {
:deep(.arco-select-view) {
min-width: 120px;

View File

@ -2,7 +2,7 @@
<a-modal
:visible="visible"
:title="modalTitle"
:width="mode === 'batch' ? 640 : 480"
:width="mode === 'batch' ? 760 : 600"
:mask-closable="false"
unmount-on-close
modal-class="order-print-modal"
@ -139,6 +139,7 @@ import {
type HiprintPrinterInfo,
} from '@/utils/hiprint/hiprintElectronSocket';
import { useHiprintClientState } from '@/utils/hiprint/hiprintClientState';
import { tPrintControlHint } from '@/utils/hiprint/printControlHints';
import { resolveOmsAssetUrl } from '@/api/oms/common';
import PrintOptionsForm from '@/components/PrintTemplate/PrintOptionsForm.vue';
import { fetchPrintAssetBytes, openPrintAssetInNewTab } from '@/utils/printPreview';
@ -243,7 +244,7 @@ const printerStatusText = computed(() => {
if (hiprintClientState.connecting) return '正在连接本机打印程序…';
if (hiprintClientState.connected) return '本机打印已就绪,可一键自动打印';
if (hiprintClientState.enabled) return '打印程序连接中,请稍候…';
return '未连接,请先打开页面顶部「打印」开关';
return tPrintControlHint('modalOff');
});
const footerBusy = computed(() => printingActive.value || printingAll.value);
@ -551,7 +552,7 @@ async function printAll() {
.print-status-bar__right {
flex-shrink: 0;
width: 180px;
width: 220px;
}
.print-printer-select {

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,10 @@
<template>
<!-- Top-nav inbox / announcement entry (unread counts stay here, not on Nana FAB). -->
<a-dropdown trigger="hover" placement="bottomRight" @visible-change="onMessageDropdownVisibleChange">
<a-dropdown
trigger="hover"
:position="dropdownPosition"
@visible-change="onMessageDropdownVisibleChange"
>
<div class="message-dropdown-trigger" :title="t('navbar.message')">
<IconNotification class="message-icon" :class="totalCount > 0 ? 'theMessage' : ''" />
<span v-if="totalCount > 0" class="message-count">({{ displayCount }})</span>
@ -17,11 +21,21 @@
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { IconNotification } from '@arco-design/web-vue/es/icon';
import { useInboxUnread } from '@/composables/useInboxUnread';
const props = withDefaults(
defineProps<{
dropdownPosition?: 'br' | 'bl' | 'tr' | 'tl' | 'rt' | 'rb';
}>(),
{ dropdownPosition: 'br' },
);
const dropdownPosition = computed(() => props.dropdownPosition);
const router = useRouter();
const { t } = useI18n();
const { notificationCount, noticeCount, totalCount, displayCount, refreshUnread } = useInboxUnread();

View File

@ -1,20 +1,34 @@
<template>
<a-dropdown
trigger="hover"
position="br"
:position="dropdownPosition"
@select="onDropdownSelect"
>
<div class="user-dropdown-trigger">
<div class="user-dropdown-trigger" :class="{ 'is-compact': compact }">
<img
v-if="showCustomerAvatar"
:src="customerAvatarUrl"
alt=""
class="user-avatar"
/>
<span class="user-name">{{ displayName }}</span>
<span v-else class="user-avatar user-avatar--fallback">{{ displayNameInitial }}</span>
<span v-if="!compact" class="user-name">{{ displayName }}</span>
</div>
<template #content>
<a-doption v-if="isCustomerPortal" value="userinfo">
<!-- 侧栏传入的资产与账户入口(替代侧栏「资产与账户」分组) -->
<a-doption
v-for="item in accountMenus"
:key="accountMenuKey(item)"
:value="accountMenuKey(item)"
>
<span class="user-menu-item">
<span class="user-menu-item__icon">
<component :is="getSubMenuIcon(item, undefined, MENU_NAME_TO_LOCALE_KEY)" />
</span>
<span class="user-menu-item__label">{{ formatAccountTitle(item) }}</span>
</span>
</a-doption>
<a-doption v-if="isCustomerPortal && !hasAccountProfileItem" value="userinfo">
<span class="user-menu-item">
<span class="user-menu-item__icon"><i class="fa fa-user" /></span>
<span class="user-menu-item__label">{{ t('menu.user.profile') }}</span>
@ -38,6 +52,21 @@
<span class="user-menu-item__label">页面配置</span>
</span>
</a-doption>
<template v-if="showLanguageMenu">
<a-doption
v-for="item in languageOptions"
:key="item.value"
:value="`lang:${item.value}`"
>
<span class="user-menu-item">
<span class="user-menu-item__icon">{{ item.icon }}</span>
<span class="user-menu-item__label">
{{ item.label }}
<span v-if="locale === item.value" class="user-menu-item__hint">✓</span>
</span>
</span>
</a-doption>
</template>
<a-doption value="logout">
<span class="user-menu-item is-danger">
<span class="user-menu-item__icon"><i class="fa fa-sign-in" /></span>
@ -116,21 +145,97 @@ import {
import { CUSTOMER_BALANCE_CHANGED_EVENT } from '@/api/customer/accountFlow';
import { getMapIdFromAccessToken } from '@/utils/jwt';
import { isValidAvatarUrl, resolveAvatarUrl } from '@/utils/avatar';
import useLocale from '@/hooks/locale';
import { resolvePostLogoutLogin } from '@/utils/login-portal';
import { openPortalSideDrawer } from '@/views/user/portal-drawer/usePortalSideDrawer';
import { resolvePortalDrawerPanel } from '@/views/user/portal-drawer/types';
import { getSubMenuIcon } from '@/utils/menu-icon';
import { MENU_NAME_TO_LOCALE_KEY, translateMenuTitle } from '@/utils/menu-i18n';
import {
getCustomerNavPath,
type CustomerNavItem,
} from '@/utils/customer-portal-nav';
const { t } = useI18n();
const props = withDefaults(
defineProps<{
/** 侧栏收起等场景只显示头像 */
compact?: boolean
/** 下拉相对触发器位置,侧栏用 rt */
dropdownPosition?: 'br' | 'bl' | 'tr' | 'tl' | 'rt' | 'rb'
/** 客户侧栏:资产与账户菜单,收进用户下拉 */
accountMenus?: CustomerNavItem[]
/** 客户侧栏:语言收进用户菜单,不再单独占底栏 */
showLanguageMenu?: boolean
}>(),
{
compact: false,
dropdownPosition: 'br',
accountMenus: () => [],
showLanguageMenu: false,
},
);
const { t, locale } = useI18n();
const { changeLocale } = useLocale();
const router = useRouter();
const userStore = useUserStore();
const appStore = useAppStore();
const rbacUserStore = useRbacUserStore();
const showLanguageMenu = computed(() => props.showLanguageMenu);
const languageOptions = [
{ value: 'zh-CN', label: '中文', icon: '🇨🇳' },
{ value: 'en-US', label: 'English', icon: '🇺🇸' },
] as const;
const isCustomerPortal = computed(() => rbacUserStore.isCustomerPortal());
const displayName = computed(() => {
return userStore.name || '--';
});
const displayNameInitial = computed(() => {
const name = String(displayName.value || '').trim();
return name ? name.slice(0, 1).toUpperCase() : '?';
});
const dropdownPosition = computed(() => props.dropdownPosition);
const compact = computed(() => props.compact);
const accountMenus = computed(() => props.accountMenus || []);
const hasAccountProfileItem = computed(() =>
accountMenus.value.some((item) => {
const path = getCustomerNavPath(item);
return path === '/user/profile' || resolvePortalDrawerPanel(path) === 'profile';
}),
);
function accountMenuKey(item: CustomerNavItem) {
return `account:${getCustomerNavPath(item) || item.id || item.name}`;
}
function formatAccountTitle(item: CustomerNavItem) {
return translateMenuTitle(t, {
name: item.name,
route: item.route || item.path,
path: item.path || item.route,
meta: item.meta,
});
}
function openAccountMenuItem(item: CustomerNavItem) {
let targetPath = getCustomerNavPath(item);
if (!targetPath) return;
if (!targetPath.startsWith('/')) targetPath = `/${targetPath}`;
const portalPanel = resolvePortalDrawerPanel(targetPath);
if (portalPanel) {
openPortalSideDrawer(portalPanel);
return;
}
if (router.currentRoute.value.path === targetPath) return;
router.push(targetPath).catch(() => undefined);
}
const customerAvatarRaw = ref('');
const customerAvatarUrl = computed(() =>
@ -272,6 +377,20 @@ const onDropdownSelect = (value: string | number | Record<string, unknown> | und
const onMenuClick = async (event: { key: string }) => {
const { key } = event;
if (key.startsWith('account:')) {
const path = key.slice('account:'.length);
const item = accountMenus.value.find(
(m) => getCustomerNavPath(m) === path || String(m.id || m.name) === path,
);
if (item) openAccountMenuItem(item);
return;
}
if (key.startsWith('lang:')) {
changeLocale(key.slice('lang:'.length));
return;
}
if (key === 'logout') {
const loginType = rbacUserStore.resolveLoginTypeForRedirect();
const currentPath = router.currentRoute.value.fullPath;
@ -309,6 +428,14 @@ const onMenuClick = async (event: { key: string }) => {
transition: background-color 0.15s ease, opacity 0.15s ease;
}
.user-dropdown-trigger.is-compact {
max-width: none;
width: 32px;
height: 32px;
padding: 0;
justify-content: center;
}
.user-dropdown-trigger:hover {
background-color: rgba(255, 255, 255, 0.12);
}
@ -322,6 +449,16 @@ const onMenuClick = async (event: { key: string }) => {
background: rgba(255, 255, 255, 0.16);
}
.user-avatar--fallback {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
color: var(--color-text-1, #1d2129);
background: var(--color-fill-3, #e5e6eb);
}
.user-name {
white-space: nowrap;
overflow: hidden;
@ -334,7 +471,8 @@ const onMenuClick = async (event: { key: string }) => {
.user-menu-item {
display: flex;
align-items: center;
width: 132px;
min-width: 148px;
max-width: 220px;
box-sizing: border-box;
}
@ -346,6 +484,7 @@ const onMenuClick = async (event: { key: string }) => {
justify-content: center;
margin-right: 10px;
font-size: 14px;
color: var(--color-text-3);
}
.user-menu-item__label {
@ -355,6 +494,12 @@ const onMenuClick = async (event: { key: string }) => {
text-align: left;
}
.user-menu-item__hint {
margin-left: 8px;
color: var(--app-primary-color, rgb(var(--primary-6)));
font-weight: 600;
}
.user-menu-item.is-danger .user-menu-item__label {
color: rgb(var(--red-6));
}

View File

@ -1,5 +1,5 @@
<template>
<a-dropdown trigger="hover" placement="bottomRight">
<a-dropdown trigger="hover" :position="dropdownPosition">
<span class="dropDown" :title="$t('app.language', '语言')">
<icon-language class="lang-icon" />
</span>
@ -20,6 +20,7 @@
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { IconLanguage } from '@arco-design/web-vue/es/icon';
import useLocale from '@/hooks/locale';
@ -28,10 +29,14 @@ const props = withDefaults(
defineProps<{
/** 为 true 时不弹出「已切换语言」提示,适合登录页等需要无感切换的场景 */
quiet?: boolean;
/** 下拉位置;侧栏沉底用 rt */
dropdownPosition?: 'br' | 'bl' | 'tr' | 'tl' | 'rt' | 'rb';
}>(),
{ quiet: false },
{ quiet: false, dropdownPosition: 'br' },
);
const dropdownPosition = computed(() => props.dropdownPosition);
const { locale } = useI18n();
const { changeLocale } = useLocale();

View File

@ -122,7 +122,24 @@
更多
</span>
<template #content>
<div class="nav-more-panel">
<!-- 客户扁平导航:溢出项直接列表,不按一级分组 -->
<div v-if="isCustomerFlatNav" class="nav-more-panel nav-more-panel--flat">
<div
v-for="item in overflowTopMenus"
:key="menuKey(item)"
class="nav-more-item"
:class="{ active: isTopMenuActive(item) }"
@pointerenter="prefetchMenuRoute(item)"
@pointerdown.prevent="gotoFromSubmenu(item)"
@click.prevent="gotoFromSubmenu(item)"
>
<span class="nav-more-item__icon">
<component :is="getSubMenuIcon(item, undefined, menuNameToLocaleKeyMap)" />
</span>
<span class="nav-more-item__text">{{ formatMenuTitle(item) }}</span>
</div>
</div>
<div v-else class="nav-more-panel">
<div
v-for="item in overflowTopMenus"
:key="menuKey(item)"
@ -281,6 +298,10 @@ import { getMapIdFromAccessToken } from '@/utils/jwt';
import { getSubMenuIcon } from '@/utils/menu-icon';
import { MENU_NAME_TO_LOCALE_KEY, translateMenuTitle } from '@/utils/menu-i18n';
import { canAccessCustomerPortalRoute, isAdminPortalOnlyPath, isCustomerPortalOnlyPath } from '@/utils/login-portal';
import {
buildCustomerFlatNavMenus,
pickCustomerTopNavMenus,
} from '@/utils/customer-portal-nav';
import { resolveCurrentCustomer } from '@/api/customer/resolve';
import { CUSTOMER_BALANCE_CHANGED_EVENT } from '@/api/customer/accountFlow';
import { resolvePortalDrawerPanel } from '@/views/user/portal-drawer/types';
@ -602,9 +623,33 @@ const menuData = computed(() => {
return convertMenuTreeToNavMenu(userMenus);
});
const visibleTopMenus = computed(() =>
menuData.value.filter((item: any) => !item.meta?.isHide),
);
/** 客户门户:压平后的全量二级(侧栏索引同源) */
const customerMenuSource = computed(() => {
const userMenus = userMenuTreeData.value;
if (!userMenus.length) return [];
if (allMenuTreeData.value.length > 0) {
return filterMenuByUserPermission(allMenuTreeData.value, userMenus);
}
return userMenus;
});
const customerFlatMenus = computed(() => {
if (!rbacUserStore.isCustomerPortal()) return [];
return buildCustomerFlatNavMenus(
customerMenuSource.value,
rbacUserStore.loginPortalType,
);
});
const isCustomerFlatNav = computed(() => rbacUserStore.isCustomerPortal());
const visibleTopMenus = computed(() => {
// 客户 P0/P1:顶栏只平铺高频二级,不再展示「订单/仓储/系统」等一级模块名
if (isCustomerFlatNav.value) {
return pickCustomerTopNavMenus(customerFlatMenus.value);
}
return menuData.value.filter((item: any) => !item.meta?.isHide);
});
const MORE_MENU_KEY = '__more__';
const MORE_BTN_RESERVE = 88;
@ -1675,6 +1720,18 @@ onUnmounted(() => {
background: var(--app-elevated-bg, var(--app-card-bg));
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
&--flat {
min-width: 180px;
.nav-more-item {
padding-left: 10px;
&::before {
display: none;
}
}
}
}
.nav-more-group {

View File

@ -1,15 +1,30 @@
<template>
<a-layout class="layout">
<!-- 顶部导航 -->
<TopNav />
<!-- 内容区域 -->
<a-layout :style="contentStyle" class="layout-content">
<!-- 运营端顶栏;客户门户无顶栏(能力沉底侧栏) -->
<TopNav v-if="!isCustomerShell" />
<!-- 客户门户:全高侧栏 + 主区(无多页签,当前页由侧栏高亮) -->
<a-layout
v-if="isCustomerShell"
class="layout-content layout-content--customer"
>
<CustomerSideRail />
<a-layout class="layout-main-wrap">
<a-layout-content class="layout-main">
<PageLayout />
</a-layout-content>
<Footer v-if="footer" />
</a-layout>
</a-layout>
<!-- 运营端:与改前一致 -->
<a-layout v-else :style="adminContentStyle" class="layout-content">
<a-layout-content>
<PageLayout />
</a-layout-content>
<Footer v-if="footer" />
</a-layout>
<NanaAssistant />
<PortalSideDrawer />
<HelpSideDrawer />
@ -20,7 +35,9 @@
import { computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useAppStore } from '@/store';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import TopNav from '@/components/top-nav/index.vue';
import CustomerSideRail from '@/components/customer-side-rail/index.vue';
import Footer from '@/components/footer/index.vue';
import NanaAssistant from '@/components/NanaAssistant/index.vue';
import PortalSideDrawer from '@/views/user/portal-drawer/PortalSideDrawer.vue';
@ -31,6 +48,7 @@
import { setRapidPackageHostCookie } from '@/utils/rapidPackagePluginHost';
const appStore = useAppStore();
const rbacUserStore = useRbacUserStore();
const route = useRoute();
const permission = usePermission();
useResponsive(true);
@ -38,15 +56,18 @@
onMounted(() => {
setRapidPackageHostCookie();
});
const footer = computed(() => appStore.footer && !route.path.startsWith('/assistant'));
// 内容区域样式:顶部留出导航栏空间
const contentStyle = computed(() => {
const headerHeight = 50; // 顶部导航高度
const tabNavHeight = appStore.tabBar ? 36 : 0; // Tab导航高度
const paddingTop = headerHeight + tabNavHeight;
return { paddingTop: `${paddingTop}px` };
const isCustomerShell = computed(
() => rbacUserStore.isCustomerPortal() && rbacUserStore.isLoggedIn,
);
// 仅运营端需要为 fixed 顶栏留白
const adminContentStyle = computed(() => {
const headerHeight = 50;
const tabNavHeight = appStore.tabBar ? 36 : 0;
return { paddingTop: `${headerHeight + tabNavHeight}px` };
});
</script>
@ -67,19 +88,49 @@
flex-direction: column;
}
/* 运营端:与改前一致 */
.layout-content {
flex: 1;
overflow: hidden;
background-color: var(--app-page-bg);
transition: padding 0.2s cubic-bezier(0.34, 0.69, 0.1, 1);
margin-top: 0; // 顶部导航是 fixed,不需要 margin
margin-top: 0;
display: flex;
flex-direction: column;
:deep(.arco-layout-content) {
flex: 1;
overflow: hidden;
height: 100%;
}
}
/* 客户门户:无顶栏;侧栏与右侧内容卡片同 inset / 圆角 */
.layout-content--customer {
flex-direction: row;
align-items: stretch;
min-height: 0;
padding-top: 0;
gap: 0;
.layout-main-wrap {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
:deep(.arco-layout-content.layout-main),
.layout-main {
flex: 1;
overflow: hidden;
height: 100%;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
}
</style>

View File

@ -1812,7 +1812,21 @@ export default {
'nav.balance.recharge': 'Recharge',
'nav.balance.rechargeRecord': 'Recharge records',
'nav.balance.accountFlow': 'Account transactions',
'nav.customerRail.group.business': 'Tasks',
'nav.customerRail.group.core': 'Essentials',
'nav.customerRail.group.warehouse': 'Warehouse',
'nav.customerRail.group.more': 'More',
'nav.customerRail.group.account': 'Account',
'nav.printControl.menu': 'Print',
'nav.printControl.download': 'Download print control / one-click parcel plugin',
'nav.printControl.hint.statusOff.admin': 'Printer not connected — enable Print in the top bar',
'nav.printControl.hint.statusOff.customer': 'Printer not connected — open Print in the left menu and connect',
'nav.printControl.hint.modalOff.admin': 'Not connected — enable Print in the top bar first',
'nav.printControl.hint.modalOff.customer': 'Not connected — open Print in the left menu and connect first',
'nav.printControl.hint.dispatchError.admin': 'Enable Print in the top bar and connect the local print app first',
'nav.printControl.hint.dispatchError.customer': 'Open Print in the left menu and connect the local print app first',
'nav.printControl.hint.pluginDownload.admin': 'Download it from the top-right icon',
'nav.printControl.hint.pluginDownload.customer': 'Download it from Print in the left menu',
// Order - My Orders
'order.myOrders.tabs.all': 'All',

View File

@ -1567,7 +1567,21 @@ export default {
'nav.balance.recharge': '去充值',
'nav.balance.rechargeRecord': '充值记录',
'nav.balance.accountFlow': '资金流水',
'nav.customerRail.group.business': '办事',
'nav.customerRail.group.core': '常用',
'nav.customerRail.group.warehouse': '仓储',
'nav.customerRail.group.more': '更多',
'nav.customerRail.group.account': '资产与账户',
'nav.printControl.menu': '打印',
'nav.printControl.download': '下载打印控件/一键添加包裹插件',
'nav.printControl.hint.statusOff.admin': '未连接打印,请打开页面顶部「打印」开关',
'nav.printControl.hint.statusOff.customer': '未连接打印,请打开左侧菜单「打印」并连接',
'nav.printControl.hint.modalOff.admin': '未连接,请先打开页面顶部「打印」开关',
'nav.printControl.hint.modalOff.customer': '未连接,请先打开左侧菜单「打印」并连接',
'nav.printControl.hint.dispatchError.admin': '请先打开页面顶部「打印」开关并连接本机打印程序',
'nav.printControl.hint.dispatchError.customer': '请先打开左侧菜单「打印」并连接本机打印程序',
'nav.printControl.hint.pluginDownload.admin': '去右上角下载插件',
'nav.printControl.hint.pluginDownload.customer': '去左侧「打印」下载插件',
// 订单 - 批量任务
'order.batchJobs.filter.jobId': '任务 ID',

View File

@ -0,0 +1,334 @@
import type { OmsLoginType } from '@/api/rbac'
import type { MenuTreeDto } from '@/api/rbac'
import { canAccessCustomerPortalRoute, isAdminPortalOnlyPath, isCustomerPortalOnlyPath } from '@/utils/login-portal'
/** 客户顶栏高频入口(路径越靠前优先级越高) */
export const CUSTOMER_TOP_NAV_PRIORITY_PATHS = [
'/home/workplace',
'/home',
'/order/priceCheck',
'/order/placeOrder',
'/order/myOrders',
] as const
export const CUSTOMER_SIDE_RAIL_COLLAPSED_WIDTH = 64
export const CUSTOMER_SIDE_RAIL_EXPANDED_WIDTH = 200
export const CUSTOMER_SIDE_RAIL_STORAGE_KEY = 'oms.customerSideRail.collapsed'
export type CustomerNavItem = {
id?: string | number
name: string
path: string
route?: string
type?: number
meta?: {
title?: string
icon?: string
order?: number
isHide?: boolean
hideInMenu?: boolean
customerPortalOnly?: boolean
adminPortalOnly?: boolean
}
/** 原一级模块名,仅侧栏分组/调试用 */
parentName?: string
}
/**
* 侧栏任务分组(客户心智):
* - core:查价 / 下单 / 订单(最高频)
* - warehouse:仓储相关
* - more:其余办事入口
* - account:资产与账户(可收进用户下拉)
*/
export type CustomerNavGroupId = 'core' | 'warehouse' | 'more' | 'account'
export type CustomerNavGroup = {
id: CustomerNavGroupId
labelKey: string
labelFallback: string
items: CustomerNavItem[]
}
/** 办事主路径:客户最常点的四个入口 */
const CUSTOMER_CORE_NAV_PATHS = [
'/home/workplace',
'/home',
'/order/priceCheck',
'/order/placeOrder',
'/order/myOrders',
] as const
export function getCustomerNavPath(item: { path?: string; route?: string } | null | undefined): string {
return String(item?.path || item?.route || '').trim()
}
/** 收进用户下拉的辅助页(非高频办事入口) */
const USER_MENU_EXTRA_PATH_PREFIXES = [
'/api/openApi',
'/system/announcement',
'/system/warehouseAddress',
] as const
/**
* 余额下拉已承接的资金入口;勿再塞进用户下拉,避免重复。
* 对应:去充值 / 充值记录 / 资金流水
*/
const BALANCE_MENU_PATH_PREFIXES = [
'/user/recharge',
'/finance/rechargeRecord',
'/finance/accountFlow',
] as const
function matchesPathPrefix(path: string, prefix: string): boolean {
return path === prefix || path.startsWith(`${prefix}/`)
}
/** 是否由余额下拉独占(用户菜单应排除) */
export function isCustomerBalanceMenuPath(path: string): boolean {
const p = path.trim()
if (!p) return false
return BALANCE_MENU_PATH_PREFIXES.some((prefix) => matchesPathPrefix(p, prefix))
}
export function resolveCustomerNavGroupId(path: string): CustomerNavGroupId {
const p = path.trim() || '/'
if (p === '/user' || p.startsWith('/user/')) return 'account'
if (p === '/finance' || p.startsWith('/finance/')) return 'account'
if (p === '/marketing' || p.startsWith('/marketing/')) return 'account'
if (USER_MENU_EXTRA_PATH_PREFIXES.some((prefix) => matchesPathPrefix(p, prefix))) {
return 'account'
}
if (p === '/warehouse' || p.startsWith('/warehouse/')) return 'warehouse'
if (CUSTOMER_CORE_NAV_PATHS.some((core) => p === core || p.startsWith(`${core}/`))) {
return 'core'
}
return 'more'
}
/** 是否账户类路由(含资金页,用于底栏高亮) */
export function isCustomerAccountNavPath(path: string): boolean {
return resolveCustomerNavGroupId(path) === 'account'
}
export function partitionCustomerNavMenus(items: CustomerNavItem[]): {
business: CustomerNavItem[]
account: CustomerNavItem[]
} {
const business: CustomerNavItem[] = []
const account: CustomerNavItem[] = []
for (const item of items) {
const g = resolveCustomerNavGroupId(getCustomerNavPath(item))
if (g === 'account') account.push(item)
else business.push(item)
}
return { business, account }
}
const BUSINESS_GROUP_META: Record<
Exclude<CustomerNavGroupId, 'account'>,
{ labelKey: string; labelFallback: string }
> = {
core: {
labelKey: 'nav.customerRail.group.core',
labelFallback: '常用',
},
warehouse: {
labelKey: 'nav.customerRail.group.warehouse',
labelFallback: '仓储',
},
more: {
labelKey: 'nav.customerRail.group.more',
labelFallback: '更多',
},
}
/**
* 侧栏主列表分组。
* foldAccountIntoUserMenu=true 时,「资产与账户」不进侧栏,改由用户下拉承接。
*/
export function groupCustomerNavMenus(
items: CustomerNavItem[],
options?: { foldAccountIntoUserMenu?: boolean },
): CustomerNavGroup[] {
const buckets: Record<CustomerNavGroupId, CustomerNavItem[]> = {
core: [],
warehouse: [],
more: [],
account: [],
}
for (const item of items) {
buckets[resolveCustomerNavGroupId(getCustomerNavPath(item))].push(item)
}
const groups: CustomerNavGroup[] = []
;(['core', 'warehouse', 'more'] as const).forEach((id) => {
if (!buckets[id].length) return
const meta = BUSINESS_GROUP_META[id]
groups.push({
id,
labelKey: meta.labelKey,
labelFallback: meta.labelFallback,
items: buckets[id],
})
})
if (!options?.foldAccountIntoUserMenu && buckets.account.length) {
groups.push({
id: 'account',
labelKey: 'nav.customerRail.group.account',
labelFallback: '资产与账户',
items: buckets.account,
})
}
return groups
}
/** 客户侧栏默认展开:图标轨对首次使用不友好 */
export function readCustomerSideRailCollapsed(defaultCollapsed = false): boolean {
try {
const raw = localStorage.getItem(CUSTOMER_SIDE_RAIL_STORAGE_KEY)
if (raw === null) return defaultCollapsed
return raw === '1' || raw === 'true'
} catch {
return defaultCollapsed
}
}
export function writeCustomerSideRailCollapsed(collapsed: boolean) {
try {
localStorage.setItem(CUSTOMER_SIDE_RAIL_STORAGE_KEY, collapsed ? '1' : '0')
} catch {
// ignore quota / private mode
}
}
function navPriority(path: string): number {
if (!path) return 9999
const idx = CUSTOMER_TOP_NAV_PRIORITY_PATHS.findIndex(
(p) => path === p || path.startsWith(`${p}/`),
)
return idx >= 0 ? idx : 1000
}
export function isCustomerTopNavPriorityPath(path: string): boolean {
return navPriority(path) < 1000
}
function isVisibleNavNode(
item: {
type?: number
name?: string
path?: string
route?: string
meta?: CustomerNavItem['meta']
isHidden?: boolean
},
loginType: OmsLoginType,
): boolean {
if (item.type === 2) return false
if (item.meta?.isHide || item.meta?.hideInMenu || item.isHidden) return false
const path = getCustomerNavPath(item)
if (
!canAccessCustomerPortalRoute(loginType, {
path,
customerPortalOnly: item.meta?.customerPortalOnly,
adminPortalOnly: item.meta?.adminPortalOnly,
})
) {
return false
}
return true
}
function toNavItem(menu: MenuTreeDto, parentName?: string): CustomerNavItem {
const route = String(menu.route || '')
const name = String(menu.name || '').trim() || route
return {
id: menu.id,
name,
path: route,
route,
type: menu.type as number | undefined,
parentName,
meta: {
title: name,
icon: menu.icon || undefined,
order: menu.order,
isHide: menu.isHidden || false,
customerPortalOnly: isCustomerPortalOnlyPath(route),
adminPortalOnly: isAdminPortalOnlyPath(route),
},
}
}
/**
* 将客户菜单树压平为可点击二级页(含无子级的一级,如首页)。
* 不改 RBAC 结构,仅改变客户门户展示。
*/
export function buildCustomerFlatNavMenus(
menuTree: MenuTreeDto[],
loginType: OmsLoginType,
): CustomerNavItem[] {
const result: CustomerNavItem[] = []
const seen = new Set<string>()
const push = (item: CustomerNavItem) => {
const path = getCustomerNavPath(item)
if (!path || seen.has(path)) return
if (!isVisibleNavNode(item, loginType)) return
seen.add(path)
result.push(item)
}
for (const menu of menuTree || []) {
if (!isVisibleNavNode(menu, loginType)) continue
const children = (menu.children || []).filter((c) => isVisibleNavNode(c, loginType))
if (children.length > 0) {
for (const child of children) {
push(toNavItem(child, menu.name))
}
continue
}
// 无二级的一级(首页等)直接作为入口
push(toNavItem(menu))
}
result.sort((a, b) => {
const pa = navPriority(getCustomerNavPath(a))
const pb = navPriority(getCustomerNavPath(b))
if (pa !== pb) return pa - pb
const oa = a.meta?.order ?? 0
const ob = b.meta?.order ?? 0
if (oa !== ob) return oa - ob
return getCustomerNavPath(a).localeCompare(getCustomerNavPath(b))
})
return result
}
/** 顶栏只展示高频入口;其余进侧栏全量索引 */
export function pickCustomerTopNavMenus(flatMenus: CustomerNavItem[]): CustomerNavItem[] {
return flatMenus.filter((item) => isCustomerTopNavPriorityPath(getCustomerNavPath(item)))
}
/** 最长路径匹配,避免短前缀误高亮 */
export function matchCustomerActiveNavPath(
currentPath: string,
items: Array<{ path?: string; route?: string }>,
): string {
let best = ''
let bestLen = -1
for (const item of items) {
const path = getCustomerNavPath(item)
if (!path) continue
if (
(currentPath === path || currentPath.startsWith(`${path}/`)) &&
path.length > bestLen
) {
bestLen = path.length
best = path
}
}
return best
}

View File

@ -7,6 +7,7 @@ import {
hiprintSendHtmlPrint,
} from '@/utils/hiprint/hiprintElectronSocket';
import { useHiprintClientState } from '@/utils/hiprint/hiprintClientState';
import { tPrintControlHint } from '@/utils/hiprint/printControlHints';
import {
buildHiprintBlobPrintExtras,
readHiprintPrintFormFromStorage,
@ -43,7 +44,7 @@ async function sendBytesToHiprint(rawUrl: string, bytes: Uint8Array) {
export async function dispatchPrintUrl(rawUrl: string) {
const hiprintState = useHiprintClientState();
if (!hiprintState.enabled || !hiprintState.connected) {
throw new Error('请先打开页面顶部「打印」开关并连接本机打印程序');
throw new Error(tPrintControlHint('dispatchError'));
}
const url = String(rawUrl || '').trim();

View File

@ -0,0 +1,14 @@
import i18n from '@/locale'
import { useRbacUserStore } from '@/store/modules/rbac-user'
/** 打印入口位置提示:客户侧栏 vs 运营顶栏文案不同 */
export type PrintControlHintKind =
| 'statusOff'
| 'modalOff'
| 'dispatchError'
| 'pluginDownload'
export function tPrintControlHint(kind: PrintControlHintKind): string {
const portal = useRbacUserStore().isCustomerPortal() ? 'customer' : 'admin'
return String(i18n.global.t(`nav.printControl.hint.${kind}.${portal}`))
}

View File

@ -33,7 +33,7 @@
{{
printerReady
? $t('operation.printLabel.printerStatus.ready')
: $t('operation.printLabel.printerStatus.off')
: printerStatusOffText
}}
</span>
<a-checkbox v-model="keepScanFocus" class="pl-keep-focus">
@ -212,6 +212,7 @@ import { useOperationScanFocus } from '@/composables/useOperationScanFocus'
import PrintPreviewPane from '@/components/PrintTemplate/PrintPreviewPane.vue'
import { postOrderPrint } from '@/api/order'
import { useHiprintClientState } from '@/utils/hiprint/hiprintClientState'
import { tPrintControlHint } from '@/utils/hiprint/printControlHints'
import { dispatchPrintUrl } from '@/utils/hiprint/dispatchPrintUrl'
import {
getOrPrefetchPrintPreview,
@ -234,6 +235,7 @@ const { tableContainerRef, tableHeight } = useTableContainerHeight()
const scanInputRef = ref<{ focus?: () => void } | null>(null)
const { keepScanFocus, focusScanInput, maybeRefocusScan } = useOperationScanFocus('printLabel', scanInputRef)
const hiprintState = useHiprintClientState()
const printerStatusOffText = computed(() => tPrintControlHint('statusOff'))
defineOptions({ name: 'PrintLabel' })

View File

@ -2092,10 +2092,10 @@
class="list-empty-hint"
>
<span>{{ t('order.myOrders.empty.hint') }}</span>
<a-button type="text" @click="router.push('/order/placeOrder')">
<a-button class="app-btn-primary-soft" @click="router.push('/order/placeOrder')">
{{ t('order.myOrders.empty.goPlaceOrder') }}
</a-button>
<a-button type="text" @click="router.push('/order/priceCheck')">
<a-button @click="router.push('/order/priceCheck')">
{{ t('order.myOrders.empty.goPriceCheck') }}
</a-button>
</div>
@ -2514,7 +2514,7 @@
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
gap: 8px;
margin: 0 16px 12px;
padding: 10px 12px;
border-radius: 8px;

View File

@ -43,6 +43,7 @@ import { useWarehouseAdminCustomer } from '@/composables/useWarehouseAdminCustom
import { setRapidPackageHostCookie, setRapidPackageWarehouseCookie, setRapidPackageCustomerCookie } from '@/utils/rapidPackagePluginHost'
import { useCurrentWarehouseStore } from '@/store'
import { PRINT_CONTROL_DOWNLOAD_URL } from '@/constants/printControl'
import { tPrintControlHint } from '@/utils/hiprint/printControlHints'
defineOptions({ name: 'WarehouseParcel' })
@ -58,6 +59,7 @@ interface ParcelItemRow {
}
const { t } = useI18n()
const pluginHintDownloadText = computed(() => tPrintControlHint('pluginDownload'))
const { tableContainerRef, tableHeight } = useTableContainerHeight()
const {
isAdmin,
@ -671,7 +673,7 @@ onMounted(async () => {
rel="noopener noreferrer"
>
<icon-download />
{{ t('warehouse.parcel.pluginHintDownload') }}
{{ pluginHintDownloadText }}
</a>
</p>
</div>