feat: H5 底栏与顾问体验、查价失败分组,以及下单/渠道与凭证加固
All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m51s
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:
parent
1026d909e9
commit
2a4251f9db
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
9
src/assets/icons/h5/tab-me.svg
Normal file
9
src/assets/icons/h5/tab-me.svg
Normal 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 |
@ -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 |
20
src/assets/icons/h5/tab-service.svg
Normal file
20
src/assets/icons/h5/tab-service.svg
Normal 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 |
@ -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();
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
|
||||
3
src/constants/printControl.ts
Normal file
3
src/constants/printControl.ts
Normal file
@ -0,0 +1,3 @@
|
||||
/** 打印控件 / 一键添加包裹插件安装包(百度网盘,提取码 idex) */
|
||||
export const PRINT_CONTROL_DOWNLOAD_URL =
|
||||
'https://pan.baidu.com/s/1FrpI1prvtRKG1f9jXYSL1A?pwd=idex'
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
// 删除因素值
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '重置',
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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: '无要求' },
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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"
|
||||
|
||||
109
src/views/order/priceCheck/priceCheckFailures.ts
Normal file
109
src/views/order/priceCheck/priceCheckFailures.ts
Normal 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');
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -54,7 +54,7 @@
|
||||
|
||||
<div ref="bodyRef" class="psd__body">
|
||||
<KeepAlive :max="5">
|
||||
<component :is="activePanel" />
|
||||
<component :is="visibleModel ? activePanel : null" />
|
||||
</KeepAlive>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user