feat: 客户侧栏账户坞与运单发货后状态变更
All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m59s

侧栏改为 Cursor 式账户面板(仓库眼标预览、语言切换、假邮箱过滤),运单「更多」支持发货后四态变更且妥投成功仅可进入。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-10-26 11:46:32 +08:00
parent 2dd3e656a3
commit 3642d650db
15 changed files with 1540 additions and 274 deletions

View File

@ -396,6 +396,25 @@ export function postOrderDelete(body: OrderIdRequest) {
return postOrderById('/order/delete', body)
}
export interface OrderChangePostShipStatusRequest extends OrderIdRequest {
/** pendingOnline / shipping / deliverySuccess / deliveryFailed */
targetStatus: string
/** 必填备注 */
remark: string
}
/** POST /order/change-post-ship-status — 出库后四态校正(派送成功只进不出) */
export async function postOrderChangePostShipStatus(
body: OrderChangePostShipStatusRequest,
): Promise<void> {
const res = await axiosInstance.post(orderUrl('/order/change-post-ship-status'), {
id: Number(body.id),
targetStatus: String(body.targetStatus || '').trim(),
remark: String(body.remark || '').trim(),
})
assertBusinessOk(res.data)
}
export interface OrderTrackUpsertResult {
skipped?: boolean
skipReason?: string

1
src/components.d.ts vendored
View File

@ -70,5 +70,6 @@ declare module 'vue' {
TopNav: typeof import('./components/top-nav/index.vue')['default']
UserEditDrawer: typeof import('./components/UserEditDrawer/index.vue')['default']
UserProfileFields: typeof import('./components/UserProfileFields/index.vue')['default']
WarehouseAddressPeekModal: typeof import('./components/customer-side-rail/WarehouseAddressPeekModal.vue')['default']
}
}

View File

