feat: 客户门户可折叠侧栏导航,去掉页签并区分打印入口提示
All checks were successful
Build and Deploy Front / deploy (push) Successful in 17m52s
All checks were successful
Build and Deploy Front / deploy (push) Successful in 17m52s
运营端顶栏布局不变;客户侧栏按常用/仓储/更多分组,余额与打印沉底,打印提示按门户区分。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
fbf1064ab8
commit
88909258cc
@ -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
1
src/components.d.ts
vendored
@ -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']
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
1115
src/components/customer-side-rail/index.vue
Normal file
1115
src/components/customer-side-rail/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
@ -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();
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -1,15 +1,30 @@
|
||||
<template>
|
||||
<a-layout class="layout">
|
||||
<!-- 顶部导航 -->
|
||||
<TopNav />
|
||||
<!-- 运营端顶栏;客户门户无顶栏(能力沉底侧栏) -->
|
||||
<TopNav v-if="!isCustomerShell" />
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<a-layout :style="contentStyle" class="layout-content">
|
||||
<!-- 客户门户:全高侧栏 + 主区(无多页签,当前页由侧栏高亮) -->
|
||||
<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);
|
||||
@ -41,12 +59,15 @@
|
||||
|
||||
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,12 +88,13 @@
|
||||
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;
|
||||
|
||||
@ -82,4 +104,33 @@
|
||||
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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
334
src/utils/customer-portal-nav.ts
Normal file
334
src/utils/customer-portal-nav.ts
Normal 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
|
||||
}
|
||||
@ -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();
|
||||
|
||||
14
src/utils/hiprint/printControlHints.ts
Normal file
14
src/utils/hiprint/printControlHints.ts
Normal 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}`))
|
||||
}
|
||||
@ -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' })
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user