1.02 #3

Merged
zzh merged 4 commits from test into master 2026-10-09 18:55:53 +08:00
16 changed files with 876 additions and 256 deletions
Showing only changes of commit 640562654d - Show all commits

View File

@ -9,6 +9,10 @@ export interface WarehouseDto {
recipient?: string
contactPhone?: string
shippingAddress?: string
/** 展示排序,越小越靠前;可不填 */
sort?: number | null
/** 备注(选填) */
remark?: string
createTime?: string
updateTime?: string
createBy?: string

View File

@ -220,7 +220,8 @@
originalParamIds.value = source
? buildOriginalParamIdsFromHydrated(source)
: createEmptyOriginalParamIds();
nextTick(() => captureDirtyBaseline());
// 等表格/公共参数/表单回填落稳后再采基线,避免「刚打开就提示未保存」
void settleDirtyBaseline();
}
watch(
@ -288,6 +289,10 @@
const exampleDirtyBaseline = ref({ request: '', response: '' });
const manualDirty = ref(false);
const isDirty = ref(false);
/** 接口明细加载/回填中:不算未保存,避免误报 */
const detailHydrating = ref(false);
let baselineSettleSeq = 0;
const dirtyBaselineReady = ref(false);
function pickFormDirtySnapshot() {
const d: any = props.basicFormData || {};
@ -318,15 +323,24 @@
}
function hasUnsavedChanges() {
// 加载未完成时不提示「有未保存更改」
if (detailHydrating.value || !dirtyBaselineReady.value) return false;
if (manualDirty.value) return true;
if (fixedParamsHasChanges.value) return true;
if (returnConfigHasChanges.value) return true;
if (formDirtyBaseline.value && pickFormDirtySnapshot() !== formDirtyBaseline.value) return true;
if ((requestExample.value || '') !== (exampleDirtyBaseline.value.request || '')) return true;
if ((responseExample.value || '') !== (exampleDirtyBaseline.value.response || '')) return true;
if (actionRowsDirty(() => queryTableRef.value?.getActionRows?.())) return true;
if (actionRowsDirty(() => headerTableRef.value?.getActionRows?.())) return true;
if (actionRowsDirty(() => bodyFormTableRef.value?.getActionRows?.())) return true;
// 公共参数行不参与脏检查
if (actionRowsDirty(() => filterCommonFromActionRows(queryTableRef.value?.getActionRows?.()))) {
return true;
}
if (actionRowsDirty(() => filterCommonFromActionRows(headerTableRef.value?.getActionRows?.()))) {
return true;
}
if (actionRowsDirty(() => filterCommonFromActionRows(bodyFormTableRef.value?.getActionRows?.()))) {
return true;
}
if (
actionRowsDirty(() =>
stripBodyRootFromActionRows(requestTableRef.value?.getActionRows?.() || {}),
@ -344,6 +358,7 @@
}
function markDirty() {
if (detailHydrating.value) return;
manualDirty.value = true;
isDirty.value = true;
}
@ -356,6 +371,60 @@
response: responseExample.value || '',
};
isDirty.value = false;
dirtyBaselineReady.value = true;
}
/** 用当前 props.data 再 reload 一次,清掉 VXE keep-source 在回填过程中留下的假 insert/update */
function reseatParamTables() {
const reload = (refLike: { value?: { reloadData?: (rows: any[]) => void } }, rows: any[]) => {
if (!refLike?.value?.reloadData) return;
refLike.value.reloadData(Array.isArray(rows) ? [...rows] : []);
};
reload(queryTableRef, queryTableProps.data);
reload(headerTableRef, headerTableProps.data);
reload(bodyFormTableRef, bodyFormTableProps.data);
reload(mappingBodyTableRef, mappingBodyTableProps.data);
reload(requestTableRef, requestTableProps.data);
reload(responseTableRef, responseTableProps.data);
}
/**
* 等待公共参数合并、表格挂载、FormPro/SelectPro 回填后再采脏检查基线。
* 解决:选中接口后立刻切 API 调试误报「有未保存的更改」。
*/
async function settleDirtyBaseline() {
const seq = ++baselineSettleSeq;
detailHydrating.value = true;
dirtyBaselineReady.value = false;
manualDirty.value = false;
isDirty.value = false;
await nextTick();
await nextTick();
if (seq !== baselineSettleSeq) return;
// 等服务商公共参数拉取并合并进 Query/Header/Body
const waitStart = Date.now();
while (inheritedCommonParamsLoading.value && Date.now() - waitStart < 4000) {
await new Promise((r) => setTimeout(r, 40));
if (seq !== baselineSettleSeq) return;
}
await nextTick();
reseatParamTables();
await nextTick();
if (seq !== baselineSettleSeq) return;
captureDirtyBaseline();
detailHydrating.value = false;
// SelectPro/字典异步回写可能再改一帧表单值,二次采基线(仅无手动编辑时)
await new Promise((r) => setTimeout(r, 120));
if (seq !== baselineSettleSeq || manualDirty.value) return;
reseatParamTables();
await nextTick();
if (seq !== baselineSettleSeq || manualDirty.value) return;
captureDirtyBaseline();
}
/** 浏览器关闭/刷新:有未保存更改时提示 */
@ -370,6 +439,8 @@
* resolve true = 可离开(丢弃更改),false = 留下继续编辑。
*/
async function confirmLeaveUnsaved(options?: { content?: string }): Promise<boolean> {
// 仍在回填:用户不可能已编辑,直接放行
if (detailHydrating.value || !dirtyBaselineReady.value) return true;
refreshDirtyState();
if (!hasUnsavedChanges()) return true;
return new Promise((resolve) => {
@ -562,11 +633,19 @@
(id, oldId) => {
if (oldId) saveTabMemory(oldId);
if (id) {
// 切换接口:进入回填态,避免沿用上一接口的脏标记
detailHydrating.value = true;
dirtyBaselineReady.value = false;
manualDirty.value = false;
isDirty.value = false;
nextTick(() => {
restoreTabMemory(id);
// 加载完成后由父组件 hydrate 再 capture;此处先清 manual
manualDirty.value = false;
});
} else {
detailHydrating.value = false;
dirtyBaselineReady.value = false;
manualDirty.value = false;
isDirty.value = false;
}
},
);
@ -3283,7 +3362,8 @@
}
} else {
fixedParams.value = payload.fixedParams;
fixedParamsHasChanges.value = payload.fixedParams.length > 0;
// 有固定配置数据 ≠ 未保存更改;加载后应视为干净
fixedParamsHasChanges.value = false;
await nextTick();
queryFixedTableRef.value?.reloadData?.(payload.fixedParams);
}

View File

@ -3046,6 +3046,8 @@ export default {
'system.interface.mapping.table.providerParamName': 'Provider parameter name',
'system.interface.mapping.table.providerParamNameCn': 'Provider parameter description',
'system.interface.mapping.table.paramValue': 'Parameter value',
'system.interface.mapping.table.paramValueTip':
'If a value looks like both a number and a string (e.g. phone 075525131031), wrap it in single quotes as \'075525131031\' to mark it as a string. Entering it without quotes treats it as a number and may drop leading zeros or cause type errors.',
'system.interface.mapping.table.inputParam': 'Input parameter',
'system.interface.mapping.table.inputParamCn': 'Input parameter description',
'system.interface.mapping.actions.prefixScript': 'Prefix script',
@ -4085,8 +4087,12 @@ export default {
'system.warehouseAddress.fields.recipient': 'Recipient',
'system.warehouseAddress.fields.contactPhone': 'Contact Phone',
'system.warehouseAddress.fields.shippingAddress': 'Shipping Address',
'system.warehouseAddress.fields.sort': 'Sort',
'system.warehouseAddress.fields.remark': 'Remark',
'system.warehouseAddress.fields.servicePhone': 'Service Phone',
'system.warehouseAddress.fields.serviceWechat': 'Service WeChat',
'system.warehouseAddress.placeholder.sort': 'Smaller first (optional)',
'system.warehouseAddress.placeholder.remark': 'Optional note shown to customers',
'system.warehouseAddress.actions.add': 'Add',
'system.warehouseAddress.actions.edit': 'Edit',
'system.warehouseAddress.actions.copy': 'Copy',

View File

@ -2908,6 +2908,8 @@ export default {
'system.interface.mapping.table.providerParamName': '服务商参数名',
'system.interface.mapping.table.providerParamNameCn': '服务商参数描述',
'system.interface.mapping.table.paramValue': '参数值',
'system.interface.mapping.table.paramValueTip':
'若填写的值既像数字又像字符串(例如电话号 075525131031),请用单引号包起来写成 \'075525131031\',明确为字符串。直接填写会被当成数字解析,可能导致前导零丢失或类型错误。',
'system.interface.mapping.table.inputParam': '入参',
'system.interface.mapping.table.inputParamCn': '入参描述',
'system.interface.mapping.actions.prefixScript': '前置脚本',
@ -4113,8 +4115,12 @@ export default {
'system.warehouseAddress.fields.recipient': '收件人',
'system.warehouseAddress.fields.contactPhone': '联系电话',
'system.warehouseAddress.fields.shippingAddress': '收货地址',
'system.warehouseAddress.fields.sort': '排序',
'system.warehouseAddress.fields.remark': '备注',
'system.warehouseAddress.fields.servicePhone': '客服电话',
'system.warehouseAddress.fields.serviceWechat': '客服微信',
'system.warehouseAddress.placeholder.sort': '越小越靠前,可不填',
'system.warehouseAddress.placeholder.remark': '选填,展示给客户的说明',
'system.warehouseAddress.actions.add': '新增',
'system.warehouseAddress.actions.edit': '编辑',
'system.warehouseAddress.actions.copy': '复制',

View File

@ -0,0 +1,49 @@
/**
* 客户登录时展示/复制仓库寄件信息:
* - 收件人后拼接客户编号(空格分隔)
* - 地址后拼接客户编号 +「号」(无空格)
* 例:张志坚 AI0A1 / …二楼AI0A1号
*/
/** 有 Sort 的升序靠前,无 Sort 的靠后,再按 id */
export function compareWarehouseSort(
a: { sort?: number | null; id?: number | string | null; value?: string | number | null },
b: { sort?: number | null; id?: number | string | null; value?: string | number | null },
): number {
const aHas = a.sort != null && Number.isFinite(Number(a.sort));
const bHas = b.sort != null && Number.isFinite(Number(b.sort));
if (aHas !== bHas) return aHas ? -1 : 1;
if (aHas && bHas) {
const diff = Number(a.sort) - Number(b.sort);
if (diff !== 0) return diff;
}
const aId = Number(a.id ?? a.value ?? 0);
const bId = Number(b.id ?? b.value ?? 0);
return aId - bId;
}
export function formatWarehouseShipRecipient(
recipient?: string | null,
customerCode?: string | null,
): string {
const base = String(recipient ?? '').trim();
const code = String(customerCode ?? '').trim();
if (!code) return base || '-';
if (!base) return code;
if (base === code || base.endsWith(` ${code}`) || base.endsWith(code)) return base;
return `${base} ${code}`;
}
export function formatWarehouseShipAddress(
address?: string | null,
customerCode?: string | null,
): string {
const base = String(address ?? '').trim();
const code = String(customerCode ?? '').trim();
if (!code) return base || '-';
const withHao = `${code}号`;
if (!base) return withHao;
if (base.endsWith(withHao)) return base;
if (base.endsWith(code)) return `${base}号`;
return `${base}${withHao}`;
}

View File

@ -1,5 +1,6 @@
<script lang="ts" setup>
import { ref, computed, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import {
getServiceProviderAccountGetAccountPageList,
getServiceProviderAccountGetFieldList,
@ -18,6 +19,8 @@
name: 'AccountList',
});
const { t } = useI18n();
const emit = defineEmits<{
(e: 'saved', payload: { id: number; name?: string; code?: string }): void;
}>();
@ -487,7 +490,8 @@
>
<div class="account-list-body">
<a-alert type="info" :show-icon="false" class="modal-form-tip">
配置服务商账户凭证;可先自定义表头再录入。修改后点确定保存,发布后才生效。
<p>配置服务商账户凭证;可先自定义表头再录入。修改后点确定保存,发布后才生效。</p>
<p>{{ t('system.interface.mapping.table.paramValueTip') }}</p>
</a-alert>
<div class="form-section-title">账户凭证</div>
@ -670,6 +674,14 @@
color: var(--color-text-2);
font-size: 13px;
line-height: 1.5;
p {
margin: 0;
}
p + p {
margin-top: 6px;
}
}
.form-section-title {

View File

@ -8,7 +8,6 @@
getGetMapParameterCascadedList,
} from '@/api/InterfaceMappingServices/interfaceMap';
import { getParameterGetParameterList } from '@/api/ParameterServices/parameter';
import AddParameter from './AddParameter.vue';
import MapAiAssistModal from './MapAiAssistModal.vue';
@ -41,52 +40,21 @@
const tableRef1 = ref();
const active = ref(0);
const AddParameterRef = ref();
const formsTypeList_new = ref<any[]>([]);
const maxCompletedIndex = ref<number>(-1);
const outputAttention = ref(false);
const outputStepRef = ref<HTMLElement | null>(null);
let outputAttentionTimer: ReturnType<typeof setTimeout> | null = null;
/** 当前已加载的映射上下文,避免切 tab 重复拉取 */
const loadedContextKey = ref('');
let initSeq = 0;
let inflightInit: Promise<void> | null = null;
let inflightInitKey = '';
const detailLoading = ref(false);
interface Indexable {
[key: string]: any;
}
// 请求去重:记录正在请求的接口ID
const loadingParameterId = ref<number | null>(null);
async function formsValueInfoGetList() {
// 如果正在加载相同的接口,直接返回
if (loadingParameterId.value === props.interfaceId) {
return;
}
// 如果没有接口ID,直接返回
if (!props.interfaceId) {
return;
}
loadingParameterId.value = props.interfaceId;
try {
const { data } = await getParameterGetParameterList({
isInPara: true,
id: Number(props.interfaceId),
onlyLeafs: true,
});
formsTypeList_new.value = data;
formsTypeList.value = data;
} catch (error) {
} finally {
loadingParameterId.value = null;
}
}
// 监听 interfaceId 变化,只在变化时请求
watch(() => props.interfaceId, (newId) => {
if (newId) {
formsValueInfoGetList();
}
}, { immediate: true });
const tableProps1 = computed(() => {
return {
columnsKey: 'mapDetails111',
@ -168,6 +136,7 @@
title: t('system.interface.mapping.table.paramValue'),
minWidth: 120,
slots: {
header: 'fixedValueHeader',
edit: 'fixedValueEdit',
},
editRender: {},
@ -285,9 +254,17 @@
id: e.interfaceId,
};
});
invalidateCascaderOptionsCache();
};
onMounted(() => {
restaurants.value = loadAll();
// 面板首次挂载时自行拉数,避免依赖父级 setTimeout
if (props.serviceProviderId && props.interfaceId) {
void initData({
serviceProviderId: props.serviceProviderId,
interfaceId: props.interfaceId,
});
}
});
type RestaurantItem = {
value: string;
@ -328,24 +305,48 @@
return data;
}
const mapAliasEditRef = ref();
function getOptions(item: any, isReverse = false) {
if (isReverse)
return setlabel(cascadedList.value.slice(1, cascadedList.value.length));
/** 级联 options 按步骤缓存,避免每行渲染都深拷贝/打标 */
const cascaderOptionsCache = ref<Map<string, any[]>>(new Map());
const ids: number[] = inParaMapList.value.map((t) => t.interfaceId!);
const findI = ids.findIndex((i) => i === item.interfaceId);
let data: any[] = [];
if (findI === -1 || findI === 0) {
data = [cascadedList.value[0]];
} else {
data = [cascadedList.value[0]].concat(
cascadedList.value.filter((t) => {
return ids.slice(0, findI).includes(t.interfaceId!);
})
);
}
return setlabel(data);
function invalidateCascaderOptionsCache() {
cascaderOptionsCache.value = new Map();
}
function getOptions(item: any, isReverse = false) {
const cacheKey = isReverse
? 'reverse'
: `fwd-${item?.interfaceId ?? 'unknown'}`;
const cached = cascaderOptionsCache.value.get(cacheKey);
if (cached) return cached;
let result: any[];
if (isReverse) {
result = setlabel(cascadedList.value.slice(1, cascadedList.value.length));
} else {
const ids: number[] = inParaMapList.value.map((t) => t.interfaceId!);
const findI = ids.findIndex((i) => i === item.interfaceId);
let data: any[] = [];
if (findI === -1 || findI === 0) {
data = cascadedList.value[0] ? [cascadedList.value[0]] : [];
} else {
data = [cascadedList.value[0]].concat(
cascadedList.value.filter((t) => {
return ids.slice(0, findI).includes(t.interfaceId!);
}),
).filter(Boolean);
}
result = setlabel(data);
}
cascaderOptionsCache.value.set(cacheKey, result);
return result;
}
/** 当前入参步骤 / 返参步骤共用一份 options,模板里不要对每行再算 */
const activeInputCascaderOptions = computed(() => {
if (!activeInParaItem.value) return [];
return getOptions(activeInParaItem.value, false);
});
const outputCascaderOptions = computed(() => getOptions({ interfaceId: 0 }, true));
function mapAliasEditCheng(
e: any,
row: API.InterfaceParameterMapOutputValueObject
@ -424,7 +425,6 @@
temporaryItem.value = undefined;
emits('save-model-enter');
}
const formsTypeList = ref<any[]>([]);
// 支持多关键词(空格分隔)、大小写不敏感,匹配别名/中文名/名称/代码等字段
function filterMethod(val: string, node: any) {
const query = (val ?? '').toString().trim().toLowerCase();
@ -471,7 +471,9 @@
}
function getVali(row: Indexable) {
tableRef.value?.forEach((element: any) => {
const refs = tableRef.value;
const tables = Array.isArray(refs) ? refs : refs ? [refs] : [];
tables.forEach((element: any) => {
setTimeout(() => {
element?.editRowCellEvent(row, 'mapAlias');
element?.xGrid.scrollToRow(row, 'mapAlias');
@ -485,36 +487,77 @@
}, 300);
}
function resultsFn() {
formsTypeList_new.value = [];
formsTypeList.value = [];
mapDetails.value = [];
active.value = 0;
maxCompletedIndex.value = -1;
inParaMapList.value = [];
function buildContextKey(data: { serviceProviderId?: number; interfaceId?: number }) {
return `${data?.serviceProviderId ?? 0}:${data?.interfaceId ?? 0}`;
}
async function initData(data: any, options?: { activeIndex?: number }) {
function hasLoadedData() {
return !!(inParaMapList.value.length || mapDetails.value.length);
}
async function initData(
data: any,
options?: { activeIndex?: number; force?: boolean },
) {
const ctx = buildContextKey(data);
const nextActive = options?.activeIndex;
resultsFn();
if (typeof nextActive === 'number') {
active.value = nextActive;
const force = !!options?.force || typeof nextActive === 'number';
// 同服务商/接口已加载过:切回映射明细 tab 时跳过重复请求
if (!force && loadedContextKey.value === ctx && hasLoadedData()) {
return;
}
// 挂载与父级同时触发时合并为一次请求
if (!force && inflightInit && inflightInitKey === ctx) {
await inflightInit;
return;
}
getEditMapDetails(data).then((resp) => {
const detail = (resp as any)?.data || resp || {};
inParaMapList.value = detail.inParaMapList || [];
mapDetails.value = detail.mapDetails || [];
});
const seq = ++initSeq;
detailLoading.value = true;
getCascadedList(data);
const run = (async () => {
try {
const [detailResp] = await Promise.all([
getEditMapDetails(data),
getCascadedList(data),
]);
if (seq !== initSeq) return;
setActive(active.value);
const detail = (detailResp as any)?.data || detailResp || {};
inParaMapList.value = detail.inParaMapList || [];
mapDetails.value = detail.mapDetails || [];
loadedContextKey.value = ctx;
invalidateCascaderOptionsCache();
if (typeof nextActive === 'number') {
active.value = nextActive;
} else {
active.value = 0;
maxCompletedIndex.value = -1;
}
} finally {
if (seq === initSeq) detailLoading.value = false;
}
})();
inflightInit = run;
inflightInitKey = ctx;
try {
await run;
} finally {
if (inflightInit === run) {
inflightInit = null;
inflightInitKey = '';
}
}
}
defineExpose({
tableRef,
getdata,
initData,
hasLoadedData,
getVali,
getReturnVali,
advanceAfterSave,
@ -640,20 +683,38 @@
<span class="output-hint-action">{{ t('system.interface.mapping.outputMappingGoFill') }}</span>
</button>
<div class="content-pane">
<div class="content-scroll">
<div class="content-body">
<template v-for="(item, index) in inParaMapList" :key="index">
<div v-show="active === index" class="map-table-wrapper">
<a-spin :loading="detailLoading" class="map-detail-spin">
<div class="content-scroll">
<div class="content-body">
<!-- 只挂载当前步骤表格,避免多步骤×多参数同时渲染 -->
<div
v-if="activeInParaItem && active >= 0"
:key="`in-${activeInParaItem.interfaceMapId || active}`"
class="map-table-wrapper"
>
<div class="map-scroll">
<TablePro
ref="tableRef"
v-bind="tableProps"
class="map-table"
:table-config="{ data: item?.mapDetails || [] }"
:table-config="{ data: activeInParaItem?.mapDetails || [] }"
>
<template #toolbar_right>
<slot name="toolbar_right" />
</template>
<template #fixedValueHeader>
<span class="param-value-header">
{{ t('system.interface.mapping.table.paramValue') }}
<a-tooltip position="top" :content-style="{ maxWidth: '320px' }">
<template #content>
<div class="param-value-help">
{{ t('system.interface.mapping.table.paramValueTip') }}
</div>
</template>
<IconQuestionCircle class="param-value-help-icon" @click.stop />
</a-tooltip>
</span>
</template>
<template #fixedValueEdit="{ row }">
<a-auto-complete
v-model="row.fixedValue"
@ -677,7 +738,7 @@
<a-cascader
ref="mapAliasEditRef"
v-model="row.mapId"
:options="getOptions(item, false)"
:options="activeInputCascaderOptions"
:filter-option="filterMethod"
allow-clear
allow-search
@ -712,9 +773,7 @@
</TablePro>
</div>
</div>
</template>
<template v-if="active === -1">
<div class="map-table-wrapper">
<div v-else-if="active === -1" class="map-table-wrapper">
<a-alert
v-if="outputNeedsAttention"
type="warning"
@ -734,11 +793,10 @@
</span>
</template>
<template #mapAlias="{ row }">
<a-cascader
ref="mapAliasEditRef"
v-model="row.mapId"
:options="getOptions({ interfaceId: 0 }, true)"
:options="outputCascaderOptions"
:filter-option="filterMethod"
allow-search
allow-clear
@ -766,9 +824,9 @@
</TablePro>
</div>
</div>
</template>
</div>
</div>
</div>
</a-spin>
</div>
<AddParameter ref="AddParameterRef" @seva="save" />
<MapAiAssistModal
@ -783,6 +841,13 @@
</template>
<style scoped>
.entered-map { display: flex; flex-direction: column; width: 100%; height: 100%; min-height: 0; overflow: hidden; }
.map-detail-spin { display: block; width: 100%; height: 100%; min-height: 0; }
.map-detail-spin :deep(.arco-spin-children) {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.steps-bar {
display: flex;
align-items: center;
@ -821,13 +886,24 @@
white-space: nowrap;
}
.unequal-help-icon,
.script-help-icon {
.script-help-icon,
.param-value-help-icon {
color: var(--color-text-3);
font-size: 14px;
cursor: help;
}
.param-value-header {
display: inline-flex;
align-items: center;
gap: 4px;
}
.param-value-help {
font-size: 12px;
line-height: 1.5;
}
.unequal-help-icon:hover,
.script-help-icon:hover {
.script-help-icon:hover,
.param-value-help-icon:hover {
color: var(--app-primary-color);
}
.step-item { position: relative; display: flex; align-items: center; color: var(--color-text-3); cursor: pointer; flex-shrink: 0; }

View File

@ -119,10 +119,10 @@
}
function handleSearch() {
loadList();
loadList({ keepSelection: true });
}
async function loadList() {
async function loadList(options?: { keepSelection?: boolean }) {
if (!props.id) {
list.value = [];
selectedId.value = null;
@ -141,10 +141,14 @@
const raw: any = response || {};
const data: any = raw.items ?? raw.data ?? raw.list ?? raw.records ?? raw;
list.value = Array.isArray(data) ? data : [];
// 仅当当前选中项在新列表中不存在时才清空,避免“点击一次又掉选中”
if (
if (!options?.keepSelection) {
// 切换上游接口时不默认选中第一条映射
selectedId.value = null;
} else if (
selectedId.value !== null &&
!list.value.some((row: any, index: number) => getRowKey(row, index) === selectedId.value)
!list.value.some(
(row: any, index: number) => String(getRowKey(row, index)) === String(selectedId.value),
)
) {
selectedId.value = null;
}
@ -157,19 +161,21 @@
}
watch(() => props.id, (id) => {
if (id) loadList();
else {
list.value = [];
selectedId.value = null;
}
// 换接口先清选中,避免沿用上一次的服务商高亮
selectedId.value = null;
if (id) loadList({ keepSelection: false });
else list.value = [];
}, { immediate: true });
// 暴露与旧用法兼容的 reload 接口
tableRef.value = { reload: loadList };
// 暴露与旧用法兼容的 reload:默认保留当前选中(发布/保存后刷新不丢高亮)
tableRef.value = {
reload: (options?: { keepSelection?: boolean }) =>
loadList({ keepSelection: options?.keepSelection !== false }),
};
function setSearchKeyword(val: string) {
searchKeyword.value = val || '';
loadList();
loadList({ keepSelection: true });
}
// 右侧内容
@ -191,7 +197,7 @@
serviceProviderId: row.serviceProviderId,
}).then(() => {
$baseMessage(t('system.interface.message.deleteSuccess'), 'success');
loadList();
loadList({ keepSelection: false });
});
},
});
@ -350,6 +356,8 @@
);
registerReleaseMethods.closeModal();
// 发布后刷新列表,保留当前服务商选中
void loadList({ keepSelection: true });
},
});
@ -390,7 +398,7 @@
:show-node-index="true"
:show-node-code="true"
@search="handleSearch"
@refresh="loadList"
@refresh="() => loadList({ keepSelection: true })"
@add="handleAdd"
@select="handleMenuSelect"
@node-click="handleNodeClick"

View File

@ -53,6 +53,8 @@
const active = ref(0);
const activeContentTab = ref('detail'); // 接口明细、API调试、映射明细
const currentActiveTab = ref('detail'); // 用于保存按钮判断的当前tab
/** 映射明细首次打开后保持挂载,避免反复销毁重建 */
const mappingPanelReady = ref(false);
const apiDebuggerRef = ref<any>();
const apiDebuggerRefreshKey = ref(0);
@ -1147,10 +1149,11 @@
// 将上游接口ID同步给右侧明细区域
upStreamIdRef.value = Number(nodeData.id);
// 重置映射明细区域,确保EnteredMap组件重新渲染
// 重置映射明细区域
serviceProviderId.value = 0;
serviceProviderName.value = '';
MapServiceProviderROW.value = null;
mappingPanelReady.value = false;
// 未选服务商时映射/调试不可用,回到接口明细
if (activeContentTab.value === 'mapping' || activeContentTab.value === 'debugger') {
activeContentTab.value = 'detail';
@ -1159,7 +1162,8 @@
// 刷新右侧映射服务商列表
nextTick(() => {
if (MapServiceProviderRef.value && MapServiceProviderRef.value.tableRef) {
MapServiceProviderRef.value.tableRef.reload();
// 切换上游接口:清空中间映射选中,不默认第一条
MapServiceProviderRef.value.tableRef.reload({ keepSelection: false });
}
});
@ -1262,17 +1266,22 @@
initInterfaceConfig();
}, 100);
} else if (newTab === 'mapping') {
// 切换回映射明细tab时,重新初始化EnteredMap组件
mappingPanelReady.value = true;
await nextTick();
await nextTick();
setTimeout(() => {
if (enteredMapRef.value && selectedInterface.value && upStreamIdRef.value) {
(unref(enteredMapRef) as any)?.initData?.({
serviceProviderId: serviceProviderId.value,
interfaceId: upStreamIdRef.value,
});
}
}, 100);
const map = unref(enteredMapRef) as any;
// 已缓存同一服务商映射时不再重复拉数;首次挂载或未加载时再 init
if (
map &&
selectedInterface.value &&
upStreamIdRef.value &&
serviceProviderId.value &&
!map.hasLoadedData?.()
) {
await map.initData?.({
serviceProviderId: serviceProviderId.value,
interfaceId: upStreamIdRef.value,
});
}
}
});
@ -1566,10 +1575,14 @@
<div class="interface-detail-tab-content">
<template v-if="selectedInterface">
<!-- 映射明细内容 -->
<div v-if="activeContentTab === 'mapping'" class="mapping-content">
<!-- 映射明细:首次打开后 v-show 保活,避免切 tab 整表重挂 -->
<div
v-if="mappingPanelReady && serviceProviderId"
v-show="activeContentTab === 'mapping'"
class="mapping-content"
>
<EnteredMap
:key="`entered-map-${selectedInterface?.id}-${serviceProviderId}-${activeContentTab}`"
:key="`entered-map-${selectedInterface?.id}-${serviceProviderId}`"
ref="enteredMapRef"
class="h-full w-full"
:service-provider-id="serviceProviderId"

View File

@ -325,40 +325,67 @@
</p>
<div v-if="form.inboundMethod === 'selfShip'" class="h5-po__wh-card">
<div class="h5-po__wh-head">
<strong>
{{ t('h5.placeOrder.selfShipAddressTitle') }}
<template v-if="selectedWarehouseOption?.label">
· {{ selectedWarehouseOption.label }}
</template>
</strong>
<button
v-if="selectedWarehouseHasAddress"
type="button"
class="h5-po__wh-copy"
@click="copyWarehouseShipAddress"
>
{{ t('h5.placeOrder.selfShipCopy') }}
</button>
<strong>{{ t('h5.placeOrder.selfShipAddressTitle') }}</strong>
</div>
<p v-if="!selectedWarehouseOption" class="h5-po__hint">
{{ t('h5.placeOrder.selfShipSelectWarehouse') }}
<p v-if="!warehouseOptions.length" class="h5-po__hint">
{{ t('h5.placeOrder.selfShipNoWarehouse') }}
</p>
<p v-else-if="!warehousesWithShipAddress.length" class="h5-po__hint">
{{ t('h5.placeOrder.selfShipNoAddressConfigured') }}
</p>
<template v-else-if="!selectedWarehouseHasAddress">
<p class="h5-po__hint">{{ t('h5.placeOrder.selfShipAddressEmpty') }}</p>
</template>
<template v-else>
<p>
<span>{{ t('h5.placeOrder.selfShipRecipient') }}</span>
{{ selectedWarehouseOption.recipient || '-' }}
</p>
<p>
<span>{{ t('h5.placeOrder.selfShipPhone') }}</span>
{{ selectedWarehouseOption.contactPhone || '-' }}
</p>
<p class="is-addr">
<span>{{ t('h5.placeOrder.selfShipAddress') }}</span>
{{ selectedWarehouseOption.shippingAddress || '-' }}
</p>
<div class="h5-po__wh-list">
<div
v-for="opt in warehouseOptions"
:key="opt.value"
class="h5-po__wh-item"
:class="{
'is-selected': String(opt.value) === String(form.warehouseId ?? ''),
'is-empty': !warehouseHasAddress(opt),
}"
>
<div class="h5-po__wh-item-head">
<strong>
{{ opt.label || opt.value }}
<span
v-if="String(opt.value) === String(form.warehouseId ?? '')"
class="h5-po__wh-badge"
>
{{ t('h5.placeOrder.selfShipCurrent') }}
</span>
</strong>
<button
v-if="warehouseHasAddress(opt)"
type="button"
class="h5-po__wh-copy"
@click="copyWarehouseShipAddress(opt)"
>
{{ t('h5.placeOrder.selfShipCopy') }}
</button>
</div>
<p v-if="!warehouseHasAddress(opt)" class="h5-po__hint">
{{ t('h5.placeOrder.selfShipAddressEmpty') }}
</p>
<template v-else>
<p>
<span>{{ t('h5.placeOrder.selfShipRecipient') }}</span>
{{ displayWarehouseRecipient(opt) }}
</p>
<p>
<span>{{ t('h5.placeOrder.selfShipPhone') }}</span>
{{ opt.contactPhone || '-' }}
</p>
<p class="is-addr">
<span>{{ t('h5.placeOrder.selfShipAddress') }}</span>
{{ displayWarehouseShippingAddress(opt) }}
</p>
<p v-if="opt.remark">
<span>{{ t('h5.placeOrder.selfShipRemark') }}</span>
{{ opt.remark }}
</p>
</template>
</div>
</div>
<p class="h5-po__hint">{{ t('h5.placeOrder.selfShipAddressHint') }}</p>
</template>
</div>
@ -1153,6 +1180,7 @@ import { getSalesProductDetail, getSalesProductList } from '@/api/channel/salesP
import { getDictListAll } from '@/api/system/dict';
import { getCountryList } from '@/api/system/country';
import { getWarehouseList } from '@/api/system/warehouse';
import { resolveCurrentCustomer } from '@/api/customer/resolve';
import { resolveOmsAssetUrl } from '@/api/oms/common';
import { normalizeCargoAttributeCode } from '@/utils/cargoAttribute';
import {
@ -1160,6 +1188,13 @@ import {
normalizeSkuImageForStorage,
pickField,
} from '@/utils/warehouseListMapper';
import {
compareWarehouseSort,
formatWarehouseShipAddress,
formatWarehouseShipRecipient,
} from '@/utils/warehouseShipAddress';
import { getMapIdFromAccessToken } from '@/utils/jwt';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import { h5Navigate } from '@/utils/device';
import { isCustomerContactRequiredError } from '@/utils/customerContactGate';
import {
@ -1306,6 +1341,8 @@ const warehouseOptions = ref<
recipient?: string;
contactPhone?: string;
shippingAddress?: string;
sort?: number | null;
remark?: string;
}[]
>([]);
const countryOptions = ref<{ value: string; label: string }[]>([]);
@ -1526,25 +1563,49 @@ function skuImageSrc(url?: string | null) {
return resolveOmsAssetUrl(url || '') || '';
}
const selectedWarehouseOption = computed(() => {
const id = String(form.warehouseId ?? '').trim();
if (!id) return undefined;
return warehouseOptions.value.find((opt) => String(opt.value) === id);
});
const selectedWarehouseHasAddress = computed(() => {
const opt = selectedWarehouseOption.value;
type WarehouseOption = (typeof warehouseOptions.value)[number];
const rbacUserStore = useRbacUserStore();
const currentCustomerCode = ref('');
async function loadCurrentCustomerCode() {
try {
const mapId = getMapIdFromAccessToken(rbacUserStore.accessToken);
const account = rbacUserStore.userInfo?.account?.trim() || '';
const data = await resolveCurrentCustomer({ mapId, account });
currentCustomerCode.value = String(data?.customerCode ?? '').trim();
} catch {
currentCustomerCode.value = '';
}
}
function displayWarehouseRecipient(opt?: WarehouseOption) {
return formatWarehouseShipRecipient(opt?.recipient, currentCustomerCode.value);
}
function displayWarehouseShippingAddress(opt?: WarehouseOption) {
return formatWarehouseShipAddress(opt?.shippingAddress, currentCustomerCode.value);
}
function warehouseHasAddress(opt?: WarehouseOption) {
if (!opt) return false;
return Boolean(opt.recipient || opt.contactPhone || opt.shippingAddress);
});
}
async function copyWarehouseShipAddress() {
const opt = selectedWarehouseOption.value;
if (!opt) return;
const warehousesWithShipAddress = computed(() =>
warehouseOptions.value.filter((opt) => warehouseHasAddress(opt)),
);
async function copyWarehouseShipAddress(opt?: WarehouseOption) {
if (!opt || !warehouseHasAddress(opt)) {
Message.warning(t('h5.placeOrder.selfShipAddressEmpty'));
return;
}
const text = [
opt.label || '',
`${t('h5.placeOrder.selfShipRecipient')}:${opt.recipient || '-'}`,
`${t('h5.placeOrder.selfShipRecipient')}:${displayWarehouseRecipient(opt)}`,
`${t('h5.placeOrder.selfShipPhone')}:${opt.contactPhone || '-'}`,
`${t('h5.placeOrder.selfShipAddress')}:${opt.shippingAddress || '-'}`,
`${t('h5.placeOrder.selfShipAddress')}:${displayWarehouseShippingAddress(opt)}`,
]
.filter(Boolean)
.join('\n');
@ -3172,15 +3233,20 @@ async function loadOptions(
}
return '';
};
const sortRaw = row?.sort ?? row?.Sort;
const sortNum = sortRaw == null || sortRaw === '' ? null : Number(sortRaw);
return {
value: String(id ?? ''),
label: name || String(id),
recipient: pick('recipient', 'Recipient'),
contactPhone: pick('contactPhone', 'ContactPhone'),
shippingAddress: pick('shippingAddress', 'ShippingAddress'),
sort: sortNum != null && Number.isFinite(sortNum) ? sortNum : null,
remark: pick('remark', 'Remark'),
};
})
.filter((x) => x.value);
.filter((x) => x.value)
.sort(compareWarehouseSort);
if (!opts?.skipDefaults && warehouseOptions.value.length && !form.warehouseId) {
form.warehouseId = warehouseOptions.value[0].value;
}
@ -3342,6 +3408,7 @@ watch(
return;
}
resetForm();
void loadCurrentCustomerCode();
if (isEditMode.value) {
await loadOptions(null, { skipDefaults: true, skipEnsureClientNo: true });
await loadSenders({ skipAutoSelect: true });
@ -3838,6 +3905,61 @@ watch(
}
}
.h5-po__wh-list {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 8px;
max-height: 320px;
overflow-y: auto;
}
.h5-po__wh-item {
padding: 10px;
border-radius: 10px;
border: 1px solid #e5e6eb;
background: #f7f8fa;
&.is-selected {
border-color: var(--app-primary-color);
background: color-mix(in srgb, var(--app-primary-color) 8%, #fff);
}
&.is-empty {
opacity: 0.72;
}
}
.h5-po__wh-item-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
strong {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #1d2129;
line-height: 20px;
}
}
.h5-po__wh-badge {
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
color: var(--app-primary-color);
background: color-mix(in srgb, var(--app-primary-color) 14%, #fff);
}
.h5-po__wh-copy {
border: none;
background: color-mix(in srgb, var(--app-primary-color) 12%, #fff);
@ -3847,9 +3969,11 @@ watch(
height: 28px;
padding: 0 10px;
border-radius: 14px;
flex-shrink: 0;
}
.h5-po__wh-card p {
.h5-po__wh-card p,
.h5-po__wh-item p {
margin: 8px 0 0;
font-size: 13px;
color: #1d2129;
@ -3863,6 +3987,10 @@ watch(
}
}
.h5-po__wh-item .h5-po__hint {
margin-top: 4px;
}
.h5-po__more-panel {
display: flex;
flex-direction: column;

View File

@ -35,11 +35,15 @@
{{ t('h5.journey.warehouse.copy') }}
</button>
</div>
<p><span>{{ t('h5.journey.warehouse.recipient') }}</span>{{ item.recipient || '-' }}</p>
<p><span>{{ t('h5.journey.warehouse.recipient') }}</span>{{ displayRecipient(item) }}</p>
<p><span>{{ t('h5.journey.warehouse.phone') }}</span>{{ item.contactPhone || '-' }}</p>
<p class="is-addr">
<span>{{ t('h5.journey.warehouse.address') }}</span>
{{ item.shippingAddress || '-' }}
{{ displayShippingAddress(item) }}
</p>
<p v-if="item.remark">
<span>{{ t('h5.journey.warehouse.remark') }}</span>
{{ item.remark }}
</p>
</article>
</a-spin>
@ -53,6 +57,14 @@ import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue';
import { IconClose, IconHome } from '@arco-design/web-vue/es/icon';
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 H5EmptyState from './H5EmptyState.vue';
defineOptions({ name: 'H5WarehouseAddressSheet' });
@ -61,8 +73,10 @@ const props = defineProps<{ visible: boolean }>();
const emit = defineEmits<{ 'update:visible': [boolean] }>();
const { t } = useI18n();
const rbacUserStore = useRbacUserStore();
const loading = ref(false);
const list = ref<WarehouseDto[]>([]);
const currentCustomerCode = ref('');
const drawerStyle = {
borderRadius: '20px 20px 0 0',
overflow: 'hidden',
@ -82,20 +96,46 @@ function pick(raw: any, ...keys: string[]) {
}
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'),
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, currentCustomerCode.value);
}
function displayShippingAddress(item: WarehouseDto) {
return formatWarehouseShipAddress(item.shippingAddress, currentCustomerCode.value);
}
async function loadCurrentCustomerCode() {
try {
const mapId = getMapIdFromAccessToken(rbacUserStore.accessToken);
const account = rbacUserStore.userInfo?.account?.trim() || '';
const data = await resolveCurrentCustomer({ mapId, account });
currentCustomerCode.value = String(data?.customerCode ?? '').trim();
} catch {
currentCustomerCode.value = '';
}
}
async function loadList() {
loading.value = true;
try {
const res = await getWarehouseList({ page: 1, pageSize: 100 });
list.value = (res.items ?? []).map(normalize);
const [res] = await Promise.all([
getWarehouseList({ page: 1, pageSize: 100 }),
loadCurrentCustomerCode(),
]);
list.value = (res.items ?? []).map(normalize).sort(compareWarehouseSort);
} catch {
list.value = [];
} finally {
@ -106,9 +146,9 @@ async function loadList() {
async function copyItem(item: WarehouseDto) {
const text = [
item.warehouseName,
`${t('h5.journey.warehouse.recipient')}${item.recipient || '-'}`,
`${t('h5.journey.warehouse.recipient')}${displayRecipient(item)}`,
`${t('h5.journey.warehouse.phone')}${item.contactPhone || '-'}`,
`${t('h5.journey.warehouse.address')}${item.shippingAddress || '-'}`,
`${t('h5.journey.warehouse.address')}${displayShippingAddress(item)}`,
].join('\n');
try {
await navigator.clipboard.writeText(text);

View File

@ -406,13 +406,17 @@ export default {
'h5.placeOrder.selfShipRecipient': 'Recipient',
'h5.placeOrder.selfShipPhone': 'Phone',
'h5.placeOrder.selfShipAddress': 'Address',
'h5.placeOrder.selfShipRemark': 'Remark',
'h5.placeOrder.selfShipCopy': 'Copy',
'h5.placeOrder.selfShipCopySuccess': 'Warehouse shipping info copied',
'h5.placeOrder.selfShipSelectWarehouse': 'Select a warehouse first',
'h5.placeOrder.selfShipAddressEmpty':
'This warehouse has no shipping address yet. Contact support or choose another warehouse.',
'h5.placeOrder.selfShipCurrent': 'Current',
'h5.placeOrder.selfShipNoWarehouse': 'No warehouse info yet. Retry later or contact support.',
'h5.placeOrder.selfShipNoAddressConfigured':
'No warehouse has a shipping address configured. Contact support.',
'h5.placeOrder.selfShipAddressEmpty': 'No shipping address configured',
'h5.placeOrder.selfShipAddressHint':
'Ship to this address yourself. Put the client number on the courier label so the warehouse can match it.',
'Ship to the selected warehouse address yourself. Put the client number on the courier label so the warehouse can match it.',
'h5.placeOrder.pickupAddress': 'Pickup address',
'h5.placeOrder.pickupAddressPh': 'Select a saved sender address',
'h5.placeOrder.expectPickupTime': 'Expected pickup time',
@ -792,6 +796,7 @@ export default {
'h5.journey.warehouse.recipient': 'Recipient: ',
'h5.journey.warehouse.phone': 'Phone: ',
'h5.journey.warehouse.address': 'Address: ',
'h5.journey.warehouse.remark': 'Remark: ',
'h5.journey.ship.title': 'Pickup / ship to warehouse',
'h5.journey.ship.lead': 'Create a logistics order first. Then either self-ship to the warehouse, or add a sender address and request pickup.',

View File

@ -404,11 +404,15 @@ export default {
'h5.placeOrder.selfShipRecipient': '收件人',
'h5.placeOrder.selfShipPhone': '电话',
'h5.placeOrder.selfShipAddress': '地址',
'h5.placeOrder.selfShipRemark': '备注',
'h5.placeOrder.selfShipCopy': '复制',
'h5.placeOrder.selfShipCopySuccess': '仓库寄件信息已复制',
'h5.placeOrder.selfShipSelectWarehouse': '请先选择仓库',
'h5.placeOrder.selfShipAddressEmpty': '该仓库暂未配置收货地址,请联系客服或更换仓库',
'h5.placeOrder.selfShipAddressHint': '按此地址自行寄出。快递面单建议备注客户单号,方便仓库认领。',
'h5.placeOrder.selfShipCurrent': '当前',
'h5.placeOrder.selfShipNoWarehouse': '暂无仓库信息,请稍后重试或联系客服',
'h5.placeOrder.selfShipNoAddressConfigured': '暂无已配置收货地址的仓库,请联系客服',
'h5.placeOrder.selfShipAddressEmpty': '暂未配置收货地址',
'h5.placeOrder.selfShipAddressHint': '按所选仓库地址自行寄出。快递面单建议备注客户单号,方便仓库认领。',
'h5.placeOrder.pickupAddress': '取件地址',
'h5.placeOrder.pickupAddressPh': '请选择发件地址簿',
'h5.placeOrder.expectPickupTime': '期望揽收时间',
@ -778,6 +782,7 @@ export default {
'h5.journey.warehouse.recipient': '收件人:',
'h5.journey.warehouse.phone': '电话:',
'h5.journey.warehouse.address': '地址:',
'h5.journey.warehouse.remark': '备注:',
'h5.journey.ship.title': '揽收寄仓',
'h5.journey.ship.lead': '请先创建物流订单。两种方式任选:自己快递寄到仓库,或填写寄件地址后预约上门揽收。',

View File

@ -36,7 +36,7 @@
'login.hero.title': 'IDE',
'login.hero.tagline': 'Faster · Simpler · Professional',
'login.hero.desc.customer':
'查价说得清、下单走得顺、轨迹跟得上。AI 帮你看风险、提建议、少踩坑——从揽收到签收,IDE 即配陪你把每一票发好、盯紧、交代清楚。',
'查价说得清、下单走得顺、轨迹跟得上。AI 帮你看风险、提建议、少踩坑——从揽收到签收,IDE 陪你把每一票发好、盯紧、交代清楚。',
'login.hero.admin.brief': '开工前对齐三件事,并让 AI 成为搭档',
'login.hero.admin.pillar.care.title': '更专业',
'login.hero.admin.pillar.care.desc': '线路选对 · 异常不丢 · 全程说得清',

View File

@ -41,8 +41,15 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
import { getDictListAll } from '@/api/system/dict';
import { getCountryList } from '@/api/system/country';
import { getWarehouseList } from '@/api/system/warehouse';
import { resolveCurrentCustomer } from '@/api/customer/resolve';
import { mapWarehouseSkuToDeclarationFields, normalizeListRow, normalizeSkuImageForStorage, pickField } from '@/utils/warehouseListMapper';
import { normalizeCargoAttributeCode } from '@/utils/cargoAttribute';
import {
compareWarehouseSort,
formatWarehouseShipAddress,
formatWarehouseShipRecipient,
} from '@/utils/warehouseShipAddress';
import { getMapIdFromAccessToken } from '@/utils/jwt';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import { useWarehouseAdminCustomer } from '@/composables/useWarehouseAdminCustomer';
import { useNanaBatchPanel } from '@/composables/useNanaBatchPanel';
@ -319,6 +326,8 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
recipient?: string;
contactPhone?: string;
shippingAddress?: string;
sort?: number | null;
remark?: string;
};
const singleWarehouseOptions = ref<WarehouseSelectOption[]>([]);
const singleCurrencyOptions = ref<SingleSelectOption[]>([]);
@ -362,12 +371,16 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
}
return '';
};
const sortRaw = row?.sort ?? row?.Sort;
const sortNum = sortRaw == null || sortRaw === '' ? null : Number(sortRaw);
return {
id: id != null && id !== '' ? String(id) : '',
name,
recipient: pick('recipient', 'Recipient'),
contactPhone: pick('contactPhone', 'ContactPhone'),
shippingAddress: pick('shippingAddress', 'ShippingAddress'),
sort: sortNum != null && Number.isFinite(sortNum) ? sortNum : null,
remark: pick('remark', 'Remark'),
};
}
@ -2135,20 +2148,51 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
return singleWarehouseOptions.value.find((opt) => String(opt.value) === id);
});
const selectedWarehouseHasAddress = computed(() => {
const opt = selectedWarehouseOption.value;
/** 客户登录时拼接到仓库收件人/地址后的客户编号 */
const currentCustomerCode = ref('');
async function loadCurrentCustomerCode() {
if (!rbacUserStore.isCustomerPortal()) {
currentCustomerCode.value = '';
return;
}
try {
const mapId = getMapIdFromAccessToken(rbacUserStore.accessToken);
const account = rbacUserStore.userInfo?.account?.trim() || '';
const data = await resolveCurrentCustomer({ mapId, account });
currentCustomerCode.value = String(data?.customerCode ?? '').trim();
} catch {
currentCustomerCode.value = '';
}
}
function displayWarehouseRecipient(opt?: WarehouseSelectOption) {
return formatWarehouseShipRecipient(opt?.recipient, currentCustomerCode.value);
}
function displayWarehouseShippingAddress(opt?: WarehouseSelectOption) {
return formatWarehouseShipAddress(opt?.shippingAddress, currentCustomerCode.value);
}
function warehouseHasAddress(opt?: WarehouseSelectOption) {
if (!opt) return false;
return Boolean(opt.recipient || opt.contactPhone || opt.shippingAddress);
});
}
function buildWarehouseShipCopyText() {
const opt = selectedWarehouseOption.value;
if (!opt) return '';
const warehouseShipAddressList = computed(() => singleWarehouseOptions.value);
const warehousesWithShipAddress = computed(() =>
singleWarehouseOptions.value.filter((opt) => warehouseHasAddress(opt)),
);
function buildWarehouseShipCopyText(opt?: WarehouseSelectOption) {
const target = opt ?? selectedWarehouseOption.value;
if (!target) return '';
const lines = [
opt.label || '',
`收件人:${opt.recipient || '-'}`,
`电话:${opt.contactPhone || '-'}`,
`地址:${opt.shippingAddress || '-'}`,
target.label || '',
`收件人:${displayWarehouseRecipient(target)}`,
`电话:${target.contactPhone || '-'}`,
`地址:${displayWarehouseShippingAddress(target)}`,
];
const clientNo = String(singleForm.clientNo ?? '').trim();
if (clientNo) {
@ -2157,15 +2201,10 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
return lines.filter(Boolean).join('\n');
}
async function copyWarehouseShipAddress() {
const text = buildWarehouseShipCopyText();
if (!text.trim()) {
Message.warning('该仓库暂未配置收货地址');
return;
}
async function writeClipboardText(text: string, successMsg: string) {
try {
await navigator.clipboard.writeText(text);
Message.success('仓库寄件信息已复制');
Message.success(successMsg);
} catch {
const area = document.createElement('textarea');
area.value = text;
@ -2175,7 +2214,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
area.select();
try {
document.execCommand('copy');
Message.success('仓库寄件信息已复制');
Message.success(successMsg);
} catch {
Message.error('复制失败');
}
@ -2183,6 +2222,15 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
}
}
async function copyWarehouseShipAddress(opt?: WarehouseSelectOption) {
const text = buildWarehouseShipCopyText(opt);
if (!text.trim()) {
Message.warning('该仓库暂未配置收货地址');
return;
}
await writeClipboardText(text, '仓库寄件信息已复制');
}
function resolveDefaultDeclaredCurrency() {
const opts = singleCurrencyOptions.value;
const usd = opts.find((o) => String(o.value).trim().toUpperCase() === 'USD');
@ -2446,7 +2494,10 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
recipient: w.recipient,
contactPhone: w.contactPhone,
shippingAddress: w.shippingAddress,
}));
sort: w.sort,
remark: w.remark,
}))
.sort(compareWarehouseSort);
singleWarehouseOptions.value = whOpts;
if (whOpts.length && !String(singleForm.warehouseId ?? '').trim()) {
singleForm.warehouseId = whOpts[0].value;
@ -2592,6 +2643,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
onMounted(() => {
loadSingleFormReferenceOptions();
void loadCurrentCustomerCode();
loadReceiverAiPrefs();
void Promise.all([loadReceiverAiProviders(), loadReceiverAiSkills()]);
void (async () => {
@ -3926,49 +3978,85 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
<template #content>
<div class="warehouse-ship-popover__body">
<div class="warehouse-ship-popover__head">
<strong>
寄往仓库
<template v-if="selectedWarehouseOption?.label">
· {{ selectedWarehouseOption.label }}
</template>
</strong>
<a-button
v-if="selectedWarehouseHasAddress"
type="text"
size="mini"
class="warehouse-ship-popover__copy"
@click="copyWarehouseShipAddress"
>
<template #icon><IconCopy /></template>
复制
</a-button>
<strong>寄往仓库</strong>
</div>
<p v-if="!selectedWarehouseOption" class="warehouse-ship-popover__empty">
请先选择上方仓库
</p>
<p
v-else-if="!selectedWarehouseHasAddress"
v-if="!warehouseShipAddressList.length"
class="warehouse-ship-popover__empty"
>
该仓库暂未配置收货地址,请联系客服或更换仓库
暂无仓库信息,请稍后重试或联系客服
</p>
<p
v-else-if="!warehousesWithShipAddress.length"
class="warehouse-ship-popover__empty"
>
暂无已配置收货地址的仓库,请联系客服
</p>
<template v-else>
<dl class="warehouse-ship-popover__rows">
<div>
<dt>收件人</dt>
<dd>{{ selectedWarehouseOption.recipient || '-' }}</dd>
<div class="warehouse-ship-popover__list">
<div
v-for="opt in warehouseShipAddressList"
:key="opt.value"
class="warehouse-ship-popover__item"
:class="{
'is-selected':
String(opt.value) ===
String(singleForm.warehouseId ?? ''),
'is-empty': !warehouseHasAddress(opt),
}"
>
<div class="warehouse-ship-popover__item-head">
<strong>
{{ opt.label || opt.value }}
<span
v-if="
String(opt.value) ===
String(singleForm.warehouseId ?? '')
"
class="warehouse-ship-popover__badge"
>
当前
</span>
</strong>
<a-button
v-if="warehouseHasAddress(opt)"
type="text"
size="mini"
class="warehouse-ship-popover__copy"
@click="copyWarehouseShipAddress(opt)"
>
<template #icon><IconCopy /></template>
复制
</a-button>
</div>
<p
v-if="!warehouseHasAddress(opt)"
class="warehouse-ship-popover__empty"
>
暂未配置收货地址
</p>
<dl v-else class="warehouse-ship-popover__rows">
<div>
<dt>收件人</dt>
<dd>{{ displayWarehouseRecipient(opt) }}</dd>
</div>
<div>
<dt>电话</dt>
<dd>{{ opt.contactPhone || '-' }}</dd>
</div>
<div class="is-addr">
<dt>地址</dt>
<dd>{{ displayWarehouseShippingAddress(opt) }}</dd>
</div>
<div v-if="opt.remark">
<dt>备注</dt>
<dd>{{ opt.remark }}</dd>
</div>
</dl>
</div>
<div>
<dt>电话</dt>
<dd>{{ selectedWarehouseOption.contactPhone || '-' }}</dd>
</div>
<div class="is-addr">
<dt>地址</dt>
<dd>{{ selectedWarehouseOption.shippingAddress || '-' }}</dd>
</div>
</dl>
</div>
<p class="warehouse-ship-popover__hint">
按此地址自行寄出。快递面单建议备注客户单号,方便仓库认领。
按所选仓库地址自行寄出。快递面单建议备注客户单号,方便仓库认领。
</p>
</template>
</div>
@ -7210,7 +7298,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
<style lang="less">
.warehouse-ship-popover {
max-width: 360px;
max-width: 400px;
padding: 0;
.arco-popover-content {
@ -7219,8 +7307,8 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
}
.warehouse-ship-popover__body {
min-width: 240px;
max-width: 320px;
min-width: 260px;
max-width: 360px;
}
.warehouse-ship-popover__head {
@ -7244,8 +7332,62 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
flex-shrink: 0;
}
.warehouse-ship-popover__list {
margin-top: 8px;
max-height: 320px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.warehouse-ship-popover__item {
padding: 8px;
border: 1px solid var(--color-border-2);
border-radius: 4px;
background: var(--color-fill-1);
&.is-selected {
border-color: var(--app-primary-color);
background: color-mix(in srgb, var(--app-primary-color) 8%, #fff);
}
&.is-empty {
opacity: 0.72;
}
}
.warehouse-ship-popover__item-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
strong {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: var(--color-text-1);
line-height: 22px;
}
}
.warehouse-ship-popover__badge {
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 6px;
border-radius: 2px;
font-size: 11px;
font-weight: 500;
color: var(--app-primary-color);
background: color-mix(in srgb, var(--app-primary-color) 14%, #fff);
}
.warehouse-ship-popover__rows {
margin: 8px 0 0;
margin: 6px 0 0;
display: flex;
flex-direction: column;
gap: 6px;
@ -7280,4 +7422,8 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
line-height: 18px;
color: var(--color-text-3);
}
.warehouse-ship-popover__item .warehouse-ship-popover__empty {
margin-top: 4px;
}
</style>

View File

@ -18,6 +18,13 @@
<icon-home />
</span>
<span class="warehouse-card-title-text" :title="item.warehouseName">{{ item.warehouseName }}</span>
<a-tag
v-if="item.sort != null && item.sort !== ''"
size="small"
class="warehouse-card-sort-tag"
>
{{ $t('system.warehouseAddress.fields.sort') }} {{ item.sort }}
</a-tag>
</div>
</template>
<template #extra>
@ -58,6 +65,10 @@
<span class="label">{{ $t('system.warehouseAddress.fields.shippingAddress') }}:</span>
<span class="value value-address" :title="displayShippingAddress(item)">{{ displayShippingAddress(item) }}</span>
</div>
<div v-if="item.remark" class="card-row card-row--remark">
<span class="label">{{ $t('system.warehouseAddress.fields.remark') }}:</span>
<span class="value" :title="item.remark">{{ item.remark }}</span>
</div>
</div>
</div>
</a-card>
@ -105,6 +116,18 @@
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="$t('system.warehouseAddress.fields.sort')">
<a-input-number
v-model="formData.sort"
:placeholder="$t('system.warehouseAddress.placeholder.sort')"
:precision="0"
allow-clear
hide-button
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<div class="form-section-title">{{ $t('system.warehouseAddress.section.contact') }}</div>
@ -134,6 +157,17 @@
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item :label="$t('system.warehouseAddress.fields.remark')">
<a-textarea
v-model="formData.remark"
:placeholder="$t('system.warehouseAddress.placeholder.remark')"
:max-length="512"
show-word-limit
:auto-size="{ minRows: 2, maxRows: 4 }"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-modal>
@ -156,6 +190,11 @@ import {
} from '@/api/system/warehouse';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import { getMapIdFromAccessToken } from '@/utils/jwt';
import {
compareWarehouseSort,
formatWarehouseShipAddress,
formatWarehouseShipRecipient,
} from '@/utils/warehouseShipAddress';
const { t } = useI18n();
const rbacUserStore = useRbacUserStore();
@ -172,22 +211,12 @@ const editingId = ref<string | number | null>(null);
const warehouseTypeOptions = ref<Array<{ value: string; label: string }>>([]);
const currentCustomerCode = ref('');
function appendCustomerCode(value?: string, codeSuffix = ''): string {
const base = value?.trim() || '';
const code = currentCustomerCode.value.trim();
if (!code) return base || '-';
const tail = `${code}${codeSuffix}`;
if (!base) return tail;
if (base.endsWith(tail)) return base;
return `${base} ${tail}`;
}
function displayRecipient(item: { recipient?: string }) {
return appendCustomerCode(item.recipient);
return formatWarehouseShipRecipient(item.recipient, currentCustomerCode.value);
}
function displayShippingAddress(item: { shippingAddress?: string }) {
return appendCustomerCode(item.shippingAddress, '号');
return formatWarehouseShipAddress(item.shippingAddress, currentCustomerCode.value);
}
async function loadCurrentCustomerCode() {
@ -242,9 +271,11 @@ function getWarehouseTypeDisplay(item: {
const formData = reactive({
warehouseName: '',
warehouseType: undefined as string | undefined,
sort: undefined as number | undefined,
recipient: '',
contactPhone: '',
shippingAddress: '',
remark: '',
});
function handleDelete(item: any) {
@ -268,7 +299,7 @@ function handleDelete(item: any) {
async function loadList() {
try {
const { items } = await getWarehouseList({ page: 1, pageSize: 200 });
list.value = items || [];
list.value = [...(items || [])].sort(compareWarehouseSort);
} catch (e: any) {
$baseMessage(e?.message || '加载仓库列表失败', 'error');
list.value = [];
@ -281,9 +312,11 @@ function handleAdd() {
Object.assign(formData, {
warehouseName: '',
warehouseType: undefined,
sort: undefined,
recipient: '',
contactPhone: '',
shippingAddress: '',
remark: '',
});
modalVisible.value = true;
}
@ -304,12 +337,15 @@ function handleCopy(item: any) {
function handleEdit(item: any) {
isEdit.value = true;
editingId.value = item.id ?? null;
const sortRaw = item.sort ?? item.Sort;
Object.assign(formData, {
warehouseName: item.warehouseName,
warehouseType: item.warehouseType,
sort: sortRaw == null || sortRaw === '' ? undefined : Number(sortRaw),
recipient: item.recipient,
contactPhone: item.contactPhone,
shippingAddress: item.shippingAddress,
remark: item.remark || '',
});
modalVisible.value = true;
}
@ -322,9 +358,11 @@ async function handleModalOk() {
const payload = {
warehouseName: formData.warehouseName.trim(),
warehouseType: formData.warehouseType,
sort: formData.sort == null || Number.isNaN(Number(formData.sort)) ? null : Number(formData.sort),
recipient: formData.recipient,
contactPhone: formData.contactPhone,
shippingAddress: formData.shippingAddress,
remark: formData.remark?.trim() || null,
};
try {
if (isEdit.value && editingId.value != null) {
@ -505,6 +543,10 @@ onMounted(() => {
color: var(--color-text-1);
}
.warehouse-card-sort-tag {
flex-shrink: 0;
}
.card-extra {
display: flex;
align-items: center;