@ -11,11 +11,19 @@ const props = withDefaults(
dropdownPosition?: 'br' | 'bl' | 'tr' | 'tl' | 'rt' | 'rb'
/** 侧栏收起:窄宽展示缩略文案(无图标) */
compact?: boolean
/** 下拉触发;侧栏默认 click,顶栏 hover */
trigger?: 'hover' | 'click'
/** 侧栏展开:显示「发货仓库」语义前缀 */
showCaption?: boolean
contentClass?: string
}>(),
{
variant: 'navbar',
dropdownPosition: 'br',
compact: false,
trigger: undefined,
showCaption: false,
contentClass: '',
},
)
@ -39,6 +47,22 @@ const labelText = computed(() => {
const useDropdown = computed(() => props.variant === 'navbar' || props.variant === 'sider')
const dropdownTrigger = computed(() => {
if (props.trigger) return props.trigger
return props.variant === 'sider' ? 'click' : 'hover'
})
const showSiderCaption = computed(
() => props.showCaption && props.variant === 'sider' && !props.compact,
)
const contentClass = computed(() => props.contentClass || undefined)
const popupTranslate = computed<[number, number] | undefined>(() => {
const pos = props.dropdownPosition
return pos === 'tl' || pos === 'tr' ? [0, -6] : undefined
})
async function loadServiceDataList() {
try {
const { items } = await getWarehouseList({
@ -77,13 +101,23 @@ onMounted(() => {
>
<a-dropdown
v-if="useDropdown"
trigger="hover"
:trigger="dropdownTrigger"
:position="dropdownPosition"
:content-class="contentClass"
:popup-translate="popupTranslate"
:popup-max-height="320"
>
<span class="contact-service__label" :title="displayName">
<button
type="button"
class="contact-service__label"
:title="displayName"
:aria-label="`${t('nav.contactService.shipWarehouse')}: ${displayName}`"
>
<span v-if="showSiderCaption" class="contact-service__caption">
{{ t('nav.contactService.shipWarehouse') }}
</span>
<span class="contact-service__text">{{ labelText }}</span>
</span>
</button>
<template #content>
<a-doption
v-for="opt in warehouseStore.options"
@ -142,7 +176,9 @@ onMounted(() => {
max-width: 180px;
height: 28px;
padding: 0 8px;
border: none;
border-radius: 6px;
background: transparent;
font-size: 13px;
line-height: 28px;
color: #fff;
@ -163,30 +199,72 @@ onMounted(() => {
.contact-service__label {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 8px;
gap: 4px;
width: 100%;
min-height: 32px;
height: 30px;
min-height: 30px;
padding: 0 8px;
border-radius: 8px;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.55);
font-size: 12px;
color: var(--color-text-2);
font-weight: 400;
color: var(--color-text-1);
cursor: pointer;
text-align: left;
transition: background-color 0.14s ease, color 0.14s ease;
&:hover {
color: var(--color-text-1);
background: rgba(255, 255, 255, 0.72);
background: rgba(255, 255, 255, 0.92);
color: var(--app-primary-color, rgb(var(--primary-6)));
}
}
.contact-service__caption {
flex-shrink: 0;
font-size: 11px;
font-weight: 500;
color: var(--color-text-3);
&::after {
content: '·';
margin-left: 4px;
color: var(--color-text-4, #c9cdd4);
}
}
.contact-service__text {
min-width: 0;
flex: 1;
line-height: 1.2;
}
&.is-compact .contact-service__label {
justify-content: center;
padding: 0 2px;
min-width: 28px;
align-items: center;
gap: 0;
padding: 0;
min-width: 36px;
width: 36px;
height: 28px;
min-height: 28px;
font-size: 12px;
font-weight: 600;
background: transparent;
font-size: 14px;
font-weight: 400;
text-align: center;
&:hover {
background: rgba(255, 255, 255, 0.85);
}
}
&.is-compact .contact-service__text {
flex: none;
width: 100%;
text-align: center;
line-height: 1;
}
}

View File

@ -0,0 +1,404 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue'
import ModalPro from '@/components/ModalPro/index.vue'
import useModal from '@/components/ModalPro/useModal'
import { getWarehouseList, type WarehouseDto } from '@/api/system/warehouse'
import { resolveCurrentCustomer } from '@/api/customer/resolve'
import {
compareWarehouseSort,
formatWarehouseShipAddress,
formatWarehouseShipRecipient,
} from '@/utils/warehouseShipAddress'
import { getMapIdFromAccessToken } from '@/utils/jwt'
import { useRbacUserStore } from '@/store/modules/rbac-user'
import { useCurrentWarehouseStore } from '@/store'
const props = defineProps<{
visible: boolean
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
}>()
const { t } = useI18n()
const [registerModal] = useModal()
const rbacUserStore = useRbacUserStore()
const warehouseStore = useCurrentWarehouseStore()
const loading = ref(false)
const list = ref<WarehouseDto[]>([])
const customerCode = ref('')
const currentId = computed(() => warehouseStore.warehouseId)
const orderedList = computed(() => {
const rows = [...list.value]
const cur = currentId.value
if (!cur) return rows
return rows.sort((a, b) => {
const aCur = Number(a.id) === cur ? 0 : 1
const bCur = Number(b.id) === cur ? 0 : 1
if (aCur !== bCur) return aCur - bCur
return compareWarehouseSort(a, b)
})
})
function pick(raw: any, ...keys: string[]) {
for (const k of keys) {
const v = raw?.[k]
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v)
}
return ''
}
function normalize(row: any): WarehouseDto {
const sortRaw = row?.sort ?? row?.Sort
const sortNum = sortRaw == null || sortRaw === '' ? null : Number(sortRaw)
return {
id: row?.id ?? row?.Id,
warehouseName: pick(row, 'warehouseName', 'WarehouseName'),
warehouseType: pick(row, 'warehouseType', 'WarehouseType') || undefined,
warehouseTypeName: pick(row, 'warehouseTypeName', 'WarehouseTypeName') || undefined,
recipient: pick(row, 'recipient', 'Recipient'),
contactPhone: pick(row, 'contactPhone', 'ContactPhone'),
shippingAddress: pick(row, 'shippingAddress', 'ShippingAddress'),
sort: sortNum != null && Number.isFinite(sortNum) ? sortNum : null,
remark: pick(row, 'remark', 'Remark') || undefined,
}
}
function displayRecipient(item: WarehouseDto) {
return formatWarehouseShipRecipient(item.recipient, customerCode.value)
}
function displayAddress(item: WarehouseDto) {
return formatWarehouseShipAddress(item.shippingAddress, customerCode.value)
}
function buildCopyText(item: WarehouseDto) {
return [
item.warehouseName || '-',
`${t('system.warehouseAddress.fields.recipient')}: ${displayRecipient(item)}`,
`${t('system.warehouseAddress.fields.contactPhone')}: ${item.contactPhone || '-'}`,
`${t('system.warehouseAddress.fields.shippingAddress')}: ${displayAddress(item)}`,
].join('\n')
}
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text)
Message.success(t('system.warehouseAddress.message.copySuccess'))
} catch {
Message.error(t('system.warehouseAddress.message.copyFailed'))
}
}
function copyItem(item: WarehouseDto) {
void copyText(buildCopyText(item))
}
function copyField(value: string) {
const text = String(value || '').trim()
if (!text || text === '-') {
Message.warning(t('nav.warehousePeek.emptyField'))
return
}
void copyText(text)
}
async function loadCustomerCode() {
try {
const mapId = getMapIdFromAccessToken(rbacUserStore.accessToken)
const account = rbacUserStore.userInfo?.account?.trim() || ''
const data = await resolveCurrentCustomer({ mapId, account })
customerCode.value = String(data?.customerCode ?? '').trim()
} catch {
customerCode.value = ''
}
}
async function loadList() {
loading.value = true
try {
const [res] = await Promise.all([
getWarehouseList({ page: 1, pageSize: 100 }),
loadCustomerCode(),
])
list.value = (res.items ?? []).map(normalize).sort(compareWarehouseSort)
} catch {
list.value = []
} finally {
loading.value = false
}
}
function onUpdateVisible(val: boolean) {
emit('update:visible', val)
}
function handleClose() {
emit('update:visible', false)
}
watch(
() => props.visible,
(open) => {
if (open) void loadList()
},
)
</script>
<template>
<ModalPro
:visible="visible"
:title="t('nav.warehousePeek.title')"
:width="420"
:mask-closable="true"
:footer="false"
unmount-on-close
@register="registerModal"
@update:visible="onUpdateVisible"
@cancel="handleClose"
>
<template #footer>
<span />
</template>
<p class="peek-tip">
{{ t('nav.warehousePeek.tip') }}
<span v-if="customerCode" class="peek-tip__code">{{ customerCode }}</span>
</p>
<a-spin :loading="loading" class="peek-spin">
<div v-if="!orderedList.length && !loading" class="peek-empty">
{{ t('nav.warehousePeek.empty') }}
</div>
<article
v-for="item in orderedList"
:key="String(item.id)"
class="peek-card"
:class="{ 'is-current': Number(item.id) === currentId }"
>
<header class="peek-card__head">
<div class="peek-card__title">
<icon-home class="peek-card__icon" />
<strong>{{ item.warehouseName || '-' }}</strong>
<span v-if="Number(item.id) === currentId" class="peek-card__badge">
{{ 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>
</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))">
<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 || '')">
<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))">
<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 || '')">
<icon-copy />
</button>
</div>
</div>
</article>
</a-spin>
</ModalPro>
</template>
<style scoped lang="less">
.peek-tip {
margin: 0 0 12px;
padding: 8px 10px;
border-radius: 6px;
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 8%, #fff);
border: 1px solid color-mix(in srgb, var(--app-primary-color, #1d7281) 16%, transparent);
color: var(--color-text-2);
font-size: 12px;
line-height: 1.5;
&__code {
margin-left: 4px;
font-weight: 700;
color: var(--app-primary-color, #1d7281);
font-variant-numeric: tabular-nums;
}
}
.peek-spin {
display: block;
width: 100%;
max-height: min(60vh, 480px);
overflow: auto;
}
.peek-empty {
padding: 28px 12px;
text-align: center;
color: var(--color-text-3);
font-size: 13px;
}
.peek-card {
margin-bottom: 10px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--color-border-2, #e5e6eb);
background: #fff;
&:last-child {
margin-bottom: 0;
}
&.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);
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
&__title {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
font-size: 14px;
color: var(--color-text-1);
strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 650;
}
}
&__icon {
flex-shrink: 0;
color: var(--app-primary-color, #1d7281);
font-size: 15px;
}
&__badge {
flex-shrink: 0;
padding: 1px 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 14%, #fff);
color: var(--app-primary-color, #1d7281);
font-size: 11px;
font-weight: 600;
line-height: 1.4;
}
&__copy-all {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
height: 26px;
padding: 0 8px;
border: none;
border-radius: 6px;
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 10%, #fff);
color: var(--app-primary-color, #1d7281);
font-size: 12px;
font-weight: 600;
cursor: pointer;
&:hover {
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 16%, #fff);
}
}
}
.peek-row {
display: grid;
grid-template-columns: 64px minmax(0, 1fr) 24px;
align-items: start;
gap: 6px;
padding: 5px 0;
border-top: 1px dashed var(--color-border-2, #e5e6eb);
&:first-child {
border-top: none;
padding-top: 0;
}
&__label {
color: var(--color-text-3);
font-size: 12px;
line-height: 1.45;
}
&__value {
min-width: 0;
color: var(--color-text-1);
font-size: 13px;
line-height: 1.45;
word-break: break-word;
}
&__copy {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
margin-top: -1px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--color-text-4, #c9cdd4);
cursor: pointer;
font-size: 13px;
&:hover {
color: var(--app-primary-color, #1d7281);
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 8%, #fff);
}
}
&--address .peek-row__value {
font-weight: 500;
}
}
</style>

View File

@ -71,55 +71,39 @@
</section>
</div>
<!-- 底栏:用户+余额 → 工具一行 → 仓库+收起 -->
<!-- 底栏:头像+消息+打印 → 仓库+收起(昵称/余额进头像菜单) -->
<div class="customer-side-rail__dock">
<div
class="customer-side-rail__user-row"
:class="{ 'is-account-active': isAccountRouteActive }"
class="customer-side-rail__toolbar"
:class="{ 'is-account-active': isAccountRouteActive || isBalanceRouteActive }"
>
<div class="customer-side-rail__user">
<RightTopUser
:compact="compactLayout"
dropdown-position="rt"
variant="sider"
compact
dropdown-position="tl"
:account-menus="accountMenus"
show-language-menu
show-recharge-cta
:balance-text="balanceDisplay"
/>
</div>
<div
class="customer-side-rail__user-balance customer-side-rail__fade"
:class="{ 'is-balance-active': isBalanceRouteActive }"
>
<a-dropdown trigger="hover" position="rt">
<div
class="customer-side-rail__balance-trigger"
:title="`${t('nav.balance.label')}: ¥${balanceDisplay}`"
>
<span class="customer-side-rail__balance-currency">¥</span>
<span class="customer-side-rail__balance-value">{{ balanceDisplay }}</span>
</div>
<template #content>
<a-doption @click="() => onBalanceMenuClick('recharge')">
{{ t('nav.balance.recharge') }}
</a-doption>
<a-doption @click="() => onBalanceMenuClick('rechargeRecord')">
{{ t('nav.balance.rechargeRecord') }}
</a-doption>
<a-doption @click="() => onBalanceMenuClick('accountFlow')">
{{ t('nav.balance.accountFlow') }}
</a-doption>
<a-doption :disabled="balanceRefreshing" @click="onRefreshBalance">
{{ t('nav.balance.refresh') }}
</a-doption>
</template>
</a-dropdown>
<div class="customer-side-rail__message-slot">
<RightTopMessage
dropdown-position="tl"
trigger="click"
content-class="csr-dock-dropdown"
/>
</div>
</div>
<div class="customer-side-rail__utils">
<a-dropdown trigger="hover" position="rt">
<a-dropdown
trigger="click"
position="tl"
content-class="csr-dock-dropdown"
:popup-translate="[0, -6]"
>
<button
type="button"
class="customer-side-rail__tool"
class="customer-side-rail__tool customer-side-rail__tool--print"
:class="{
'is-enabled': printConnectionState.enabled,
'is-loading': printConnectionState.connecting,
@ -142,21 +126,34 @@
</a-doption>
</template>
</a-dropdown>
<div class="customer-side-rail__tool-slot">
<RightTopMessage dropdown-position="rt" />
</div>
</div>
<div class="customer-side-rail__footer-row">
<div class="customer-side-rail__warehouse">
<ContactService variant="sider" dropdown-position="rt" :compact="compactLayout" />
<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__toggle"
:aria-label="collapsed ? '展开菜单' : '收起菜单'"
:title="collapsed ? '展开菜单' : '收起菜单'"
:aria-label="collapsed ? t('nav.sideRail.expand') : t('nav.sideRail.collapse')"
:title="collapsed ? t('nav.sideRail.expand') : t('nav.sideRail.collapse')"
@click="toggleCollapsed"
>
<icon-double-right v-if="collapsed" class="customer-side-rail__toggle-icon" />
@ -165,6 +162,8 @@
</div>
</div>
</div>
<WarehouseAddressPeekModal v-model:visible="warehousePeekVisible" />
</aside>
</template>
@ -176,6 +175,7 @@ import { Message } from '@arco-design/web-vue'
import {
IconDoubleLeft,
IconDoubleRight,
IconEye,
IconPrinter,
} from '@arco-design/web-vue/es/icon'
import { useRbacUserStore } from '@/store/modules/rbac-user'
@ -212,8 +212,9 @@ import { openPortalSideDrawer } from '@/views/user/portal-drawer/usePortalSideDr
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'
const { t, te } = useI18n()
const { t, te, locale } = useI18n()
const route = useRoute()
const router = useRouter()
const rbacUserStore = useRbacUserStore()
@ -275,7 +276,6 @@ const printConnectionTitle = computed(() => {
})
const customerBalance = ref<number | null>(null)
const balanceRefreshing = ref(false)
let balanceInflight: Promise<void> | null = null
const balanceDisplay = computed(() => {
@ -311,38 +311,18 @@ function loadCustomerBalance() {
return balanceInflight
}
async function onRefreshBalance() {
if (balanceRefreshing.value) return
balanceRefreshing.value = true
const previous = customerBalance.value
try {
await loadCustomerBalance()
Message.success(t('nav.balance.refreshed'))
} catch (error) {
console.error('Failed to load customer balance', error)
customerBalance.value = previous
Message.error(t('nav.balance.refreshFailed'))
} finally {
balanceRefreshing.value = false
}
}
function onBalanceMenuClick(key: string) {
if (key === 'recharge') {
router.push({ name: 'VipRechargeDeposit' })
} else if (key === 'rechargeRecord') {
router.push({ name: 'FinanceRechargeRecord' })
} else if (key === 'accountFlow') {
router.push({ name: 'FinanceAccountFlow' })
}
}
function handleCustomerBalanceChanged() {
void loadCustomerBalance().catch((error) => {
console.error('Failed to load customer balance', error)
})
}
const warehousePeekVisible = ref(false)
function openWarehouseAddressPeek() {
warehousePeekVisible.value = true
}
function groupLabel(group: CustomerNavGroup) {
if (te(group.labelKey)) return t(group.labelKey)
return group.labelFallback
@ -353,6 +333,7 @@ function navKey(item: CustomerNavItem) {
}
function formatTitle(item: CustomerNavItem) {
void locale.value
return translateMenuTitle(t, {
name: item.name,
route: item.route || item.path,
@ -576,7 +557,15 @@ onUnmounted(() => {
&__footer-row {
display: flex;
align-items: center;
gap: 6px;
gap: 2px;
min-width: 0;
}
&__warehouse-group {
flex: 1;
display: flex;
align-items: center;
gap: 0;
min-width: 0;
}
@ -589,10 +578,50 @@ onUnmounted(() => {
}
:deep(.contact-service--sider .contact-service__label) {
min-height: 32px;
padding: 0 8px;
font-size: 12px;
justify-content: flex-start;
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;
background: rgba(255, 255, 255, 0.55);
color: var(--color-text-3);
&:hover {
color: var(--app-primary-color);
background: rgba(255, 255, 255, 0.92);
}
}
&__message-slot {
flex: 1;
min-width: 0;
display: flex;
justify-content: center;
:deep(.message-dropdown-trigger) {
width: 100%;
max-width: 56px;
height: 30px;
justify-content: center;
padding: 0 4px;
border-radius: 6px;
color: var(--color-text-2);
gap: 2px;
}
:deep(.message-dropdown-trigger:hover) {
color: var(--color-text-1);
background: rgba(255, 255, 255, 0.85);
}
:deep(.message-label) {
display: none;
}
}
@ -751,18 +780,19 @@ onUnmounted(() => {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px var(--csr-pad-x) 12px;
gap: 4px;
padding: 8px var(--csr-pad-x) 8px;
border-top: 1px solid var(--color-border-2, rgba(0, 0, 0, 0.06));
}
&__user-row {
/* 工具条:头像 + 消息 + 打印 */
&__toolbar {
display: flex;
align-items: center;
gap: 4px;
gap: 2px;
min-width: 0;
padding: 2px;
border-radius: 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.45);
transition:
background-color 0.14s ease,
@ -778,85 +808,26 @@ onUnmounted(() => {
}
}
&__user-balance {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: flex-end;
max-width: 72px;
min-width: 0;
height: 36px;
padding-right: 4px;
border-radius: 8px;
&.is-balance-active {
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 12%, #fff);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--app-primary-color, #1d7281) 18%, transparent);
}
}
&__balance-trigger {
display: flex;
align-items: center;
justify-content: flex-end;
max-width: 100%;
min-width: 0;
height: 32px;
padding: 0 4px;
border-radius: 6px;
cursor: pointer;
color: var(--app-primary-color);
font-weight: 600;
font-size: 12px;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
&:hover {
background: rgba(255, 255, 255, 0.85);
}
}
&__balance-currency {
flex-shrink: 0;
margin-right: 1px;
opacity: 0.85;
font-weight: 600;
}
&__balance-value {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 工具:打印(含下载)/ 消息 */
&__utils {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 2px;
padding: 2px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.5);
}
&__tool {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 32px;
flex-shrink: 0;
width: 30px;
height: 30px;
border: none;
border-radius: 8px;
border-radius: 6px;
background: transparent;
color: var(--color-text-3);
cursor: pointer;
text-decoration: none;
font-size: 16px;
font-size: 14px;
transition: color 0.14s ease, background-color 0.14s ease;
&:hover {
color: var(--color-text-1);
background: rgba(255, 255, 255, 0.95);
background: rgba(255, 255, 255, 0.85);
}
&.is-enabled {
@ -867,12 +838,16 @@ onUnmounted(() => {
opacity: 0.6;
pointer-events: none;
}
&--print {
opacity: 0.9;
}
}
&__tool-dot {
position: absolute;
top: 5px;
right: 7px;
right: 5px;
width: 5px;
height: 5px;
border-radius: 50%;
@ -883,60 +858,24 @@ onUnmounted(() => {
background: rgb(var(--green-6));
}
&__tool-slot {
display: flex;
align-items: center;
justify-content: center;
height: 32px;
border-radius: 8px;
color: var(--color-text-3);
:deep(.dropDown),
:deep(.lang-icon),
:deep(.arco-badge) {
color: inherit;
}
:deep(.lang-icon) {
font-size: 16px;
}
:deep(.message-dropdown-trigger) {
height: 32px;
min-width: 28px;
padding: 0 4px;
}
}
&__user {
flex: 1;
flex: 0 0 auto;
display: flex;
align-items: center;
min-width: 0;
:deep(.user-dropdown-trigger) {
width: 100%;
max-width: none;
height: 36px;
padding: 0 8px;
:deep(.user-dropdown-trigger),
:deep(.user-dropdown-trigger.is-compact) {
width: 30px;
height: 30px;
padding: 0;
margin: 0;
justify-content: center;
color: var(--color-text-1);
}
:deep(.user-dropdown-trigger:hover) {
background: rgba(255, 255, 255, 0.85);
}
:deep(.user-dropdown-trigger.is-compact) {
width: 36px;
height: 36px;
padding: 0;
margin: 0;
}
:deep(.user-name) {
max-width: none;
font-size: 13px;
}
}
&__toggle {
@ -944,23 +883,24 @@ onUnmounted(() => {
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 32px;
height: 32px;
width: 28px;
height: 28px;
border: none;
border-radius: 8px;
border-radius: 6px;
background: transparent;
color: var(--color-text-3);
cursor: pointer;
font-size: 15px;
font-size: 13px;
&:hover {
color: var(--app-primary-color);
background: rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.65);
}
}
&__toggle-icon {
display: inline-flex;
font-size: 13px;
}
/*
@ -1038,15 +978,15 @@ onUnmounted(() => {
.customer-side-rail__dock {
align-items: center;
gap: 6px;
padding: 8px 0 10px;
gap: 4px;
padding: 6px 0 8px;
}
.customer-side-rail__user-row {
.customer-side-rail__toolbar {
width: 40px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 2px;
background: transparent;
box-shadow: none;
@ -1058,57 +998,81 @@ onUnmounted(() => {
}
.customer-side-rail__user {
width: 36px;
width: 30px;
flex: 0 0 auto;
}
.customer-side-rail__message-slot {
width: 30px;
flex: 0 0 auto;
:deep(.user-dropdown-trigger),
:deep(.user-dropdown-trigger.is-compact) {
width: 36px;
height: 36px;
margin: 0;
:deep(.message-dropdown-trigger) {
width: 30px;
max-width: none;
height: 28px;
padding: 0;
justify-content: center;
background: transparent;
}
:deep(.message-count) {
display: none;
}
}
.customer-side-rail__user-balance {
display: none;
}
.customer-side-rail__utils {
width: 40px;
grid-template-columns: 1fr;
gap: 2px;
justify-items: center;
padding: 2px 0;
background: transparent;
}
.customer-side-rail__tool,
.customer-side-rail__tool-slot {
width: 36px;
height: 32px;
}
.customer-side-rail__tool-slot :deep(.message-count) {
display: none;
.customer-side-rail__tool {
width: 30px;
height: 28px;
}
.customer-side-rail__footer-row {
width: 40px;
flex-direction: column;
align-items: center;
gap: 4px;
gap: 2px;
}
.customer-side-rail__warehouse-group {
width: 36px;
flex: 0 0 auto;
flex-direction: column;
align-items: center;
gap: 0;
}
.customer-side-rail__warehouse {
width: 32px;
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;
background: transparent;
}
.customer-side-rail__toggle {
width: 32px;
height: 32px;
width: 28px;
height: 28px;
padding: 0;
}
}
}

View File

@ -1,12 +1,19 @@
<template>
<!-- Top-nav inbox / announcement entry (unread counts stay here, not on Nana FAB). -->
<a-dropdown
trigger="hover"
:trigger="trigger"
:position="dropdownPosition"
:content-class="contentClass"
:popup-translate="popupTranslate"
@visible-change="onMessageDropdownVisibleChange"
>
<div class="message-dropdown-trigger" :title="t('navbar.message')">
<div
class="message-dropdown-trigger"
:class="{ 'is-emphasized': emphasize && totalCount > 0 }"
:title="t('navbar.message')"
>
<IconNotification class="message-icon" :class="totalCount > 0 ? 'theMessage' : ''" />
<span v-if="showLabel" class="message-label">{{ t('navbar.message') }}</span>
<span v-if="totalCount > 0" class="message-count">({{ displayCount }})</span>
</div>
<template #content>
@ -21,7 +28,7 @@
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { computed, toRefs } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { IconNotification } from '@arco-design/web-vue/es/icon';
@ -29,12 +36,32 @@ import { useInboxUnread } from '@/composables/useInboxUnread';
const props = withDefaults(
defineProps<{
dropdownPosition?: 'br' | 'bl' | 'tr' | 'tl' | 'rt' | 'rb';
dropdownPosition?: 'br' | 'bl' | 'tr' | 'tl' | 'rt' | 'rb'
trigger?: 'hover' | 'click'
/** 侧栏:展示「消息」文案,强化未读入口 */
showLabel?: boolean
/** 有未读时加强视觉 */
emphasize?: boolean
contentClass?: string
}>(),
{ dropdownPosition: 'br' },
{
dropdownPosition: 'br',
trigger: 'hover',
showLabel: false,
emphasize: false,
contentClass: '',
},
);
const { showLabel, emphasize } = toRefs(props);
const dropdownPosition = computed(() => props.dropdownPosition);
const trigger = computed(() => props.trigger);
const contentClass = computed(() => props.contentClass || undefined);
const popupTranslate = computed<[number, number] | undefined>(() =>
props.dropdownPosition === 'tl' || props.dropdownPosition === 'tr'
? [0, -6]
: undefined,
);
const router = useRouter();
const { t } = useI18n();
@ -90,18 +117,31 @@ const onMessageMenuClick = (event: { key?: string }) => {
border-radius: 6px;
cursor: pointer;
color: inherit;
transition: background-color 0.15s ease, opacity 0.15s ease;
transition: background-color 0.15s ease, opacity 0.15s ease, color 0.15s ease;
}
.message-dropdown-trigger:hover {
background-color: rgba(255, 255, 255, 0.12);
}
.message-dropdown-trigger.is-emphasized {
color: var(--app-primary-color, rgb(var(--primary-6)));
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 10%, #fff);
}
.message-icon {
font-size: 16px;
flex-shrink: 0;
}
.message-label {
flex-shrink: 0;
font-size: 12px;
font-weight: 500;
line-height: 1;
color: inherit;
}
.message-count {
flex-shrink: 0;
font-size: 14px;

View File

@ -1,7 +1,10 @@
<template>
<a-dropdown
trigger="hover"
v-model:popup-visible="menuVisible"
:trigger="dropdownTrigger"
:position="dropdownPosition"
:content-class="contentClass"
:popup-translate="popupTranslate"
@select="onDropdownSelect"
>
<div class="user-dropdown-trigger" :class="{ 'is-compact': compact }">
@ -14,8 +17,103 @@
<span v-else class="user-avatar user-avatar--fallback">{{ displayNameInitial }}</span>
<span v-if="!compact" class="user-name">{{ displayName }}</span>
</div>
<template #content>
<!-- 侧栏传入的资产与账户入口(替代侧栏「资产与账户」分组) -->
<!-- Cursor 风格:侧栏账户面板(头信息 / 菜单 / 登出固定底) -->
<template v-if="isSiderVariant" #content>
<div class="csr-user-panel" @click.stop>
<div class="csr-user-panel__header">
<div class="csr-user-panel__top">
<div class="csr-user-panel__identity">
<img
v-if="showCustomerAvatar"
:src="customerAvatarUrl"
alt=""
class="csr-user-panel__avatar"
/>
<span v-else class="csr-user-panel__avatar csr-user-panel__avatar--fallback">
{{ displayNameInitial }}
</span>
<div class="csr-user-panel__meta">
<div class="csr-user-panel__name">{{ displayName }}</div>
<div v-if="accountSubtitle" class="csr-user-panel__sub">{{ accountSubtitle }}</div>
</div>
</div>
<button
v-if="showLanguageMenu"
type="button"
class="csr-user-panel__lang-toggle"
:title="t('nav.userMenu.switchLanguage', { lang: nextLanguageOption.label })"
:aria-label="t('nav.userMenu.switchLanguage', { lang: nextLanguageOption.label })"
@click="onPanelAction(`lang:${nextLanguageOption.value}`)"
>
{{ nextLanguageOption.short }}
</button>
</div>
<button
v-if="showRechargeCta"
type="button"
class="csr-user-panel__cta"
@click="onPanelAction('recharge')"
>
{{ t('nav.balance.recharge') }}
<span v-if="balanceText" class="csr-user-panel__cta-balance">¥{{ balanceText }}</span>
</button>
</div>
<div class="csr-user-panel__body">
<button
v-for="item in accountMenus"
:key="accountMenuKey(item)"
type="button"
class="csr-user-panel__item"
@click="onPanelAction(accountMenuKey(item))"
>
<span class="csr-user-panel__item-icon">
<component :is="getSubMenuIcon(item, undefined, MENU_NAME_TO_LOCALE_KEY)" />
</span>
<span class="csr-user-panel__item-label">{{ formatAccountTitle(item) }}</span>
</button>
<button
v-if="isCustomerPortal && !hasAccountProfileItem"
type="button"
class="csr-user-panel__item"
@click="onPanelAction('userinfo')"
>
<span class="csr-user-panel__item-icon"><i class="fa fa-user" /></span>
<span class="csr-user-panel__item-label">{{ t('menu.user.profile') }}</span>
</button>
<button
type="button"
class="csr-user-panel__item"
@click="onPanelAction('changePassword')"
>
<span class="csr-user-panel__item-icon"><i class="fa fa-lock" /></span>
<span class="csr-user-panel__item-label">{{ t('profile.changePassword') }}</span>
</button>
<button
type="button"
class="csr-user-panel__item"
@click="onPanelAction('settings')"
>
<span class="csr-user-panel__item-icon"><i class="fa fa-cog" /></span>
<span class="csr-user-panel__item-label">{{ t('nav.userMenu.pageSettings') }}</span>
</button>
</div>
<div class="csr-user-panel__footer">
<button
type="button"
class="csr-user-panel__item is-danger"
@click="onPanelAction('logout')"
>
<span class="csr-user-panel__item-icon"><i class="fa fa-sign-out" /></span>
<span class="csr-user-panel__item-label">{{ t('nav.userMenu.logout') }}</span>
</button>
</div>
</div>
</template>
<template v-else #content>
<a-doption
v-for="item in accountMenus"
:key="accountMenuKey(item)"
@ -37,7 +135,7 @@
<a-doption v-if="!isCustomerPortal" value="settings">
<span class="user-menu-item">
<span class="user-menu-item__icon"><i class="fa fa-cog" /></span>
<span class="user-menu-item__label">页面配置</span>
<span class="user-menu-item__label">{{ t('nav.userMenu.pageSettings') }}</span>
</span>
</a-doption>
<a-doption value="changePassword">
@ -49,7 +147,7 @@
<a-doption v-if="isCustomerPortal" value="settings">
<span class="user-menu-item">
<span class="user-menu-item__icon"><i class="fa fa-cog" /></span>
<span class="user-menu-item__label">页面配置</span>
<span class="user-menu-item__label">{{ t('nav.userMenu.pageSettings') }}</span>
</span>
</a-doption>
<template v-if="showLanguageMenu">
@ -69,8 +167,8 @@
</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>
<span class="user-menu-item__label">退出登录</span>
<span class="user-menu-item__icon"><i class="fa fa-sign-out" /></span>
<span class="user-menu-item__label">{{ t('nav.userMenu.logout') }}</span>
</span>
</a-doption>
</template>
@ -160,18 +258,28 @@ const props = withDefaults(
defineProps<{
/** 侧栏收起等场景只显示头像 */
compact?: boolean
/** 下拉相对触发器位置,侧栏用 rt */
/** 下拉相对触发器位置;侧栏 Cursor 风格默认 tl(向上) */
dropdownPosition?: 'br' | 'bl' | 'tr' | 'tl' | 'rt' | 'rb'
/** 客户侧栏:资产与账户菜单,收进用户下拉 */
accountMenus?: CustomerNavItem[]
/** 客户侧栏:语言收进用户菜单,不再单独占底栏 */
showLanguageMenu?: boolean
/** navbar 保持原下拉;sider 用 Cursor 式面板 */
variant?: 'navbar' | 'sider'
trigger?: 'hover' | 'click'
/** 侧栏面板展示余额并提供去充值 CTA */
balanceText?: string
showRechargeCta?: boolean
}>(),
{
compact: false,
dropdownPosition: 'br',
dropdownPosition: undefined,
accountMenus: () => [],
showLanguageMenu: false,
variant: 'navbar',
trigger: undefined,
balanceText: '',
showRechargeCta: false,
},
);
@ -182,12 +290,42 @@ const userStore = useUserStore();
const appStore = useAppStore();
const rbacUserStore = useRbacUserStore();
const menuVisible = ref(false);
const isSiderVariant = computed(() => props.variant === 'sider');
const showLanguageMenu = computed(() => props.showLanguageMenu);
const showRechargeCta = computed(() => props.showRechargeCta && isSiderVariant.value);
const balanceText = computed(() => String(props.balanceText || '').trim());
const dropdownTrigger = computed(() => {
if (props.trigger) return props.trigger;
return isSiderVariant.value ? 'click' : 'hover';
});
const dropdownPosition = computed(
() => props.dropdownPosition ?? (isSiderVariant.value ? 'tl' : 'br'),
);
const contentClass = computed(() =>
isSiderVariant.value ? 'csr-dock-dropdown csr-account-dropdown' : 'user-account-dropdown',
);
/** 向上弹出时略抬离触发器,贴近 Cursor */
const popupTranslate = computed<[number, number] | undefined>(() =>
isSiderVariant.value ? [0, -6] : undefined,
);
const languageOptions = [
{ value: 'zh-CN', label: '中文', icon: '🇨🇳' },
{ value: 'en-US', label: 'English', icon: '🇺🇸' },
{ value: 'zh-CN', label: '中文', short: '中', icon: '🇨🇳' },
{ value: 'en-US', label: 'English', short: 'EN', icon: '🇺🇸' },
] as const;
/** 点击切换到另一种语言(单按钮,非分段滑块) */
const nextLanguageOption = computed(() => {
const current = String(locale.value || 'zh-CN');
return languageOptions.find((item) => item.value !== current) || languageOptions[1];
});
const isCustomerPortal = computed(() => rbacUserStore.isCustomerPortal());
const displayName = computed(() => {
@ -199,7 +337,22 @@ const displayNameInitial = computed(() => {
return name ? name.slice(0, 1).toUpperCase() : '?';
});
const dropdownPosition = computed(() => props.dropdownPosition);
/** 后端占位邮箱,不当真实邮箱展示 */
function isPlaceholderEmail(value: string) {
const v = value.trim().toLowerCase();
if (!v) return true;
return v.endsWith('@noemail.local') || v.includes('@noemail.');
}
const accountSubtitle = computed(() => {
const info = rbacUserStore.userInfo as Record<string, unknown> | null | undefined;
const account = String(info?.account ?? '').trim();
const email = String(info?.email ?? '').trim();
if (email && !isPlaceholderEmail(email)) return email;
if (account && !isPlaceholderEmail(account)) return account;
return '';
});
const compact = computed(() => props.compact);
const accountMenus = computed(() => props.accountMenus || []);
@ -215,6 +368,7 @@ function accountMenuKey(item: CustomerNavItem) {
}
function formatAccountTitle(item: CustomerNavItem) {
void locale.value;
return translateMenuTitle(t, {
name: item.name,
route: item.route || item.path,
@ -363,12 +517,24 @@ async function handleChangePasswordSubmit() {
}
} catch (err: any) {
if (err?.errors || err?.errorFields) return;
Message.error(err?.message || '修改密码失败');
Message.error(err?.message || t('profile.changePasswordFailed'));
} finally {
changePasswordLoading.value = false;
}
}
function closeMenu() {
menuVisible.value = false;
}
function onPanelAction(key: string) {
// 切语言留在面板内,其它操作关闭
if (!key.startsWith('lang:')) {
closeMenu();
}
void onMenuClick({ key });
}
const onDropdownSelect = (value: string | number | Record<string, unknown> | undefined) => {
if (value === undefined || value === null) return;
onMenuClick({ key: String(value) });
@ -377,6 +543,11 @@ const onDropdownSelect = (value: string | number | Record<string, unknown> | und
const onMenuClick = async (event: { key: string }) => {
const { key } = event;
if (key === 'recharge') {
router.push({ name: 'VipRechargeDeposit' }).catch(() => undefined);
return;
}
if (key.startsWith('account:')) {
const path = key.slice('account:'.length);
const item = accountMenus.value.find(
@ -515,4 +686,232 @@ const onMenuClick = async (event: { key: string }) => {
padding: 8px 12px;
}
}
/* Cursor 风格:侧栏 dock 账户面板 */
.csr-account-dropdown {
width: 200px !important;
min-width: 200px !important;
max-width: 200px !important;
padding: 0 !important;
border: 1px solid var(--color-border-2, rgba(0, 0, 0, 0.08)) !important;
border-radius: 10px !important;
box-shadow:
0 8px 28px rgba(15, 23, 42, 0.12),
0 2px 8px rgba(15, 23, 42, 0.06) !important;
overflow: hidden;
background: #fff !important;
.arco-dropdown {
width: 100%;
min-width: 0 !important;
padding: 0;
background: transparent;
box-shadow: none;
}
.arco-dropdown-list-wrapper,
.arco-scrollbar,
.arco-dropdown-list {
max-height: none !important;
padding: 0 !important;
margin: 0 !important;
}
}
.csr-user-panel {
/* 与客户侧栏展开宽对齐 */
width: 200px;
box-sizing: border-box;
display: flex;
flex-direction: column;
background: #fff;
color: var(--color-text-1);
&__header {
padding: 10px 10px 8px;
border-bottom: 1px solid var(--color-border-2, rgba(0, 0, 0, 0.06));
}
&__top {
display: flex;
align-items: flex-start;
gap: 6px;
min-width: 0;
}
&__identity {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
&__lang-toggle {
flex-shrink: 0;
min-width: 28px;
height: 26px;
padding: 0 4px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--color-text-2);
font-size: 12px;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition: color 0.12s ease, background-color 0.12s ease;
&:hover {
color: var(--app-primary-color, #1d7281);
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 6%, #fff);
}
}
&__avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
background: var(--color-fill-3, #e5e6eb);
&--fallback {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
color: var(--color-text-1);
}
}
&__meta {
min-width: 0;
flex: 1;
}
&__name {
font-size: 14px;
font-weight: 650;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__sub {
margin-top: 2px;
font-size: 12px;
color: var(--color-text-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__cta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
width: 100%;
margin-top: 8px;
height: 30px;
padding: 0 10px;
border: 1px solid color-mix(in srgb, var(--app-primary-color, #1d7281) 28%, transparent);
border-radius: 6px;
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 8%, #fff);
color: var(--app-primary-color, #1d7281);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.14s ease;
&:hover {
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 14%, #fff);
}
}
&__cta-balance {
font-variant-numeric: tabular-nums;
font-weight: 700;
}
&__body {
padding: 6px;
max-height: min(52vh, 360px);
overflow: auto;
}
&__footer {
padding: 6px;
border-top: 1px solid var(--color-border-2, rgba(0, 0, 0, 0.06));
background: color-mix(in srgb, var(--color-fill-2, #f2f3f5) 55%, #fff);
}
&__item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 36px;
padding: 0 10px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--color-text-1);
font-size: 13px;
font-weight: 500;
text-align: left;
cursor: pointer;
transition: background-color 0.12s ease;
&:hover {
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 8%, #fff);
}
&.is-danger {
color: rgb(var(--red-6));
.csr-user-panel__item-icon {
color: rgb(var(--red-6));
}
}
}
&__item-icon {
width: 18px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: var(--color-text-3);
}
&__item-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__hint {
margin-left: 8px;
color: var(--app-primary-color, rgb(var(--primary-6)));
font-weight: 600;
}
}
.csr-dock-dropdown {
.arco-dropdown-option {
padding: 0 4px;
}
.arco-dropdown-option-content {
padding: 8px 10px;
border-radius: 6px;
}
}
</style>

View File

@ -153,6 +153,27 @@ export const ORDER_SHIPPED_STATUSES: readonly OrderStatusCode[] = [
'deliveryFailed',
]
/**
* 出库后可人工校正的状态集合。
* 规则:四态互改;派送成功只进不出(见 getPostShipStatusChangeTargets)。
*/
export const ORDER_POST_SHIP_CHANGEABLE_STATUSES: readonly OrderStatusCode[] = [
'pendingOnline',
'shipping',
'deliverySuccess',
'deliveryFailed',
]
/** 当前状态允许改到的目标(派送成功返回空) */
export function getPostShipStatusChangeTargets(fromStatus: unknown): OrderStatusCode[] {
const code = normalizeOrderStatusCode(
typeof fromStatus === 'string' ? fromStatus.trim() : resolveOrderStatusCode(fromStatus as any),
) as OrderStatusCode
if (!ORDER_POST_SHIP_CHANGEABLE_STATUSES.includes(code)) return []
if (code === 'deliverySuccess') return []
return ORDER_POST_SHIP_CHANGEABLE_STATUSES.filter((s) => s !== code)
}
const ORDER_SHIPPED_STATUS_SET = new Set<string>(ORDER_SHIPPED_STATUSES)
/** 未发货:待上网之前的状态均可尝试拦截/取消拦截 */

View File

@ -189,6 +189,7 @@ export const PERMISSION_ACTION_LABELS: Record<string, string> = {
RecalculatePayable: '重算应付',
BindSnapshotVersions: '切换版本',
ImportDownstreamWeight: '导入对账件重尺',
ChangeStatus: '修改订单状态',
AddDeclarationRow: '添加申报行',
DownstreamForecastResult: '下游预报结果',
More: '更多',
@ -577,6 +578,7 @@ const CORE_GROUPS: PageButtonGroup[] = [
{ code: 'Delete', name: '删除' },
{ code: 'Export', name: '导出' },
{ code: 'ImportDownstreamWeight', name: '导入对账件重尺' },
{ code: 'ChangeStatus', name: '修改订单状态' },
{ code: 'View', name: '查看详情' },
{ code: 'ViewTrack', name: '轨迹' },
{ code: 'More', name: '更多' },

View File

@ -1689,7 +1689,20 @@ export default {
// Top nav - contact & balance
'nav.contactService.title': 'Q&A & Support',
'nav.contactService.selectWarehouse': 'Select warehouse',
'nav.contactService.shipWarehouse': 'Ship-from warehouse',
'nav.contactService.whseName': 'Warehouse name',
'nav.sideRail.collapse': 'Collapse menu',
'nav.sideRail.expand': 'Expand menu',
'nav.userMenu.logout': 'Log out',
'nav.userMenu.pageSettings': 'Page settings',
'nav.userMenu.language': 'Language',
'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.current': 'Current',
'nav.warehousePeek.copyAll': 'Copy',
'nav.warehousePeek.empty': 'No warehouse address yet',
'nav.warehousePeek.emptyField': 'Nothing to copy',
'nav.contactService.serviceWechat': 'Customer WeChat',
'nav.contactService.wechatQrcode': 'WeChat QR code',
'nav.contactService.phone': 'Contact phone',
@ -1839,9 +1852,24 @@ export default {
'order.myOrders.createPickup.predeclaredOnly': 'Only pre-declared orders can be added to a pickup',
'order.myOrders.actions.export': 'Export',
'order.myOrders.actions.modifyOrder': 'Edit order',
'order.myOrders.actions.changeStatus': 'Change order status',
'order.myOrders.actions.createProblematic': 'Create problematic',
'order.myOrders.actions.createClaimed': 'Create claim',
'order.myOrders.actions.reallocate': 'Reallocate',
'order.waybillManage.changeStatus.modalTitle': 'Change order status',
'order.waybillManage.changeStatus.modalTip':
'Only among Pending online, Shipping, Delivered, and Delivery failed. Delivered is enter-only; entering it runs delivery marketing side effects.',
'order.waybillManage.changeStatus.current': 'Current status',
'order.waybillManage.changeStatus.target': 'Target status',
'order.waybillManage.changeStatus.remark': 'Remark',
'order.waybillManage.changeStatus.remarkPlaceholder': 'Explain why (required)',
'order.waybillManage.changeStatus.needOrder': 'Select an order first',
'order.waybillManage.changeStatus.singleOnly': 'Change status supports a single selection only',
'order.waybillManage.changeStatus.notAllowed':
'Current status cannot be corrected (only Pending online / Shipping / Delivery failed)',
'order.waybillManage.changeStatus.noTargets': 'No targets available (Delivered cannot be rolled back)',
'order.waybillManage.changeStatus.success': 'Order status updated',
'order.waybillManage.changeStatus.failed': 'Failed to update order status',
'order.waybillManage.importDownstream.action': 'Import reconcile weight',
'order.waybillManage.importDownstream.modalTitle': 'Import reconcile weight',
'order.waybillManage.importDownstream.tip':

View File

@ -125,7 +125,7 @@ export default {
'menu.order.myOrders': '我的订单',
'menu.order.fulfillOrders': '代发订单',
'menu.order.waybillManage': '运单管理',
'menu.order.pickupOrder': '揽收单',
'menu.order.pickupOrder': '揽收订单',
'menu.order.customs9610Order': '9610订单',
'menu.order.batchJobs': '批量任务',
'menu.order.platformStore': '店铺授权',
@ -1445,7 +1445,20 @@ export default {
// 顶部导航 - 客服 & 余额
'nav.contactService.title': '疑问解答 & 问题咨询',
'nav.contactService.selectWarehouse': '选择仓库',
'nav.contactService.shipWarehouse': '发货仓库',
'nav.contactService.whseName': '仓库名称',
'nav.sideRail.collapse': '收起菜单',
'nav.sideRail.expand': '展开菜单',
'nav.userMenu.logout': '退出登录',
'nav.userMenu.pageSettings': '页面配置',
'nav.userMenu.language': '语言',
'nav.userMenu.switchLanguage': '切换到{lang}',
'nav.warehousePeek.title': '仓库地址',
'nav.warehousePeek.tip': '寄件请备注客户编号,便于仓库认领',
'nav.warehousePeek.current': '当前',
'nav.warehousePeek.copyAll': '复制',
'nav.warehousePeek.empty': '暂无仓库地址',
'nav.warehousePeek.emptyField': '无可复制内容',
'nav.contactService.serviceWechat': '客服微信',
'nav.contactService.wechatQrcode': '微信二维码',
'nav.contactService.phone': '联系电话',
@ -1664,11 +1677,25 @@ export default {
'order.myOrders.createPickup.predeclaredOnly': '仅已预报订单可创建揽收',
'order.myOrders.actions.export': '导出',
'order.myOrders.actions.modifyOrder': '修改订单',
'order.myOrders.actions.changeStatus': '修改订单状态',
'order.myOrders.actions.createProblematic': '创建问题件',
'order.myOrders.actions.createClaimed': '创建理赔件',
'order.myOrders.actions.reallocate': '重新排货',
'order.myOrders.actions.markProcessed': '标记已处理',
'order.myOrders.actions.more': '更多',
'order.waybillManage.changeStatus.modalTitle': '修改订单状态',
'order.waybillManage.changeStatus.modalTip':
'仅可在待上网、运输中、派送成功、派送失败之间校正;派送成功只进不出,进入成功会触发签收相关营销逻辑。',
'order.waybillManage.changeStatus.current': '当前状态',
'order.waybillManage.changeStatus.target': '目标状态',
'order.waybillManage.changeStatus.remark': '变更备注',
'order.waybillManage.changeStatus.remarkPlaceholder': '请说明校正原因(必填)',
'order.waybillManage.changeStatus.needOrder': '请先选择订单',
'order.waybillManage.changeStatus.singleOnly': '修改订单状态仅支持单选',
'order.waybillManage.changeStatus.notAllowed': '当前订单状态不支持校正(仅待上网/运输中/派送失败)',
'order.waybillManage.changeStatus.noTargets': '当前状态无可选目标(派送成功不可回退)',
'order.waybillManage.changeStatus.success': '订单状态已更新',
'order.waybillManage.changeStatus.failed': '订单状态更新失败',
'order.waybillManage.importDownstream.action': '导入对账件重尺',
'order.waybillManage.importDownstream.modalTitle': '导入对账件重尺',
'order.waybillManage.importDownstream.tip':

View File

@ -66,6 +66,12 @@ export function getCustomerNavPath(item: { path?: string; route?: string } | nul
/** 收进用户下拉的辅助页(非高频办事入口) */
const USER_MENU_EXTRA_PATH_PREFIXES = [
'/api/openApi',
] as const
/**
* 客户侧栏/用户菜单不展示的页面(由消息铃铛、仓名旁眼睛等入口打开)。
*/
const CUSTOMER_NAV_DOCK_ONLY_PATH_PREFIXES = [
'/system/announcement',
'/system/warehouseAddress',
] as const
@ -277,6 +283,9 @@ export function buildCustomerFlatNavMenus(
const path = getCustomerNavPath(item)
if (!path || seen.has(path)) return
if (!isVisibleNavNode(item, loginType)) return
if (CUSTOMER_NAV_DOCK_ONLY_PATH_PREFIXES.some((prefix) => matchesPathPrefix(path, prefix))) {
return
}
seen.add(path)
result.push(item)
}

View File

@ -194,15 +194,22 @@ export function translateMenuTitle(
menu: MenuTitleSource,
): string {
const rbacName = (menu.name || menu.meta?.title || '').trim()
// 菜单管理配置的名称优先于静态 locale 文案
if (rbacName && !rbacName.startsWith('menu.')) {
const key = resolveMenuI18nKey(menu)
const locale = String(i18n.global.locale.value || 'zh-CN')
const isZh = locale.toLowerCase().startsWith('zh')
// 中文:菜单管理配置名优先(与 RBAC 一致,避免被语言包简称覆盖,如「揽收订单」→「揽收单」)
if (isZh && rbacName && !rbacName.startsWith('menu.')) {
return rbacName
}
const key = resolveMenuI18nKey(menu)
if (!key) return rbacName
const translated = String(t(key))
return translated && translated !== key ? translated : rbacName
// 非中文:跟语言包走,保证切英文能切换;无文案再回退 RBAC 名
if (key) {
const translated = String(t(key))
if (translated && translated !== key) return translated
}
if (rbacName && !rbacName.startsWith('menu.')) return rbacName
return rbacName || key || ''
}
/** 在非 setup 场景(如 pinia)用全局 i18n 翻译路径标题;fallback 为菜单管理名称时直接使用 */

View File

@ -0,0 +1,217 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue'
import ModalPro from '@/components/ModalPro/index.vue'
import useModal from '@/components/ModalPro/useModal'
import FormPro from '@/components/FormPro/index.vue'
import { postOrderChangePostShipStatus } from '@/api/order'
import {
getOrderStatusMeta,
getPostShipStatusChangeTargets,
type OrderStatusCode,
} from '@/constants/orderStatus'
const props = defineProps<{
visible: boolean
orderId: number | null
currentStatus: string
waybillLabel?: string
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: []
}>()
const { t } = useI18n()
const [registerModal, modalMethods] = useModal()
const formRef = ref<InstanceType<typeof FormPro>>()
const submitting = ref(false)
const formState = reactive({
targetStatus: undefined as OrderStatusCode | undefined,
remark: '',
})
const targetOptions = computed(() =>
getPostShipStatusChangeTargets(props.currentStatus).map((code) => {
const meta = getOrderStatusMeta(code)
return {
value: code,
label: meta ? t(meta.labelKey) : code,
}
}),
)
const currentStatusLabel = computed(() => {
const meta = getOrderStatusMeta(props.currentStatus)
return meta ? t(meta.labelKey) : props.currentStatus || '--'
})
const itemList = computed(() => {
const requiredMsg = t('system.serviceProviders.modal.rules.requiredInput')
return [
{
label: t('order.waybillManage.changeStatus.current'),
field: 'currentStatusDisplay',
comp: 'slot',
span: 24,
},
{
label: t('order.waybillManage.changeStatus.target'),
field: 'targetStatus',
comp: 'ASelect',
span: 24,
rules: [{ required: true, message: requiredMsg }],
compProps: {
options: targetOptions.value,
placeholder: t('common.selectPlaceholder'),
allowClear: false,
},
},
{
label: t('order.waybillManage.changeStatus.remark'),
field: 'remark',
comp: 'ATextarea',
span: 24,
rules: [{ required: true, message: requiredMsg }],
compProps: {
autoSize: { minRows: 3, maxRows: 5 },
maxlength: 512,
showWordLimit: true,
placeholder: t('order.waybillManage.changeStatus.remarkPlaceholder'),
},
},
]
})
function resetForm() {
formState.targetStatus = targetOptions.value[0]?.value
formState.remark = ''
}
watch(
() => props.visible,
async (open) => {
if (!open) return
resetForm()
await nextTick()
formRef.value?.setValues?.({ ...formState })
formRef.value?.formRef?.clearValidate?.()
},
{ immediate: true },
)
function onUpdateVisible(val: boolean) {
if (val === props.visible) return
emit('update:visible', val)
}
function handleCancel() {
emit('update:visible', false)
}
async function handleSubmit() {
if (!props.orderId) {
Message.warning(t('order.waybillManage.changeStatus.needOrder'))
return
}
if (!targetOptions.value.length) {
Message.warning(t('order.waybillManage.changeStatus.noTargets'))
return
}
const valid = await formRef.value?.formRef?.validate?.()
if (valid) return
const values = formRef.value?.getValues?.() ?? {}
const targetStatus = String(values.targetStatus ?? formState.targetStatus ?? '').trim()
const remark = String(values.remark ?? formState.remark ?? '').trim()
if (!targetStatus || !remark) {
Message.warning(t('system.serviceProviders.modal.rules.requiredInput'))
return
}
submitting.value = true
try {
await postOrderChangePostShipStatus({
id: props.orderId,
targetStatus,
remark,
})
Message.success(t('order.waybillManage.changeStatus.success'))
emit('success')
emit('update:visible', false)
modalMethods.closeModal()
} catch (e: any) {
Message.error(e?.message || t('order.waybillManage.changeStatus.failed'))
} finally {
submitting.value = false
}
}
</script>
<template>
<ModalPro
:visible="visible"
:title="t('order.waybillManage.changeStatus.modalTitle')"
:width="520"
:mask-closable="false"
:confirm-loading="submitting"
@register="registerModal"
@update:visible="onUpdateVisible"
@ok="handleSubmit"
@cancel="handleCancel"
>
<a-alert type="info" :show-icon="false" class="modal-form-tip">
{{ t('order.waybillManage.changeStatus.modalTip') }}
</a-alert>
<FormPro
ref="formRef"
:model="formState"
:item-list="itemList"
:row-gutter="16"
:default-span="24"
form-layout="vertical"
>
<template #currentStatusDisplay>
<div class="current-status-line">
<span class="current-status-line__label">{{ currentStatusLabel }}</span>
<span v-if="waybillLabel" class="current-status-line__waybill">{{ waybillLabel }}</span>
</div>
</template>
</FormPro>
</ModalPro>
</template>
<style scoped lang="less">
.modal-form-tip {
margin-bottom: 16px;
border-radius: 6px;
background: color-mix(in srgb, var(--app-primary-color) 8%, #fff);
border: 1px solid color-mix(in srgb, var(--app-primary-color) 18%, transparent);
color: var(--color-text-2);
font-size: 13px;
line-height: 1.5;
}
.current-status-line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-height: 32px;
color: var(--color-text-1);
&__label {
font-weight: 600;
}
&__waybill {
color: var(--color-text-3);
font-size: 12px;
}
}
</style>

View File

@ -87,6 +87,7 @@
isOrderDeletable,
isOrderNotShipped,
getOrderStatusMeta,
getPostShipStatusChangeTargets,
buildOrderStatusTagHtml,
buildOrderStatusSubTagHtml,
buildBooleanTagHtml,
@ -133,6 +134,9 @@
const StockBindModal = defineAsyncComponent(
() => import('../myOrders/components/StockBindModal.vue'),
);
const ChangePostShipStatusModal = defineAsyncComponent(
() => import('./components/ChangePostShipStatusModal.vue'),
);
defineOptions({
name: 'WaybillManage',
@ -579,11 +583,20 @@
const showStockBindAction = computed(
() => hasPermission('Order.WaybillManage.StockBind'),
);
const showChangeStatusAction = computed(
() =>
hasPermission('Order.WaybillManage.ChangeStatus') &&
isActiveTab('all', 'pendingOnline', 'shipping', 'deliverySuccess', 'deliveryFailed'),
);
const stockBindModalVisible = ref(false);
const stockBindOrderIds = ref<number[]>([]);
/** 本次打开绑定前是否含直发单(用于成功后提示已转代发) */
const stockBindDidConvert = ref(false);
const changeStatusModalVisible = ref(false);
const changeStatusOrderId = ref<number | null>(null);
const changeStatusCurrent = ref('');
const changeStatusWaybillLabel = ref('');
const problematicModalVisible = ref(false);
const claimedModalVisible = ref(false);
const reallocateModalVisible = ref(false);
@ -1636,6 +1649,29 @@
await openDetailWithOptionalFetch(rows[0], { editBasic: true });
};
const openChangeStatusModal = () => {
const rows = requireSelectedRows();
if (!rows.length) return;
if (rows.length > 1) {
Message.warning(t('order.waybillManage.changeStatus.singleOnly'));
return;
}
const row = rows[0];
const id = getOrderRowId(row);
if (id == null) return;
const status = resolveOrderStatusCode(row);
if (!getPostShipStatusChangeTargets(status).length) {
Message.warning(t('order.waybillManage.changeStatus.notAllowed'));
return;
}
const waybillNo = String(row?.waybillNo ?? row?.WaybillNo ?? '').trim();
const serviceNo = String(row?.serviceNo ?? row?.ServiceNo ?? '').trim();
changeStatusOrderId.value = Number(id);
changeStatusCurrent.value = status;
changeStatusWaybillLabel.value = waybillNo || serviceNo || `#${id}`;
changeStatusModalVisible.value = true;
};
const openProblematicModal = () => {
const rows = requireSelectedRows();
if (!rows.length) return;
@ -1799,6 +1835,7 @@
if (key === 'export') return handleExport();
if (key === 'importDownstreamWeight') return openDownstreamWeightImport();
if (key === 'modifyOrder') return handleModifyOrder();
if (key === 'changeStatus') return openChangeStatusModal();
if (key === 'stockBind') return openStockBindModal();
};
@ -2319,6 +2356,11 @@
:disabled="selectedRowCount === 0"
@click="onMoreActionClick('modifyOrder')"
>{{ t('order.myOrders.actions.modifyOrder') }}</a-doption>
<a-doption
v-if="showChangeStatusAction"
:disabled="selectedRowCount === 0"
@click="onMoreActionClick('changeStatus')"
>{{ t('order.myOrders.actions.changeStatus') }}</a-doption>
</template>
</a-dropdown>
</div>
@ -2511,6 +2553,14 @@
:order-ids="stockBindOrderIds"
@success="handleStockBindSuccess"
/>
<ChangePostShipStatusModal
v-if="changeStatusModalVisible"
v-model:visible="changeStatusModalVisible"
:order-id="changeStatusOrderId"
:current-status="changeStatusCurrent"
:waybill-label="changeStatusWaybillLabel"
@success="handleExceptionActionSuccess"
/>
</div>
</template>