fix: 登录页签残留、表单校验脱节与仓库入口合并
All checks were successful
Build and Deploy Front / deploy (push) Successful in 6m30s

排除登录路由进页签;FormPro 内部 model 优先;侧栏仓库选择与地址弹窗合并并可切换。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-10-26 12:19:47 +08:00
parent 3642d650db
commit 3bfe6c6a26
10 changed files with 220 additions and 103 deletions

View File

@ -223,6 +223,7 @@
<template>
<a-form
ref="formRef"
v-bind="$attrs"
v-model="formValue"
:label-col-props="labelColProps"
:layout="formLayout"
@ -230,7 +231,6 @@
:size="size"
:wrapper-col-props="wrapperColProps"
class="form-pro-surface"
v-bind="$attrs"
>
<a-row
v-if="layout === 'default' || formLayout === 'vertical'"

View File

@ -120,6 +120,30 @@ async function loadCustomerCode() {
}
}
function syncWarehouseOptions(rows: WarehouseDto[]) {
warehouseStore.setOptions(
rows
.filter((row) => row.id != null)
.map((row) => ({
label: row.warehouseName || String(row.id),
value: Number(row.id),
})),
)
}
function selectWarehouse(item: WarehouseDto) {
const id = Number(item.id)
if (!id) return
if (id === currentId.value) return
syncWarehouseOptions(list.value)
warehouseStore.setWarehouseId(id)
Message.success(
t('nav.warehousePeek.switched', {
name: item.warehouseName || String(id),
}),
)
}
async function loadList() {
loading.value = true
try {
@ -128,6 +152,7 @@ async function loadList() {
loadCustomerCode(),
])
list.value = (res.items ?? []).map(normalize).sort(compareWarehouseSort)
syncWarehouseOptions(list.value)
} catch {
list.value = []
} finally {
@ -171,6 +196,7 @@ watch(
{{ t('nav.warehousePeek.tip') }}
<span v-if="customerCode" class="peek-tip__code">{{ customerCode }}</span>
</p>
<p class="peek-hint">{{ t('nav.warehousePeek.selectHint') }}</p>
<a-spin :loading="loading" class="peek-spin">
<div v-if="!orderedList.length && !loading" class="peek-empty">
@ -182,6 +208,12 @@ watch(
:key="String(item.id)"
class="peek-card"
:class="{ 'is-current': Number(item.id) === currentId }"
role="button"
tabindex="0"
:aria-pressed="Number(item.id) === currentId"
@click="selectWarehouse(item)"
@keydown.enter.prevent="selectWarehouse(item)"
@keydown.space.prevent="selectWarehouse(item)"
>
<header class="peek-card__head">
<div class="peek-card__title">
@ -191,43 +223,53 @@ watch(
{{ t('nav.warehousePeek.current') }}
</span>
</div>
<button
type="button"
class="peek-card__copy-all"
:title="t('system.warehouseAddress.actions.copy')"
@click="copyItem(item)"
>
<icon-copy />
<span>{{ t('nav.warehousePeek.copyAll') }}</span>
</button>
<div class="peek-card__actions" @click.stop>
<button
v-if="Number(item.id) !== currentId"
type="button"
class="peek-card__use"
@click="selectWarehouse(item)"
>
{{ t('nav.warehousePeek.useThis') }}
</button>
<button
type="button"
class="peek-card__copy-all"
:title="t('system.warehouseAddress.actions.copy')"
@click="copyItem(item)"
>
<icon-copy />
<span>{{ t('nav.warehousePeek.copyAll') }}</span>
</button>
</div>
</header>
<div class="peek-card__rows">
<div class="peek-row">
<span class="peek-row__label">{{ t('system.warehouseAddress.fields.recipient') }}</span>
<span class="peek-row__value" :title="displayRecipient(item)">{{ displayRecipient(item) }}</span>
<button type="button" class="peek-row__copy" @click="copyField(displayRecipient(item))">
<button type="button" class="peek-row__copy" @click.stop="copyField(displayRecipient(item))">
<icon-copy />
</button>
</div>
<div class="peek-row">
<span class="peek-row__label">{{ t('system.warehouseAddress.fields.contactPhone') }}</span>
<span class="peek-row__value" :title="item.contactPhone || '-'">{{ item.contactPhone || '-' }}</span>
<button type="button" class="peek-row__copy" @click="copyField(item.contactPhone || '')">
<button type="button" class="peek-row__copy" @click.stop="copyField(item.contactPhone || '')">
<icon-copy />
</button>
</div>
<div class="peek-row peek-row--address">
<span class="peek-row__label">{{ t('system.warehouseAddress.fields.shippingAddress') }}</span>
<span class="peek-row__value" :title="displayAddress(item)">{{ displayAddress(item) }}</span>
<button type="button" class="peek-row__copy" @click="copyField(displayAddress(item))">
<button type="button" class="peek-row__copy" @click.stop="copyField(displayAddress(item))">
<icon-copy />
</button>
</div>
<div v-if="item.remark" class="peek-row">
<span class="peek-row__label">{{ t('system.warehouseAddress.fields.remark') }}</span>
<span class="peek-row__value" :title="item.remark">{{ item.remark }}</span>
<button type="button" class="peek-row__copy" @click="copyField(item.remark || '')">
<button type="button" class="peek-row__copy" @click.stop="copyField(item.remark || '')">
<icon-copy />
</button>
</div>
@ -239,7 +281,7 @@ watch(
<style scoped lang="less">
.peek-tip {
margin: 0 0 12px;
margin: 0 0 6px;
padding: 8px 10px;
border-radius: 6px;
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 8%, #fff);
@ -256,6 +298,13 @@ watch(
}
}
.peek-hint {
margin: 0 0 12px;
color: var(--color-text-3);
font-size: 12px;
line-height: 1.4;
}
.peek-spin {
display: block;
width: 100%;
@ -276,15 +325,22 @@ watch(
border-radius: 8px;
border: 1px solid var(--color-border-2, #e5e6eb);
background: #fff;
cursor: pointer;
transition: border-color 0.14s ease, background-color 0.14s ease, box-shadow 0.14s ease;
&:last-child {
margin-bottom: 0;
}
&:hover {
border-color: color-mix(in srgb, var(--app-primary-color, #1d7281) 28%, transparent);
}
&.is-current {
border-color: color-mix(in srgb, var(--app-primary-color, #1d7281) 35%, transparent);
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 5%, #fff);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--app-primary-color, #1d7281) 12%, transparent);
cursor: default;
}
&__head {
@ -295,6 +351,29 @@ watch(
margin-bottom: 8px;
}
&__actions {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
&__use {
height: 26px;
padding: 0 8px;
border: none;
border-radius: 6px;
background: var(--app-primary-color, #1d7281);
color: #fff;
font-size: 12px;
font-weight: 600;
cursor: pointer;
&:hover {
filter: brightness(1.05);
}
}
&__title {
display: flex;
align-items: center;

View File

@ -129,26 +129,16 @@
</div>
<div class="customer-side-rail__footer-row">
<div class="customer-side-rail__warehouse-group">
<div class="customer-side-rail__warehouse">
<ContactService
variant="sider"
dropdown-position="tl"
trigger="click"
content-class="csr-dock-dropdown"
:compact="compactLayout"
/>
</div>
<button
type="button"
class="customer-side-rail__tool customer-side-rail__warehouse-view"
:title="t('nav.warehousePeek.title')"
:aria-label="t('nav.warehousePeek.title')"
@click="openWarehouseAddressPeek"
>
<icon-eye />
</button>
</div>
<button
type="button"
class="customer-side-rail__warehouse-btn"
:class="{ 'is-compact': compactLayout }"
:title="warehouseDockTitle"
:aria-label="warehouseDockTitle"
@click="openWarehouseAddressPeek"
>
<span class="customer-side-rail__warehouse-btn-text">{{ warehouseDockLabel }}</span>
</button>
<button
type="button"
class="customer-side-rail__toggle"
@ -175,10 +165,11 @@ import { Message } from '@arco-design/web-vue'
import {
IconDoubleLeft,
IconDoubleRight,
IconEye,
IconPrinter,
} from '@arco-design/web-vue/es/icon'
import { useCurrentWarehouseStore } from '@/store'
import { useRbacUserStore } from '@/store/modules/rbac-user'
import { getWarehouseList } from '@/api/system/warehouse'
import { getSubMenuIcon } from '@/utils/menu-icon'
import { MENU_NAME_TO_LOCALE_KEY, translateMenuTitle } from '@/utils/menu-i18n'
import { openWindow, regexUrl } from '@/utils'
@ -209,7 +200,6 @@ import {
} from '@/utils/customer-portal-nav'
import { resolvePortalDrawerPanel } from '@/views/user/portal-drawer/types'
import { openPortalSideDrawer } from '@/views/user/portal-drawer/usePortalSideDrawer'
import ContactService from '@/components/ContactService/index.vue'
import RightTopMessage from '@/components/top-nav/RightTopMessage.vue'
import RightTopUser from '@/components/top-nav/RightTopUser.vue'
import WarehouseAddressPeekModal from '@/components/customer-side-rail/WarehouseAddressPeekModal.vue'
@ -218,6 +208,7 @@ const { t, te, locale } = useI18n()
const route = useRoute()
const router = useRouter()
const rbacUserStore = useRbacUserStore()
const warehouseStore = useCurrentWarehouseStore()
const printConnectionState = useHiprintClientState()
/** 与 CSS transition 时长一致(Linear 风格约 300ms) */
@ -319,10 +310,41 @@ function handleCustomerBalanceChanged() {
const warehousePeekVisible = ref(false)
const warehouseDockName = computed(
() =>
warehouseStore.warehouseName ||
t('nav.contactService.selectWarehouse'),
)
const warehouseDockLabel = computed(() => {
const name = warehouseDockName.value
if (compactLayout.value) return name.slice(0, 1) || '仓'
return name
})
const warehouseDockTitle = computed(
() => `${t('nav.warehousePeek.title')}: ${warehouseDockName.value}`,
)
function openWarehouseAddressPeek() {
warehousePeekVisible.value = true
}
async function ensureWarehouseOptions() {
try {
const { items } = await getWarehouseList({ page: 1, pageSize: 100 })
const list = (items ?? [])
.filter((row) => row.id != null)
.map((row) => ({
label: row.warehouseName || String(row.id),
value: Number(row.id),
}))
warehouseStore.setOptions(list)
} catch {
/* 侧栏仅展示已缓存仓名,打开弹窗时再拉地址 */
}
}
function groupLabel(group: CustomerNavGroup) {
if (te(group.labelKey)) return t(group.labelKey)
return group.labelFallback
@ -469,6 +491,7 @@ watch(activePath, () => {
onMounted(() => {
initHiprintClientState().catch(() => undefined)
void ensureWarehouseOptions()
window.addEventListener(CUSTOMER_BALANCE_CHANGED_EVENT, handleCustomerBalanceChanged)
})
@ -561,41 +584,46 @@ onUnmounted(() => {
min-width: 0;
}
&__warehouse-group {
&__warehouse-btn {
flex: 1;
display: flex;
display: inline-flex;
align-items: center;
gap: 0;
min-width: 0;
}
&__warehouse {
flex: 1;
min-width: 0;
:deep(.contact-service--sider) {
width: 100%;
}
:deep(.contact-service--sider .contact-service__label) {
padding-right: 2px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
&__warehouse-view {
width: 26px;
height: 30px;
margin-left: -1px;
border-radius: 0 6px 6px 0;
padding: 0 8px;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.55);
color: var(--color-text-3);
color: var(--color-text-2);
font-size: 12px;
font-weight: 500;
line-height: 1.2;
cursor: pointer;
text-align: left;
transition: color 0.14s ease, background-color 0.14s ease;
&:hover {
color: var(--app-primary-color);
color: var(--app-primary-color, #1d7281);
background: rgba(255, 255, 255, 0.92);
}
&.is-compact {
flex: 0 0 auto;
width: 36px;
min-width: 36px;
height: 28px;
padding: 0;
justify-content: center;
text-align: center;
background: transparent;
}
}
&__warehouse-btn-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__message-slot {
@ -1031,41 +1059,14 @@ onUnmounted(() => {
gap: 2px;
}
.customer-side-rail__warehouse-group {
.customer-side-rail__warehouse-btn {
width: 36px;
min-width: 36px;
height: 28px;
flex: 0 0 auto;
flex-direction: column;
align-items: center;
gap: 0;
}
.customer-side-rail__warehouse {
width: 36px;
flex: 0 0 auto;
:deep(.contact-service--sider .contact-service__label) {
width: 36px;
min-width: 36px;
height: 28px;
min-height: 28px;
padding: 0;
border-radius: 6px;
justify-content: center;
text-align: center;
}
:deep(.contact-service--sider .contact-service__text) {
flex: none;
width: 100%;
text-align: center;
}
}
.customer-side-rail__warehouse-view {
width: 28px;
height: 24px;
margin-left: 0;
border-radius: 6px;
padding: 0;
justify-content: center;
text-align: center;
background: transparent;
}

View File

@ -352,9 +352,8 @@
if (nextRoute.query?._refresh || nextRoute.query?._r) {
return;
}
if (!nextRoute.meta.noAffix) {
tabBarStore.updateTabList(nextRoute);
}
// 登录/官网等独立壳不进页签;具体过滤在 updateTabList
tabBarStore.updateTabList(nextRoute);
nextTick(() => scrollActiveTagIntoView());
}, true);

View File

@ -1699,6 +1699,9 @@ export default {
'nav.userMenu.switchLanguage': 'Switch to {lang}',
'nav.warehousePeek.title': 'Warehouse address',
'nav.warehousePeek.tip': 'Put your customer code on the parcel for warehouse intake',
'nav.warehousePeek.selectHint': 'Click a card to switch the shipping warehouse',
'nav.warehousePeek.useThis': 'Use',
'nav.warehousePeek.switched': 'Switched to {name}',
'nav.warehousePeek.current': 'Current',
'nav.warehousePeek.copyAll': 'Copy',
'nav.warehousePeek.empty': 'No warehouse address yet',

View File

@ -1455,6 +1455,9 @@ export default {
'nav.userMenu.switchLanguage': '切换到{lang}',
'nav.warehousePeek.title': '仓库地址',
'nav.warehousePeek.tip': '寄件请备注客户编号,便于仓库认领',
'nav.warehousePeek.selectHint': '点击卡片可切换当前发货仓库',
'nav.warehousePeek.useThis': '选用',
'nav.warehousePeek.switched': '已切换为 {name}',
'nav.warehousePeek.current': '当前',
'nav.warehousePeek.copyAll': '复制',
'nav.warehousePeek.empty': '暂无仓库地址',

View File

@ -33,6 +33,8 @@ const router = createRouter({
meta: {
requiresAuth: false,
loginPortal: 'customer',
noAffix: true,
hideInMenu: true,
},
},
{
@ -42,6 +44,8 @@ const router = createRouter({
meta: {
requiresAuth: false,
loginPortal: 'admin',
noAffix: true,
hideInMenu: true,
},
},
{
@ -61,6 +65,7 @@ const router = createRouter({
meta: {
requiresAuth: false,
hideInMenu: true,
noAffix: true,
customerPortalOnly: true,
locale: 'h5.login.title',
},
@ -73,6 +78,7 @@ const router = createRouter({
meta: {
requiresAuth: false,
hideInMenu: true,
noAffix: true,
adminPortalOnly: true,
locale: 'h5Admin.login.title',
},

View File

@ -9,6 +9,7 @@ import { isString } from '@/utils/is';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import { buildMenuTitleMaps, resolveMenuTitleByPath, resolveMenuTitleFromTree } from '@/utils/menu-display';
import { translateMenuTitleByPath } from '@/utils/menu-i18n';
import { isLoginRouteName } from '@/utils/login-portal';
import i18n from '@/locale';
import type { MenuTreeDto } from '@/api/rbac';
import { TabBarState, TagProps } from './types';
@ -56,7 +57,19 @@ const formatTag = (route: RouteLocationNormalized): TagProps => {
};
};
const BAN_LIST = [REDIRECT_ROUTE_NAME];
const BAN_LIST = [REDIRECT_ROUTE_NAME, 'H5Login', 'H5AdminLogin', 'siteRoot'];
function shouldSkipTabRoute(route: RouteLocationNormalized): boolean {
const name = route.name != null ? String(route.name) : '';
if (!name) return true;
if (BAN_LIST.includes(name)) return true;
if (isLoginRouteName(name)) return true;
if (route.meta?.noAffix) return true;
if (route.meta?.requiresAuth === false && (route.path === '/login' || route.path === '/admin')) {
return true;
}
return false;
}
const useTabBarStore = defineStore('tabBar', {
state: (): TabBarState => ({
@ -104,7 +117,11 @@ const useTabBarStore = defineStore('tabBar', {
},
updateTabList(route: RouteLocationNormalized) {
if (BAN_LIST.includes(route.name as string)) return;
if (shouldSkipTabRoute(route)) return;
// 登录页曾被误写入时清掉,避免登录成功后仍挂着「login」页签
this.tagList = this.tagList.filter(
(tag) => !isLoginRouteName(tag.name) && tag.name !== 'H5Login' && tag.name !== 'H5AdminLogin',
);
const next = formatTag(route);
const idx = this.tagList.findIndex(
(tag) =>
@ -131,6 +148,13 @@ const useTabBarStore = defineStore('tabBar', {
/** 菜单/标签点击立刻高亮,不等路由 afterEach */
previewNavigate(tag: TagProps) {
if (!tag?.fullPath && !tag?.name) return;
if (
isLoginRouteName(tag.name) ||
tag.name === 'H5Login' ||
tag.name === 'H5AdminLogin'
) {
return;
}
this.previewingPath = tag.fullPath || '';
this.previewingName = tag.name || '';
const exists = this.tagList.some(

View File

@ -1,7 +1,7 @@
import type { RouteLocationRaw } from 'vue-router';
import { isNavigationFailure } from 'vue-router';
import router from '@/router';
import { useUserStore } from '@/store';
import { useUserStore, useTabBarStore } from '@/store';
import { ensureDynamicRouterReady } from '@/router/ensure-dynamic-router';
import { ensureHeavyPlugins } from '@/plugins/heavy-plugins';
import { ensureFullLocaleMessages } from '@/locale';
@ -63,6 +63,8 @@ export async function enterSystemAfterLogin(options: {
]);
skipNextPageTransition();
// 登录壳不进业务页签;进系统前清空,避免残留「login」标签
useTabBarStore().resetTabList();
try {
await router.replace({ ...options.target } as RouteLocationRaw);

View File

@ -168,9 +168,9 @@ async function handleSubmit() {
{{ t('order.waybillManage.changeStatus.modalTip') }}
</a-alert>
<!-- 勿传 :model:FormPro 用内部 formValue;attrs 会覆盖导致输入与校验脱节 -->
<FormPro
ref="formRef"
:model="formState"
:item-list="itemList"
:row-gutter="16"
:default-span="24"