front/src/components/customer-side-rail/WarehouseAddressPeekModal.vue
zzh 3bfe6c6a26
All checks were successful
Build and Deploy Front / deploy (push) Successful in 6m30s
fix: 登录页签残留、表单校验脱节与仓库入口合并
排除登录路由进页签;FormPro 内部 model 优先;侧栏仓库选择与地址弹窗合并并可切换。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-26 12:19:47 +08:00

484 lines
13 KiB
Vue

<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 = ''
}
}
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 {
const [res] = await Promise.all([
getWarehouseList({ page: 1, pageSize: 100 }),
loadCustomerCode(),
])
list.value = (res.items ?? []).map(normalize).sort(compareWarehouseSort)
syncWarehouseOptions(list.value)
} 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>
<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">
{{ 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 }"
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">
<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>
<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.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.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.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.stop="copyField(item.remark || '')">
<icon-copy />
</button>
</div>
</div>
</article>
</a-spin>
</ModalPro>
</template>
<style scoped lang="less">
.peek-tip {
margin: 0 0 6px;
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-hint {
margin: 0 0 12px;
color: var(--color-text-3);
font-size: 12px;
line-height: 1.4;
}
.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;
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 {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
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;
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>