feat: H5 底栏与顾问体验、查价失败分组,以及下单/渠道与凭证加固
All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m51s

- H5:底栏 Liquid Dock 改版(图标/FAB 提示/badge)、订单详情操作图标化、空态与顾问侧栏样式
- Nana/官网:欢迎区与运单上下文、MCP 卡片示例;顾问 API 按环境直连 admin 网关
- 下单:电商平台改为字典下拉;9610 指南免税侧文案对齐汇总申报
- 查价:失败原因抽取并按步骤分组展示
- 渠道:重量因素数值排序、费用项防重复、取整区间重叠校验;分区匹配维度/模式显示中文
- 用户/仓储:OpenAPI 凭证仅客户门户主客户可见;打印控件下载链接抽公共常量并提示

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-10-06 09:13:12 +08:00
parent 1026d909e9
commit 2a4251f9db
33 changed files with 1361 additions and 279 deletions

View File

@ -620,7 +620,7 @@
<span class="sol-badge">合规推荐</span>
</div>
<ul class="sol-list">
<li>报关方式:简易 / 汇总均可</li>
<li>报关方式:汇总申报(正式报关单)</li>
<li>HS编码:前4位即可</li>
<li>核心凭证:综服平台免税登记记录</li>
<li>操作平台:单一窗口 + 综服平台</li>
@ -677,7 +677,7 @@
<tr>
<td>报关方式</td>
<td class="col-refund">汇总申报</td>
<td class="col-exempt">简易 / 汇总均可</td>
<td class="col-exempt">汇总申报(正式报关单)</td>
<td style="color:#5a6472;">统一简易申报</td>
</tr>
<tr>

View File

