feat: 接入企业微信客服,并同步工作台与下单页改动

官网/H5 支持企微客服跳转;用户资料新增 WecomKfUrl;工作台与下单相关体验一并更新。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-09-01 19:09:34 +08:00
parent df35a90321
commit 6fb1f4fd66
38 changed files with 1239 additions and 425 deletions

View File

@ -2312,9 +2312,74 @@ body.command-home .ai-stage-map--eu {
/* Composer + trust strip */
.ai-composer-wrap {
position: relative;
flex-shrink: 0;
}
.ai-new-chat-tip {
position: absolute;
left: 50%;
bottom: calc(100% + 10px);
z-index: 6;
display: inline-flex;
align-items: center;
max-width: min(92%, 360px);
padding: 8px 14px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.78);
background: rgba(5, 79, 116, 0.92);
color: #fff;
font-size: 0.78rem;
font-weight: 600;
line-height: 1.3;
letter-spacing: 0.01em;
white-space: nowrap;
pointer-events: none;
transform: translate(-50%, 6px);
opacity: 0;
box-shadow: 0 10px 28px rgba(5, 79, 116, 0.22);
transition:
opacity 0.28s var(--ai-ease),
transform 0.28s var(--ai-ease);
}
.ai-new-chat-tip::after {
content: '';
position: absolute;
left: 50%;
top: 100%;
width: 10px;
height: 10px;
margin-left: -5px;
background: rgba(5, 79, 116, 0.92);
border-right: 1px solid rgba(255, 255, 255, 0.5);
border-bottom: 1px solid rgba(255, 255, 255, 0.5);
transform: rotate(45deg) translateY(-4px);
}
.ai-new-chat-tip[hidden] {
display: none !important;
}
.ai-new-chat-tip.is-visible {
opacity: 1;
transform: translate(-50%, 0);
}
.ai-composer-wrap.has-new-chat-tip .ai-composer {
animation: ai-composer-tip-pulse 1.1s var(--ai-ease) 2;
}
@keyframes ai-composer-tip-pulse {
0%,
100% {
box-shadow: none;
}
45% {
box-shadow: 0 0 0 3px rgba(5, 79, 116, 0.18);
}
}
.ai-composer-wrap.is-welcome {
padding: 0;
border-top: none;
@ -2444,16 +2509,22 @@ body.command-home .ai-stage-map--eu {
@media (prefers-reduced-motion: reduce) {
.ai-composer__sheen,
.ai-composer__caustic,
.ai-intent-door::after {
.ai-intent-door::after,
.ai-composer-wrap.has-new-chat-tip .ai-composer {
animation: none;
}
.ai-new-chat-tip {
transition: none;
}
}
.ai-trust-bar {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 8px 18px;
margin-top: 4px;
padding: 14px 4px 16px;
border-top: 1px solid color-mix(in srgb, var(--ai-border) 55%, transparent);
@ -2461,23 +2532,6 @@ body.command-home .ai-stage-map--eu {
font-size: 0.72rem;
}
.ai-trust-bar__contacts {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 10px 22px;
}
.ai-trust-bar__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 8px 18px;
text-align: center;
}
.ai-trust-bar a,
.ai-trust-wechat {
display: inline-flex;
@ -2526,19 +2580,19 @@ body.command-home .ai-stage-map--eu {
.ai-trust-address {
display: inline-flex;
align-items: flex-start;
align-items: center;
justify-content: center;
gap: 6px;
margin: 0;
max-width: min(100%, 560px);
color: var(--ai-text-3);
font-size: 0.72rem;
line-height: 1.45;
text-align: left;
white-space: nowrap;
}
.ai-trust-address i {
margin-top: 2px;
margin-top: 0;
font-size: 0.88rem;
line-height: 1;
color: var(--ai-action);
@ -2722,15 +2776,8 @@ body.command-home .ai-stage-map--eu {
font-size: 0.9rem;
}
.ai-trust-bar__contacts {
flex-direction: column;
align-items: center;
gap: 10px;
}
.ai-trust-bar__meta {
flex-direction: column;
gap: 8px;
.ai-trust-bar {
gap: 8px 14px;
}
.ai-trust-address {
@ -2738,6 +2785,13 @@ body.command-home .ai-stage-map--eu {
justify-content: center;
}
.ai-new-chat-tip {
max-width: min(94vw, 320px);
white-space: normal;
text-align: center;
border-radius: 14px;
}
.ai-composer {
min-height: 76px;
padding: 12px 12px 12px 18px;
@ -2754,3 +2808,123 @@ body.command-home .ai-stage-map--eu {
height: 46px;
}
}
/* 企业微信客服 — PC 扫码浮层 */
.ide-kf-modal {
position: fixed;
inset: 0;
z-index: 10050;
display: grid;
place-items: center;
padding: 24px;
}
.ide-kf-modal__backdrop {
position: absolute;
inset: 0;
background: rgba(12, 18, 28, 0.55);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.ide-kf-modal__panel {
position: relative;
z-index: 1;
width: min(100%, 360px);
padding: 28px 24px 22px;
border-radius: 16px;
border: 1px solid color-mix(in srgb, var(--ai-border) 80%, transparent);
background: var(--ai-surface, #fff);
box-shadow: 0 24px 64px rgba(12, 18, 28, 0.22);
text-align: center;
}
.ide-kf-modal__close {
position: absolute;
top: 10px;
right: 10px;
width: 36px;
height: 36px;
display: grid;
place-items: center;
margin: 0;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: var(--ai-text-3, #6b7280);
cursor: pointer;
}
.ide-kf-modal__close:hover {
color: var(--ai-text, #111);
background: color-mix(in srgb, var(--ai-border) 40%, transparent);
}
.ide-kf-modal__kicker {
margin: 0 0 6px;
font-size: 0.75rem;
letter-spacing: 0.04em;
color: var(--ai-text-3, #6b7280);
}
.ide-kf-modal__title {
margin: 0 0 8px;
font-size: 1.15rem;
font-weight: 650;
color: var(--ai-text, #111);
}
.ide-kf-modal__hint {
margin: 0 0 18px;
font-size: 0.84rem;
line-height: 1.5;
color: var(--ai-text-2, #4b5563);
}
.ide-kf-modal__qr-wrap {
display: grid;
place-items: center;
margin: 0 auto 16px;
padding: 12px;
width: fit-content;
border-radius: 12px;
background: #fff;
border: 1px solid color-mix(in srgb, var(--ai-border) 70%, transparent);
}
.ide-kf-modal__qr {
display: block;
width: 220px;
height: 220px;
}
.ide-kf-modal__qr-fallback {
margin: 0;
max-width: 220px;
font-size: 0.8rem;
line-height: 1.45;
color: var(--ai-text-2, #4b5563);
}
.ide-kf-modal__open {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 36px;
padding: 0 14px;
border-radius: 8px;
font-size: 0.84rem;
font-weight: 600;
color: var(--ai-action, #1d7281);
text-decoration: none;
}
.ide-kf-modal__open:hover {
text-decoration: underline;
}
button.ai-card-link {
font: inherit;
cursor: pointer;
}

View File

@ -10,6 +10,8 @@
const PHONE = '18123959245';
const EMAIL = 'bd01@1st56.com';
const WECHAT = 'ydgjzzh';
// 企业微信管理后台 → 微信客服 → 在微信外接入 → 复制客服链接(勿带尾部空格)
const WECOM_KF_URL = 'https://work.weixin.qq.com/kfid/kfcae09c206f6c7045a';
const ICP = '粤ICP备2025463970号-2';
const ADDRESS_ZH = '深圳宝安区福永怀德翠岗5区36栋';
const ADDRESS_EN = 'Bldg 36, Cuigang 5, Fuyong, Bao’an, Shenzhen';
@ -33,8 +35,8 @@
const COPY = {
en: {
welcomeTitle: 'IDE, a digital one-stop cross-border logistics provider',
welcomeTitleShort: 'IDE · Cross-border logistics',
welcomeTitle: 'IDE, your cross-border logistics advisor',
welcomeTitleShort: 'IDE · Your logistics advisor',
welcomeSubLead: 'Parcels, express, dropship, and 9610 clearance — ',
welcomeSubAccent: 'built for SMB cross-border sellers',
welcomeSubTail: '.',
@ -44,11 +46,12 @@
you: 'You',
historyTitle: 'Chat history',
newChat: 'New chat',
newChatHint: 'New chat started — ask here',
emptyHistory: 'No conversations yet',
fallback: 'Quotes are in the customer system after you register. You can also talk to an advisor.',
trustPhone: PHONE,
trustAddress: ADDRESS_EN,
trustWechat: 'WeChat ' + WECHAT,
trustWechat: 'WeChat support',
trustEmail: EMAIL,
trustIcp: ICP,
guide9610Title: 'Ship first, tax second',
@ -64,8 +67,8 @@
composeLead: 'Ask about a lane, or pick a shortcut below',
},
zh: {
welcomeTitle: 'IDE,全球数字化一站式跨境物流服务商',
welcomeTitleShort: 'IDE · 一站式跨境物流',
welcomeTitle: 'IDE,您身边的跨境物流顾问',
welcomeTitleShort: 'IDE · 跨境物流顾问',
welcomeSubLead: '专线小包、国际快递、一件代发与 9610 报关,',
welcomeSubAccent: '专为中小跨境卖家而生',
welcomeSubTail: '。',
@ -75,11 +78,12 @@
you: '你',
historyTitle: '历史记录',
newChat: '新会话',
newChatHint: '已开启新会话,在这里提问',
emptyHistory: '暂无会话',
fallback: '官网不提供询价。完成注册登记后,即可在客户系统里看明码价。也可以先联系顾问。',
trustPhone: PHONE,
trustAddress: ADDRESS_ZH,
trustWechat: '微信 ' + WECHAT,
trustWechat: '微信客服',
trustEmail: EMAIL,
trustIcp: ICP,
guide9610Title: '先发货,再办税',
@ -365,6 +369,7 @@
let historyListEl;
let globeController = null;
let coverageOpen = false;
let newChatTipTimer = null;
const MAP_REGION_EU = new Set(['eu'].concat(COVER_EU.map((item) => item.mapId)));
const MAP_REGION_AM = new Set(['us', 'mx', 'ca']);
@ -696,7 +701,8 @@
const rows = el('div', 'ai-card__rows');
[
[zh ? '电话' : 'Phone', PHONE],
[zh ? '微信' : 'WeChat', WECHAT],
[zh ? '微信客服' : 'WeCom', zh ? '扫码 / 手机直达' : 'Scan or tap to chat'],
[zh ? '微信号' : 'WeChat ID', WECHAT],
[zh ? '邮箱' : 'Email', EMAIL],
[zh ? '地址' : 'Address', zh ? ADDRESS_ZH : ADDRESS_EN],
].forEach(([label, value]) => {
@ -710,7 +716,9 @@
el(
'p',
'ai-card__body',
zh ? '要询价请先注册登记,或直接打电话、加微信。' : 'Register first to quote, or call / WeChat us directly.'
zh
? '点「在线咨询」用微信对接顾问。询价请先注册登记。'
: 'Tap Live chat to reach an advisor on WeChat. Register first if you need a quote.'
)
);
wrap.appendChild(contactActions());
@ -817,15 +825,90 @@
return actions;
}
function isWeChatEnv() {
return /MicroMessenger/i.test(navigator.userAgent);
}
function isMobileEnv() {
return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent);
}
function closeKfQrModal() {
document.getElementById('ide-kf-modal')?.remove();
}
function showKfQrModal(url) {
closeKfQrModal();
const zh = lang === 'zh';
const modal = document.createElement('div');
modal.id = 'ide-kf-modal';
modal.className = 'ide-kf-modal';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.setAttribute('aria-label', zh ? '微信扫码咨询' : 'Scan to chat on WeChat');
const qrSrc =
'https://api.qrserver.com/v1/create-qr-code/?size=220x220&margin=8&data=' +
encodeURIComponent(url);
modal.innerHTML = `
<div class="ide-kf-modal__backdrop" data-kf-close></div>
<div class="ide-kf-modal__panel">
<button type="button" class="ide-kf-modal__close" data-kf-close aria-label="${zh ? '关闭' : 'Close'}">
<i class="bi bi-x-lg" aria-hidden="true"></i>
</button>
<p class="ide-kf-modal__kicker">${zh ? '企业微信客服' : 'WeCom support'}</p>
<h3 class="ide-kf-modal__title">${zh ? '微信扫码咨询' : 'Scan with WeChat'}</h3>
<p class="ide-kf-modal__hint">${zh ? '打开手机微信,扫一扫下方二维码即可对话顾问' : 'Open WeChat on your phone and scan the code to chat'}</p>
<div class="ide-kf-modal__qr-wrap">
<img class="ide-kf-modal__qr" src="${qrSrc}" width="220" height="220" alt="WeCom QR">
<p class="ide-kf-modal__qr-fallback" hidden>${zh ? '二维码加载失败,请点下方打开客服页后用微信扫码' : 'QR failed to load — open the support page and scan there'}</p>
</div>
<a class="ide-kf-modal__open" href="${url}" target="_blank" rel="noopener noreferrer">${zh ? '在新窗口打开客服页' : 'Open support page'}</a>
</div>
`;
const qrImg = modal.querySelector('.ide-kf-modal__qr');
const qrFallback = modal.querySelector('.ide-kf-modal__qr-fallback');
qrImg?.addEventListener('error', () => {
qrImg.hidden = true;
if (qrFallback) qrFallback.hidden = false;
});
modal.addEventListener('click', (e) => {
if (e.target.closest('[data-kf-close]')) closeKfQrModal();
});
document.addEventListener(
'keydown',
function onEsc(e) {
if (e.key === 'Escape') {
closeKfQrModal();
document.removeEventListener('keydown', onEsc);
}
},
{ once: true }
);
document.body.appendChild(modal);
}
/** 手机 / 微信内:直跳唤起会话;PC:弹二维码浮层 */
function openWecomKf(e) {
if (e) e.preventDefault();
const url = WECOM_KF_URL;
if (isWeChatEnv() || isMobileEnv()) {
window.location.href = url;
return;
}
showKfQrModal(url);
}
function contactActions() {
const actions = el('div', 'ai-card__actions');
const tel = el('a', 'ai-card-link is-primary', PHONE);
const kf = el('button', 'ai-card-link is-primary', lang === 'zh' ? '在线咨询' : 'Live chat');
kf.type = 'button';
kf.addEventListener('click', openWecomKf);
const tel = el('a', 'ai-card-link', PHONE);
tel.href = `tel:${PHONE}`;
const mail = el('a', 'ai-card-link', EMAIL);
mail.href = `mailto:${EMAIL}`;
const wx = el('span', 'ai-card-link', (lang === 'zh' ? '微信 ' : 'WeChat ') + WECHAT);
actions.appendChild(kf);
actions.appendChild(tel);
actions.appendChild(wx);
actions.appendChild(mail);
return actions;
}
@ -899,6 +982,9 @@
<div class="ai-followup" role="toolbar"></div>
<div class="ai-composer-slot--chat"></div>
<div class="ai-composer-wrap">
<div class="ai-new-chat-tip" hidden role="status" aria-live="polite">
<span class="ai-new-chat-tip__text"></span>
</div>
<div class="ai-composer">
<span class="ai-composer__caustic" aria-hidden="true"></span>
<span class="ai-composer__sheen" aria-hidden="true"></span>
@ -909,31 +995,27 @@
</div>
</div>
<div class="ai-trust-bar">
<div class="ai-trust-bar__contacts">
<a class="ai-trust-phone" href="tel:${PHONE}">
<i class="bi bi-telephone" aria-hidden="true"></i>
<span class="ai-trust-label"></span>
</a>
<button type="button" class="ai-trust-wechat" title="${WECHAT}">
<i class="bi bi-wechat" aria-hidden="true"></i>
<span class="ai-trust-label"></span>
</button>
<a class="ai-trust-email" href="mailto:${EMAIL}">
<i class="bi bi-envelope" aria-hidden="true"></i>
<span class="ai-trust-label"></span>
</a>
<a class="ai-trust-bar__human" href="#contact"></a>
</div>
<div class="ai-trust-bar__meta">
<p class="ai-trust-address">
<i class="bi bi-geo-alt" aria-hidden="true"></i>
<span class="ai-trust-label"></span>
</p>
<a class="ai-trust-icp" href="https://beian.miit.gov.cn" target="_blank" rel="noopener">
<i class="bi bi-shield-check" aria-hidden="true"></i>
<span class="ai-trust-label"></span>
</a>
</div>
<a class="ai-trust-phone" href="tel:${PHONE}">
<i class="bi bi-telephone" aria-hidden="true"></i>
<span class="ai-trust-label"></span>
</a>
<button type="button" class="ai-trust-wechat" title="${WECHAT}">
<i class="bi bi-wechat" aria-hidden="true"></i>
<span class="ai-trust-label"></span>
</button>
<a class="ai-trust-email" href="mailto:${EMAIL}">
<i class="bi bi-envelope" aria-hidden="true"></i>
<span class="ai-trust-label"></span>
</a>
<a class="ai-trust-bar__human" href="#contact"></a>
<p class="ai-trust-address">
<i class="bi bi-geo-alt" aria-hidden="true"></i>
<span class="ai-trust-label"></span>
</p>
<a class="ai-trust-icp" href="https://beian.miit.gov.cn" target="_blank" rel="noopener">
<i class="bi bi-shield-check" aria-hidden="true"></i>
<span class="ai-trust-label"></span>
</a>
</div>
</div>
</div>
@ -987,19 +1069,7 @@
sendUserMessage(card.dataset.guideAsk, { intentId: card.dataset.guideIntent || 'hard' });
});
shell.querySelector('.ai-trust-wechat').addEventListener('click', async () => {
const btn = shell.querySelector('.ai-trust-wechat');
const label = btn.querySelector('.ai-trust-label');
try {
await navigator.clipboard.writeText(WECHAT);
if (label) label.textContent = lang === 'zh' ? '微信已复制' : 'WeChat copied';
} catch {
if (label) label.textContent = WECHAT;
}
window.setTimeout(() => {
if (label) label.textContent = t('trustWechat');
}, 1600);
});
shell.querySelector('.ai-trust-wechat').addEventListener('click', openWecomKf);
shell.querySelector('.ai-composer-form').addEventListener('submit', (e) => {
e.preventDefault();
@ -1014,6 +1084,7 @@
});
inputEl.addEventListener('input', () => {
hideNewChatTip();
inputEl.style.height = 'auto';
inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + 'px';
syncComposerSendState();
@ -1344,6 +1415,7 @@
closeHistorySidebar();
return;
}
hideNewChatTip();
syncActiveThreadMessages();
activeThreadId = threadId;
applyThreadMessages(getActiveThread());
@ -1355,13 +1427,45 @@
inputEl.focus();
}
function hideNewChatTip() {
const tip = shell?.querySelector('.ai-new-chat-tip');
const wrap = shell?.querySelector('.ai-composer-wrap');
if (newChatTipTimer) {
clearTimeout(newChatTipTimer);
newChatTipTimer = null;
}
if (tip) {
tip.classList.remove('is-visible');
tip.hidden = true;
}
wrap?.classList.remove('has-new-chat-tip');
}
function showNewChatTip() {
const tip = shell?.querySelector('.ai-new-chat-tip');
const wrap = shell?.querySelector('.ai-composer-wrap');
const textEl = tip?.querySelector('.ai-new-chat-tip__text');
if (!tip || !wrap || !textEl) return;
if (newChatTipTimer) clearTimeout(newChatTipTimer);
textEl.textContent = t('newChatHint');
tip.hidden = false;
wrap.classList.add('has-new-chat-tip');
// Restart enter animation if already visible
tip.classList.remove('is-visible');
void tip.offsetWidth;
tip.classList.add('is-visible');
newChatTipTimer = setTimeout(() => hideNewChatTip(), 4200);
}
function startNewChat() {
if (isBusy) return;
syncActiveThreadMessages();
const current = getActiveThread();
if (current && current.messages.length === 0) {
closeHistorySidebar();
updateViewMode();
inputEl.focus();
showNewChatTip();
return;
}
createThread(true);
@ -1371,6 +1475,7 @@
closeHistorySidebar();
setNavActive('home');
inputEl.focus();
showNewChatTip();
}
function toggleHistorySidebar() {
@ -1462,6 +1567,8 @@
shell.querySelector('.ai-history-sidebar__title').textContent = t('historyTitle');
shell.querySelector('.ai-new-chat-btn__label').textContent = t('newChat');
shell.querySelector('.ai-new-chat-ghost__label').textContent = t('newChat');
const tipText = shell.querySelector('.ai-new-chat-tip__text');
if (tipText) tipText.textContent = t('newChatHint');
applyWelcomeTitle();
renderWelcomeSub();
const heroCta = shell.querySelector('.ai-welcome-cta');
@ -1597,7 +1704,9 @@
if (intentId === 'human') {
flow = null;
return {
text: zh ? '直接打给我们或加微信。询价请先注册登记。' : 'Call or WeChat us. Register first if you need a quote.',
text: zh
? '点「在线咨询」用微信对接顾问,也可打电话。询价请先注册登记。'
: 'Tap Live chat for WeChat support, or call us. Register first if you need a quote.',
card: { type: 'contact' },
};
}
@ -1793,6 +1902,7 @@
}
function sendUserMessage(text, extra) {
hideNewChatTip();
if (!text || isBusy || !shell) return;
appendMessageRow('user', text, null, true);
@ -1895,6 +2005,8 @@
e.preventDefault();
openNavTopic('contact');
});
window.IDEOpenWecomKf = openWecomKf;
}
function init() {
@ -1913,7 +2025,10 @@
});
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeDrawers();
if (e.key === 'Escape') {
closeKfQrModal();
closeDrawers();
}
});
const bootTopic = (location.hash || '').replace('#', '');
@ -1933,6 +2048,7 @@
if (!isHomePage()) window.location.href = '/';
},
close: () => closeDrawers(),
openWecomKf,
};
if (document.readyState === 'loading') {

View File

@ -115,8 +115,9 @@ document.addEventListener('DOMContentLoaded', () => {
const languageStorageKey = 'ide-language';
const pageTitleMap = {
'IDE | Global Digital One-Stop Cross-Border Logistics': 'IDE | 全球数字化一站式跨境物流服务商',
'IDE|Immediate Delivery Express — Cross-Border Logistics': 'IDE | 全球数字化一站式跨境物流服务商',
'IDE - Cross-border Logistics, Delivered Globally': 'IDE - 跨境物流,全球即递',
'IDE | Global Digital One-Stop Cross-Border Logistics': 'IDE - 跨境物流,全球即递',
'IDE|Immediate Delivery Express — Cross-Border Logistics': 'IDE - 跨境物流,全球即递',
'Faster & Simpler': '更快 · 更简单',
'|Faster & Simpler': '更快 · 更简单',
'IDE - About': 'IDE - 关于我们',
@ -394,7 +395,8 @@ document.addEventListener('DOMContentLoaded', () => {
'IDE logo': 'IDE标志',
'Immediate Delivery Express': 'Immediate Delivery Express'
,'IDE is a global digital one-stop cross-border logistics provider for SMB sellers and difficult orders — with smart systems and dedicated advisors at every step.': '体量不大、线路偏门?IDE 照样发。全球订单一站搞定,专属顾问从报价跟到签收,疑难单也有人管。'
,'IDE | Global one-stop cross-border logistics solution provider': 'IDE | 全球一站式跨境物流解决方案服务商'
,'IDE | Global one-stop cross-border logistics solution provider': 'IDE - 跨境物流,全球即递'
,'IDE - Cross-border Logistics, Delivered Globally': 'IDE - 跨境物流,全球即递'
,'Explore Services': '了解服务'
,'Quote Response': '报价响应'
,'Global Routes': '全球线路'

View File

@ -5,7 +5,7 @@
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>IDE | 全球数字化一站式跨境物流服务商</title>
<title>IDE - 跨境物流,全球即递</title>
<meta content="IDE 面向中小卖家的跨境物流。全球数字化一站式跨境物流服务商。注册登记后即可询价,一口价不砍,专属顾问跟到签收。" name="description">
<meta content="IDE, 跨境物流, 中小卖家, 9610, 疑难单, 出口退税, 欧美专线, 东南亚专线" name="keywords">
@ -20,7 +20,7 @@
<!-- Template Main CSS File -->
<link href="assets/css/main.css" rel="stylesheet">
<link href="assets/css/ai-advisor.css?v=20260824an" rel="stylesheet">
<link href="assets/css/ai-advisor.css?v=20260901a" rel="stylesheet">
</head>
@ -85,7 +85,7 @@
<!-- Template Main JS File -->
<script src="assets/js/main.js"></script>
<script src="assets/js/stage-globe.js?v=20260824an"></script>
<script src="assets/js/ai-advisor.js?v=20260824an"></script>
<script src="assets/js/ai-advisor.js?v=20260901a"></script>
</body>

View File

@ -98,6 +98,7 @@ export interface CustomerServiceAdvisorDto {
name?: string
wechatId?: string
wechatQrCode?: string
wecomKfUrl?: string
}
export const CustomerService = {

View File

@ -1720,7 +1720,8 @@ export async function deleteOrderTemplateCustom(templateId: number) {
export async function postOrderUpload(
file: File,
templateId: number,
senderAddressId?: number
senderAddressId?: number,
customerId?: number
): Promise<{ jobId: number; async: boolean }> {
const form = new FormData()
form.append('file', file)
@ -1728,6 +1729,9 @@ export async function postOrderUpload(
if (senderAddressId != null && senderAddressId > 0) {
form.append('senderAddressId', String(senderAddressId))
}
if (customerId != null && customerId > 0) {
form.append('customerId', String(customerId))
}
const res = await axiosInstance.post(orderUrl('/order/upload'), form)
assertBusinessOk(res.data)
const d = unwrapApiData(res.data)

View File

@ -0,0 +1,12 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="27" cy="44" rx="13" ry="3" fill="#FF7D00" opacity="0.28"/>
<path d="M18.5 22a6.5 6.5 0 0 1 6.5-6.5h3.2a2 2 0 1 1 0 4H25a2.5 2.5 0 1 0 0 5h4.5a6.5 6.5 0 1 1 0 13H26.3a2 2 0 1 1 0-4H29.5a2.5 2.5 0 1 0 0-5H25a6.5 6.5 0 0 1-6.5-6.5z" fill="url(#bind)"/>
<circle cx="38.5" cy="33.5" r="7.5" fill="#F53F3F"/>
<path d="M38.5 29.8v4.4M36.3 34.2h4.4" stroke="#fff" stroke-width="2" stroke-linecap="round"/>
<defs>
<linearGradient id="bind" x1="16" y1="15" x2="40" y2="40" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFB65D"/>
<stop offset="1" stop-color="#FF7D00"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 738 B

View File

@ -0,0 +1,12 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="27" cy="44" rx="12" ry="3" fill="#7DA2FF" opacity="0.28"/>
<path d="M14 20.5l26.2 8.2c1.1.3 1.1 1.9 0 2.2L28.5 34.5 25 40.8c-.5.9-1.9.6-2-.4l-1.2-9.2L14 20.5z" fill="url(#dp)"/>
<path d="M14 20.5l10.8 18.7 1.2-9.2L36.5 27 14 20.5z" fill="#165DFF" opacity="0.35"/>
<path d="M25 31.5l11.5-4.5" stroke="#fff" stroke-width="1.8" stroke-linecap="round" opacity="0.85"/>
<defs>
<linearGradient id="dp" x1="14" y1="20" x2="40" y2="40" gradientUnits="userSpaceOnUse">
<stop stop-color="#7DA2FF"/>
<stop offset="1" stop-color="#165DFF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 703 B

View File

@ -0,0 +1,14 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="27" cy="44" rx="13" ry="3" fill="#14C9C9" opacity="0.28"/>
<path d="M13 24.5l14-8.5 14 8.5V38c0 1.1-.9 2-2 2H15c-1.1 0-2-.9-2-2V24.5z" fill="url(#ib)"/>
<path d="M13 24.5l14 8.5 14-8.5" stroke="#fff" stroke-width="1.6" stroke-linejoin="round" opacity="0.55"/>
<path d="M24 28.5h6v11.5h-6V28.5z" fill="#E8FFFB"/>
<path d="M27 16v8.5" stroke="#0FC6C2" stroke-width="2.4" stroke-linecap="round"/>
<path d="M23.2 21.5L27 25.3l3.8-3.8" stroke="#0FC6C2" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
<defs>
<linearGradient id="ib" x1="13" y1="16" x2="41" y2="40" gradientUnits="userSpaceOnUse">
<stop stop-color="#37D4CF"/>
<stop offset="1" stop-color="#0FC6C2"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 858 B

View File

@ -0,0 +1,13 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="27" cy="44" rx="13" ry="3" fill="#FF9A2E" opacity="0.28"/>
<path d="M16 24.5c0-1.4 1.1-2.5 2.5-2.5H28c2.5 0 4.5 2 4.5 4.5v1.2h3.2c1.6 0 2.9 1.4 2.7 3l-.8 7.2c-.2 1.5-1.4 2.6-2.9 2.6H18.5c-1.4 0-2.5-1.1-2.5-2.5V24.5z" fill="url(#pay)"/>
<path d="M19 22v-1.2c0-2.1 1.7-3.8 3.8-3.8h5.4c2.1 0 3.8 1.7 3.8 3.8V22" stroke="#FF7D00" stroke-width="2.2" stroke-linecap="round"/>
<circle cx="27" cy="33" r="3.2" fill="#FFF7E8"/>
<path d="M27 31.2v3.6M25.6 32.4h2.8" stroke="#FF7D00" stroke-width="1.4" stroke-linecap="round"/>
<defs>
<linearGradient id="pay" x1="16" y1="17" x2="38" y2="40" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFB65D"/>
<stop offset="1" stop-color="#FF7D00"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 858 B

View File

@ -0,0 +1,13 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="27" cy="44" rx="13" ry="3" fill="#FF9A2E" opacity="0.28"/>
<path d="M15 20.5h16c1.1 0 2 .9 2 2v16c0 1.1-.9 2-2 2H15c-1.1 0-2-.9-2-2v-16c0-1.1.9-2 2-2z" fill="url(#pk)"/>
<path d="M17.5 24h11M17.5 28.5h11M17.5 33h7.5" stroke="#fff" stroke-width="1.8" stroke-linecap="round" opacity="0.85"/>
<path d="M33 24.5l8.5 3.2c.7.3 1.1 1 1.1 1.8v6.2c0 .9-.6 1.7-1.5 1.9l-7.2 1.8c-.6.2-1.2-.3-1.2-.9V25.4c0-.6.5-1 1.1-.9z" fill="#FF7D00"/>
<path d="M36.2 29.2l2.6 1.5-2.6 1.5v-3z" fill="#FFF7E8"/>
<defs>
<linearGradient id="pk" x1="13" y1="20" x2="33" y2="40" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFB65D"/>
<stop offset="1" stop-color="#FF7D00"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 826 B

View File

@ -0,0 +1,12 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="27" cy="44" rx="13" ry="3" fill="#FF9A2E" opacity="0.28"/>
<path d="M16 21.5l10-5.5 10 5.5v12L26 39 16 33.5v-12z" fill="url(#pu)"/>
<path d="M26 16v23M16 21.5l10 5.5 10-5.5" stroke="#fff" stroke-width="1.5" stroke-linejoin="round" opacity="0.5"/>
<path d="M33.5 28.5c2.4 1.2 4 3.7 4 6.5v1.2c1.5.5 2.5 1.9 2.5 3.5 0 2.1-1.7 3.8-3.8 3.8h-9.2c-1.9 0-3.5-1.6-3.5-3.5 0-1.4.8-2.6 2-3.2v-.8c0-2.2 1.3-4.1 3.2-5 .6-.3 1.3.2 1.3.9v3.6l3.5-2.2v-4.3c0-.7.7-1.2 1.4-.9z" fill="#FF7D00"/>
<defs>
<linearGradient id="pu" x1="16" y1="16" x2="36" y2="39" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFB65D"/>
<stop offset="1" stop-color="#FF7D00"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 815 B

View File

@ -0,0 +1,17 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="27" cy="44" rx="13" ry="3" fill="#4080FF" opacity="0.28"/>
<path d="M11 28h17.5l4-7c.4-.7 1.1-1.1 1.9-1.1H40c1.1 0 2 .9 2 2v12.5c0 .8-.7 1.5-1.5 1.5H11V28z" fill="url(#tr)"/>
<path d="M11 22h15v18H13c-1.1 0-2-.9-2-2V22z" fill="#165DFF"/>
<rect x="14" y="25.5" width="6.5" height="4" rx="1" fill="#E8F3FF"/>
<circle cx="17.5" cy="39.5" r="3.4" fill="#1D2129"/>
<circle cx="17.5" cy="39.5" r="1.5" fill="#C9CDD4"/>
<circle cx="35.5" cy="39.5" r="3.4" fill="#1D2129"/>
<circle cx="35.5" cy="39.5" r="1.5" fill="#C9CDD4"/>
<path d="M6 24.5h4M5 28h3.5M6.5 31.5h3" stroke="#4080FF" stroke-width="1.8" stroke-linecap="round" opacity="0.85"/>
<defs>
<linearGradient id="tr" x1="11" y1="19" x2="42" y2="40" gradientUnits="userSpaceOnUse">
<stop stop-color="#7DA2FF"/>
<stop offset="1" stop-color="#165DFF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 983 B

View File

@ -252,8 +252,16 @@
{{ t('nanaAssistant.copyWechat') }}
</button>
</div>
<button type="button" class="ai-advisor-drawer__kf" @click="openWecomKf(advisor.wecomKfUrl)">
{{ t('nanaAssistant.wecomKf') }}
</button>
</template>
<p v-else class="ai-advisor-drawer__empty">{{ t('nanaAssistant.noAdvisor') }}</p>
<div v-else class="ai-advisor-drawer__empty-wrap">
<p class="ai-advisor-drawer__empty">{{ t('nanaAssistant.noAdvisor') }}</p>
<button type="button" class="ai-advisor-drawer__kf" @click="openWecomKf()">
{{ t('nanaAssistant.wecomKf') }}
</button>
</div>
</div>
</aside>
</div>
@ -303,6 +311,7 @@ import {
type McpCapabilityId,
} from '@/composables/assistantMcpGuide';
import { isMobileUserAgent, isWeChatBrowser } from '@/utils/device';
import { openWecomKf } from '@/utils/wecomKf';
import {
providerModelTags,
} from '@/views/system/aiAssistant/providerCatalog';
@ -690,6 +699,16 @@ async function loadAdvisor() {
}
function openAdvisorView() {
// H5 / 微信 / 手机:优先企业微信客服(即时会话);顾问个人链接优先
if (route.path.startsWith('/h5') || isWeChatBrowser() || isMobileUserAgent()) {
void (async () => {
if (!advisorLoaded.value) {
await loadAdvisor();
}
openWecomKf(advisor.value.wecomKfUrl);
})();
return;
}
advisorDrawerOpen.value = true;
if (!advisorLoaded.value) {
void loadAdvisor();

View File

@ -1520,6 +1520,35 @@
background: rgba(45, 140, 140, 0.06);
}
.ai-advisor-drawer__kf {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 40px;
margin-top: 16px;
padding: 0 14px;
border: none;
border-radius: 4px;
background: var(--app-primary-color, #2d8c8c);
color: #fff;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
}
.ai-advisor-drawer__kf:hover {
filter: brightness(1.05);
}
.ai-advisor-drawer__empty-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 100%;
}
.ai-advisor-drawer__empty {
font-size: 0.88rem;
color: var(--kc-secondary, #6a75b8);

View File

@ -179,6 +179,15 @@
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('system.user.form.wecomKfUrl')" field="wecomKfUrl">
<a-input
v-model="formState.wecomKfUrl"
:placeholder="t('system.user.form.wecomKfUrlPlaceholder')"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('system.user.form.wechatQrCode')" field="wechatQrFiles">
<a-upload

View File

@ -16,6 +16,10 @@
<a-input v-model="wechatId" :placeholder="t('system.user.form.wechatIdPlaceholder')" />
</a-form-item>
<a-form-item :label="t('system.user.form.wecomKfUrl')" field="wecomKfUrl">
<a-input v-model="wecomKfUrl" :placeholder="t('system.user.form.wecomKfUrlPlaceholder')" />
</a-form-item>
<a-form-item :label="t('system.user.form.wechatQrCode')" field="wechatQrFiles">
<a-upload
v-model:file-list="wechatQrFiles"
@ -55,6 +59,7 @@ withDefaults(
const phone = defineModel<string>('phone', { default: '' })
const employeeNo = defineModel<string>('employeeNo', { default: '' })
const wechatId = defineModel<string>('wechatId', { default: '' })
const wecomKfUrl = defineModel<string>('wecomKfUrl', { default: '' })
const wechatQrFiles = defineModel<any[]>('wechatQrFiles', { default: () => [] })
async function handleWechatCustomRequest(options: any) {

View File

@ -466,6 +466,7 @@ export default {
'user.senderAddress.table.senderName': 'Contact',
'user.senderAddress.table.phone': 'Phone',
'user.senderAddress.table.destCountry': 'Country',
'user.senderAddress.table.country': 'Country',
'user.senderAddress.table.province': 'Province',
'user.senderAddress.table.city': 'City',
'user.senderAddress.table.address': 'Address',
@ -477,6 +478,7 @@ export default {
'user.senderAddress.form.phone': 'Phone',
'user.senderAddress.form.company': 'Company',
'user.senderAddress.form.destCountryCode': 'Country Code',
'user.senderAddress.form.countryCode': 'Country Code',
'user.senderAddress.form.province': 'Province',
'user.senderAddress.form.city': 'City',
'user.senderAddress.form.zipCode': 'Zip Code',
@ -1256,7 +1258,8 @@ export default {
'nanaAssistant.mcp.field.product': 'Sales product code/name',
'nanaAssistant.mcp.field.cargoName': 'Cargo name',
'nanaAssistant.mcp.field.customsOrderNo': 'Waybill No.',
'nanaAssistant.human': 'Talk to a human advisor →',
'nanaAssistant.human': 'Talk to online advisor',
'nanaAssistant.wecomKf': 'Chat on WeCom',
'nanaAssistant.you': 'You',
'nanaAssistant.backToChat': 'Back to chat',
'nanaAssistant.quickSmall': 'Small or difficult orders?',
@ -1265,7 +1268,7 @@ export default {
'nanaAssistant.quickTrack': 'Track my shipment',
'nanaAssistant.quickVisibility': 'Real-time tracking?',
'nanaAssistant.quickAfterSales': 'Something went wrong?',
'nanaAssistant.quickHuman': 'Talk to human advisor',
'nanaAssistant.quickHuman': 'Talk to online advisor',
'nanaAssistant.replySmall': 'Yes. IDE focuses on SMB sellers and shipments others decline — special routes, complex cargo, and low-volume orders are in scope. Tell me origin, destination, and cargo type to get started.',
'nanaAssistant.replyPrice': 'For a quote, destination destCountry + actual weight (kg) is enough — e.g. US 0.5kg. Postal code/dims help; origin city is not required.',
'nanaAssistant.replyQuoteSpeed': 'With destCountry + weight we can return an indicative quote right away; final billing follows inbound weight and product rules.',
@ -2050,6 +2053,7 @@ export default {
'order.priceCheck.table.viewGuide': 'Channel guide',
'order.priceCheck.table.viewGuideHint': 'Click to view route guide',
'order.priceCheck.table.customsOrderNow': 'Place order',
'order.priceCheck.table.orderNow': 'Place order',
'order.priceCheck.table.askAi': 'Ask AI',
'order.priceCheck.table.askAiCanShip': 'Can this ship?',
'order.priceCheck.table.askAiCheaper': 'Anything cheaper?',
@ -2150,10 +2154,14 @@ export default {
'order.placeOrder.template.updatedAt': 'Updated: {time}',
'order.placeOrder.upload.dropText': 'Drag file here, or click to upload',
'order.placeOrder.upload.tip1': 'Download the template: red headers are required; use dropdowns where provided.',
'order.placeOrder.upload.tip2': 'Up to 2000 orders per upload. Pick a default sender below — do not put sender info in Excel.',
'order.placeOrder.batch.senderLabel': 'Default sender address',
'order.placeOrder.batch.templateTip': 'Simple (recommended) and Standard (extended fields) templates available. Red headers are required; pick sender below.',
'order.placeOrder.batch.senderPlaceholder': 'Select a sender address',
'order.placeOrder.selectCustomer': 'Select customer',
'order.placeOrder.customerLabel': 'Customer',
'order.placeOrder.selectCustomerRequired': 'Select a customer first',
'order.placeOrder.batch.templateTip': 'Red headers are required. Up to 2000 orders.',
'order.placeOrder.batch.addressCardTitle': 'Customer & sender',
'order.placeOrder.batch.addressTip': 'Choose sender here, not in Excel.',
'order.placeOrder.batch.block.noSender': 'Please select a default sender address',
'order.placeOrder.batchOrder': 'Batch order',
'order.placeOrder.fromPriceCheck.filled':
@ -2185,7 +2193,7 @@ export default {
'order.placeOrder.batch.stepUpload': 'Upload Excel',
'order.placeOrder.batch.stepSubmit': 'Submit',
'order.placeOrder.batch.guideHint': 'Click a step to jump · pick template on the right, then upload & submit',
'order.placeOrder.batch.pickTemplateHint': 'Select a template from the library on the right',
'order.placeOrder.batch.pickTemplateHint': 'Select a template',
'order.placeOrder.batch.block.noTemplate': 'Please select an order template on the right first',
'order.placeOrder.batch.block.templateDraft': 'Finish mapping this custom template before submitting',
'order.placeOrder.batch.block.noFile': 'Please upload an Excel file first',
@ -2222,7 +2230,6 @@ export default {
'order.placeOrder.single.senderEmpty': 'No sender address yet. Add one in Address Management first.',
'order.placeOrder.single.senderRequired': 'Please select a sender address',
'order.placeOrder.single.senderPickHint': 'Select sender address first',
'order.placeOrder.single.senderClear': 'Clear sender',
'order.placeOrder.single.senderManage': 'Manage addresses',
'order.placeOrder.single.senderEditHint': 'Click to change sender',
'order.placeOrder.single.senderLoadFailed': 'Failed to load sender addresses',
@ -2241,6 +2248,8 @@ export default {
'order.placeOrder.contact.blockTitle': 'Complete your contact info',
'order.placeOrder.contact.blockDesc': 'Add phone and email to continue placing orders',
'order.placeOrder.contact.blockAction': 'Complete now',
'order.placeOrder.contact.adminCustomerIncomplete':
'The selected customer is missing phone or email. Complete their profile before placing the order.',
'order.placeOrder.single.viewCustomerGuide': 'View customer guide',
'order.placeOrder.single.declaredCurrencyHint': 'Default is USD; enter declared prices in that currency',
'order.placeOrder.single.exportCustomsPlaceholder': 'Optional (defaults to simplified declaration)',
@ -2760,6 +2769,8 @@ export default {
'system.user.form.employeeNoPlaceholder': 'Auto-generated',
'system.user.form.wechatId': 'WeChat ID',
'system.user.form.wechatIdPlaceholder': 'Enter WeChat ID',
'system.user.form.wecomKfUrl': 'WeCom support link',
'system.user.form.wecomKfUrlPlaceholder': 'e.g. https://work.weixin.qq.com/kfid/...',
'system.user.form.wechatQrCode': 'WeChat QR Code',
'system.user.form.wechatQrUpload': 'Upload QR code',
'system.user.table.employeeNo': 'Employee No.',

View File

@ -467,6 +467,7 @@ export default {
'user.senderAddress.table.senderName': '联系人',
'user.senderAddress.table.phone': '手机',
'user.senderAddress.table.destCountry': '国家',
'user.senderAddress.table.country': '国家',
'user.senderAddress.table.province': '省/州',
'user.senderAddress.table.city': '城市',
'user.senderAddress.table.address': '地址',
@ -478,6 +479,7 @@ export default {
'user.senderAddress.form.phone': '手机',
'user.senderAddress.form.company': '公司',
'user.senderAddress.form.destCountryCode': '国家二字码',
'user.senderAddress.form.countryCode': '国家二字码',
'user.senderAddress.form.province': '省/州',
'user.senderAddress.form.city': '城市',
'user.senderAddress.form.zipCode': '邮编',
@ -1071,7 +1073,8 @@ export default {
'nanaAssistant.mcp.field.product': '销售产品代码/名称',
'nanaAssistant.mcp.field.cargoName': '客户品名',
'nanaAssistant.mcp.field.customsOrderNo': '运单号',
'nanaAssistant.human': '联系人工顾问 →',
'nanaAssistant.human': '联系在线顾问',
'nanaAssistant.wecomKf': '企业微信在线咨询',
'nanaAssistant.you': '你',
'nanaAssistant.backToChat': '返回对话',
'nanaAssistant.quickSmall': '小批量 / 疑难单能发吗?',
@ -1080,7 +1083,7 @@ export default {
'nanaAssistant.quickTrack': '查物流单号',
'nanaAssistant.quickVisibility': '能实时追踪吗?',
'nanaAssistant.quickAfterSales': '出问题了怎么办?',
'nanaAssistant.quickHuman': '联系人工顾问',
'nanaAssistant.quickHuman': '联系在线顾问',
'nanaAssistant.replySmall': '可以。IDE 专注中小卖家和别人不接的单 — 特殊线路、复杂货型、小批量都在服务范围内。告诉我起运地、目的地和货型即可开始。',
'nanaAssistant.replyPrice': '查价只需目的国和实重(kg)。例如:美国 0.5kg。有邮编/尺寸会更准;不需要起运地。',
'nanaAssistant.replyQuoteSpeed': '提交目的国和重量后可即时试算参考价;正式出账以入仓重与产品规则为准。',
@ -1969,6 +1972,7 @@ export default {
'order.priceCheck.table.viewGuide': '渠道说明',
'order.priceCheck.table.viewGuideHint': '点击查看线路说明',
'order.priceCheck.table.customsOrderNow': '立刻下单',
'order.priceCheck.table.orderNow': '立刻下单',
'order.priceCheck.table.askAi': '问 AI',
'order.priceCheck.table.askAiCanShip': '这个能不能发?',
'order.priceCheck.table.askAiCheaper': '有没有更便宜?',
@ -2070,10 +2074,14 @@ export default {
'order.placeOrder.template.updatedAt': '更新: {time}',
'order.placeOrder.upload.dropText': '将文件拖到此处,或点击上传',
'order.placeOrder.upload.tip1': '请下载模板填写:红色表头为必填,带下拉的列请点选编码。',
'order.placeOrder.upload.tip2': '单次最多 2000 单;发件人请在下方选择,无需写入 Excel。',
'order.placeOrder.batch.senderLabel': '默认发件地址',
'order.placeOrder.batch.templateTip': '提供精简版(日常推荐)与标准版(含扩展字段);红色表头必填,发件人在下方选择。',
'order.placeOrder.batch.senderPlaceholder': '请选择发件人地址',
'order.placeOrder.selectCustomer': '请选择客户',
'order.placeOrder.customerLabel': '客户',
'order.placeOrder.selectCustomerRequired': '请先选择客户',
'order.placeOrder.batch.templateTip': '红色表头必填,单次最多 2000 单。',
'order.placeOrder.batch.addressCardTitle': '客户地址',
'order.placeOrder.batch.addressTip': '发件人在此选择,不必写入 Excel。',
'order.placeOrder.batch.block.noSender': '请选择默认发件地址',
'order.placeOrder.batchOrder': '批量下单',
'order.placeOrder.fromPriceCheck.filled': '已带入查价所选渠道与包裹信息,请继续完善收件人',
@ -2104,7 +2112,7 @@ export default {
'order.placeOrder.batch.stepUpload': '上传 Excel',
'order.placeOrder.batch.stepSubmit': '提交下单',
'order.placeOrder.batch.guideHint': '点步骤可定位 · 右侧选模板,左侧上传后提交',
'order.placeOrder.batch.pickTemplateHint': '请在右侧模板库点击选择模板',
'order.placeOrder.batch.pickTemplateHint': '请选择模板',
'order.placeOrder.batch.block.noTemplate': '请先在右侧选择下单模板',
'order.placeOrder.batch.block.templateDraft': '当前自定义模板待编辑完成映射后才能提交',
'order.placeOrder.batch.block.noFile': '请先上传 Excel 文件',
@ -2141,7 +2149,6 @@ export default {
'order.placeOrder.single.senderEmpty': '暂无发件人地址,请先在地址管理中新增',
'order.placeOrder.single.senderRequired': '请选择发件人地址',
'order.placeOrder.single.senderPickHint': '请先选择发件地址',
'order.placeOrder.single.senderClear': '清空发件人',
'order.placeOrder.single.senderManage': '去地址管理',
'order.placeOrder.single.senderEditHint': '点击可更换发件人',
'order.placeOrder.single.senderLoadFailed': '加载发件地址失败',
@ -2160,6 +2167,8 @@ export default {
'order.placeOrder.contact.blockTitle': '请先完善联系方式',
'order.placeOrder.contact.blockDesc': '补全手机号和邮箱后,即可继续下单',
'order.placeOrder.contact.blockAction': '去完善',
'order.placeOrder.contact.adminCustomerIncomplete':
'所选客户尚未完善手机号和邮箱,请先在客户资料中补全后再下单',
'order.placeOrder.single.viewCustomerGuide': '查看对客说明',
'order.placeOrder.single.declaredCurrencyHint': '默认美元,请按币种填写申报价格',
'order.placeOrder.single.exportCustomsPlaceholder': '请选择(可不选,默认简易申报)',
@ -2684,6 +2693,8 @@ export default {
'system.user.form.employeeNoPlaceholder': '自动生成',
'system.user.form.wechatId': '微信号',
'system.user.form.wechatIdPlaceholder': '请输入微信号',
'system.user.form.wecomKfUrl': '企业微信接入链接',
'system.user.form.wecomKfUrlPlaceholder': '如 https://work.weixin.qq.com/kfid/...',
'system.user.form.wechatQrCode': '微信二维码',
'system.user.form.wechatQrUpload': '上传二维码',
'system.user.table.employeeNo': '工号',

View File

@ -3,6 +3,8 @@ export type UserProfileExt = {
employeeNo?: string
wechatId?: string
wechatQrCode?: string
/** 企业微信客服接入链接(微信外接入) */
wecomKfUrl?: string
}
export type UserFormExtState = UserProfileExt & {
@ -32,6 +34,7 @@ export function pickUserProfileExt(
employeeNo: pickStr(user, 'employeeNo', 'EmployeeNo'),
wechatId: pickStr(user, 'wechatId', 'WechatId'),
wechatQrCode: pickStr(user, 'wechatQrCode', 'WechatQrCode'),
wecomKfUrl: pickStr(user, 'wecomKfUrl', 'WecomKfUrl'),
}
}
@ -45,5 +48,6 @@ export function toUserProfilePayload(state: UserFormExtState): UserProfileExt {
employeeNo: state.employeeNo?.trim() || undefined,
wechatId: state.wechatId?.trim() || undefined,
wechatQrCode: (fromFile ? String(fromFile) : state.wechatQrCode?.trim()) || undefined,
wecomKfUrl: state.wecomKfUrl?.trim() || undefined,
}
}

39
src/utils/wecomKf.ts Normal file
View File

@ -0,0 +1,39 @@
import { isMobileUserAgent, isWeChatBrowser } from '@/utils/device';
/**
* 企业微信客服(微信外接入链接)
* 管理后台 → 微信客服 → 在微信外接入 → 复制
* 可用 VITE_WECOM_KF_URL 覆盖;顾问资料里的 wecomKfUrl 优先
*/
export const WECOM_KF_URL = (
(import.meta.env.VITE_WECOM_KF_URL as string | undefined) ||
'https://work.weixin.qq.com/kfid/kfcae09c206f6c7045a'
).trim();
/**
* 微信内 / 手机:直跳唤起客服会话
* PC:打开企业微信客服页(自带扫码)
* @param url 可选,优先使用顾问个人接入链接
*/
export function openWecomKf(url?: string | null): void {
const target = (url?.trim() || WECOM_KF_URL).trim();
if (!target) return;
if (isWeChatBrowser() || isMobileUserAgent()) {
window.location.href = target;
return;
}
const w = 480;
const h = 720;
const left = Math.max(0, Math.round((window.screen.width - w) / 2));
const top = Math.max(0, Math.round((window.screen.height - h) / 2));
const popup = window.open(
target,
'ide_wecom_kf',
`width=${w},height=${h},left=${left},top=${top},noopener,noreferrer`,
);
if (!popup) {
window.location.href = target;
}
}

View File

@ -530,6 +530,7 @@ export default {
'h5.me.placeOrderDesc': 'Ship in a few taps',
'h5.me.section.services': 'Services',
'h5.me.section.account': 'Account',
'h5.me.onlineKf': 'Dedicated advisor',
'h5.me.section.journey': 'Shipping guide',
'h5.storeOrders.title': 'Store orders',

View File

@ -526,6 +526,7 @@ export default {
'h5.me.placeOrderDesc': '填好收件,一键寄出',
'h5.me.section.services': '常用服务',
'h5.me.section.account': '账户设置',
'h5.me.onlineKf': '专属顾问',
'h5.me.section.journey': '发货指引',
'h5.storeOrders.title': '店铺订单',

View File

@ -134,6 +134,13 @@
<icon-right />
</span>
</button>
<button type="button" class="h5-me-list__item" @click="openOnlineKf">
<span class="h5-me-list__left">
<icon-customer-service class="h5-me-list__icon" />
{{ t('h5.me.onlineKf') }}
</span>
<icon-right />
</button>
<button type="button" class="h5-me-list__item" @click="bindWechat">
<span class="h5-me-list__left">
<icon-wechat class="h5-me-list__icon" />
@ -207,6 +214,7 @@ import {
IconLocation,
IconLanguage,
IconWechat,
IconCustomerService,
} from '@arco-design/web-vue/es/icon';
import type { CustomerDto } from '@/api/customer';
import {
@ -220,6 +228,7 @@ import { isLogin } from '@/utils/auth';
import { resolveAvatarUrl } from '@/utils/avatar';
import { getMapIdFromAccessToken } from '@/utils/jwt';
import { h5Navigate, isWeChatBrowser } from '@/utils/device';
import { openWecomKf } from '@/utils/wecomKf';
import { redirectToWeChatOAuth } from '@/utils/wechatOAuth';
import { useUserStore } from '@/store';
import { useRbacUserStore } from '@/store/modules/rbac-user';
@ -543,6 +552,10 @@ function bindWechat() {
redirectToWeChatOAuth('bind');
}
function openOnlineKf() {
openWecomKf();
}
function handleLogout() {
Modal.confirm({
title: t('h5.me.logout'),

View File

@ -20,7 +20,7 @@
<p class="aw-trend__empty-desc">{{ t('workplace.admin.businessTrendEmptyDesc') }}</p>
</div>
<div v-else class="aw-trend__chart">
<Chart width="100%" height="320px" :options="chartOption" />
<Chart width="100%" height="280px" :options="chartOption" />
</div>
</section>
</template>
@ -95,12 +95,12 @@ const chartOption = computed(() => {
grid: {
left: 8,
right: 8,
top: 12,
bottom: 28,
top: 28,
bottom: 8,
containLabel: true,
},
legend: {
bottom: 0,
top: 0,
left: 'center',
icon: 'circle',
itemWidth: 8,
@ -238,9 +238,15 @@ onMounted(() => {
.aw-trend {
--aw-ink: #1a2e38;
--aw-soft: rgba(26, 46, 56, 0.48);
--aw-chart-h: 320px;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
max-height: 100%;
padding: 12px 14px 14px;
box-sizing: border-box;
overflow: hidden;
}
.aw-trend__head {
@ -250,6 +256,7 @@ onMounted(() => {
gap: 12px;
margin-bottom: 8px;
flex-wrap: wrap;
flex-shrink: 0;
}
.aw-trend__title {
@ -259,11 +266,18 @@ onMounted(() => {
color: var(--aw-ink);
}
.aw-trend__loading,
.aw-trend__empty {
flex: 1 1 0%;
min-height: 0;
overflow: hidden;
}
.aw-trend__loading {
display: flex;
justify-content: center;
align-items: center;
height: var(--aw-chart-h);
min-height: 280px;
}
.aw-trend__empty {
@ -271,7 +285,7 @@ onMounted(() => {
flex-direction: column;
align-items: center;
justify-content: center;
height: var(--aw-chart-h);
min-height: 280px;
padding: 16px;
text-align: center;
}
@ -291,7 +305,9 @@ onMounted(() => {
}
.aw-trend__chart {
flex: 0 0 auto;
width: 100%;
height: var(--aw-chart-h);
height: 280px;
overflow: hidden;
}
</style>

View File

@ -74,7 +74,7 @@ import {
type AdminOpsMetricKey,
} from '@/utils/workplace-care'
import WorldTime from './world-time.vue'
import iconPendingOnline from '@/assets/icons/workplace/order-pending-online.svg?url'
import iconPayment from '@/assets/icons/workplace/order-payment.svg?url'
import iconPendingShip from '@/assets/icons/workplace/order-pending-ship.svg?url'
import iconProblematic from '@/assets/icons/workplace/order-problematic.svg?url'
import iconGrowth from '@/assets/icons/workplace/order-growth.svg?url'
@ -110,7 +110,7 @@ const METRIC_META: Record<
pendingPayment: {
titleKey: 'workplace.admin.ops.pendingPayment',
hintKey: 'workplace.stat.clickHint',
icon: iconPendingOnline,
icon: iconPayment,
tone: 'warning',
alert: true,
path: WAYBILL,

View File

@ -89,7 +89,7 @@ const items = computed(() =>
.stat-grid--inner {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
@ -105,12 +105,6 @@ const items = computed(() =>
font-size: 22px;
}
@media (max-width: 1280px) {
.stat-grid--inner {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 480px) {
.stat-grid--inner {
grid-template-columns: 1fr;

View File

@ -6,6 +6,7 @@
<header class="aw-hero aw-hero--compact">
<Banner compact />
</header>
<CareTodayPanel variant="strip" />
<AdminOpsQueue />
</section>
@ -19,14 +20,10 @@
</section>
</div>
<section class="aw-panel aw-panel--care" aria-label="today-care">
<CareTodayPanel variant="hero" />
<section class="aw-panel aw-panel--trend" aria-label="business-trend">
<AdminBusinessTrend />
</section>
</div>
<section class="aw-panel aw-panel--trend" aria-label="business-trend">
<AdminBusinessTrend />
</section>
</div>
<aside class="admin-right">
@ -163,7 +160,7 @@ function go(path: string, query?: Record<string, string>) {
width: 100%;
height: 100%;
min-height: 100%;
max-height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
@ -194,12 +191,12 @@ function go(path: string, query?: Record<string, string>) {
}
.admin-left__body {
flex: 1 1 auto;
flex: 1 1 0%;
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
overflow: hidden;
}
.admin-right {
@ -233,7 +230,8 @@ function go(path: string, query?: Record<string, string>) {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px;
align-items: start;
align-items: stretch;
flex: 0 0 auto;
}
.aw-panel--fulfill,
@ -243,35 +241,24 @@ function go(path: string, query?: Record<string, string>) {
overflow: visible;
}
.aw-panel--pickup {
margin-top: 18px;
}
.aw-panel--trend {
flex: 0 0 auto;
flex: 1 1 0%;
min-height: 0;
padding: 0;
overflow: hidden;
margin-top: auto;
display: flex;
flex-direction: column;
:deep(.aw-trend) {
flex: 1 1 0%;
min-height: 0;
height: 100%;
max-height: 100%;
}
}
.aw-panel--care {
flex: 0 0 auto;
padding: 0;
overflow: hidden;
:deep(.care-today) {
width: 100%;
min-height: 148px;
box-sizing: border-box;
border: none;
box-shadow: none;
border-radius: var(--aw-radius);
background: transparent;
}
:deep(.care-today.is-hero.is-collapsed) {
min-height: 88px;
}
.aw-panel--hero :deep(.care-today.is-strip) {
margin: 2px 0 8px;
}
.aw-panel--links {
@ -289,37 +276,10 @@ function go(path: string, query?: Record<string, string>) {
flex-direction: column;
padding: 0;
:deep(.announcement-card.arco-card) {
:deep(.announcement-panel) {
flex: 1;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: transparent;
border: none;
box-shadow: none;
}
:deep(.arco-card-header),
:deep(.arco-card-body) {
background: transparent;
}
:deep(.arco-card-header) {
flex-shrink: 0;
border-bottom: 1px solid color-mix(in srgb, var(--app-acrylic-border) 55%, transparent);
}
:deep(.arco-card-body) {
flex: 1;
min-height: 0;
overflow: auto;
}
:deep(.arco-card-header .arco-card-header-title) {
color: var(--aw-ink);
font-weight: 700;
}
}
@ -469,7 +429,14 @@ function go(path: string, query?: Record<string, string>) {
overflow: visible;
}
.aw-panel--trend,
.aw-panel--trend {
flex: none;
min-height: 340px;
height: auto;
max-height: none;
margin-top: 0;
}
.aw-panel--announce {
margin-top: 0;
}
@ -478,10 +445,6 @@ function go(path: string, query?: Record<string, string>) {
grid-template-columns: 1fr;
}
.aw-panel--pickup {
margin-top: 0;
}
.admin-right {
min-height: 360px;
}

View File

@ -3,93 +3,149 @@
v-if="show"
class="care-today"
:class="{
'is-collapsed': collapsed,
'is-collapsed': !isStrip && collapsed,
'is-pressure': pressure,
'is-clocked-out': clockedOut,
'is-hero': variant === 'hero',
'is-strip': isStrip,
}"
>
<span class="care-today__blob care-today__blob--a" aria-hidden="true" />
<span class="care-today__blob care-today__blob--b" aria-hidden="true" />
<div class="care-today__head">
<div class="care-today__titles">
<p class="care-today__eyebrow">{{ t('workplace.care.title') }}</p>
<p v-if="!collapsed" class="care-today__date">{{ dateLabel }}</p>
</div>
<button
type="button"
class="care-today__toggle"
:aria-expanded="!collapsed"
:aria-label="collapsed ? t('workplace.care.expand') : t('workplace.care.collapse')"
@click="toggleCollapsed"
>
{{ collapsed ? t('workplace.care.expand') : t('workplace.care.collapse') }}
</button>
</div>
<!-- strip:问候下轻提示,单行/矮条 -->
<template v-if="isStrip">
<div class="care-today__strip">
<div class="care-today__strip-main">
<span class="care-today__strip-date">{{ dateLabel }}</span>
<p class="care-today__strip-text">
<span class="care-today__strip-headline">{{ headline }}</span>
<span class="care-today__strip-note">{{ note }}</span>
</p>
</div>
<template v-if="collapsed">
<p class="care-today__collapsed-line">{{ headline }}</p>
<div class="care-today__strip-aside">
<a-spin v-if="loading" :size="14" />
<template v-else>
<button
v-if="primaryGuide && !clockedOut"
type="button"
class="care-action care-action--strip"
@click="goPrimary"
>
<span class="care-action__label">
<template v-if="primaryIsHandoff">
{{ t('workplace.care.handoff', { count: primaryGuide.count }) }}
</template>
<template v-else>
{{ t(`workplace.care.pending.${primaryGuide.kind}`) }}
<em>{{ primaryGuide.count }}</em>
</template>
</span>
</button>
<button
v-if="clockOutEnabled && !clockedOut"
type="button"
class="care-clockout care-clockout--strip"
@click="clockOutVisible = true"
>
{{ t('workplace.care.clockOut') }}
</button>
<p
v-else-if="clockOutEnabled && clockedOut"
class="care-today__clocked care-today__clocked--strip"
>
{{ t('workplace.care.clockedOut') }}
<button type="button" class="care-today__undo" @click="undoClockOut">
{{ t('workplace.care.clockOutUndo') }}
</button>
</p>
</template>
</div>
</div>
</template>
<template v-else>
<div v-if="rhythmChips.length" class="care-chips">
<span v-for="chip in rhythmChips" :key="chip" class="care-chip">{{ chip }}</span>
</div>
<p class="care-today__headline">{{ headline }}</p>
<p class="care-today__note">{{ note }}</p>
<div v-if="loading" class="care-today__loading">
<a-spin :size="16" />
</div>
<div v-else class="care-today__actions">
<button
v-if="primaryGuide && !clockedOut"
type="button"
class="care-action care-action--primary"
@click="goPrimary"
>
<span class="care-action__label">
<template v-if="primaryIsHandoff">
{{ t('workplace.care.handoff', { count: primaryGuide.count }) }}
</template>
<template v-else>
{{ t(`workplace.care.pending.${primaryGuide.kind}`) }}
<em>{{ primaryGuide.count }}</em>
</template>
</span>
<span class="care-action__hint">
{{
primaryIsHandoff
? t('workplace.care.handoffHint')
: t('workplace.care.action.pendingHint')
}}
</span>
</button>
<p v-else-if="!primaryGuide && !clockedOut" class="care-today__calm">
{{ t('workplace.care.calm') }}
</p>
<div class="care-today__footer">
<button
v-if="clockOutEnabled && !clockedOut"
type="button"
class="care-clockout"
@click="clockOutVisible = true"
>
{{ t('workplace.care.clockOut') }}
</button>
<p v-else-if="clockOutEnabled && clockedOut" class="care-today__clocked">
{{ t('workplace.care.clockedOut') }}
<button type="button" class="care-today__undo" @click="undoClockOut">
{{ t('workplace.care.clockOutUndo') }}
</button>
</p>
<div class="care-today__head">
<div class="care-today__titles">
<p class="care-today__eyebrow">{{ t('workplace.care.title') }}</p>
<p v-if="!collapsed" class="care-today__date">{{ dateLabel }}</p>
</div>
<button
type="button"
class="care-today__toggle"
:aria-expanded="!collapsed"
:aria-label="collapsed ? t('workplace.care.expand') : t('workplace.care.collapse')"
@click="toggleCollapsed"
>
{{ collapsed ? t('workplace.care.expand') : t('workplace.care.collapse') }}
</button>
</div>
<template v-if="collapsed">
<p class="care-today__collapsed-line">{{ headline }}</p>
</template>
<template v-else>
<div v-if="rhythmChips.length" class="care-chips">
<span v-for="chip in rhythmChips" :key="chip" class="care-chip">{{ chip }}</span>
</div>
<p class="care-today__headline">{{ headline }}</p>
<p class="care-today__note">{{ note }}</p>
<div v-if="loading" class="care-today__loading">
<a-spin :size="16" />
</div>
<div v-else class="care-today__actions">
<button
v-if="primaryGuide && !clockedOut"
type="button"
class="care-action care-action--primary"
@click="goPrimary"
>
<span class="care-action__label">
<template v-if="primaryIsHandoff">
{{ t('workplace.care.handoff', { count: primaryGuide.count }) }}
</template>
<template v-else>
{{ t(`workplace.care.pending.${primaryGuide.kind}`) }}
<em>{{ primaryGuide.count }}</em>
</template>
</span>
<span class="care-action__hint">
{{
primaryIsHandoff
? t('workplace.care.handoffHint')
: t('workplace.care.action.pendingHint')
}}
</span>
</button>
<p v-else-if="!primaryGuide && !clockedOut" class="care-today__calm">
{{ t('workplace.care.calm') }}
</p>
<div class="care-today__footer">
<button
v-if="clockOutEnabled && !clockedOut"
type="button"
class="care-clockout"
@click="clockOutVisible = true"
>
{{ t('workplace.care.clockOut') }}
</button>
<p v-else-if="clockOutEnabled && clockedOut" class="care-today__clocked">
{{ t('workplace.care.clockedOut') }}
<button type="button" class="care-today__undo" @click="undoClockOut">
{{ t('workplace.care.clockOutUndo') }}
</button>
</p>
</div>
</div>
</template>
</template>
<a-modal
@ -144,8 +200,8 @@ defineOptions({ name: 'CareTodayPanel' })
const props = withDefaults(
defineProps<{
/** hero:员工工作台主视觉,默认展开 */
variant?: 'default' | 'hero'
/** hero:主视觉大块;strip:问候下轻条;default:独立面板 */
variant?: 'default' | 'hero' | 'strip'
}>(),
{ variant: 'default' },
)
@ -176,6 +232,8 @@ const clockedOut = ref(false)
const clockOutVisible = ref(false)
const overdueThreshold = careSettings.overdueCareThreshold
const isStrip = computed(() => props.variant === 'strip')
const orderListPath = computed(() =>
rbacUserStore.isCustomerPortal() ? '/order/myOrders' : '/order/waybillManage',
)
@ -372,10 +430,12 @@ async function load() {
}
onMounted(() => {
try {
collapsed.value = localStorage.getItem(CARE_PANEL_COLLAPSED_KEY) === '1'
} catch {
collapsed.value = false
if (!isStrip.value) {
try {
collapsed.value = localStorage.getItem(CARE_PANEL_COLLAPSED_KEY) === '1'
} catch {
collapsed.value = false
}
}
void load()
})
@ -445,6 +505,16 @@ onMounted(() => {
color: var(--care-ink);
}
}
&.is-strip {
padding: 0;
border: none;
border-radius: 0;
background: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
overflow: visible;
}
}
.care-today__blob {
@ -459,11 +529,65 @@ onMounted(() => {
.care-today__loading,
.care-today__calm,
.care-today__footer,
.care-chips {
.care-chips,
.care-today__strip {
position: relative;
z-index: 1;
}
.care-today__strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 36px;
padding: 4px 0 2px;
}
.care-today__strip-main {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
flex: 1;
}
.care-today__strip-date {
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
color: var(--care-muted);
white-space: nowrap;
}
.care-today__strip-text {
margin: 0;
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px 10px;
line-height: 1.4;
}
.care-today__strip-headline {
font-size: 13px;
font-weight: 700;
color: var(--care-ink);
}
.care-today__strip-note {
font-size: 12px;
color: var(--care-muted);
}
.care-today__strip-aside {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.care-chips {
display: flex;
flex-wrap: wrap;
@ -585,6 +709,19 @@ onMounted(() => {
border-color: color-mix(in srgb, var(--app-primary-color) 18%, var(--app-acrylic-border));
background: transparent;
}
&--strip {
width: auto;
flex-direction: row;
align-items: center;
padding: 4px 10px;
border-radius: 8px;
min-height: 28px;
.care-action__label {
font-size: 12px;
}
}
}
.care-action__label {
@ -632,6 +769,14 @@ onMounted(() => {
&:hover {
border-color: rgba(193, 122, 82, 0.5);
}
&--strip {
width: auto;
height: 28px;
padding: 0 10px;
font-size: 12px;
background: transparent;
}
}
.care-today__clocked {
@ -642,6 +787,11 @@ onMounted(() => {
align-items: center;
justify-content: space-between;
gap: 8px;
&--strip {
justify-content: flex-end;
white-space: nowrap;
}
}
.care-today__undo {
@ -668,6 +818,18 @@ onMounted(() => {
line-height: 1.5;
}
@media (max-width: 640px) {
.care-today__strip {
flex-wrap: wrap;
align-items: flex-start;
}
.care-today__strip-aside {
width: 100%;
justify-content: flex-end;
}
}
@media (prefers-reduced-motion: reduce) {
.care-action {
transition: none;

View File

@ -158,23 +158,10 @@ defineOptions({ name: 'CustomerWorkplace' })
display: flex;
flex-direction: column;
:deep(.announcement-card.arco-card) {
:deep(.announcement-panel) {
flex: 1;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
:deep(.arco-card-header) {
flex-shrink: 0;
}
:deep(.arco-card-body) {
flex: 1;
min-height: 0;
overflow: auto;
}
}

View File

@ -1,32 +1,31 @@
<template>
<a-card
class="general-card announcement-card"
:title="panelTitle"
:header-style="{ paddingBottom: '0' }"
:body-style="{ padding: '12px 16px 16px' }"
>
<div v-if="loading" class="announcement-state">
<a-spin />
<div class="announcement-panel">
<p class="announcement-panel__title">{{ panelTitle }}</p>
<div class="announcement-panel__body">
<div v-if="loading" class="announcement-state">
<a-spin />
</div>
<div v-else-if="list.length === 0" class="announcement-state announcement-state--empty">
<p class="announcement-state__title">{{ emptyTitle }}</p>
<p class="announcement-state__desc">{{ emptyDesc }}</p>
</div>
<ul v-else class="announcement-list">
<li
v-for="item in list"
:key="String(item.id)"
class="announcement-item"
@click="openDetail(item)"
>
<div class="announcement-item__main">
<span v-if="isNew(item)" class="announcement-item__dot" />
<span class="announcement-item__title">{{ displaySubject(item) }}</span>
</div>
<span class="announcement-item__time">{{ formatTime(item.createTime) }}</span>
</li>
</ul>
</div>
<div v-else-if="list.length === 0" class="announcement-state announcement-state--empty">
<p class="announcement-state__title">{{ emptyTitle }}</p>
<p class="announcement-state__desc">{{ emptyDesc }}</p>
</div>
<ul v-else class="announcement-list">
<li
v-for="item in list"
:key="String(item.id)"
class="announcement-item"
@click="openDetail(item)"
>
<div class="announcement-item__main">
<span v-if="isNew(item)" class="announcement-item__dot" />
<span class="announcement-item__title">{{ displaySubject(item) }}</span>
</div>
<span class="announcement-item__time">{{ formatTime(item.createTime) }}</span>
</li>
</ul>
</a-card>
</div>
<a-modal
v-model:visible="detailVisible"
@ -192,6 +191,32 @@
</script>
<style scoped lang="less">
.announcement-panel {
--aw-ink: #1a2e38;
--aw-soft: rgba(26, 46, 56, 0.48);
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
box-sizing: border-box;
padding: 14px 14px 16px;
}
.announcement-panel__title {
margin: 0 0 10px;
flex-shrink: 0;
font-size: 14px;
font-weight: 700;
color: var(--aw-ink);
}
.announcement-panel__body {
flex: 1;
min-height: 0;
overflow: auto;
}
.announcement-state {
display: flex;
flex-direction: column;
@ -203,19 +228,21 @@
}
.announcement-state--empty {
color: var(--color-text-3);
color: var(--aw-soft);
}
.announcement-state__title {
margin: 0 0 4px;
color: var(--color-text-2);
color: var(--aw-ink);
font-size: 13px;
font-weight: 600;
}
.announcement-state__desc {
margin: 0;
font-size: 12px;
line-height: 1.4;
color: var(--aw-soft);
}
.announcement-list {
@ -229,18 +256,23 @@
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 8px 6px;
margin: 0 -6px;
border-radius: 6px;
padding: 10px 12px;
margin: 0 0 8px;
border-radius: 12px;
border: 1px solid var(--app-acrylic-border);
background: var(--app-acrylic-bg);
backdrop-filter: var(--app-acrylic-blur);
-webkit-backdrop-filter: var(--app-acrylic-blur);
cursor: pointer;
transition: background-color 0.15s;
transition: border-color 0.2s ease, background 0.2s ease;
&:hover {
background-color: var(--color-fill-2);
&:last-child {
margin-bottom: 0;
}
& + & {
border-top: 1px solid var(--color-border-1);
&:hover {
border-color: var(--app-acrylic-border-strong);
background: var(--app-acrylic-hover-bg);
}
&__main {
@ -261,8 +293,9 @@
}
&__title {
color: var(--color-text-1);
color: var(--aw-ink);
font-size: 13px;
font-weight: 600;
line-height: 1.45;
display: -webkit-box;
-webkit-line-clamp: 2;
@ -272,7 +305,7 @@
&__time {
flex-shrink: 0;
color: var(--color-text-3);
color: var(--aw-soft);
font-size: 12px;
line-height: 1.45;
white-space: nowrap;

View File

@ -5,11 +5,14 @@ import {
type FulfillWorkplaceStats,
type PickupWorkplaceStats,
} from '@/api/workplace'
import iconStorePending from '@/assets/icons/workplace/order-store-pending.svg?url'
import iconPendingShip from '@/assets/icons/workplace/order-pending-ship.svg?url'
import iconPendingOnline from '@/assets/icons/workplace/order-pending-online.svg?url'
import iconBind from '@/assets/icons/workplace/order-bind.svg?url'
import iconTransit from '@/assets/icons/workplace/order-transit.svg?url'
import iconPick from '@/assets/icons/workplace/order-pick.svg?url'
import iconShipped from '@/assets/icons/workplace/order-shipped.svg?url'
import iconNew from '@/assets/icons/workplace/order-new.svg?url'
import iconPayment from '@/assets/icons/workplace/order-payment.svg?url'
import iconDispatch from '@/assets/icons/workplace/order-dispatch.svg?url'
import iconPickup from '@/assets/icons/workplace/order-pickup.svg?url'
import iconInbound from '@/assets/icons/workplace/order-inbound.svg?url'
export type WorkplaceStatTone = 'neutral' | 'primary' | 'warning' | 'danger' | 'success'
@ -72,7 +75,7 @@ export function useWorkplaceFulfillPickupStats() {
key: 'pendingBind',
titleKey: 'workplace.admin.fulfill.pendingBind',
value: fulfill.value.pendingBind,
icon: iconStorePending,
icon: iconBind,
tone: 'warning',
alert: true,
path: FULFILL_PATH,
@ -82,7 +85,7 @@ export function useWorkplaceFulfillPickupStats() {
key: 'domesticInTransit',
titleKey: 'workplace.admin.fulfill.domesticInTransit',
value: fulfill.value.domesticInTransit,
icon: iconPendingOnline,
icon: iconTransit,
tone: 'neutral',
path: FULFILL_PATH,
query: { progress: 'domesticInTransit' },
@ -91,7 +94,7 @@ export function useWorkplaceFulfillPickupStats() {
key: 'arrivedPendingPick',
titleKey: 'workplace.admin.fulfill.arrivedPendingPick',
value: fulfill.value.arrivedPendingPick,
icon: iconPendingShip,
icon: iconPick,
tone: 'warning',
alert: true,
path: FULFILL_PATH,
@ -113,7 +116,7 @@ export function useWorkplaceFulfillPickupStats() {
key: 'pendingPayment',
titleKey: 'workplace.admin.pickup.pendingPayment',
value: pickup.value.pendingPayment,
icon: iconPendingOnline,
icon: iconPayment,
tone: 'warning',
alert: true,
path: PICKUP_PATH,
@ -123,7 +126,7 @@ export function useWorkplaceFulfillPickupStats() {
key: 'pendingSchedule',
titleKey: 'workplace.admin.pickup.pendingSchedule',
value: pickup.value.pendingSchedule,
icon: iconNew,
icon: iconDispatch,
tone: 'neutral',
path: PICKUP_PATH,
query: { status: 'draft' },
@ -132,7 +135,7 @@ export function useWorkplaceFulfillPickupStats() {
key: 'pendingPickup',
titleKey: 'workplace.admin.pickup.pendingPickup',
value: pickup.value.pendingPickup,
icon: iconPendingShip,
icon: iconPickup,
tone: 'warning',
alert: true,
path: PICKUP_PATH,
@ -142,7 +145,7 @@ export function useWorkplaceFulfillPickupStats() {
key: 'pendingInbound',
titleKey: 'workplace.admin.pickup.pendingInbound',
value: pickup.value.pendingInbound,
icon: iconShipped,
icon: iconInbound,
tone: 'primary',
path: PICKUP_PATH,
query: { status: 'picked' },

View File

@ -213,7 +213,7 @@ export default {
'workplace.admin.tip.peak': 'Peak season: accuracy and safety first.',
'workplace.admin.tip.late': 'It’s late — ease up when you can.',
'workplace.admin.linksTitle': 'Quick links',
'workplace.admin.opsTitle': 'Today’s queue',
'workplace.admin.opsTitle': 'Waybills',
'workplace.admin.ops.pendingPayment': 'Pending payment',
'workplace.admin.ops.pendingShip': 'Pending ship',
'workplace.admin.ops.exception': 'Exceptions',

View File

@ -210,7 +210,7 @@ export default {
'workplace.admin.tip.peak': '旺季优先保准确与安全。',
'workplace.admin.tip.late': '夜深了,能缓就缓一缓。',
'workplace.admin.linksTitle': '常用入口',
'workplace.admin.opsTitle': '今日待办',
'workplace.admin.opsTitle': '运单',
'workplace.admin.ops.pendingPayment': '待支付',
'workplace.admin.ops.pendingShip': '待发货',
'workplace.admin.ops.exception': '异常跟进',

View File

@ -10,6 +10,7 @@ import { buildWarehouseSkuSelectPayload, normalizeListRow, pickField, pickSkuEng
const props = defineProps<{
visible: boolean
customerId?: number
}>()
const emit = defineEmits<{
@ -66,6 +67,7 @@ async function loadList() {
keyword: keyword.value.trim() || undefined,
page: currentPage.value,
pageSize: pageSize.value,
...(props.customerId && props.customerId > 0 ? { customerId: props.customerId } : {}),
})
tableRows.value = (items || []).map((x) => mapRow(x as Record<string, unknown>))
totalCount.value = Number(total ?? tableRows.value.length)

View File

@ -34,6 +34,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
import { mapWarehouseSkuToDeclarationFields, normalizeListRow, pickField } from '@/utils/warehouseListMapper';
import { normalizeCargoAttributeCode } from '@/utils/cargoAttribute';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import { useWarehouseAdminCustomer } from '@/composables/useWarehouseAdminCustomer';
import {
checkCustomerContactRequired,
isCustomerContactRequiredError,
@ -56,6 +57,18 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
const router = useRouter();
const route = useRoute();
const rbacUserStore = useRbacUserStore();
const {
isAdmin,
customerId: adminCustomerId,
customerOptions,
customerLoading,
withCustomerId,
ensureMutationCustomerId,
resolveSelectedCustomerId,
} = useWarehouseAdminCustomer();
const adminCustomerSelected = computed(
() => !isAdmin.value || resolveSelectedCustomerId() != null,
);
const contactVisible = ref(false);
const contactProfile = ref<CustomerProfileCompleteness | null>(null);
const contactBlocked = ref(false);
@ -903,6 +916,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
const canSubmitBatch = computed(
() =>
adminCustomerSelected.value &&
Boolean(pendingUploadFile.value) &&
batchAgreementChecked.value &&
selectedTemplate.value != null &&
@ -912,6 +926,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
const batchSubmitBlockReason = computed(() => {
if (canSubmitBatch.value) return '';
if (!adminCustomerSelected.value) return t('order.placeOrder.selectCustomerRequired');
if (selectedTemplate.value == null) return t('order.placeOrder.batch.block.noTemplate');
if (!selectedTemplateReady.value) return t('order.placeOrder.batch.block.templateDraft');
if (!selectedSenderAddressId.value) return t('order.placeOrder.batch.block.noSender');
@ -1230,6 +1245,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
const canSubmitSingle = computed(
() =>
adminCustomerSelected.value &&
singleAgreementChecked.value &&
isSingleDeclarationComplete() &&
isSingleRecipientComplete() &&
@ -1240,6 +1256,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
const singleSubmitBlockReason = computed(() => {
if (canSubmitSingle.value) return '';
if (!adminCustomerSelected.value) return t('order.placeOrder.selectCustomerRequired');
const err = getSingleFormValidationError();
if (err) return err.message;
if (!singleAgreementChecked.value) return t('order.placeOrder.agreement.required');
@ -1347,6 +1364,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
}
async function handleBatchOrderClick() {
if (isAdmin.value && ensureMutationCustomerId() == null) return;
if (!canSubmitBatch.value) {
Message.warning(batchSubmitBlockReason.value || t('order.placeOrder.batch.block.generic'));
return;
@ -1356,6 +1374,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
}
async function handleSingleOrderClick() {
if (isAdmin.value && ensureMutationCustomerId() == null) return;
if (!canSubmitSingle.value) {
singleShowFieldErrors.value = true;
const err = getSingleFormValidationError();
@ -1502,13 +1521,22 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
}
async function loadSenderAddressOptions({ applyDefault = true } = {}) {
if (isAdmin.value && resolveSelectedCustomerId() == null) {
senderAddressOptions.value = [];
if (applyDefault) {
applySenderAddressById(undefined, { silentEmpty: true });
}
return;
}
senderAddressLoading.value = true;
try {
const res = await getSenderAddressList({
page: 1,
pageSize: 200,
addressType: 'sender',
});
const res = await getSenderAddressList(
withCustomerId({
page: 1,
pageSize: 200,
addressType: 'sender',
}),
);
senderAddressOptions.value = (res.items || [])
.map((item: Record<string, unknown>) => mapSenderAddressOption(item))
.filter((x): x is SenderAddressOption => !!x);
@ -1532,18 +1560,16 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
}
async function openSenderModal() {
if (isAdmin.value && ensureMutationCustomerId() == null) return;
senderModalVisible.value = true;
await loadSenderAddressOptions({ applyDefault: !isSingleSenderFilled() });
}
function handleSenderAddressSelect(id: number | undefined) {
if (id == null) return;
applySenderAddressById(id);
}
function clearSelectedSender() {
applySenderAddressById(undefined);
}
function goToSenderAddressManage() {
router.push({ path: '/user/senderAddress' });
}
@ -2106,6 +2132,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
loadReceiverAiPrefs();
void Promise.all([loadReceiverAiProviders(), loadReceiverAiSkills()]);
void (async () => {
if (isAdmin.value) return;
const { needContact, profile } = await checkCustomerContactRequired();
if (needContact) {
contactBlocked.value = true;
@ -2175,6 +2202,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
uploadFile,
selectedTemplate.value,
selectedSenderAddressId.value,
resolveSelectedCustomerId(),
);
clearUploadedFile();
if (uploadResult.jobId > 0) {
@ -2448,6 +2476,10 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
}
const inbound = String(singleForm.inboundMethod || 'selfShip').trim() || 'selfShip';
payload.inboundMethod = inbound;
const mutationCustomerId = resolveSelectedCustomerId();
if (mutationCustomerId != null) {
payload.customerId = mutationCustomerId;
}
if (inbound === 'doorPickup') {
payload.senderAddressId = parseNumberValue(singleForm.senderAddressId);
payload.expectPickupTime = formTrim(singleForm.expectPickupTime) || undefined;
@ -2510,6 +2542,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
}
function openDeclarationSkuModal() {
if (isAdmin.value && ensureMutationCustomerId() == null) return;
declarationSkuModalVisible.value = true;
}
@ -2788,6 +2821,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
async function handleCreateFailSaveAsDraft() {
const info = createFailInfo.value;
if (!info || info.draftSaved) return;
if (isAdmin.value && ensureMutationCustomerId() == null) return;
createFailSavingDraft.value = true;
try {
@ -2903,6 +2937,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
}
async function handleSingleOrderSubmit() {
if (isAdmin.value && ensureMutationCustomerId() == null) return;
const validationError = getSingleFormValidationError();
if (validationError) {
singleShowFieldErrors.value = true;
@ -2938,9 +2973,13 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
});
} catch (e: any) {
if (isCustomerContactRequiredError(e)) {
contactBlocked.value = true;
contactVisible.value = true;
Message.warning(t('h5.contact.requiredToast'));
if (isAdmin.value) {
Message.warning(t('order.placeOrder.contact.adminCustomerIncomplete'));
} else {
contactBlocked.value = true;
contactVisible.value = true;
Message.warning(t('h5.contact.requiredToast'));
}
} else if (
String(e?.message || '').includes('发件人姓名不能为空') ||
/sender\s*name/i.test(String(e?.message || ''))
@ -2982,6 +3021,12 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
}
});
watch(adminCustomerId, () => {
if (!isAdmin.value) return;
applySenderAddressById(undefined);
void loadSenderAddressOptions({ applyDefault: true });
});
function handleSingleFlowClick(stepKey: string) {
if (stepKey === 'sender') {
void openSenderModal();
@ -3077,6 +3122,17 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
</template>
</div>
<div class="toolbar-right single-toolbar-right">
<a-select
v-if="isAdmin"
v-model="adminCustomerId"
allow-search
allow-clear
size="small"
:placeholder="t('order.placeOrder.selectCustomer')"
:loading="customerLoading"
:options="customerOptions"
class="place-order-customer-select"
/>
<a-button type="secondary" size="small" @click="handleGenerateSampleOrder">
{{ t('order.placeOrder.single.sampleOrder') }}
</a-button>
@ -3732,47 +3788,62 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
<div class="upload-card-main">
<div class="upload-card-main__left">
<div class="template-selector">
<label class="selector-label">{{ t('order.placeOrder.template.label') }}</label>
<a-select
v-model="selectedTemplate"
class="template-select"
allow-search
:placeholder="t('order.placeOrder.batch.pickTemplateHint')"
:options="templateOptions"
:loading="templatePanelLoading"
/>
<div
v-if="selectedTemplateRow?.type === 'custom' && selectedTemplateRow.mappingStatus !== 'ready'"
class="template-draft-tip"
>
<a-tag size="small" color="orangered">待编辑</a-tag>
<span>{{ t('order.placeOrder.batch.block.templateDraft') }}</span>
<div class="upload-subcard">
<div class="upload-subcard__title">{{ t('order.placeOrder.template.label') }}</div>
<div class="template-selector">
<a-select
v-model="selectedTemplate"
class="template-select"
allow-search
:placeholder="t('order.placeOrder.batch.pickTemplateHint')"
:options="templateOptions"
:loading="templatePanelLoading"
/>
<div
v-if="selectedTemplateRow?.type === 'custom' && selectedTemplateRow.mappingStatus !== 'ready'"
class="template-draft-tip"
>
<a-tag size="small" color="orangered">待编辑</a-tag>
<span>{{ t('order.placeOrder.batch.block.templateDraft') }}</span>
</div>
</div>
</div>
<div class="batch-sender-block">
<label class="selector-label">{{ t('order.placeOrder.batch.senderLabel') }}</label>
<a-select
:model-value="selectedSenderAddressId"
allow-search
class="template-select"
:loading="senderAddressLoading"
:placeholder="t('order.placeOrder.single.senderSelectPlaceholder')"
:options="senderSelectOptions"
@change="handleSenderAddressSelect"
/>
<p v-if="selectedSenderSummary" class="batch-sender-summary">{{ selectedSenderSummary }}</p>
<p v-else-if="!senderAddressLoading && !senderAddressOptions.length" class="batch-sender-empty">
{{ t('order.placeOrder.single.senderEmpty') }}
<a-link @click="goToSenderAddressManage">{{ t('order.placeOrder.single.senderManage') }}</a-link>
</p>
</div>
<div class="upload-left-tips">
<p>{{ t('order.placeOrder.upload.tip1') }}</p>
<p>{{ t('order.placeOrder.batch.templateTip') }}</p>
<p>{{ t('order.placeOrder.upload.tip2') }}</p>
<div class="upload-subcard upload-subcard--fill">
<div class="upload-subcard__title">{{ t('order.placeOrder.batch.addressCardTitle') }}</div>
<div class="batch-sender-block">
<template v-if="isAdmin">
<label class="selector-label">{{ t('order.placeOrder.customerLabel') }}</label>
<a-select
v-model="adminCustomerId"
allow-search
allow-clear
class="template-select"
:placeholder="t('order.placeOrder.selectCustomer')"
:loading="customerLoading"
:options="customerOptions"
/>
</template>
<label class="selector-label">{{ t('order.placeOrder.batch.senderLabel') }}</label>
<a-select
:model-value="selectedSenderAddressId"
allow-search
class="template-select"
:loading="senderAddressLoading"
:placeholder="t('order.placeOrder.batch.senderPlaceholder')"
:options="senderSelectOptions"
@change="handleSenderAddressSelect"
/>
<p v-if="selectedSenderSummary" class="batch-sender-summary">{{ selectedSenderSummary }}</p>
</div>
<div class="upload-subcard__bottom">
<p class="upload-left-tips">{{ t('order.placeOrder.batch.addressTip') }}</p>
<div class="upload-subcard__footer">
<a-button class="app-btn-toolbar" @click="goToSenderAddressManage">
{{ t('order.placeOrder.single.senderManage') }}
</a-button>
</div>
</div>
</div>
</div>
@ -3795,7 +3866,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
<div class="upload-file-hint">{{ t('order.placeOrder.upload.fileReady') }}</div>
</div>
<div v-if="!pendingUploadFile" class="upload-inline-tips">
<p class="upload-inline-tip primary">{{ t('order.placeOrder.batch.block.noFile') }}</p>
<p class="upload-inline-tip">{{ t('order.placeOrder.batch.templateTip') }}</p>
</div>
<div v-if="pendingUploadFile" class="upload-file-actions" @click.stop>
<a-button size="small" @click="triggerFilePicker">
@ -4084,10 +4155,8 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
:title="t('order.placeOrder.single.senderModalTitle')"
:mask-closable="true"
:esc-to-close="true"
width="560px"
width="600px"
unmount-on-close
:ok-text="t('order.placeOrder.single.senderModalOk')"
@ok="senderModalVisible = false"
>
<a-spin :loading="senderAddressLoading" class="sender-modal-spin">
<div class="sender-modal-body">
@ -4096,7 +4165,6 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
<a-select
:model-value="selectedSenderAddressId"
allow-search
allow-clear
:placeholder="t('order.placeOrder.single.senderSelectPlaceholder')"
:options="senderSelectOptions"
@change="handleSenderAddressSelect"
@ -4124,17 +4192,27 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
v-else-if="!senderAddressOptions.length"
:description="t('order.placeOrder.single.senderEmpty')"
/>
<div class="sender-modal-actions">
<a-button size="small" type="text" status="danger" :disabled="!selectedSenderSummary" @click="clearSelectedSender">
{{ t('order.placeOrder.single.senderClear') }}
</div>
</a-spin>
<template #footer>
<div class="sender-modal-footer">
<a-button class="app-btn-toolbar" @click="goToSenderAddressManage">
{{ t('order.placeOrder.single.senderManage') }}
</a-button>
<div class="sender-modal-footer__right">
<a-button class="app-btn-toolbar" @click="senderModalVisible = false">
{{ t('order.placeOrder.template.cancel') }}
</a-button>
<a-button size="small" type="text" @click="goToSenderAddressManage">
{{ t('order.placeOrder.single.senderManage') }}
<a-button
type="primary"
:disabled="!selectedSenderAddressId"
@click="senderModalVisible = false"
>
{{ t('order.placeOrder.single.senderModalOk') }}
</a-button>
</div>
</div>
</a-spin>
</template>
</a-modal>
<a-modal
@ -4160,6 +4238,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
<SelectDeclarationSkuModal
v-model:visible="declarationSkuModalVisible"
:customer-id="resolveSelectedCustomerId()"
@select="handleDeclarationSkuSelect"
/>
@ -4280,8 +4359,14 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
}
.single-toolbar-right {
flex-wrap: wrap;
flex-wrap: nowrap;
flex-shrink: 0;
justify-content: flex-end;
gap: 8px;
}
.place-order-customer-select {
width: 180px;
}
.single-tips {
@ -4837,11 +4922,45 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
.upload-card-main__left {
gap: 12px;
justify-content: flex-start;
height: 100%;
}
.upload-subcard {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
border-radius: 10px;
border-radius: 8px;
border: 1px solid var(--color-border-2);
background: var(--color-fill-2);
justify-content: flex-start;
box-sizing: border-box;
}
.upload-subcard--fill {
flex: 1;
min-height: 0;
}
.upload-subcard__title {
font-size: 14px;
font-weight: 600;
color: var(--color-text-1);
line-height: 22px;
}
.upload-subcard__bottom {
margin-top: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.upload-subcard__footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.upload-card-main__right {
@ -4893,17 +5012,10 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
.batch-sender-summary {
margin: 0;
font-size: 12px;
line-height: 1.5;
line-height: 1.45;
color: var(--color-text-2);
}
.batch-sender-empty {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--color-text-3);
}
.template-draft-tip {
display: flex;
align-items: flex-start;
@ -4914,20 +5026,10 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
}
.upload-left-tips {
margin-top: auto;
padding-top: 12px;
border-top: 1px dashed var(--color-border-2);
p {
margin: 0 0 4px;
font-size: 12px;
line-height: 1.6;
color: var(--color-text-2);
&:last-child {
margin-bottom: 0;
}
}
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--color-text-3);
}
.upload-area {
@ -5693,6 +5795,10 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
display: flex;
flex-direction: column;
gap: 12px;
:deep(.arco-form-item) {
margin-bottom: 0;
}
}
.sender-preview {
@ -5724,10 +5830,18 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
}
}
.sender-modal-actions {
.sender-modal-footer {
display: flex;
justify-content: space-between;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
}
.sender-modal-footer__right {
display: flex;
align-items: center;
gap: 8px;
}
.single-form-footer {

View File

@ -379,6 +379,7 @@ const userFormState = reactive<UserCreateDto & UserFormExtState & { organization
employeeNo: '',
wechatId: '',
wechatQrCode: '',
wecomKfUrl: '',
wechatQrFiles: [],
})
@ -689,6 +690,7 @@ async function handleAddUser() {
employeeNo,
wechatId: '',
wechatQrCode: '',
wecomKfUrl: '',
wechatQrFiles: [],
})
applyEmployeeDto(employeeFormState, null, '')
@ -738,6 +740,7 @@ async function handleEditUser(record: UserDto) {
employeeNo: profile.employeeNo || '',
wechatId: profile.wechatId || '',
wechatQrCode: profile.wechatQrCode || '',
wecomKfUrl: profile.wecomKfUrl || '',
wechatQrFiles: urlToUploadFileList(profile.wechatQrCode, 'user-wx'),
})
await loadEmployeeForm(record.id || '', userDetail.name || '')
@ -754,6 +757,7 @@ async function handleEditUser(record: UserDto) {
employeeNo: profile.employeeNo || '',
wechatId: profile.wechatId || '',
wechatQrCode: profile.wechatQrCode || '',
wecomKfUrl: profile.wecomKfUrl || '',
wechatQrFiles: urlToUploadFileList(profile.wechatQrCode, 'user-wx'),
})
await loadEmployeeForm(record.id || '', record.name || '')

View File

@ -247,6 +247,7 @@ const formState = reactive<UserCreateDto & UserFormExtState & { id?: string; org
employeeNo: '',
wechatId: '',
wechatQrCode: '',
wecomKfUrl: '',
wechatQrFiles: [],
})
@ -342,6 +343,7 @@ async function handleAdd() {
employeeNo,
wechatId: '',
wechatQrCode: '',
wecomKfUrl: '',
wechatQrFiles: [],
})
applyEmployeeDto(
@ -373,6 +375,7 @@ async function handleEdit(record: UserDto) {
employeeNo: profile.employeeNo || '',
wechatId: profile.wechatId || '',
wechatQrCode: profile.wechatQrCode || '',
wecomKfUrl: profile.wecomKfUrl || '',
wechatQrFiles: urlToUploadFileList(profile.wechatQrCode, 'user-wx'),
})
await loadEmployeeForm(record.id || '', userDetail.name || '')
@ -389,6 +392,7 @@ async function handleEdit(record: UserDto) {
employeeNo: profile.employeeNo || '',
wechatId: profile.wechatId || '',
wechatQrCode: profile.wechatQrCode || '',
wecomKfUrl: profile.wecomKfUrl || '',
wechatQrFiles: urlToUploadFileList(profile.wechatQrCode, 'user-wx'),
})
await loadEmployeeForm(record.id || '', record.name || '')