@ -707,10 +707,38 @@
return window.location.origin;
}
/**
* Admin API 根(方案 A:浏览器直连 api 网关,勿打前端 nginx SPA)。
* - prod:https://api.ideship.com/admin
* - test:https://api.test.ideship.com/admin
* - 本地 Vite 同源代理:与 resolveOmsOrigin 相同(无 /admin 前缀)
*/
function resolveOmsApiOrigin() {
const configured =
window.IDE_OMS_API_ORIGIN || localStorage.getItem('ide-oms-api-origin');
if (configured) return String(configured).replace(/\/$/, '');
const host = (window.location.hostname || '').toLowerCase();
if (host === 'test.ideship.com' || host.endsWith('.test.ideship.com')) {
return 'https://api.test.ideship.com/admin';
}
if (host === 'ideship.com' || host === 'www.ideship.com' || host.endsWith('.ideship.com')) {
return 'https://api.ideship.com/admin';
}
// 本地 / 文件打开:走 OMS 开发源(Vite 代理 /channel/*)
return resolveOmsOrigin();
}
function omsUrl(pathname) {
return `${resolveOmsOrigin()}${pathname}`;
}
function omsApiUrl(pathname) {
const path = pathname.startsWith('/') ? pathname : `/${pathname}`;
return `${resolveOmsApiOrigin()}${path}`;
}
function loginTrackUrl(number) {
const target = preferOmsH5() ? '/h5/login' : '/login';
const redirect = preferOmsH5() ? '/h5/orders' : '/home/workplace';
@ -2234,7 +2262,7 @@
routesLoadState = 'loading';
renderRoutesList();
try {
const res = await fetch(omsUrl('/channel/sales-product/portal/catalog'), {
const res = await fetch(omsApiUrl('/channel/sales-product/portal/catalog'), {
method: 'GET',
headers: { Accept: 'application/json' },
credentials: 'omit',

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="8" r="3.2" stroke="currentColor" stroke-width="1.8" />
<path
d="M5.5 20.2c.9-3.6 3.4-5.4 6.5-5.4s5.6 1.8 6.5 5.4"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 303 B

View File

@ -1,12 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path
d="M4.5 8.2L12 4l7.5 4.2v8.6L12 21l-7.5-4.2V8.2z"
<rect
x="5"
y="3"
width="14"
height="18"
rx="2.2"
stroke="currentColor"
stroke-width="1.8"
stroke-linejoin="round"
/>
<path d="M4.5 8.2L12 12.4l7.5-4.2" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" />
<path d="M12 12.4V21" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
<path d="M9.2 6.5l2.8 1.6 2.8-1.6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
<path d="M12 8.1v4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
<path
d="M8.5 9h7M8.5 13h7M8.5 17h4.5"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
/>
</svg>

Before

Width:  |  Height:  |  Size: 637 B

After

Width:  |  Height:  |  Size: 332 B

View File

@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path
d="M4 14.5V12a8 8 0 0 1 16 0v2.5"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
/>
<path
d="M4 14h2.8v5.2H4A1.6 1.6 0 0 1 2.4 17.6v-2A1.6 1.6 0 0 1 4 14z"
stroke="currentColor"
stroke-width="1.8"
stroke-linejoin="round"
/>
<path
d="M20 14h-2.8v5.2H20a1.6 1.6 0 0 0 1.6-1.6v-2A1.6 1.6 0 0 0 20 14z"
stroke="currentColor"
stroke-width="1.8"
stroke-linejoin="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 530 B

View File

@ -121,14 +121,15 @@
</div>
<div ref="mainScrollRef" class="ai-app-main">
<section v-show="showWelcome" class="ai-welcome">
<section v-show="showWelcome && !waybillContext" class="ai-welcome">
<div class="ai-welcome__hero">
<NanaAvatar class="ai-welcome__avatar" size="md" />
<h2>{{ t('nanaAssistant.welcomeTitle') }}</h2>
<NanaAvatar class="ai-welcome__avatar" size="sm" />
<div class="ai-welcome__copy">
<h2>{{ welcomeHeadline }}</h2>
<p>{{ t('nanaAssistant.welcomeSub') }}</p>
</div>
</div>
<p class="ai-quick-section-label">{{ t('nanaAssistant.mcp.sectionLabel') }}</p>
<div class="ai-mcp-grid">
<button
v-for="cap in mcpCapabilities"
@ -138,8 +139,53 @@
:disabled="chatLoading"
@click="startMcpCapability(cap.id)"
>
<span class="ai-mcp-card__icon" aria-hidden="true">
<component :is="mcpCardIcon(cap.id)" />
</span>
<span class="ai-mcp-card__body">
<span class="ai-mcp-card__title">{{ t(cap.titleKey) }}</span>
<span class="ai-mcp-card__desc">{{ t(cap.descKey) }}</span>
<span class="ai-mcp-card__desc">{{ t(cap.exampleKey) }}</span>
</span>
</button>
</div>
</section>
<section v-show="showWelcome && waybillContext" class="ai-welcome ai-welcome--waybill">
<div class="ai-welcome__hero">
<NanaAvatar class="ai-welcome__avatar" size="sm" />
<div class="ai-welcome__copy">
<h2>{{ welcomeHeadline }}</h2>
<p>{{ t('nanaAssistant.waybillWelcomeSub', { no: waybillDisplayNo }) }}</p>
</div>
</div>
<div class="ai-mcp-grid ai-mcp-grid--waybill">
<button
type="button"
class="ai-mcp-card"
:disabled="chatLoading"
@click="askWaybillTrack"
>
<span class="ai-mcp-card__icon" aria-hidden="true">
<component :is="mcpCardIcon('track')" />
</span>
<span class="ai-mcp-card__body">
<span class="ai-mcp-card__title">{{ t('nanaAssistant.waybillAskTrack') }}</span>
<span class="ai-mcp-card__desc">{{ waybillDisplayNo }}</span>
</span>
</button>
<button
type="button"
class="ai-mcp-card"
:disabled="chatLoading"
@click="startMcpCapability('price')"
>
<span class="ai-mcp-card__icon" aria-hidden="true">
<component :is="mcpCardIcon('price')" />
</span>
<span class="ai-mcp-card__body">
<span class="ai-mcp-card__title">{{ t('nanaAssistant.waybillAskFee') }}</span>
<span class="ai-mcp-card__desc">{{ t('nanaAssistant.mcp.priceExample') }}</span>
</span>
</button>
</div>
</section>
@ -196,7 +242,7 @@
:aria-label="t('nanaAssistant.send')"
:disabled="!canSend"
>
<icon-arrow-up />
<icon-send />
</button>
</form>
<div class="ai-composer-footer">
@ -268,12 +314,12 @@
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type Component } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue';
import {
IconArrowUp,
IconSend,
IconClockCircle,
IconCustomerService,
IconDelete,
@ -284,6 +330,7 @@ import {
IconMessage,
IconPlus,
IconPublic,
IconSafe,
IconStorage,
IconUser,
IconWifi,
@ -303,6 +350,8 @@ import {
parseWaybillContextFromQuery,
type WaybillContext,
} from '@/composables/useAskAdvisor';
import IconTabCalculator from '@/assets/icons/h5/tab-calculator.svg';
import IconTabPackage from '@/assets/icons/h5/tab-package.svg';
import {
MCP_CAPABILITY_GUIDES,
detectMissingFields,
@ -377,6 +426,16 @@ const guidedSession = ref(false);
const threads = ref<Thread[]>([]);
const mcpCapabilities = MCP_CAPABILITY_GUIDES;
const mcpCardIcons: Record<McpCapabilityId, Component> = {
price: IconTabCalculator,
guide: IconSafe,
track: IconTabPackage,
};
function mcpCardIcon(id: McpCapabilityId) {
return mcpCardIcons[id];
}
const activeThreadId = ref<string | null>(null);
const messages = ref<ChatMessage[]>([]);
const mainScrollRef = ref<HTMLElement>();
@ -445,7 +504,20 @@ const isChatting = computed(
() => messages.value.some((m) => m.role === 'user') || guidedSession.value,
);
const showWelcome = computed(() => !isChatting.value && !waybillContext.value);
const showWelcome = computed(() => !isChatting.value);
const welcomeGreet = computed(() => {
const hour = new Date().getHours();
if (hour < 6) return t('nanaAssistant.greet.night');
if (hour < 12) return t('nanaAssistant.greet.morning');
if (hour < 14) return t('nanaAssistant.greet.noon');
if (hour < 18) return t('nanaAssistant.greet.afternoon');
return t('nanaAssistant.greet.evening');
});
const welcomeHeadline = computed(() =>
t('nanaAssistant.welcomeHello', { greet: welcomeGreet.value }),
);
const canSend = computed(() => Boolean(inputText.value.trim() && !chatLoading.value));
@ -876,7 +948,7 @@ async function startMcpCapability(id: McpCapabilityId) {
messages.value.push({ role: 'assistant', content: coach });
guidedSession.value = true;
saveThreads();
inputText.value = '';
inputText.value = t(cap.exampleKey);
await nextTick();
resizeInput();
scrollToBottom();

View File

@ -691,12 +691,40 @@
}
.ai-welcome__hero {
text-align: center;
padding: 24px 12px 28px;
display: flex;
align-items: flex-start;
gap: 12px;
text-align: left;
padding: 12px 4px 16px;
}
.ai-welcome__avatar {
margin: 0 auto 16px;
.ai-welcome__avatar.nana-fab-wrap,
.ai-welcome__avatar.nana-fab-wrap--sm {
flex-shrink: 0;
margin: 0;
width: 48px;
height: 48px;
}
.ai-welcome__copy {
min-width: 0;
flex: 1;
}
.ai-welcome__hero h2 {
margin: 0 0 4px;
font-size: clamp(1.15rem, 3.6vw, 1.4rem);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--kc-primary, #1b2a6b);
}
.ai-welcome__hero p {
margin: 0;
max-width: none;
font-size: 0.88rem;
line-height: 1.5;
color: var(--color-text-3, #86909c);
}
.ai-avatar {
@ -713,22 +741,6 @@
box-shadow: 0 8px 24px rgba(27, 42, 107, 0.2);
}
.ai-welcome__hero h2 {
margin: 0 0 8px;
font-size: clamp(1.35rem, 4vw, 1.65rem);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--kc-primary, #1b2a6b);
}
.ai-welcome__hero p {
margin: 0 auto;
max-width: 420px;
font-size: 0.95rem;
line-height: 1.55;
color: var(--kc-secondary, #6a75b8);
}
.ai-quick-section-label {
margin: 0 0 12px;
padding: 0 4px;
@ -748,35 +760,39 @@
.ai-mcp-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-bottom: 8px;
}
@media (max-width: 720px) {
.ai-mcp-grid {
grid-template-columns: 1fr;
}
.ai-mcp-grid--waybill {
grid-template-columns: 1fr 1fr;
}
.ai-mcp-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 14px 12px;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 10px;
min-height: 92px;
padding: 16px 12px;
text-align: left;
border-radius: 14px;
border: none;
background: color-mix(in srgb, var(--app-primary-color, #007979) 8%, var(--app-card-bg));
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--color-text-1, #1d2129) 10%, transparent);
background: transparent;
box-shadow: none;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
transition: opacity 0.15s ease;
}
.ai-mcp-card:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px color-mix(in srgb, var(--app-primary-color, #007979) 16%, transparent);
transform: none;
box-shadow: none;
}
.ai-mcp-card:active:not(:disabled) {
opacity: 0.72;
}
.ai-mcp-card:disabled {
@ -785,6 +801,31 @@
transform: none;
}
.ai-mcp-card__icon {
flex: 0 0 28px;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--app-primary-color, #007979);
background: transparent;
}
.ai-mcp-card__icon svg,
.ai-mcp-card__icon .arco-icon {
width: 24px;
height: 24px;
}
.ai-mcp-card__body {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
}
.ai-mcp-card__title {
font-size: 14px;
font-weight: 700;
@ -799,7 +840,7 @@
@media (max-width: 900px) {
.ai-mcp-grid {
grid-template-columns: 1fr;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@ -1199,7 +1240,7 @@
}
.ai-welcome__hero {
padding: 16px 0 20px;
padding: 8px 0 12px;
}
.ai-avatar {

View File

@ -284,7 +284,7 @@ import { canAccessCustomerPortalRoute, isAdminPortalOnlyPath, isCustomerPortalOn
import { resolveCurrentCustomer } from '@/api/customer/resolve';
import { CUSTOMER_BALANCE_CHANGED_EVENT } from '@/api/customer/accountFlow';
import { resolvePortalDrawerPanel } from '@/views/user/portal-drawer/types';
import { openPortalSideDrawer } from '@/views/user/portal-drawer/usePortalSideDrawer';
import { PRINT_CONTROL_DOWNLOAD_URL } from '@/constants/printControl';
const { t } = useI18n();
const route = useRoute();
@ -297,9 +297,6 @@ const topMenuCon = ref<HTMLElement | null>(null);
const topNavEnable = computed(() => true); // 启用顶部导航
const tabNavEnable = computed(() => appStore.tabBar);
const printConnectionState = useHiprintClientState();
/** 打印控件安装包(百度网盘,提取码 idex) */
const PRINT_CONTROL_DOWNLOAD_URL =
'https://pan.baidu.com/s/1FrpI1prvtRKG1f9jXYSL1A?pwd=idex';
const menuItemEls = new Map<string, HTMLElement>();
const indicatorReady = ref(false);

View File

@ -0,0 +1,3 @@
/** 打印控件 / 一键添加包裹插件安装包(百度网盘,提取码 idex) */
export const PRINT_CONTROL_DOWNLOAD_URL =
'https://pan.baidu.com/s/1FrpI1prvtRKG1f9jXYSL1A?pwd=idex'

View File

@ -24,6 +24,10 @@
}
.warehouse-list-page-card .status-tabs {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 16px;
flex-shrink: 0;
border-bottom: 1px solid var(--color-border-2);

View File

@ -73,15 +73,29 @@ export function weightValueToIntervalCell(
return formatWeightIntervalLeftOpenRightClosed(start, upper);
}
/** 提取因素值列表中的重量上限并升序排列(用于区间左界)。 */
export function weightFactorValuesToSortedUpperBounds(values: string[]): number[] {
const nums = (values ?? [])
.map((v) => {
function factorValueSortNumber(v: string): number | null {
const parsed = parseWeightIntervalBounds(v);
if (parsed) return parsed.end;
const n = Number.parseFloat(String(v).trim());
return Number.isFinite(n) ? n : null;
})
}
/** 按数值大小升序排列因素值(如 2, 0.5, 1 → 0.5, 1, 2)。 */
export function sortFactorValuesBySize(values: string[] | undefined): string[] {
return [...(values ?? [])].sort((a, b) => {
const na = factorValueSortNumber(String(a));
const nb = factorValueSortNumber(String(b));
if (na != null && nb != null && na !== nb) return na - nb;
if (na != null && nb == null) return -1;
if (na == null && nb != null) return 1;
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
});
}
/** 提取因素值列表中的重量上限并升序排列(用于区间左界)。 */
export function weightFactorValuesToSortedUpperBounds(values: string[]): number[] {
const nums = (values ?? [])
.map((v) => factorValueSortNumber(v))
.filter((n): n is number => n != null);
return [...new Set(nums)].sort((a, b) => a - b);
}

View File

@ -551,10 +551,24 @@ export function useKpiAreaPage(areaType: KpiAreaType) {
const selectedAreaName = computed(() => currentId.value?.name ?? '');
function formatMatchDim(code?: string) {
const dim = String(code || '').trim().toLowerCase();
const key = `channel.area.matchDim.${dim}`;
const label = t(key);
return label === key ? dim : label;
}
function formatMatchMode(code?: string) {
const mode = String(code || '').trim().toLowerCase();
const key = `channel.area.matchMode.${mode}`;
const label = t(key);
return label === key ? mode : label;
}
const hotColumns = computed<ChannelHotColumn[]>(() => [
{ key: 'countryName', label: t('channel.area.form.country'), width: 120 },
{ key: 'matchDim', label: t('channel.area.form.matchDim'), width: 100 },
{ key: 'matchMode', label: t('channel.area.form.matchMode'), width: 100 },
{ key: 'matchDimName', label: t('channel.area.form.matchDim'), width: 100 },
{ key: 'matchModeName', label: t('channel.area.form.matchMode'), width: 100 },
{ key: 'province', label: t('channel.area.form.province'), width: 120 },
{ key: 'city', label: t('channel.area.form.city'), width: 120 },
{ key: 'leftPostcode', label: t('channel.area.table.leftPostcode'), width: 120 },
@ -732,7 +746,18 @@ export function useKpiAreaPage(areaType: KpiAreaType) {
totalCount.value = Number(
response?.totalCount ?? response?.total ?? items.length ?? 0
);
tableData.value = items;
tableData.value = items.map((item) => {
const inferred = inferGeoMatch(item);
const matchDim = item.matchDim || inferred.matchDim;
const matchMode = item.matchMode || inferred.matchMode;
return {
...item,
matchDim,
matchMode,
matchDimName: formatMatchDim(matchDim),
matchModeName: formatMatchMode(matchMode),
};
});
} catch {
tableData.value = [];
totalCount.value = 0;

View File

@ -4,6 +4,7 @@
createSalesProduct,
createSalesProductPrice,
deleteSalesProduct,
deleteSalesProductPrice,
getSalesProductDetail,
getSalesProductList,
getSalesProductAllocation,
@ -29,6 +30,7 @@
import {
canonicalWeightFactorCellForApi,
isWeightPricingFactor,
sortFactorValuesBySize,
weightValueToIntervalCell,
} from '@/utils/priceWeightInterval';
import { IconPlus, IconDelete, IconSearch, IconEdit, IconDragArrow, IconQuestionCircleFill, IconQuestionCircle } from '@arco-design/web-vue/es/icon';
@ -768,7 +770,11 @@
});
return normalizeCustomerFactorSelection(mapped);
}
return vs.map((v) => String(v));
const mapped = vs.map((v) => String(v));
if (isWeightValueFactor(factor)) {
return sortFactorValuesBySize(mapped);
}
return mapped;
}
/** 价格表客户组列表头:与写死全量字典列一致,用 customer_group 的 dictName;入参为勾选的因素编码 */
@ -2216,6 +2222,17 @@
});
const costItemOptions = computed(() => mapDictToSelectOptions('fee_item'));
/** 同一产品下已占用的费用项(编辑当前条时排除自身) */
function isCostItemAlreadyUsed(costItem: string) {
const code = String(costItem ?? '').trim().toLowerCase();
if (!code) return false;
const editingId = isEditMode.value ? editingPriceRecord.value?.id : null;
return priceTableData.value.some((row: any) => {
if (editingId != null && row?.id === editingId) return false;
return String(row?.costItem ?? '').trim().toLowerCase() === code;
});
}
const costUnitOptions = computed(() => mapDictToSelectOptions('cost_unit'));
const currencyOptions = computed(() => mapDictToSelectOptions('currency'));
const factorOptions = computed(() => {
@ -2389,7 +2406,9 @@
addFactorValueSelected.value = normalizeCustomerFactorSelection(vals || []);
return;
}
addFactorValueSelected.value = vals || [];
const next = vals || [];
addFactorValueSelected.value =
factor && isWeightValueFactor(factor) ? sortFactorValuesBySize(next) : next;
}
// 添加因素值
@ -2410,9 +2429,12 @@
}
const allowed = new Set(opts.map((o) => String(o.value)));
const canonical = toCanonicalFactorValues(factor, row.factorValues || []);
addFactorValueSelected.value = canonical
const selected = canonical
.map((v) => String(v).trim())
.filter((v) => v && allowed.has(v));
addFactorValueSelected.value = isWeightValueFactor(factor)
? sortFactorValuesBySize(selected)
: selected;
addFactorValueModalVisible.value = true;
};
@ -2464,6 +2486,11 @@
currentFactorRow.value.factorValues,
);
}
if (factor && isWeightValueFactor(factor)) {
currentFactorRow.value.factorValues = sortFactorValuesBySize(
currentFactorRow.value.factorValues,
);
}
Message.success('添加成功');
return true;
}
@ -2499,6 +2526,8 @@
addFactorValueSelected.value =
factor && isCustomerGroupValueFactor(factor)
? normalizeCustomerFactorSelection(enabled.filter((v) => !isAllCustomerCode(v)))
: factor && isWeightValueFactor(factor)
? sortFactorValuesBySize(enabled)
: enabled;
};
@ -2929,6 +2958,10 @@
Message.warning('请先填写费用项');
return;
}
if (isCostItemAlreadyUsed(basicInfoForm.costItem)) {
Message.warning('该产品已存在此费用项,同一产品下每种费用项只能有一条');
return;
}
try {
const msg = await submitPriceConfig(pid);
Message.success(msg);
@ -3362,32 +3395,40 @@
deletePriceModalVisible.value = true;
};
// 确认删除价格配置
const handleConfirmDeletePrice = () => {
// 确认删除价格配置(落库 POST /channel/sales-product/price/delete)
const handleConfirmDeletePrice = async () => {
if (!deletingPriceRecord.value) return;
const correctName = deletingPriceRecord.value.priceName || deletingPriceRecord.value.costItem || '';
if (deletePriceInput.value !== correctName) {
const record = deletingPriceRecord.value;
const typed = String(deletePriceInput.value ?? '').trim();
const correctNames = [
record.priceName,
record.costItem,
dictLabelByType('fee_item', record.costItem),
]
.map((x) => String(x ?? '').trim())
.filter(Boolean);
if (!correctNames.includes(typed)) {
Message.error('输入的费用项名称不正确,请重新输入');
return;
}
// 从列表中删除
const index = priceTableData.value.findIndex((item) => item.id === deletingPriceRecord.value.id);
if (index > -1) {
priceTableData.value.splice(index, 1);
// 重新编号
priceTableData.value.forEach((item, idx) => {
item.serialNumber = idx + 1;
});
Message.success('删除成功');
} else {
const productId = selectedProduct.value;
const priceId = record.id;
if (priceId == null) {
Message.error('删除失败,未找到该记录');
return;
}
try {
await deleteSalesProductPrice(priceId);
Message.success('删除成功');
deletePriceModalVisible.value = false;
deletingPriceRecord.value = null;
deletePriceInput.value = '';
if (productId != null) await loadPriceTabData(productId);
} catch (e: any) {
Message.error(e?.message || '删除失败');
}
};
// 取消删除价格配置
@ -4314,6 +4355,7 @@
v-for="option in costItemOptions"
:key="option.value"
:value="option.value"
:disabled="isCostItemAlreadyUsed(option.value)"
>
{{ option.label }}
</a-option>

View File

@ -525,6 +525,7 @@
v-for="option in costItemOptions"
:key="option.value"
:value="option.value"
:disabled="isCostItemAlreadyUsed(option.value)"
>
{{ option.label }}
</a-option>
@ -1324,13 +1325,14 @@
</a-button>
</div>
<a-select
v-model="addFactorValueSelected"
:model-value="addFactorValueSelected"
:options="currentFactorValueOptions"
:placeholder="currentFactorValueOptions.length ? '请选择因素值' : '该因素暂无可选值'"
multiple
allow-clear
allow-search
style="width: 100%"
@change="handleAddFactorValueSelectedChange"
@press-enter="handleConfirmAddFactorValue"
/>
</div>
@ -1441,6 +1443,7 @@ import Sortable from 'sortablejs'
import {
canonicalWeightFactorCellForApi,
isWeightPricingFactor,
sortFactorValuesBySize,
weightValueToIntervalCell,
} from '@/utils/priceWeightInterval'
import { getAppGetList } from '@/api/ApplicationServices/apps'
@ -1712,7 +1715,11 @@ function toCanonicalFactorValues(factor: string, values: string[] | undefined):
return hit?.dictCode ?? s
})
}
return vs.map((v) => String(v))
const mapped = vs.map((v) => String(v))
if (isWeightValueFactor(factor)) {
return sortFactorValuesBySize(mapped)
}
return mapped
}
function dictFactorDisplayNameOnly(dictType: string, stored: string): string {
@ -3221,6 +3228,10 @@ const handleSavePriceList = async () => {
Message.warning('请先填写费用项')
return
}
if (isCostItemAlreadyUsed(basicInfoForm.costItem)) {
Message.warning('该渠道已存在此费用项,同一渠道下每种费用项只能有一条')
return
}
const cid = (selectedChannel.value as any)?.id
if (cid == null) {
Message.warning('请先选择渠道')
@ -4066,6 +4077,17 @@ const factorOptions = computed(() =>
)
)
const costItemOptions = computed(() => mapDictToSelectOptions('fee_item'))
/** 同一渠道下已占用的费用项(编辑当前条时排除自身) */
function isCostItemAlreadyUsed(costItem: string) {
const code = String(costItem ?? '').trim().toLowerCase()
if (!code) return false
const editingId = isEditMode.value ? editingPriceRecord.value?.id : null
return priceTableData.value.some((row: any) => {
if (editingId != null && row?.id === editingId) return false
return String(row?.costItem ?? '').trim().toLowerCase() === code
})
}
const costUnitOptions = computed(() => mapDictToSelectOptions('cost_unit'))
const currencyOptions = computed(() => mapDictToSelectOptions('currency'))
const calculationFormulaOptions = computed(() => mapDictToSelectOptions('calculation_formula'))
@ -4117,6 +4139,13 @@ const isAllFactorValuesSelected = computed(() => {
return options.every((option) => addFactorValueSelected.value.includes(option.value))
})
function handleAddFactorValueSelectedChange(vals: string[]) {
const factor = currentFactorRow.value?.factor
const next = vals || []
addFactorValueSelected.value =
factor && isWeightValueFactor(factor) ? sortFactorValuesBySize(next) : next
}
const handleAddFactorValue = async (row: any) => {
if (!row?.factor) {
Message.warning('请先选择因素')
@ -4129,9 +4158,12 @@ const handleAddFactorValue = async (row: any) => {
const opts = factorValuesOptionsByFactor.value[factor] || []
const allowed = new Set(opts.map((o) => String(o.value)))
const canonical = toCanonicalFactorValues(factor, row.factorValues || [])
addFactorValueSelected.value = canonical
const selected = canonical
.map((v) => String(v).trim())
.filter((v) => v && allowed.has(v))
addFactorValueSelected.value = isWeightValueFactor(factor)
? sortFactorValuesBySize(selected)
: selected
addFactorValueModalVisible.value = true
}
@ -4160,6 +4192,11 @@ function commitAddFactorSelections(opts: { forbidEmpty: boolean }): boolean {
}
currentFactorRow.value.factorValues.push(...toAdd)
if (isWeightValueFactor(currentFactorRow.value.factor)) {
currentFactorRow.value.factorValues = sortFactorValuesBySize(
currentFactorRow.value.factorValues,
)
}
Message.success('添加成功')
return true
}
@ -4186,7 +4223,10 @@ const handleSelectAllFactorValues = () => {
addFactorValueSelected.value = []
return
}
addFactorValueSelected.value = currentFactorValueOptions.value.map((option) => option.value)
const enabled = currentFactorValueOptions.value.map((option) => option.value)
const factor = currentFactorRow.value?.factor
addFactorValueSelected.value =
factor && isWeightValueFactor(factor) ? sortFactorValuesBySize(enabled) : enabled
}
// 删除因素值

View File

@ -180,6 +180,81 @@
return n;
}
function rangesOverlap(startA: number, endA: number, startB: number, endB: number) {
return startA < endB && startB < endA;
}
function findOverlappingRange(
startValue: number,
endValue: number,
rows: any[],
excludeId?: number
) {
return rows.find((row) => {
if (excludeId != null && Number(row?.id) === excludeId) return false;
const start = Number(row?.startValue);
const end = Number(row?.endValue);
if (!Number.isFinite(start) || !Number.isFinite(end)) return false;
return rangesOverlap(startValue, endValue, start, end);
});
}
async function loadAllRoundingInfos(roundingId: number) {
const { items } = await postWeightRoundingInfoList({
roundingId,
page: 1,
pageSize: 500,
});
return items || [];
}
function findRangeGap(
startValue: number,
endValue: number,
rows: any[],
excludeId?: number
) {
const merged = rows
.filter((row) => excludeId == null || Number(row?.id) !== excludeId)
.map((row) => ({ start: Number(row?.startValue), end: Number(row?.endValue) }))
.filter((row) => Number.isFinite(row.start) && Number.isFinite(row.end))
.concat([{ start: startValue, end: endValue }])
.sort((a, b) => a.start - b.start || a.end - b.end);
for (let i = 0; i < merged.length - 1; i++) {
if (merged[i].end !== merged[i + 1].start) {
return { left: merged[i], right: merged[i + 1] };
}
}
return null;
}
async function assertRangeNoOverlap(form: Record<string, any>, startValue: number, endValue: number) {
const roundingId = Number(form.roundingId ?? editFormRightParams.roundingId ?? currentId.value?.id);
const excludeId = form.id != null && form.id !== '' ? Number(form.id) : undefined;
const exclude = Number.isFinite(excludeId) ? excludeId : undefined;
let rows = tableData.value;
if (roundingId > 0 && (totalCount.value > rows.length || rows.length === 0)) {
rows = await loadAllRoundingInfos(roundingId);
}
const hit = findOverlappingRange(startValue, endValue, rows, exclude);
if (hit) {
const message = t('channel.weightRounding.form.rules.rangeOverlap', {
current: `${startValue}~${endValue}`,
existed: `${hit.startValue}~${hit.endValue}`,
});
$baseMessage(message, 'warning');
throw new Error(message);
}
const gap = findRangeGap(startValue, endValue, rows, exclude);
if (!gap) return;
const message = t('channel.weightRounding.form.rules.rangeGap', {
left: `${gap.left.start}~${gap.left.end}`,
right: `${gap.right.start}~${gap.right.end}`,
});
$baseMessage(message, 'warning');
throw new Error(message);
}
const leftModalMode = ref<DialogType>('add');
const rightModalMode = ref<DialogType>('add');
@ -390,6 +465,7 @@
throw new Error('missing-rounding-id');
}
const payload = beforeSubmitRoundingInfo(form);
await assertRangeNoOverlap(form, payload.startValue, payload.endValue);
await addWeightRoundingInfo({
roundingId,
startValue: payload.startValue,
@ -400,6 +476,7 @@
async function apiEditWeightRoundingInfo(form: Record<string, any>) {
const payload = beforeSubmitRoundingInfo(form);
await assertRangeNoOverlap(form, payload.startValue, payload.endValue);
await updateWeightRoundingInfo({
id: form.id,
roundingId: form.roundingId ?? currentId.value?.id,

View File

@ -147,6 +147,7 @@ function clearPriceContext() {
}
.h5-assistant-shell {
position: relative;
flex: 1;
min-height: 0;
border-radius: 18px;
@ -163,6 +164,7 @@ function clearPriceContext() {
:deep(.ai-advisor-shell),
:deep(.ai-advisor-app),
:deep(.ai-advisor-layout) {
position: relative;
height: 100%;
min-height: 0;
}
@ -193,9 +195,26 @@ function clearPriceContext() {
}
:deep(.ai-history-sidebar) {
position: absolute;
top: 0;
left: 0;
bottom: 0;
z-index: 20;
width: min(280px, 86%);
border-radius: 0 18px 18px 0;
}
/* 透明可点区域:点侧栏外关闭,但不加灰色遮罩 */
:deep(.ai-advisor-shell.ai-history-open .ai-history-backdrop) {
display: block !important;
position: absolute !important;
inset: 0 !important;
z-index: 19 !important;
border: none;
background: transparent !important;
cursor: pointer;
}
/* 运单上下文:移动端更紧凑 */
:deep(.ai-waybill-context) {
margin: 0 0 8px;
@ -226,27 +245,42 @@ function clearPriceContext() {
color: var(--app-primary-color);
}
/* 欢迎区 + MCP 能力 */
/* 欢迎区 + 快捷入口 */
:deep(.ai-welcome__hero) {
padding: 14px 0 12px;
padding: 8px 2px 12px;
gap: 10px;
}
:deep(.ai-welcome__avatar.nana-fab-wrap) {
width: 48px;
height: 48px;
}
:deep(.ai-welcome__hero h2) {
font-size: 1.15rem;
font-size: 1.08rem;
}
:deep(.ai-welcome__hero p) {
font-size: 0.84rem;
font-size: 0.82rem;
color: var(--color-text-3, #86909c);
}
:deep(.ai-mcp-grid) {
grid-template-columns: 1fr;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
:deep(.ai-mcp-grid--waybill) {
grid-template-columns: 1fr 1fr;
}
:deep(.ai-mcp-card) {
border-radius: 14px;
padding: 12px 14px;
background: transparent;
box-shadow: none;
min-height: 96px;
padding: 16px 12px;
border-radius: 12px;
border: 1px solid rgba(15, 23, 42, 0.08);
}
:deep(.ai-followup) {

View File

@ -10,34 +10,7 @@
/>
<router-link
v-for="item in leftTabs"
:key="item.path"
:to="item.path"
replace
class="h5-dock__item"
:class="{ 'is-active': isActive(item.path) }"
>
<span class="h5-dock__icon-wrap">
<component :is="item.icon" class="h5-dock__icon" />
</span>
<span class="h5-dock__label">{{ t(item.labelKey) }}</span>
</router-link>
<button
v-if="!hideCreate"
type="button"
class="h5-dock__item h5-dock__item--create"
:aria-label="t('h5.tab.createOrder')"
@click="emit('create-order')"
>
<span class="h5-dock__icon-wrap h5-dock__icon-wrap--create">
<icon-plus :size="20" class="h5-dock__icon h5-dock__icon--plus" />
</span>
<span class="h5-dock__label">{{ t('h5.tab.createOrder') }}</span>
</button>
<router-link
v-for="item in rightTabs"
v-for="item in tabs"
:key="item.path"
:to="item.path"
replace
@ -53,6 +26,29 @@
<span class="h5-dock__label">{{ t(item.labelKey) }}</span>
</router-link>
</nav>
<div class="h5-dock-fab-slot" :class="{ 'is-hidden': hideCreate }">
<button
v-show="showFabHint && !hideCreate"
type="button"
class="h5-dock-fab-hint"
@click="dismissFabHint"
>
{{ t('h5.tab.createOrder') }}
</button>
<button
type="button"
class="h5-dock-fab"
:tabindex="hideCreate ? -1 : 0"
:aria-hidden="hideCreate ? 'true' : 'false'"
:aria-label="t('h5.tab.createOrder')"
@click="onCreateOrder"
>
<span class="h5-dock-fab__glass" aria-hidden="true" />
<span class="h5-dock-fab__blob" aria-hidden="true" />
<icon-plus :size="26" class="h5-dock-fab__icon" />
</button>
</div>
</div>
</template>
@ -60,9 +56,11 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { IconRobot, IconUser, IconPlus } from '@arco-design/web-vue/es/icon';
import { IconPlus } from '@arco-design/web-vue/es/icon';
import IconTabCalculator from '@/assets/icons/h5/tab-calculator.svg';
import IconTabPackage from '@/assets/icons/h5/tab-package.svg';
import IconTabService from '@/assets/icons/h5/tab-service.svg';
import IconTabMe from '@/assets/icons/h5/tab-me.svg';
defineOptions({ name: 'H5AppTabBar' });
@ -86,23 +84,26 @@ const sliderStyle = ref<Record<string, string>>({
opacity: '0',
});
const sliderPos = { x: 0, w: 0 };
const showFabHint = ref(false);
let settleTimer: ReturnType<typeof setTimeout> | null = null;
let hideCreateTimer: ReturnType<typeof setTimeout> | null = null;
let hintShowTimer: ReturnType<typeof setTimeout> | null = null;
let hintHideTimer: ReturnType<typeof setTimeout> | null = null;
let sliderAnimating = false;
const leftTabs = computed(() => [
{
path: '/h5/assistant',
labelKey: 'h5.tab.assistant',
icon: IconRobot,
},
const FAB_HINT_STORAGE_KEY = 'h5_create_order_fab_hint_seen';
const tabs = computed(() => [
{
path: '/h5/price',
labelKey: 'h5.tab.price',
icon: IconTabCalculator,
},
]);
const rightTabs = computed(() => [
{
path: '/h5/assistant',
labelKey: 'h5.tab.assistant',
icon: IconTabService,
},
{
path: '/h5/orders',
labelKey: 'h5.tab.orders',
@ -112,7 +113,7 @@ const rightTabs = computed(() => [
{
path: '/h5/me',
labelKey: 'h5.tab.me',
icon: IconUser,
icon: IconTabMe,
},
]);
@ -120,6 +121,67 @@ function isActive(path: string) {
return route.path === path || route.path.startsWith(`${path}/`);
}
function hasSeenFabHint() {
try {
return localStorage.getItem(FAB_HINT_STORAGE_KEY) === '1';
} catch {
return true;
}
}
function markFabHintSeen() {
try {
localStorage.setItem(FAB_HINT_STORAGE_KEY, '1');
} catch {
/* 隐私模式无法写入时,至少本页不再反复弹出 */
}
}
function clearHintTimers() {
if (hintShowTimer) {
clearTimeout(hintShowTimer);
hintShowTimer = null;
}
if (hintHideTimer) {
clearTimeout(hintHideTimer);
hintHideTimer = null;
}
}
function dismissFabHint() {
showFabHint.value = false;
clearHintTimers();
markFabHintSeen();
}
function scheduleFabHint() {
clearHintTimers();
if (props.hideCreate || hasSeenFabHint()) {
showFabHint.value = false;
return;
}
hintShowTimer = setTimeout(() => {
hintShowTimer = null;
if (props.hideCreate || hasSeenFabHint()) return;
showFabHint.value = true;
hintHideTimer = setTimeout(() => {
hintHideTimer = null;
dismissFabHint();
}, 4200);
}, 700);
}
function onCreateOrder() {
if (props.hideCreate) return;
dismissFabHint();
try {
navigator.vibrate?.(12);
} catch {
/* 部分 WebView 不支持震动 */
}
emit('create-order');
}
const SLIDER_INSET = 4;
const LIQUID_STRETCH_MS = 120;
@ -210,6 +272,7 @@ function onViewportResize() {
onMounted(() => {
syncSlider(false);
scheduleFabHint();
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(onViewportResize);
if (dockRef.value) resizeObserver.observe(dockRef.value);
@ -219,16 +282,38 @@ onMounted(() => {
onBeforeUnmount(() => {
clearSettleTimer();
clearHintTimers();
if (hideCreateTimer) {
clearTimeout(hideCreateTimer);
hideCreateTimer = null;
}
resizeObserver?.disconnect();
window.removeEventListener('resize', onViewportResize);
});
watch(
() => [route.path, props.hideCreate] as const,
() => route.path,
() => {
syncSlider(true);
},
);
watch(
() => props.hideCreate,
(hidden) => {
if (hideCreateTimer) clearTimeout(hideCreateTimer);
hideCreateTimer = setTimeout(() => {
hideCreateTimer = null;
syncSlider(true);
}, 320);
if (hidden) {
showFabHint.value = false;
clearHintTimers();
} else {
scheduleFabHint();
}
},
);
</script>
<style lang="less" scoped>
@ -239,7 +324,8 @@ watch(
bottom: 0;
z-index: 100;
display: flex;
justify-content: center;
align-items: center;
gap: 0;
/* 16px 侧边 + 半高圆角 ≈ iPhone 屏幕连续圆角 */
padding: 0 var(--h5-dock-side-inset, 16px)
var(--h5-dock-bottom-inset, max(8px, calc(env(safe-area-inset-bottom, 0px) - 12px)));
@ -252,8 +338,9 @@ watch(
display: flex;
align-items: stretch;
justify-content: center;
flex: 1 1 auto;
min-width: 0;
gap: 0;
width: 100%;
height: var(--h5-dock-height, 68px);
min-height: var(--h5-dock-height, 68px);
padding: 5px;
@ -390,33 +477,11 @@ watch(
}
}
&:active:not(.h5-dock__item--create) {
&:active {
transform: scale(0.96);
}
}
.h5-dock__item--create {
color: var(--app-primary-color);
.h5-dock__icon-wrap--create {
color: #fff;
background: var(--app-primary-color);
box-shadow:
0 4px 14px color-mix(in srgb, var(--app-primary-color) 38%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.28);
}
&:active .h5-dock__icon-wrap--create {
filter: brightness(0.96);
transform: scale(0.94);
}
.h5-dock__label {
color: var(--app-primary-color);
font-weight: 600;
}
}
.h5-dock__icon-wrap {
position: relative;
display: inline-flex;
@ -428,24 +493,12 @@ watch(
line-height: 1;
}
.h5-dock__icon-wrap--create {
width: 34px;
height: 34px;
border-radius: 50%;
transition: transform 0.18s ease, filter 0.18s ease;
}
.h5-dock__icon {
font-size: 24px;
width: 1em;
height: 1em;
}
.h5-dock__icon--plus {
color: #fff;
pointer-events: none;
}
.h5-dock__badge {
position: absolute;
top: -4px;
@ -473,6 +526,182 @@ watch(
text-align: center;
}
.h5-dock-fab-slot {
position: relative;
flex: 0 0 var(--h5-dock-fab-size, 56px);
width: var(--h5-dock-fab-size, 56px);
height: var(--h5-dock-fab-size, 56px);
margin-left: 12px;
overflow: visible;
pointer-events: none;
transition:
flex-basis 0.32s cubic-bezier(0.32, 0.72, 0, 1),
width 0.32s cubic-bezier(0.32, 0.72, 0, 1),
margin-left 0.32s cubic-bezier(0.32, 0.72, 0, 1),
opacity 0.22s ease;
&.is-hidden {
overflow: hidden;
flex-basis: 0;
width: 0;
margin-left: 0;
opacity: 0;
.h5-dock-fab,
.h5-dock-fab-hint {
pointer-events: none;
}
}
}
.h5-dock-fab-hint {
position: absolute;
left: 50%;
bottom: calc(100% + 10px);
z-index: 3;
transform: translateX(-50%);
pointer-events: auto;
padding: 6px 10px;
border: none;
border-radius: 8px;
background: #1d2129;
color: #fff;
font-size: 12px;
line-height: 1.2;
white-space: nowrap;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&::after {
content: '';
position: absolute;
left: 50%;
top: 100%;
width: 0;
height: 0;
border: 6px solid transparent;
border-top-color: #1d2129;
transform: translateX(-50%);
}
}
/* 右侧独立圆形下单 FAB:液态玻璃 */
.h5-dock-fab {
position: relative;
pointer-events: auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--h5-dock-fab-size, 56px);
height: var(--h5-dock-fab-size, 56px);
padding: 0;
border: none;
border-radius: 50%;
overflow: hidden;
isolation: isolate;
color: var(--app-primary-color);
background: transparent;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
transform-origin: center;
transition: transform 0.22s cubic-bezier(0.22, 1.2, 0.36, 1);
&:active {
transform: scale(0.9);
.h5-dock-fab__blob {
transform: translate(18%, 22%) scale(1.28);
opacity: 0.95;
}
.h5-dock-fab__icon {
transform: scale(0.92);
}
}
}
.h5-dock-fab__glass {
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.38) 0%,
rgba(255, 255, 255, 0.16) 100%
);
backdrop-filter: blur(32px) saturate(190%) brightness(1.08);
-webkit-backdrop-filter: blur(32px) saturate(190%) brightness(1.08);
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(
155deg,
rgba(255, 255, 255, 0.72) 0%,
rgba(255, 255, 255, 0.16) 36%,
rgba(255, 255, 255, 0) 62%
);
}
&::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(
160deg,
rgba(255, 255, 255, 0.95) 0%,
rgba(255, 255, 255, 0.22) 42%,
rgba(255, 255, 255, 0.4) 100%
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
}
}
.h5-dock-fab__blob {
position: absolute;
left: 8%;
top: 4%;
z-index: 1;
width: 62%;
height: 52%;
border-radius: 50%;
pointer-events: none;
background: radial-gradient(
circle at 40% 35%,
rgba(255, 255, 255, 0.78) 0%,
rgba(255, 255, 255, 0.18) 48%,
rgba(255, 255, 255, 0) 72%
);
filter: blur(1.2px);
opacity: 0.85;
transform: translate(0, 0) scale(1);
transition:
transform 0.42s cubic-bezier(0.32, 0.72, 0, 1),
opacity 0.22s ease;
}
.h5-dock-fab__icon {
position: relative;
z-index: 2;
color: var(--app-primary-color);
pointer-events: none;
transition: transform 0.22s cubic-bezier(0.22, 1.2, 0.36, 1);
}
@keyframes h5-dock-icon-pop {
0% {
transform: scale(0.88);
@ -485,6 +714,21 @@ watch(
}
}
@media (max-width: 380px) {
.h5-dock__icon {
font-size: 22px;
}
.h5-dock__label {
font-size: 10px;
}
.h5-dock-fab-slot,
.h5-dock-fab {
--h5-dock-fab-size: 52px;
}
}
@media (prefers-reduced-motion: reduce) {
.h5-dock__slider.is-ready {
transition: none;
@ -493,5 +737,10 @@ watch(
.h5-dock__item.is-active .h5-dock__icon {
animation: none;
}
.h5-dock-fab-slot,
.h5-dock-fab {
transition: none;
}
}
</style>

View File

@ -4,8 +4,10 @@
<component :is="icon" />
</div>
<p v-if="text">{{ text }}</p>
<div v-if="$slots.default" class="h5-empty__actions">
<slot />
</div>
</div>
</template>
<script lang="ts" setup>
@ -22,12 +24,12 @@ defineProps<{
<style lang="less" scoped>
.h5-empty {
padding: 48px 12px;
padding: 48px 16px;
text-align: center;
p {
margin: 0;
color: #86909c;
color: var(--h5-text-3, #86909c);
font-size: 14px;
line-height: 1.45;
}
@ -41,20 +43,40 @@ defineProps<{
width: 56px;
height: 56px;
margin: 0 auto 14px;
border-radius: 16px;
border-radius: var(--h5-radius-sm, 12px);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 26px;
color: var(--app-primary-color);
background: color-mix(in srgb, var(--app-primary-color) 12%, #fff);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.85);
background: var(--h5-primary-soft, color-mix(in srgb, var(--app-primary-color) 12%, #fff));
:deep(svg),
:deep(.arco-icon) {
width: 26px;
height: 26px;
font-size: 26px;
display: block;
}
}
.h5-empty--inline .h5-empty__mark {
width: 44px;
height: 44px;
font-size: 20px;
margin-bottom: 10px;
:deep(svg),
:deep(.arco-icon) {
width: 22px;
height: 22px;
font-size: 22px;
}
}
.h5-empty__actions {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 14px;
}
</style>

View File

@ -84,16 +84,23 @@
</a-spin>
<footer class="h5-detail__foot">
<button type="button" class="h5-detail__btn is-ghost" @click="askAdvisor">
{{ t('h5.orders.askAdvisor') }}
<button
v-if="advisorAsIcon"
type="button"
class="h5-detail__icon-btn"
:aria-label="t('h5.orders.askAdvisor')"
@click="askAdvisor"
>
<IconTabService class="h5-detail__icon" />
</button>
<button
v-if="canModify"
type="button"
class="h5-detail__btn is-ghost"
class="h5-detail__icon-btn"
:aria-label="t('h5.orders.actions.edit')"
@click="openEdit"
>
{{ t('h5.orders.actions.edit') }}
<IconEdit class="h5-detail__icon" />
</button>
<button
v-if="isDraft"
@ -112,8 +119,14 @@
>
{{ t('h5.orders.pay') }}
</button>
<button v-else type="button" class="h5-detail__btn is-primary" @click="close">
{{ t('h5.orders.detail.close') }}
<button
v-else
type="button"
class="h5-detail__btn is-primary"
@click="askAdvisor"
>
<IconTabService class="h5-detail__btn-icon" />
{{ t('h5.orders.askAdvisor') }}
</button>
</footer>
</div>
@ -132,7 +145,7 @@ import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { Message, Modal } from '@arco-design/web-vue';
import { IconClose, IconClockCircle, IconCommon } from '@arco-design/web-vue/es/icon';
import { IconClose, IconClockCircle, IconCommon, IconEdit } from '@arco-design/web-vue/es/icon';
import { getOrderDetail, postOrderPreDeclare } from '@/api/order';
import { postGetDetailTimeline } from '@/api/TrackOrder/trackOrder';
import { getDictListAll, type DictDto } from '@/api/system/dict';
@ -146,6 +159,7 @@ import {
buildAskAdvisorQuery,
buildWaybillContextFromRow,
} from '@/composables/useAskAdvisor';
import IconTabService from '@/assets/icons/h5/tab-service.svg';
import H5OrderPaySheet from './H5OrderPaySheet.vue';
import H5EmptyState from './H5EmptyState.vue';
@ -208,6 +222,8 @@ const waybillNo = computed(
const statusCode = computed(() => resolveOrderStatusCode(detail.value));
const isPendingPay = computed(() => statusCode.value === 'pendingPayment');
const isDraft = computed(() => statusCode.value === 'draft');
/** 有支付/预报主操作时,顾问收成图标;否则顾问作为主按钮突出 */
const advisorAsIcon = computed(() => isDraft.value || isPendingPay.value);
const canModify = computed(() =>
H5_MODIFY_STATUSES.includes(statusCode.value as OrderStatusCode),
);
@ -676,30 +692,54 @@ watch(
.h5-detail__foot {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px 14px;
padding: 12px 16px calc(14px + env(safe-area-inset-bottom, 0px));
background: #fff;
border-top: 1px solid #eef0f3;
flex-shrink: 0;
}
.h5-detail__icon-btn {
flex: 0 0 48px;
width: 48px;
height: 48px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #e5e6eb;
border-radius: 12px;
background: #fff;
color: #4e5969;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&:active {
background: #f7f8fa;
transform: scale(0.96);
}
}
.h5-detail__icon {
width: 22px;
height: 22px;
font-size: 22px;
display: block;
}
.h5-detail__btn {
flex: 1;
flex: 1 1 auto;
min-width: 0;
height: 48px;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
&.is-ghost {
border: 1px solid #e5e6eb;
background: #fff;
color: #4e5969;
&:active {
background: #f7f8fa;
}
}
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
-webkit-tap-highlight-color: transparent;
&.is-primary {
border: none;
@ -710,6 +750,19 @@ watch(
&:active {
opacity: 0.92;
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
box-shadow: none;
}
}
}
.h5-detail__btn-icon {
width: 18px;
height: 18px;
display: block;
color: currentColor;
}
</style>

View File

@ -38,7 +38,7 @@ export default {
'h5.price.length': 'L',
'h5.price.width': 'W',
'h5.price.height': 'H',
'h5.price.query': 'Check rates',
'h5.price.query': 'Get quotes',
'h5.price.querying': 'Checking…',
'h5.price.editQuery': 'Edit',
'h5.price.reset': 'Reset',

View File

@ -38,7 +38,7 @@ export default {
'h5.price.length': '长',
'h5.price.width': '宽',
'h5.price.height': '高',
'h5.price.query': '一键查价',
'h5.price.query': '查询报价',
'h5.price.querying': '查价中…',
'h5.price.editQuery': '修改条件',
'h5.price.reset': '重置',

View File

@ -115,7 +115,7 @@
<a-spin :loading="loading && orders.length > 0" class="h5-orders-spin">
<H5EmptyState
v-if="!loading && !orders.length"
:icon="IconList"
:icon="IconTabPackage"
:text="
isPickupSelection
? t('h5.orders.mergePickup.emptyPredeclared')
@ -124,13 +124,12 @@
>
<a-button
v-if="!selectionMode"
type="outline"
size="small"
class="app-btn-primary-soft"
@click="h5Navigate(router, '/h5/price')"
>
{{ t('h5.orders.emptyGoPrice') }}
</a-button>
<a-button v-else type="outline" size="small" @click="exitSelectionMode">
<a-button v-else @click="exitSelectionMode">
{{ t('h5.orders.mergePickup.cancel') }}
</a-button>
</H5EmptyState>
@ -412,9 +411,10 @@ import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import dayjs from 'dayjs';
import { Message, Modal } from '@arco-design/web-vue';
import { IconCheck, IconCopy, IconLeft, IconList, IconSearch } from '@arco-design/web-vue/es/icon';
import { IconCheck, IconCopy, IconLeft, IconSearch } from '@arco-design/web-vue/es/icon';
import { h5Navigate } from '@/utils/device';
import iconOrderPickup from '@/assets/icons/h5/order-pickup.svg?url';
import IconTabPackage from '@/assets/icons/h5/tab-package.svg';
import H5EmptyState from '../components/H5EmptyState.vue';
import { H5_HIDE_CREATE_FAB_KEY } from '../composables/h5TabBarUi';
import {
@ -2124,11 +2124,6 @@ void bootstrap();
}
}
.h5-orders-spin :deep(.h5-empty) {
p {
margin-bottom: 14px;
}
}
.h5-order-card {
margin-bottom: 12px;

View File

@ -78,20 +78,21 @@
}
.h5-empty {
padding: 48px 12px;
padding: 48px 16px;
text-align: center;
p {
margin: 0 0 12px;
color: #86909c;
margin: 0;
color: var(--h5-text-3, #86909c);
font-size: 14px;
}
}
.h5-empty__mark {
width: 52px;
height: 52px;
margin: 0 auto 12px;
border-radius: 16px;
background: color-mix(in srgb, var(--app-primary-color) 12%, #fff);
width: 56px;
height: 56px;
margin: 0 auto 14px;
border-radius: var(--h5-radius-sm, 12px);
background: var(--h5-primary-soft, color-mix(in srgb, var(--app-primary-color) 12%, #fff));
color: var(--app-primary-color);
}

View File

@ -27,8 +27,10 @@
--h5-dock-bottom-inset: max(8px, calc(var(--h5-safe-bottom) - 12px));
--h5-dock-side-inset: 16px;
--h5-dock-height: 68px;
--h5-dock-fab-size: 56px;
--h5-dock-radius: calc(var(--h5-dock-height) / 2);
--h5-dock-offset: calc(var(--h5-dock-height) + var(--h5-dock-bottom-inset));
/* 内容区多留一点,避免圆钮阴影压住列表末行 */
--h5-dock-offset: calc(var(--h5-dock-height) + var(--h5-dock-bottom-inset) + 6px);
background: var(--h5-bg);
color: var(--h5-text);

View File

@ -60,7 +60,7 @@
<span class="sol-badge">合规推荐</span>
</div>
<ul class="sol-list">
<li>报关方式:简易 / 汇总均可</li>
<li>报关方式:汇总申报(正式报关单)</li>
<li>HS 编码:前 4 位即可</li>
<li>核心凭证:综服平台免税登记记录</li>
<li>操作平台:单一窗口 + 综服平台</li>
@ -179,7 +179,7 @@ function handleClose() {
const compareRows = [
{ label: '进项专票', refund: '必须有', exempt: '不需要', basic: '不需要' },
{ label: '报关方式', refund: '汇总申报', exempt: '简易 / 汇总均可', basic: '统一简易申报' },
{ label: '报关方式', refund: '汇总申报', exempt: '汇总申报(正式报关单)', basic: '统一简易申报' },
{ label: 'HS 编码精度', refund: '10 位完整', exempt: '前 4 位即可', basic: '前 4 位批量' },
{ label: '正式报关单', refund: '有(退税专用联)', exempt: '可有可无', basic: '物流商统一申报' },
{ label: '综试区要求', refund: '无强制要求', exempt: '需注册在综试区内', basic: '无要求' },

View File

@ -314,6 +314,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
const singleDomesticPickupProductOptions = ref<Array<{ value: string; label: string }>>([]);
const singleCargoAttributeOptions = ref<SingleSelectOption[]>([]);
const singleExportCustomsModeOptions = ref<SingleSelectOption[]>([]);
const singleEcommercePlatformOptions = ref<SingleSelectOption[]>([]);
type WarehouseSelectOption = SingleSelectOption & {
recipient?: string;
contactPhone?: string;
@ -2351,6 +2352,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
productRes,
cargoDictRows,
exportCustomsDictRows,
ecommercePlatformDictRows,
warehouseRes,
currencyDictRows,
countryRes,
@ -2360,6 +2362,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
getSalesProductList({ page: 1, pageSize: 500, isActive: 2 }),
getDictListAll({ dictType: 'cargo_attribute' }),
getDictListAll({ dictType: 'export_customs_mode' }),
getDictListAll({ dictType: 'e-commerce_platform' }),
getWarehouseList({ page: 1, pageSize: 500 }),
getDictListAll({ dictType: 'currency' }),
getCountryList(),
@ -2428,6 +2431,12 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
singleForm.exportCustomsMode = defaultExportMode;
}
singleEcommercePlatformOptions.value = (ecommercePlatformDictRows ?? [])
.map(normalizeCargoDictRow)
.filter((d) => d.code)
.sort((a, b) => a.sort - b.sort || a.code.localeCompare(b.code))
.map((d) => ({ value: d.code, label: d.name || d.code }));
const whOpts = (warehouseRes.items ?? [])
.map(normalizeWarehouseRow)
.filter((w) => w.id)
@ -2472,6 +2481,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
singleDomesticPickupProductOptions.value = [];
singleCargoAttributeOptions.value = [];
singleExportCustomsModeOptions.value = [];
singleEcommercePlatformOptions.value = [];
singleWarehouseOptions.value = [];
singleCurrencyOptions.value = [];
singleCountryOptions.value = [];
@ -3846,12 +3856,22 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
:validate-status="fieldValidateStatus('platformCode')"
:help="fieldHelp('platformCode')"
>
<a-input
<a-select
v-model="singleForm.platformCode"
:max-length="16"
allow-clear
allow-search
:placeholder="t('order.placeOrder.single.platformCodePlaceholder')"
:loading="singleReferenceLoading"
:status="fieldValidateStatus('platformCode')"
/>
>
<a-option
v-for="opt in singleEcommercePlatformOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-option>
</a-select>
</a-form-item>
<a-form-item
class="dims-form-item"

View File

@ -6,12 +6,17 @@
import { getSalesProductDetail } from '@/api/channel/salesProduct';
import { Message } from '@arco-design/web-vue';
import type { RecommendAlternative, ShipVerdict } from '../recommendChannel';
import {
groupPriceCheckFailures,
type PriceCheckFailureItem,
} from '../priceCheckFailures';
defineOptions({ name: 'PriceCheckChannelGuidePanel' });
const props = defineProps<{
record: Record<string, any> | null;
failureText?: string;
failureItems?: PriceCheckFailureItem[];
recommendReason?: string;
recommendVerdict?: ShipVerdict | '';
recommendEvidence?: string[];
@ -35,6 +40,20 @@
const verdict = computed(() => props.recommendVerdict || '');
const evidence = computed(() => (props.recommendEvidence || []).filter(Boolean).slice(0, 3));
const alternatives = computed(() => (props.alternatives || []).slice(0, 3));
const showFailureBlock = computed(
() =>
!props.record &&
((props.failureItems && props.failureItems.length > 0) || !!props.failureText),
);
const failureGroups = computed(() => groupPriceCheckFailures(props.failureItems || []));
function failureStepLabel(step: string) {
const key = `order.priceCheck.guide.failureStep.${step}`;
const label = t(key);
return label === key ? step : label;
}
const showRecommendBlock = computed(
() =>
!!props.recommendReason ||
@ -216,9 +235,50 @@
</p>
</a-spin>
</div>
<div v-else-if="failureText" class="guide-panel__failure">
<div v-else-if="showFailureBlock" class="guide-panel__failure">
<div class="guide-panel__failure-badge">{{ t('order.priceCheck.guide.failureTitle') }}</div>
<p class="guide-panel__failure-text">{{ failureText }}</p>
<p v-if="failureGroups.length" class="guide-panel__failure-summary">
{{
t('order.priceCheck.guide.failureSummary', {
productCount: failureGroups.length,
itemCount: failureItems?.length || 0,
})
}}
</p>
<ol v-if="failureGroups.length" class="guide-panel__failure-list">
<li v-for="(group, gi) in failureGroups" :key="group.key" class="guide-panel__failure-product">
<div class="guide-panel__failure-product-head">
<span class="guide-panel__failure-index">{{ gi + 1 }}</span>
<div class="guide-panel__failure-product-title">
<strong>{{
group.productName ||
group.productCode ||
t('order.priceCheck.guide.failureUnknownProduct')
}}</strong>
<span v-if="group.productCode && group.productName" class="guide-panel__failure-code">
{{ group.productCode }}
</span>
</div>
</div>
<ul class="guide-panel__failure-reasons">
<li v-for="(item, ii) in group.items" :key="`${group.key}-${ii}`">
<div class="guide-panel__failure-meta">
<span v-if="item.cargoTypeName">
{{ t('order.priceCheck.guide.failureCargo', { name: item.cargoTypeName }) }}
</span>
<span v-if="item.warehouseName">
{{ t('order.priceCheck.guide.failureWarehouse', { name: item.warehouseName }) }}
</span>
<span v-if="item.step" class="guide-panel__failure-step">
{{ t('order.priceCheck.guide.failureStepLabel', { step: failureStepLabel(item.step) }) }}
</span>
</div>
<p class="guide-panel__failure-reason">{{ item.reason }}</p>
</li>
</ul>
</li>
</ol>
<p v-else-if="failureText" class="guide-panel__failure-text">{{ failureText }}</p>
</div>
<div v-else class="guide-panel__idle">
<a-empty :description="t('order.priceCheck.guide.idle')" />
@ -341,7 +401,9 @@
display: flex;
flex-direction: column;
gap: 12px;
min-height: 200px;
min-height: 0;
height: 100%;
overflow: auto;
}
.guide-panel__failure-badge {
@ -355,6 +417,100 @@
line-height: 20px;
}
.guide-panel__failure-summary {
margin: 0;
color: var(--color-text-2);
font-size: 12px;
line-height: 1.5;
}
.guide-panel__failure-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
}
.guide-panel__failure-product {
padding: 10px 12px;
border: 1px solid var(--color-border-2);
border-radius: 8px;
background: var(--color-fill-1);
}
.guide-panel__failure-product-head {
display: flex;
align-items: flex-start;
gap: 8px;
}
.guide-panel__failure-index {
flex: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--app-primary-color);
color: #fff;
font-size: 12px;
font-weight: 600;
line-height: 20px;
text-align: center;
}
.guide-panel__failure-product-title {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
strong {
font-size: 13px;
font-weight: 600;
color: var(--color-text-1);
line-height: 20px;
word-break: break-word;
}
}
.guide-panel__failure-code {
color: var(--color-text-3);
font-size: 12px;
line-height: 18px;
}
.guide-panel__failure-reasons {
margin: 8px 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
}
.guide-panel__failure-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
color: var(--color-text-3);
font-size: 12px;
line-height: 18px;
}
.guide-panel__failure-step {
color: var(--app-primary-color);
font-weight: 600;
}
.guide-panel__failure-reason {
margin: 4px 0 0;
color: var(--color-text-2);
font-size: 13px;
line-height: 1.6;
word-break: break-word;
}
.guide-panel__failure-text {
margin: 0;
color: var(--color-text-2);

View File

@ -18,6 +18,11 @@
import { recommendWithAi, type QuoteCandidate, type RecommendAlternative, type ShipVerdict } from './recommendChannel';
import { normalizeCargoAttributeCode } from '@/utils/cargoAttribute';
import { openPriceAskAdvisor, type PriceAskIntent } from '@/composables/priceAskAdvisor';
import {
formatPriceCheckFailures,
normalizePriceCheckFailures,
type PriceCheckFailureItem,
} from './priceCheckFailures';
defineOptions({
name: 'PriceCheck',
@ -417,36 +422,26 @@
const queryResults = ref<any[]>([]);
const queryLoading = ref(false);
const queryFailureText = ref('');
const queryFailureItems = ref<PriceCheckFailureItem[]>([]);
/** 单卡切换货物属性 / 仓库时的加载态 */
const recalcLoadingKeys = ref<Record<string, boolean>>({});
function formatPriceCheckFailures(
failures: Array<{
reason?: string;
Reason?: string;
step?: string;
Step?: string;
productName?: string;
ProductName?: string;
cargoTypeName?: string;
CargoTypeName?: string;
warehouseName?: string;
WarehouseName?: string;
}>
) {
return (failures ?? [])
.map((f) => {
const reason = (f.reason ?? f.Reason ?? '').trim();
if (!reason) return '';
const step = (f.step ?? f.Step ?? '').trim();
const product = (f.productName ?? f.ProductName ?? '').trim();
const cargo = (f.cargoTypeName ?? f.CargoTypeName ?? '').trim();
const warehouse = (f.warehouseName ?? f.WarehouseName ?? '').trim();
const prefix = [product, cargo, warehouse, step].filter(Boolean).join(' / ');
return prefix ? `${prefix}:${reason}` : reason;
})
.filter(Boolean)
.join(';');
function failureStepLabel(step: string) {
const key = `order.priceCheck.guide.failureStep.${step}`;
const label = t(key);
return label === key ? step : label;
}
function applyPriceCheckFailures(failures: unknown[]) {
queryFailureItems.value = normalizePriceCheckFailures((failures ?? []) as any);
queryFailureText.value =
formatPriceCheckFailures((failures ?? []) as any, failureStepLabel) ||
t('order.priceCheck.table.empty');
}
function clearPriceCheckFailures() {
queryFailureItems.value = [];
queryFailureText.value = '';
}
const currentPage = ref(1);
@ -562,7 +557,7 @@
}
hasQueried.value = true;
queryLoading.value = true;
queryFailureText.value = '';
clearPriceCheckFailures();
queryHint.value = null;
hintBannerDismissed.value = false;
try {
@ -593,13 +588,14 @@
currentPage.value = 1;
recalcLoadingKeys.value = {};
if (!queryResults.value.length) {
const failureText = formatPriceCheckFailures(failures ?? []);
queryFailureText.value = failureText || t('order.priceCheck.table.empty');
applyPriceCheckFailures(failures ?? []);
} else {
clearPriceCheckFailures();
Message.success(t('order.priceCheck.message.querySuccess', { count: queryResults.value.length }));
}
} catch (e: any) {
const errText = e?.message || t('order.priceCheck.message.queryFailed');
queryFailureItems.value = [];
queryFailureText.value = errText;
queryResults.value = [];
selectedGuideRecord.value = null;
@ -631,7 +627,7 @@
selectedGuideRecord.value = null;
resetRecommendState();
currentPage.value = 1;
queryFailureText.value = '';
clearPriceCheckFailures();
sortMode.value = 'recommend';
couponInstanceId.value = undefined;
availableCoupons.value = [];
@ -682,7 +678,7 @@
const replacement =
nextRows.find((row) => String(row.productCode || '').trim() === productCode) || nextRows[0];
if (!replacement) {
const failureText = formatPriceCheckFailures(failures ?? []);
const failureText = formatPriceCheckFailures(failures ?? [], failureStepLabel);
Message.warning(failureText || t('order.priceCheck.message.recalcNoQuote'));
return;
}
@ -1278,7 +1274,13 @@
/>
</div>
<div v-else-if="!queryLoading && hasQueried" class="quote-empty">
<a-empty :description="queryFailureText || t('order.priceCheck.table.empty')">
<a-empty
:description="
queryFailureItems.length
? t('order.priceCheck.table.emptyWithFailures', { count: queryFailureItems.length })
: queryFailureText || t('order.priceCheck.table.empty')
"
>
<template #image />
<p class="empty-hint">{{ t('order.priceCheck.table.emptyHint') }}</p>
</a-empty>
@ -1292,6 +1294,7 @@
<div class="price-check-aside">
<ChannelGuidePanel
:record="selectedGuideRecord"
:failure-items="!queryResults.length ? queryFailureItems : []"
:failure-text="!queryResults.length ? queryFailureText : ''"
:recommend-reason="recommendReason"
:recommend-verdict="recommendVerdict"

View File

@ -0,0 +1,109 @@
export type PriceCheckFailureRaw = {
productId?: number;
ProductId?: number;
productCode?: string;
ProductCode?: string;
productName?: string;
ProductName?: string;
cargoTypeName?: string;
CargoTypeName?: string;
warehouseName?: string;
WarehouseName?: string;
step?: string;
Step?: string;
reason?: string;
Reason?: string;
};
export type PriceCheckFailureItem = {
productId: number;
productCode: string;
productName: string;
cargoTypeName: string;
warehouseName: string;
step: string;
reason: string;
};
export type PriceCheckFailureGroup = {
key: string;
productName: string;
productCode: string;
items: PriceCheckFailureItem[];
};
const STEP_ORDER = ['product', 'snapshot', 'blacklist', 'weight', 'zone', 'price'];
function pick(raw: PriceCheckFailureRaw, camel: keyof PriceCheckFailureRaw, pascal: keyof PriceCheckFailureRaw) {
return String(raw[camel] ?? raw[pascal] ?? '').trim();
}
function stripReasonContextPrefix(reason: string) {
return reason.replace(/^\[[^\]]*(货物类型|仓库)[^\]]*\]\s*/, '').trim();
}
export function normalizePriceCheckFailures(failures: PriceCheckFailureRaw[] = []): PriceCheckFailureItem[] {
const items = (failures ?? [])
.map((raw) => {
const reason = stripReasonContextPrefix(pick(raw, 'reason', 'Reason'));
if (!reason) return null;
const productId = Number(raw.productId ?? raw.ProductId ?? 0) || 0;
return {
productId,
productCode: pick(raw, 'productCode', 'ProductCode'),
productName: pick(raw, 'productName', 'ProductName'),
cargoTypeName: pick(raw, 'cargoTypeName', 'CargoTypeName'),
warehouseName: pick(raw, 'warehouseName', 'WarehouseName'),
step: pick(raw, 'step', 'Step').toLowerCase(),
reason,
} as PriceCheckFailureItem;
})
.filter((item): item is PriceCheckFailureItem => !!item);
return items.sort((a, b) => {
const nameCmp = (a.productName || a.productCode).localeCompare(b.productName || b.productCode, 'zh');
if (nameCmp) return nameCmp;
const cargoCmp = a.cargoTypeName.localeCompare(b.cargoTypeName, 'zh');
if (cargoCmp) return cargoCmp;
const warehouseCmp = a.warehouseName.localeCompare(b.warehouseName, 'zh');
if (warehouseCmp) return warehouseCmp;
return STEP_ORDER.indexOf(a.step) - STEP_ORDER.indexOf(b.step);
});
}
export function groupPriceCheckFailures(items: PriceCheckFailureItem[]): PriceCheckFailureGroup[] {
const groups: PriceCheckFailureGroup[] = [];
const index = new Map<string, PriceCheckFailureGroup>();
for (const item of items) {
const key = item.productId
? `id:${item.productId}`
: `name:${item.productName || item.productCode || '_'}`;
let group = index.get(key);
if (!group) {
group = {
key,
productName: item.productName,
productCode: item.productCode,
items: [],
};
index.set(key, group);
groups.push(group);
}
group.items.push(item);
}
return groups;
}
export function formatPriceCheckFailures(
failures: PriceCheckFailureRaw[],
stepLabel: (step: string) => string,
) {
const items = normalizePriceCheckFailures(failures);
return items
.map((item, idx) => {
const product = item.productName || item.productCode || `产品${idx + 1}`;
const meta = [item.cargoTypeName, item.warehouseName, stepLabel(item.step)].filter(Boolean).join(' / ');
return `${idx + 1}. ${product}${meta ? `(${meta})` : ''}:${item.reason}`;
})
.join('\n');
}

View File

@ -88,6 +88,7 @@ import { useI18n } from 'vue-i18n'
import { Message, Modal } from '@arco-design/web-vue'
import type { CustomerOpenApiCredentialDto } from '@/api/customer'
import { CustomerService } from '@/api/customer'
import { useRbacUserStore } from '@/store/modules/rbac-user'
defineOptions({ name: 'CustomerOpenApiCredentialPanel' })
@ -107,8 +108,14 @@ const credential = reactive<CustomerOpenApiCredentialDto>({
secretRevealed: false,
})
const rbacUserStore = useRbacUserStore()
const isAdminMode = () => props.customerId != null && props.customerId !== ''
function canCallMineApi() {
return rbacUserStore.isCustomerPortal()
}
function applyCredential(data: CustomerOpenApiCredentialDto) {
Object.assign(credential, {
id: data.id,
@ -126,6 +133,7 @@ function applyCredential(data: CustomerOpenApiCredentialDto) {
}
async function load() {
if (!isAdminMode() && !canCallMineApi()) return
loading.value = true
try {
const data = isAdminMode()
@ -140,6 +148,7 @@ async function load() {
}
async function handleEnsure() {
if (!isAdminMode() && !canCallMineApi()) return
busy.value = true
try {
const data = isAdminMode()
@ -161,6 +170,7 @@ function handleReset() {
title: t('user.customer.openapi.resetConfirmTitle'),
content: t('user.customer.openapi.resetConfirmContent'),
onOk: async () => {
if (!isAdminMode() && !canCallMineApi()) return
busy.value = true
try {
const data = isAdminMode()
@ -178,6 +188,7 @@ function handleReset() {
}
async function onEnabledChange(value: string | number | boolean) {
if (!isAdminMode() && !canCallMineApi()) return
const enabled = Boolean(value)
busy.value = true
try {

View File

@ -54,7 +54,7 @@
<div ref="bodyRef" class="psd__body">
<KeepAlive :max="5">
<component :is="activePanel" />
<component :is="visibleModel ? activePanel : null" />
</KeepAlive>
</div>
</div>

View File

@ -119,7 +119,7 @@
</section>
<!-- 5. 开放接口凭证(仅主客户) -->
<section v-if="isMainCustomer" class="profile-card profile-card--full">
<section v-if="showOpenApiCredential" class="profile-card profile-card--full">
<div class="card-header">
<h3 class="card-title">{{ t('user.customer.openapi.title') }}</h3>
</div>
@ -231,7 +231,17 @@ const profile = ref<CustomerDto>({})
const loading = ref(true)
const loadError = ref('')
const isMainCustomer = computed(() => profile.value.parentCustomerId == null)
const isCustomerPortal = computed(() => rbacUserStore.isCustomerPortal())
const isMainCustomer = computed(() => {
const id = Number(profile.value.id)
if (!Number.isFinite(id) || id <= 0) return false
return profile.value.parentCustomerId == null
})
const showOpenApiCredential = computed(
() => isCustomerPortal.value && isMainCustomer.value,
)
const editModalVisible = ref(false)
const editFormRef = ref()

View File

@ -2,7 +2,7 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Modal } from '@arco-design/web-vue'
import { IconDelete, IconImage, IconQuestionCircle } from '@arco-design/web-vue/es/icon'
import { IconDelete, IconDownload, IconImage, IconQuestionCircle } from '@arco-design/web-vue/es/icon'
import HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue'
import ModalPro from '@/components/ModalPro/index.vue'
@ -42,6 +42,7 @@ import { buildStatusTagHtml, type StatusTagTone } from '@/utils/statusTag'
import { useWarehouseAdminCustomer } from '@/composables/useWarehouseAdminCustomer'
import { setRapidPackageHostCookie, setRapidPackageWarehouseCookie, setRapidPackageCustomerCookie } from '@/utils/rapidPackagePluginHost'
import { useCurrentWarehouseStore } from '@/store'
import { PRINT_CONTROL_DOWNLOAD_URL } from '@/constants/printControl'
defineOptions({ name: 'WarehouseParcel' })
@ -661,6 +662,18 @@ onMounted(async () => {
:items="slidingStatusTabs"
@change="handleStatusTabChange"
/>
<p class="parcel-plugin-hint">
<span>{{ t('warehouse.parcel.pluginHint') }}</span>
<a
class="parcel-plugin-hint__link"
:href="PRINT_CONTROL_DOWNLOAD_URL"
target="_blank"
rel="noopener noreferrer"
>
<icon-download />
{{ t('warehouse.parcel.pluginHintDownload') }}
</a>
</p>
</div>
<div class="table-toolbar">
<div class="toolbar-left">
@ -1055,6 +1068,33 @@ onMounted(async () => {
@import '@/styles/warehouse-list-page.less';
@import '@/styles/warehouse-form-modal.less';
.parcel-plugin-hint {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 4px 8px;
margin: 0;
min-width: 0;
font-size: 13px;
line-height: 1.4;
color: var(--color-text-3);
text-align: right;
}
.parcel-plugin-hint__link {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--app-primary-color);
white-space: nowrap;
text-decoration: none;
}
.parcel-plugin-hint__link:hover {
opacity: 0.85;
}
.parcel-detail-drawer {
&__meta {
display: flex;