feat: ?????????????????/???????

??????????????????????????????? H5/?????

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-30 15:16:55 +08:00
parent c6a4484448
commit bd97ed76fd
38 changed files with 1331 additions and 825 deletions

5
.dockerignore Normal file
View File

@ -0,0 +1,5 @@
# 镜像只需要 dist/ 与 nginx-docker.conf,其余一律不进构建上下文
*
!dist/
!dist/**
!nginx-docker.conf

View File

@ -13,9 +13,104 @@
<link rel="dns-prefetch" href="//43.139.210.35" /> <link rel="dns-prefetch" href="//43.139.210.35" />
<!-- 预连接,提前建立连接 --> <!-- 预连接,提前建立连接 -->
<link rel="preconnect" href="//43.139.210.35" crossorigin /> <link rel="preconnect" href="//43.139.210.35" crossorigin />
<style>
/* 首屏兜底:避免扫码/冷启动长时间白屏 */
#app-boot {
position: fixed;
inset: 0;
z-index: 99999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
margin: 0;
padding: 24px;
box-sizing: border-box;
background: linear-gradient(180deg, #e8f4f4 0%, #f5f7f8 42%);
font-family: 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
color: #4e5969;
text-align: center;
}
#app-boot .boot-spinner {
width: 36px;
height: 36px;
border: 3px solid #d4e8e8;
border-top-color: #007979;
border-radius: 50%;
animation: app-boot-spin 0.75s linear infinite;
}
#app-boot .boot-title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: #1d2129;
}
#app-boot .boot-sub {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: #86909c;
max-width: 280px;
}
#app-boot .boot-retry {
display: none;
margin-top: 4px;
height: 36px;
padding: 0 18px;
border: none;
border-radius: 10px;
background: #007979;
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
#app-boot.is-timeout .boot-spinner {
display: none;
}
#app-boot.is-timeout .boot-retry {
display: inline-flex;
align-items: center;
justify-content: center;
}
@keyframes app-boot-spin {
to {
transform: rotate(360deg);
}
}
</style>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app">
<div id="app-boot" role="status" aria-live="polite">
<div class="boot-spinner" aria-hidden="true"></div>
<p class="boot-title" id="app-boot-title">加载中…</p>
<p class="boot-sub" id="app-boot-sub">网络较忙时请稍候,请勿关闭页面</p>
<button type="button" class="boot-retry" id="app-boot-retry" onclick="location.reload()">
重新加载
</button>
</div>
</div>
<script>
(function () {
var boot = document.getElementById('app-boot');
var title = document.getElementById('app-boot-title');
var sub = document.getElementById('app-boot-sub');
if (!boot) return;
window.setTimeout(function () {
if (window.__APP_MOUNTED__ || !document.getElementById('app-boot')) return;
if (sub) sub.textContent = '仍在加载,网络可能较慢…';
}, 4000);
window.setTimeout(function () {
if (window.__APP_MOUNTED__ || !document.getElementById('app-boot')) return;
boot.classList.add('is-timeout');
if (title) title.textContent = '加载时间过长';
if (sub)
sub.textContent = '请检查网络后重试;若持续失败,可切换网络或稍后再打开';
}, 20000);
})();
</script>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>

View File

@ -37,6 +37,7 @@ export interface CustomerDto {
customerGroup?: CustomerGroup customerGroup?: CustomerGroup
memberLevelId?: number | null memberLevelId?: number | null
memberLevelName?: string memberLevelName?: string
priceTierKey?: string | null
lastLoginIp?: string lastLoginIp?: string
lastOnlineIp?: string lastOnlineIp?: string
lastLoginTime?: string lastLoginTime?: string
@ -87,6 +88,7 @@ function mapUpdateBody(id: string | number, data: Partial<CustomerDto>) {
customerService: data.customerService, customerService: data.customerService,
customerGroup: data.customerGroup, customerGroup: data.customerGroup,
memberLevelId: data.memberLevelId, memberLevelId: data.memberLevelId,
priceTierKey: data.priceTierKey,
} }
} }
@ -132,6 +134,7 @@ export const CustomerService = {
customerService: data.customerService, customerService: data.customerService,
customerGroup: data.customerGroup ?? 0, customerGroup: data.customerGroup ?? 0,
memberLevelId: data.memberLevelId, memberLevelId: data.memberLevelId,
priceTierKey: data.priceTierKey,
permissions: data.permissions ?? [], permissions: data.permissions ?? [],
} }
const r = await axiosInstance.post(omsUrl('/customer/sub/create'), body) const r = await axiosInstance.post(omsUrl('/customer/sub/create'), body)
@ -157,6 +160,7 @@ export const CustomerService = {
customerService: data.customerService, customerService: data.customerService,
customerGroup: data.customerGroup ?? 0, customerGroup: data.customerGroup ?? 0,
memberLevelId: data.memberLevelId, memberLevelId: data.memberLevelId,
priceTierKey: data.priceTierKey,
inviteCode: data.inviteCode?.trim() || undefined, inviteCode: data.inviteCode?.trim() || undefined,
} }
const r = await axiosInstance.post(omsUrl('/customer/create'), body) const r = await axiosInstance.post(omsUrl('/customer/create'), body)

View File

@ -58,6 +58,9 @@ export const TRACK_SERVICE_CONFIG_CODE = 'track_service'
/** 微信公众号 */ /** 微信公众号 */
export const WECHAT_MP_CONFIG_CODE = 'wechat_mp' export const WECHAT_MP_CONFIG_CODE = 'wechat_mp'
/** 微信公众号关注跳转链接 */
export const WECHAT_MP_FOLLOW_CONFIG_CODE = 'wechat_mp_follow'
/** 店铺授权平台配置编码 */ /** 店铺授权平台配置编码 */
export const PLATFORM_SHOP_CONFIG_CODES = { export const PLATFORM_SHOP_CONFIG_CODES = {
common: 'platform_shop', common: 'platform_shop',

View File

@ -6,6 +6,7 @@ import type {
WeChatCustomerRegisterInput, WeChatCustomerRegisterInput,
WeChatCustomerRegisterResult, WeChatCustomerRegisterResult,
WeChatOAuthUserInfo, WeChatOAuthUserInfo,
WxFollowQrcodeResult,
WxLoginQrcodeResult, WxLoginQrcodeResult,
WxLoginResult, WxLoginResult,
} from './types'; } from './types';
@ -17,7 +18,12 @@ function unwrapOrSelf<T>(body: unknown): T {
return body as T; return body as T;
} }
const record = body as Record<string, unknown>; const record = body as Record<string, unknown>;
if ('qrCodeUrl' in record || 'accessToken' in record || 'openid' in record) { if (
'qrCodeUrl' in record ||
'jumpUrl' in record ||
'accessToken' in record ||
'openid' in record
) {
return body as T; return body as T;
} }
return unwrapData<T>(body); return unwrapData<T>(body);
@ -31,6 +37,12 @@ export async function getLoginQrcode(loginUuid?: string): Promise<WxLoginQrcodeR
return unwrapOrSelf<WxLoginQrcodeResult>(res.data); return unwrapOrSelf<WxLoginQrcodeResult>(res.data);
} }
/** 获取注册成功后跳转的公众号链接 */
export async function getFollowQrcode(): Promise<WxFollowQrcodeResult> {
const res = await axiosInstance.get(omsUrl('/wechat/mp/follow-qrcode'));
return unwrapOrSelf<WxFollowQrcodeResult>(res.data);
}
/** 查询扫码 key 是否已关联账号(轮询用,无值时不抛错) */ /** 查询扫码 key 是否已关联账号(轮询用,无值时不抛错) */
export async function getWxLoginStatus(loginUuid: string): Promise<WxLoginStatusResult> { export async function getWxLoginStatus(loginUuid: string): Promise<WxLoginStatusResult> {
const res = await axiosInstance.get('/rbac/auth/wx-login/status', { const res = await axiosInstance.get('/rbac/auth/wx-login/status', {

View File

@ -7,6 +7,14 @@ export interface WxLoginQrcodeResult {
expiresIn?: number; expiresIn?: number;
} }
/** 引导关注公众号跳转 */
export interface WxFollowQrcodeResult {
/** 微信内直接打开的公众号链接 */
jumpUrl?: string;
qrCodeUrl?: string;
scene?: string;
}
export interface WxLoginStatusResult { export interface WxLoginStatusResult {
ready: boolean; ready: boolean;
account?: string; account?: string;

View File

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, ref, watch } from 'vue'; import { onMounted, onBeforeUnmount, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue'; import { Message } from '@arco-design/web-vue';
import { buildInvitePosterDataUrl } from '@/utils/invitePoster'; import { buildInvitePosterDataUrl } from '@/utils/invitePoster';
@ -32,8 +32,10 @@ const { t } = useI18n();
const dataUrl = ref(''); const dataUrl = ref('');
const loading = ref(false); const loading = ref(false);
const failed = ref(false); const failed = ref(false);
let renderSeq = 0;
async function render() { async function render() {
const seq = ++renderSeq;
failed.value = false; failed.value = false;
dataUrl.value = ''; dataUrl.value = '';
if (!props.value) return; if (!props.value) return;
@ -43,21 +45,25 @@ async function render() {
inviteUrl: props.value, inviteUrl: props.value,
inviteCode: props.inviteCode, inviteCode: props.inviteCode,
}); });
if (seq !== renderSeq) return;
dataUrl.value = url; dataUrl.value = url;
emit('ready', url); emit('ready', url);
} catch { } catch {
if (seq !== renderSeq) return;
failed.value = true; failed.value = true;
emit('fail'); emit('fail');
} finally { } finally {
if (seq === renderSeq) {
loading.value = false; loading.value = false;
} }
} }
}
function downloadPoster() { function downloadPoster() {
if (!dataUrl.value) return; if (!dataUrl.value) return;
const a = document.createElement('a'); const a = document.createElement('a');
a.href = dataUrl.value; a.href = dataUrl.value;
a.download = 'invite-poster.png'; a.download = 'invite-poster.jpg';
a.rel = 'noopener'; a.rel = 'noopener';
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
@ -69,6 +75,10 @@ onMounted(() => {
void render(); void render();
}); });
onBeforeUnmount(() => {
renderSeq += 1;
});
watch( watch(
() => [props.value, props.inviteCode] as const, () => [props.value, props.inviteCode] as const,
() => { () => {
@ -85,7 +95,10 @@ defineExpose({
<template> <template>
<div class="invite-poster"> <div class="invite-poster">
<div v-if="loading" class="invite-poster__loading">{{ t('h5.invite.posterLoading') }}</div> <div v-if="loading" class="invite-poster__loading">
<a-spin :size="28" />
<p>{{ t('h5.invite.posterLoading') }}</p>
</div>
<template v-else-if="dataUrl"> <template v-else-if="dataUrl">
<img <img
:src="dataUrl" :src="dataUrl"
@ -103,7 +116,12 @@ defineExpose({
</button> </button>
<p class="invite-poster__tip">{{ t('h5.invite.posterTip') }}</p> <p class="invite-poster__tip">{{ t('h5.invite.posterTip') }}</p>
</template> </template>
<div v-else-if="failed" class="invite-poster__fail">{{ t('h5.invite.posterFailed') }}</div> <div v-else-if="failed" class="invite-poster__fail">
<p>{{ t('h5.invite.posterFailed') }}</p>
<button type="button" class="invite-poster__retry" @click="render">
{{ t('h5.invite.posterRetry') }}
</button>
</div>
</div> </div>
</template> </template>
@ -124,11 +142,34 @@ defineExpose({
.invite-poster__loading, .invite-poster__loading,
.invite-poster__fail { .invite-poster__fail {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 160px;
padding: 28px 12px; padding: 28px 12px;
font-size: 13px; font-size: 13px;
color: #86909c; color: #86909c;
} }
.invite-poster__loading p,
.invite-poster__fail p {
margin: 0;
}
.invite-poster__retry {
height: 32px;
padding: 0 14px;
border: 1px solid color-mix(in srgb, var(--app-primary-color) 35%, #e5e6eb);
border-radius: 8px;
background: #fff;
color: var(--app-primary-color);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.invite-poster__save { .invite-poster__save {
margin-top: 12px; margin-top: 12px;
height: 36px; height: 36px;

View File

@ -710,7 +710,12 @@ function copyWechatId() {
} }
function goToWaybill() { function goToWaybill() {
router.push(getWaybillReturnPath(waybillContext.value)); const path = getWaybillReturnPath(waybillContext.value);
if (path.startsWith('/h5')) {
router.replace(path);
return;
}
router.push(path);
} }
function clearWaybillContext() { function clearWaybillContext() {
@ -1030,7 +1035,7 @@ function goBack() {
} }
if (route.path.startsWith('/h5')) { if (route.path.startsWith('/h5')) {
const returnPath = getWaybillReturnPath(waybillContext.value); const returnPath = getWaybillReturnPath(waybillContext.value);
router.push(returnPath.startsWith('/h5') ? returnPath : '/h5/orders'); router.replace(returnPath.startsWith('/h5') ? returnPath : '/h5/orders');
return; return;
} }
router.push('/home/workplace'); router.push('/home/workplace');

View File

@ -63,10 +63,10 @@ export default {
'menu.marketing': 'Marketing', 'menu.marketing': 'Marketing',
'menu.marketing.memberLevel': 'Member Levels', 'menu.marketing.memberLevel': 'Member Levels',
'marketing.memberLevel.pageTip': 'marketing.memberLevel.pageTip':
'Member levels control price tier keys and optional extra discounts. Changes apply to future quotes only.', 'Member levels control points upgrades and optional extra discounts. Price tier keys are maintained on the customer and are independent of level upgrades.',
'marketing.memberLevel.searchPlaceholder': 'Search code / name / tier key', 'marketing.memberLevel.searchPlaceholder': 'Search code / name',
'marketing.memberLevel.modalTip': 'marketing.memberLevel.modalTip':
'Price tier key must match sell-price JSON keys (e.g. 普通客户, VIP1).', 'Reach the points threshold to upgrade; extra discount stacks after sell-price tier. Configure price tiers on the customer.',
'marketing.memberLevel.modal.add': 'Add level', 'marketing.memberLevel.modal.add': 'Add level',
'marketing.memberLevel.modal.edit': 'Edit level', 'marketing.memberLevel.modal.edit': 'Edit level',
'marketing.memberLevel.deleteConfirm': 'Delete level "{name}"? {count} customers use it.', 'marketing.memberLevel.deleteConfirm': 'Delete level "{name}"? {count} customers use it.',
@ -77,9 +77,6 @@ export default {
'marketing.memberLevel.form.levelNamePlaceholder': 'e.g. Standard, Silver', 'marketing.memberLevel.form.levelNamePlaceholder': 'e.g. Standard, Silver',
'marketing.memberLevel.form.minPoints': 'Min points', 'marketing.memberLevel.form.minPoints': 'Min points',
'marketing.memberLevel.form.minPointsExtra': 'Total points required to reach this level', 'marketing.memberLevel.form.minPointsExtra': 'Total points required to reach this level',
'marketing.memberLevel.form.priceTierKey': 'Price tier key',
'marketing.memberLevel.form.priceTierKeyPlaceholder': 'Select or type sell-price JSON key',
'marketing.memberLevel.form.priceTierKeyExtra': 'Sell-price CustomerGroupValues JSON key',
'marketing.memberLevel.form.extraDiscountRate': 'Extra discount rate', 'marketing.memberLevel.form.extraDiscountRate': 'Extra discount rate',
'marketing.memberLevel.form.extraDiscountRateExtra': 'e.g. 0.01 = extra 1% off after tier price', 'marketing.memberLevel.form.extraDiscountRateExtra': 'e.g. 0.01 = extra 1% off after tier price',
'marketing.memberLevel.form.sort': 'Sort', 'marketing.memberLevel.form.sort': 'Sort',
@ -91,11 +88,11 @@ export default {
'marketing.memberLevel.form.benefitDesc': 'Benefits', 'marketing.memberLevel.form.benefitDesc': 'Benefits',
'marketing.memberLevel.form.benefitDescPlaceholder': 'Benefit copy shown to customers', 'marketing.memberLevel.form.benefitDescPlaceholder': 'Benefit copy shown to customers',
'marketing.memberLevel.form.customerCount': 'Customers', 'marketing.memberLevel.form.customerCount': 'Customers',
'marketing.memberLevel.form.requiredHint': 'Level code, name and price tier key are required', 'marketing.memberLevel.form.requiredHint': 'Level code and name are required',
'marketing.memberLevel.section.basic': 'Basic info', 'marketing.memberLevel.section.basic': 'Basic info',
'marketing.memberLevel.section.basicHint': 'Code cannot change after create; name and status can', 'marketing.memberLevel.section.basicHint': 'Code cannot change after create; name and status can',
'marketing.memberLevel.section.pricing': 'Points & pricing', 'marketing.memberLevel.section.pricing': 'Points & discount',
'marketing.memberLevel.section.pricingHint': 'Upgrade threshold, price tier and extra discount', 'marketing.memberLevel.section.pricingHint': 'Upgrade threshold and extra discount',
'marketing.memberLevel.section.display': 'Display & benefits', 'marketing.memberLevel.section.display': 'Display & benefits',
'marketing.memberLevel.section.displayHint': 'Portal display only; does not affect pricing', 'marketing.memberLevel.section.displayHint': 'Portal display only; does not affect pricing',
'marketing.memberLevel.preview.nameFallback': 'Untitled level', 'marketing.memberLevel.preview.nameFallback': 'Untitled level',
@ -1682,14 +1679,21 @@ export default {
'order.priceCheck.empty.step3': 'Search, then AI-pick by cargo', 'order.priceCheck.empty.step3': 'Search, then AI-pick by cargo',
'order.priceCheck.card.eta': 'Business days', 'order.priceCheck.card.eta': 'Business days',
'order.priceCheck.card.total': 'Total', 'order.priceCheck.card.total': 'Total',
'order.priceCheck.card.tradeTerms': 'Trade terms',
'order.priceCheck.card.productGroup': 'Product group',
'order.priceCheck.card.cargoType': 'Cargo type',
'order.priceCheck.card.warehouse': 'Warehouse',
'order.priceCheck.card.recommendTag': 'Best',
'order.priceCheck.card.showFee': 'Fee details', 'order.priceCheck.card.showFee': 'Fee details',
'order.priceCheck.card.hideFee': 'Hide details', 'order.priceCheck.card.hideFee': 'Hide details',
'order.priceCheck.tradeTerms.ddp': 'DDP (duties included)',
'order.priceCheck.tradeTerms.ddu': 'DDU (duties excluded)',
'order.priceCheck.guide.failureTitle': 'No quotes found', 'order.priceCheck.guide.failureTitle': 'No quotes found',
'order.priceCheck.ai.title': 'AI Route Pick', 'order.priceCheck.ai.title': 'AI Route Pick',
'order.priceCheck.ai.desc': 'Enter cargo name — we check customer guides for can-ship and best route', 'order.priceCheck.ai.desc': 'Enter cargo name — we check customer guides for can-ship and best route',
'order.priceCheck.ai.descShort': 'Pick a route by cargo name', 'order.priceCheck.ai.descShort': 'Pick a route by cargo name',
'order.priceCheck.ai.productName.placeholder': 'e.g. lipstick, apparel, power bank', 'order.priceCheck.ai.productName.placeholder': 'e.g. lipstick, apparel, power bank',
'order.priceCheck.ai.action': 'Recommend', 'order.priceCheck.ai.action': 'AI Recommend',
'order.priceCheck.ai.validate.productName': 'Please enter a product name', 'order.priceCheck.ai.validate.productName': 'Please enter a product name',
'order.priceCheck.ai.validate.needQuotes': 'Run a quote search first, then use AI recommend', 'order.priceCheck.ai.validate.needQuotes': 'Run a quote search first, then use AI recommend',
'order.priceCheck.ai.noMatch': 'No suitable channel found for "{name}"', 'order.priceCheck.ai.noMatch': 'No suitable channel found for "{name}"',
@ -2688,6 +2692,7 @@ export default {
'system.otherConfig.message.deleteSuccess': 'Deleted successfully', 'system.otherConfig.message.deleteSuccess': 'Deleted successfully',
'system.otherConfig.defaults.trackServiceName': 'Small Bag Track Service', 'system.otherConfig.defaults.trackServiceName': 'Small Bag Track Service',
'system.otherConfig.defaults.wechatMpName': 'WeChat Official Account', 'system.otherConfig.defaults.wechatMpName': 'WeChat Official Account',
'system.otherConfig.defaults.wechatMpFollowName': 'WeChat Follow Jump URL',
'system.otherConfig.defaults.platformShopName': 'Store Auth - Common', 'system.otherConfig.defaults.platformShopName': 'Store Auth - Common',
'system.otherConfig.defaults.platformShopTiktokName': 'Store Auth - TikTok', 'system.otherConfig.defaults.platformShopTiktokName': 'Store Auth - TikTok',
'system.otherConfig.defaults.platformShopTemuName': 'Store Auth - Temu', 'system.otherConfig.defaults.platformShopTemuName': 'Store Auth - Temu',
@ -2701,6 +2706,9 @@ export default {
'system.otherConfig.fields.wechat.appSecret': 'AppSecret', 'system.otherConfig.fields.wechat.appSecret': 'AppSecret',
'system.otherConfig.fields.wechat.remark': 'Token', 'system.otherConfig.fields.wechat.remark': 'Token',
'system.otherConfig.hint.wechat': 'BaseUrl = OAuthRedirectBase; Remark = server Token', 'system.otherConfig.hint.wechat': 'BaseUrl = OAuthRedirectBase; Remark = server Token',
'system.otherConfig.fields.wechatFollow.baseUrl': 'Official account jump URL',
'system.otherConfig.hint.wechatFollow':
'Paste a WeChat-openable official-account profile / card / article URL. After registration the app jumps there to follow.',
'system.otherConfig.fields.platformShop.baseUrl': 'Frontend callback base URL', 'system.otherConfig.fields.platformShop.baseUrl': 'Frontend callback base URL',
'system.otherConfig.fields.platformShop.remark': 'StateSecret (optional)', 'system.otherConfig.fields.platformShop.remark': 'StateSecret (optional)',
'system.otherConfig.hint.platformShop': 'Used to build /oauth/platform-store/{platform}; set production frontend domain', 'system.otherConfig.hint.platformShop': 'Used to build /oauth/platform-store/{platform}; set production frontend domain',
@ -3404,6 +3412,7 @@ export default {
'user.customer.fields.status': 'Status', 'user.customer.fields.status': 'Status',
'user.customer.fields.customerGroup': 'Customer Group', 'user.customer.fields.customerGroup': 'Customer Group',
'user.customer.fields.memberLevel': 'Member Level', 'user.customer.fields.memberLevel': 'Member Level',
'user.customer.fields.priceTierKey': 'Price Tier Key',
'user.customer.fields.remark': 'Remark', 'user.customer.fields.remark': 'Remark',
'user.customer.fields.createTime': 'Create Time', 'user.customer.fields.createTime': 'Create Time',
'user.customer.fields.createdAt': 'Created At', 'user.customer.fields.createdAt': 'Created At',

View File

@ -304,7 +304,7 @@ export default {
'marketing.portal.invite.earnTitle': 'View earnings', 'marketing.portal.invite.earnTitle': 'View earnings',
'marketing.portal.invite.earnDesc': 'Commission settles after delivery success', 'marketing.portal.invite.earnDesc': 'Commission settles after delivery success',
'marketing.portal.invite.levelTitle': 'My membership', 'marketing.portal.invite.levelTitle': 'My membership',
'marketing.portal.invite.levelDesc': 'Earn points from paid orders for better rates', 'marketing.portal.invite.levelDesc': 'Earn points from paid orders to upgrade membership',
'marketing.portal.invite.refereeTitle': 'My referees', 'marketing.portal.invite.refereeTitle': 'My referees',
'marketing.portal.invite.refereeHint': 'Level-1 only; personal info is masked', 'marketing.portal.invite.refereeHint': 'Level-1 only; personal info is masked',
'marketing.portal.invite.emptyTitle': 'No friends have joined yet', 'marketing.portal.invite.emptyTitle': 'No friends have joined yet',
@ -314,15 +314,15 @@ export default {
'marketing.portal.invite.tag.delivered': 'Delivered', 'marketing.portal.invite.tag.delivered': 'Delivered',
'marketing.portal.member.title': 'My Level', 'marketing.portal.member.title': 'My Level',
'marketing.portal.member.subtitle': 'Points accumulate after successful payment. Higher levels unlock better pricing.', 'marketing.portal.member.subtitle': 'Points accumulate after successful payment. Higher levels unlock better extra discounts.',
'marketing.portal.member.howTitle': 'How to level up', 'marketing.portal.member.howTitle': 'How to level up',
'marketing.portal.member.howHint': 'More orders, better rates', 'marketing.portal.member.howHint': 'More orders, more points',
'marketing.portal.member.how1Title': 'Place and pay', 'marketing.portal.member.how1Title': 'Place and pay',
'marketing.portal.member.how1Desc': 'Points are granted automatically after successful payment', 'marketing.portal.member.how1Desc': 'Points are granted automatically after successful payment',
'marketing.portal.member.how2Title': 'Accumulate to upgrade', 'marketing.portal.member.how2Title': 'Accumulate to upgrade',
'marketing.portal.member.how2Desc': 'Reach the next threshold to unlock a better price tier', 'marketing.portal.member.how2Desc': 'Reach the next threshold to upgrade your membership level',
'marketing.portal.member.how3Title': 'Enjoy better rates', 'marketing.portal.member.how3Title': 'Enjoy better rates',
'marketing.portal.member.how3Desc': 'Higher levels mean better shipping quotes', 'marketing.portal.member.how3Desc': 'Higher levels unlock better extra discounts',
'marketing.portal.member.currentLevel': 'Current level', 'marketing.portal.member.currentLevel': 'Current level',
'marketing.portal.member.defaultLevel': 'Standard', 'marketing.portal.member.defaultLevel': 'Standard',
'marketing.portal.member.priceTier': 'Price tier', 'marketing.portal.member.priceTier': 'Price tier',
@ -435,6 +435,9 @@ export default {
'marketing.register.subtitle': 'Member pricing and welcome benefits', 'marketing.register.subtitle': 'Member pricing and welcome benefits',
'marketing.register.referrerHint': 'Invited by {name} ({code})', 'marketing.register.referrerHint': 'Invited by {name} ({code})',
'marketing.register.invalidRef': 'Invalid invite code — you can still register', 'marketing.register.invalidRef': 'Invalid invite code — you can still register',
'marketing.register.referrerLoading': 'Validating invite code…',
'marketing.register.referrerTimeout':
'Invite validation timed out. Check your network and refresh; you can still register.',
'marketing.register.customerName': 'Name', 'marketing.register.customerName': 'Name',
'marketing.register.phone': 'Phone', 'marketing.register.phone': 'Phone',
'marketing.register.email': 'Email', 'marketing.register.email': 'Email',
@ -460,7 +463,7 @@ export default {
'profile.marketing.coupons': 'My coupons', 'profile.marketing.coupons': 'My coupons',
'profile.marketing.commission': 'Referral earnings', 'profile.marketing.commission': 'Referral earnings',
'profile.marketing.inviteDesc': 'Share link and grow referees', 'profile.marketing.inviteDesc': 'Share link and grow referees',
'profile.marketing.memberDesc': 'Points and member pricing', 'profile.marketing.memberDesc': 'Points and membership level',
'profile.marketing.couponsDesc': 'View available coupons', 'profile.marketing.couponsDesc': 'View available coupons',
'profile.marketing.commissionDesc': 'Commission, transfer, withdraw', 'profile.marketing.commissionDesc': 'Commission, transfer, withdraw',
'profile.marketing.navigateFailed': 'Failed to open page, please refresh and try again', 'profile.marketing.navigateFailed': 'Failed to open page, please refresh and try again',

View File

@ -303,7 +303,7 @@ export default {
'marketing.portal.invite.earnTitle': '查看推荐收益', 'marketing.portal.invite.earnTitle': '查看推荐收益',
'marketing.portal.invite.earnDesc': '派送成功后可结算到佣金账户', 'marketing.portal.invite.earnDesc': '派送成功后可结算到佣金账户',
'marketing.portal.invite.levelTitle': '我的会员等级', 'marketing.portal.invite.levelTitle': '我的会员等级',
'marketing.portal.invite.levelDesc': '下单累计积分,解锁更优价区', 'marketing.portal.invite.levelDesc': '下单累计积分,升级会员等级',
'marketing.portal.invite.refereeTitle': '我的下级', 'marketing.portal.invite.refereeTitle': '我的下级',
'marketing.portal.invite.refereeHint': '仅展示一级引荐,保护隐私已脱敏', 'marketing.portal.invite.refereeHint': '仅展示一级引荐,保护隐私已脱敏',
'marketing.portal.invite.emptyTitle': '还没有好友通过您的邀请注册', 'marketing.portal.invite.emptyTitle': '还没有好友通过您的邀请注册',
@ -313,15 +313,15 @@ export default {
'marketing.portal.invite.tag.delivered': '已派送', 'marketing.portal.invite.tag.delivered': '已派送',
'marketing.portal.member.title': '我的等级', 'marketing.portal.member.title': '我的等级',
'marketing.portal.member.subtitle': '支付成功后累计积分,积分越高会员价越优惠。', 'marketing.portal.member.subtitle': '支付成功后累计积分,积分越高额外折扣越优惠。',
'marketing.portal.member.howTitle': '等级怎么升级', 'marketing.portal.member.howTitle': '等级怎么升级',
'marketing.portal.member.howHint': '下单越多,价区越好', 'marketing.portal.member.howHint': '下单越多,积分越高',
'marketing.portal.member.how1Title': '下单并支付', 'marketing.portal.member.how1Title': '下单并支付',
'marketing.portal.member.how1Desc': '运单支付成功后,系统自动发放积分', 'marketing.portal.member.how1Desc': '运单支付成功后,系统自动发放积分',
'marketing.portal.member.how2Title': '积分累计升级', 'marketing.portal.member.how2Title': '积分累计升级',
'marketing.portal.member.how2Desc': '达到下一等级门槛后,自动解锁新价区', 'marketing.portal.member.how2Desc': '达到下一等级门槛后,自动升级会员等级',
'marketing.portal.member.how3Title': '享受更优运价', 'marketing.portal.member.how3Title': '享受更优运价',
'marketing.portal.member.how3Desc': '等级越高,下单报价越优惠', 'marketing.portal.member.how3Desc': '等级越高,可叠加的额外折扣越优惠',
'marketing.portal.member.currentLevel': '当前等级', 'marketing.portal.member.currentLevel': '当前等级',
'marketing.portal.member.defaultLevel': '普通会员', 'marketing.portal.member.defaultLevel': '普通会员',
'marketing.portal.member.priceTier': '价区', 'marketing.portal.member.priceTier': '价区',
@ -434,6 +434,8 @@ export default {
'marketing.register.subtitle': '注册即享会员价与新人福利', 'marketing.register.subtitle': '注册即享会员价与新人福利',
'marketing.register.referrerHint': '您正通过 {name}({code})的邀请注册', 'marketing.register.referrerHint': '您正通过 {name}({code})的邀请注册',
'marketing.register.invalidRef': '邀请码无效或已失效,您仍可正常注册', 'marketing.register.invalidRef': '邀请码无效或已失效,您仍可正常注册',
'marketing.register.referrerLoading': '正在校验邀请码…',
'marketing.register.referrerTimeout': '邀请码校验超时,请检查网络后刷新;您仍可继续注册',
'marketing.register.customerName': '客户名称', 'marketing.register.customerName': '客户名称',
'marketing.register.phone': '手机号', 'marketing.register.phone': '手机号',
'marketing.register.email': '邮箱', 'marketing.register.email': '邮箱',
@ -459,7 +461,7 @@ export default {
'profile.marketing.coupons': '我的优惠券', 'profile.marketing.coupons': '我的优惠券',
'profile.marketing.commission': '推荐收益', 'profile.marketing.commission': '推荐收益',
'profile.marketing.inviteDesc': '分享链接,积累下级', 'profile.marketing.inviteDesc': '分享链接,积累下级',
'profile.marketing.memberDesc': '积分与会员价区', 'profile.marketing.memberDesc': 'Points and membership level',
'profile.marketing.couponsDesc': '查看可用券包', 'profile.marketing.couponsDesc': '查看可用券包',
'profile.marketing.commissionDesc': '分成、转余额、提现', 'profile.marketing.commissionDesc': '分成、转余额、提现',
'profile.marketing.navigateFailed': '页面跳转失败,请刷新后重试', 'profile.marketing.navigateFailed': '页面跳转失败,请刷新后重试',

View File

@ -63,10 +63,10 @@ export default {
'menu.marketing': '营销', 'menu.marketing': '营销',
'menu.marketing.memberLevel': '会员等级', 'menu.marketing.memberLevel': '会员等级',
'marketing.memberLevel.pageTip': 'marketing.memberLevel.pageTip':
'会员等级决定客户价区(PriceTierKey)与可选额外折扣。修改价区键或额外折扣率只影响之后的查价,不追溯历史订单。已被客户使用的等级不可删除,请改为禁用。', '会员等级用于积分升级与可选额外折扣。价区键在客户资料中单独维护,与等级升级无关。修改额外折扣率只影响之后的查价,不追溯历史订单。已被客户使用的等级不可删除,请改为禁用。',
'marketing.memberLevel.searchPlaceholder': '搜索编码/名称/价区键', 'marketing.memberLevel.searchPlaceholder': '搜索编码/名称',
'marketing.memberLevel.modalTip': 'marketing.memberLevel.modalTip':
'价区键须与渠道卖价 JSON 中的键一致(如 普通客户、VIP1、客户组1)。额外折扣率在价区价之后叠加。', '升级积分达到门槛后可升到本等级;额外折扣率在卖价款区价之后叠加。价区请到客户管理中配置。',
'marketing.memberLevel.modal.add': '新增会员等级', 'marketing.memberLevel.modal.add': '新增会员等级',
'marketing.memberLevel.modal.edit': '编辑会员等级', 'marketing.memberLevel.modal.edit': '编辑会员等级',
'marketing.memberLevel.deleteConfirm': 'marketing.memberLevel.deleteConfirm':
@ -78,9 +78,6 @@ export default {
'marketing.memberLevel.form.levelNamePlaceholder': '如 普通会员、银卡', 'marketing.memberLevel.form.levelNamePlaceholder': '如 普通会员、银卡',
'marketing.memberLevel.form.minPoints': '升级积分', 'marketing.memberLevel.form.minPoints': '升级积分',
'marketing.memberLevel.form.minPointsExtra': '达到该累计积分后可升到本等级', 'marketing.memberLevel.form.minPointsExtra': '达到该累计积分后可升到本等级',
'marketing.memberLevel.form.priceTierKey': '价区键',
'marketing.memberLevel.form.priceTierKeyPlaceholder': '选择或输入卖价款区键',
'marketing.memberLevel.form.priceTierKeyExtra': '对应卖价 CustomerGroupValues 的 JSON 键(如 普通客户、VIP1)',
'marketing.memberLevel.form.extraDiscountRate': '额外折扣率', 'marketing.memberLevel.form.extraDiscountRate': '额外折扣率',
'marketing.memberLevel.form.extraDiscountRateExtra': '如 0.01 表示在价区价基础上再减 1%', 'marketing.memberLevel.form.extraDiscountRateExtra': '如 0.01 表示在价区价基础上再减 1%',
'marketing.memberLevel.form.sort': '排序', 'marketing.memberLevel.form.sort': '排序',
@ -90,13 +87,13 @@ export default {
'marketing.memberLevel.form.iconUrlPlaceholder': '可选,等级图标 URL', 'marketing.memberLevel.form.iconUrlPlaceholder': '可选,等级图标 URL',
'marketing.memberLevel.form.iconUrlExtra': '客户门户展示用,可留空', 'marketing.memberLevel.form.iconUrlExtra': '客户门户展示用,可留空',
'marketing.memberLevel.form.benefitDesc': '权益说明', 'marketing.memberLevel.form.benefitDesc': '权益说明',
'marketing.memberLevel.form.benefitDescPlaceholder': '展示给客户的权益文案,如专属价区、额外折扣等', 'marketing.memberLevel.form.benefitDescPlaceholder': '展示给客户的权益文案,如额外折扣等',
'marketing.memberLevel.form.customerCount': '客户数', 'marketing.memberLevel.form.customerCount': '客户数',
'marketing.memberLevel.form.requiredHint': '请填写等级编码、名称与价区键', 'marketing.memberLevel.form.requiredHint': '请填写等级编码与名称',
'marketing.memberLevel.section.basic': '基础信息', 'marketing.memberLevel.section.basic': '基础信息',
'marketing.memberLevel.section.basicHint': '编码创建后不可改,名称与状态可随时调整', 'marketing.memberLevel.section.basicHint': '编码创建后不可改,名称与状态可随时调整',
'marketing.memberLevel.section.pricing': '积分与价区', 'marketing.memberLevel.section.pricing': '积分与折扣',
'marketing.memberLevel.section.pricingHint': '决定升级门槛、卖价款区与额外折扣', 'marketing.memberLevel.section.pricingHint': '决定升级门槛与额外折扣',
'marketing.memberLevel.section.display': '展示与权益', 'marketing.memberLevel.section.display': '展示与权益',
'marketing.memberLevel.section.displayHint': '门户展示用,不影响计价逻辑', 'marketing.memberLevel.section.displayHint': '门户展示用,不影响计价逻辑',
'marketing.memberLevel.preview.nameFallback': '未命名等级', 'marketing.memberLevel.preview.nameFallback': '未命名等级',
@ -1561,8 +1558,15 @@ export default {
'order.priceCheck.empty.step3': '查询后按品名荐线', 'order.priceCheck.empty.step3': '查询后按品名荐线',
'order.priceCheck.card.eta': '工作日', 'order.priceCheck.card.eta': '工作日',
'order.priceCheck.card.total': '费用', 'order.priceCheck.card.total': '费用',
'order.priceCheck.card.tradeTerms': '贸易条款',
'order.priceCheck.card.productGroup': '产品分组',
'order.priceCheck.card.cargoType': '货物属性',
'order.priceCheck.card.warehouse': '仓库',
'order.priceCheck.card.recommendTag': '推荐',
'order.priceCheck.card.showFee': '费用明细', 'order.priceCheck.card.showFee': '费用明细',
'order.priceCheck.card.hideFee': '收起明细', 'order.priceCheck.card.hideFee': '收起明细',
'order.priceCheck.tradeTerms.ddp': 'DDP(包税)',
'order.priceCheck.tradeTerms.ddu': 'DDU(不包税)',
'order.priceCheck.customerGuide.drawerTitle': '线路说明', 'order.priceCheck.customerGuide.drawerTitle': '线路说明',
'order.priceCheck.customerGuide.drawerTitleWithProduct': '{product} · 线路说明', 'order.priceCheck.customerGuide.drawerTitleWithProduct': '{product} · 线路说明',
@ -1576,7 +1580,7 @@ export default {
'order.priceCheck.ai.desc': '输入品名,对照对客说明判断能否寄、哪条更合适', 'order.priceCheck.ai.desc': '输入品名,对照对客说明判断能否寄、哪条更合适',
'order.priceCheck.ai.descShort': '输入品名,对照对客说明荐线', 'order.priceCheck.ai.descShort': '输入品名,对照对客说明荐线',
'order.priceCheck.ai.productName.placeholder': '如:口红、衣服、充电宝', 'order.priceCheck.ai.productName.placeholder': '如:口红、衣服、充电宝',
'order.priceCheck.ai.action': '荐线', 'order.priceCheck.ai.action': 'AI推荐',
'order.priceCheck.ai.validate.productName': '请先输入品名', 'order.priceCheck.ai.validate.productName': '请先输入品名',
'order.priceCheck.ai.validate.needQuotes': '请先查询报价,再进行 AI 推荐', 'order.priceCheck.ai.validate.needQuotes': '请先查询报价,再进行 AI 推荐',
'order.priceCheck.ai.noMatch': '未找到适合「{name}」的渠道,请调整品名或筛选条件', 'order.priceCheck.ai.noMatch': '未找到适合「{name}」的渠道,请调整品名或筛选条件',
@ -2697,6 +2701,7 @@ export default {
'system.otherConfig.rule.timeoutSecondsRequired': '请输入超时秒数', 'system.otherConfig.rule.timeoutSecondsRequired': '请输入超时秒数',
'system.otherConfig.defaults.trackServiceName': '小包轨迹服务', 'system.otherConfig.defaults.trackServiceName': '小包轨迹服务',
'system.otherConfig.defaults.wechatMpName': '微信公众号', 'system.otherConfig.defaults.wechatMpName': '微信公众号',
'system.otherConfig.defaults.wechatMpFollowName': '公众号关注跳转',
'system.otherConfig.defaults.platformShopName': '店铺授权-公共', 'system.otherConfig.defaults.platformShopName': '店铺授权-公共',
'system.otherConfig.defaults.platformShopTiktokName': '店铺授权-TikTok', 'system.otherConfig.defaults.platformShopTiktokName': '店铺授权-TikTok',
'system.otherConfig.defaults.platformShopTemuName': '店铺授权-Temu', 'system.otherConfig.defaults.platformShopTemuName': '店铺授权-Temu',
@ -2710,6 +2715,9 @@ export default {
'system.otherConfig.fields.wechat.appSecret': 'AppSecret', 'system.otherConfig.fields.wechat.appSecret': 'AppSecret',
'system.otherConfig.fields.wechat.remark': 'Token', 'system.otherConfig.fields.wechat.remark': 'Token',
'system.otherConfig.hint.wechat': 'BaseUrl=OAuthRedirectBase;Remark 填服务器 Token', 'system.otherConfig.hint.wechat': 'BaseUrl=OAuthRedirectBase;Remark 填服务器 Token',
'system.otherConfig.fields.wechatFollow.baseUrl': '公众号跳转链接',
'system.otherConfig.hint.wechatFollow':
'填微信内可直接打开的公众号主页/名片/图文链接。注册成功后会自动跳转该链接引导关注。',
'system.otherConfig.fields.platformShop.baseUrl': '前端回调根地址', 'system.otherConfig.fields.platformShop.baseUrl': '前端回调根地址',
'system.otherConfig.fields.platformShop.remark': 'StateSecret(可选)', 'system.otherConfig.fields.platformShop.remark': 'StateSecret(可选)',
'system.otherConfig.hint.platformShop': '用于拼接 /oauth/platform-store/{平台};生产请改为正式前端域名', 'system.otherConfig.hint.platformShop': '用于拼接 /oauth/platform-store/{平台};生产请改为正式前端域名',
@ -3421,6 +3429,7 @@ export default {
'user.customer.fields.status': '状态', 'user.customer.fields.status': '状态',
'user.customer.fields.customerGroup': '客户分组', 'user.customer.fields.customerGroup': '客户分组',
'user.customer.fields.memberLevel': '会员等级', 'user.customer.fields.memberLevel': '会员等级',
'user.customer.fields.priceTierKey': '价区键',
'user.customer.fields.remark': '备注', 'user.customer.fields.remark': '备注',
'user.customer.fields.createTime': '创建时间', 'user.customer.fields.createTime': '创建时间',
'user.customer.fields.createdAt': '创建时间', 'user.customer.fields.createdAt': '创建时间',

View File

@ -13,6 +13,7 @@ import {
ensureHeavyPlugins, ensureHeavyPlugins,
} from './plugins/heavy-plugins'; } from './plugins/heavy-plugins';
import { initAuthKeepAlive } from '@/utils/auth-keepalive'; import { initAuthKeepAlive } from '@/utils/auth-keepalive';
import { installWeChatH5HistoryGuard } from '@/utils/device';
import { setAuthToken } from './api/rbac'; import { setAuthToken } from './api/rbac';
import { applyThemeColor } from '@/utils/themeColor'; import { applyThemeColor } from '@/utils/themeColor';
import { useRbacUserStore } from '@/store/modules/rbac-user'; import { useRbacUserStore } from '@/store/modules/rbac-user';
@ -29,6 +30,7 @@ const LIGHT_ENTRY_PATHS = [
'/', '/',
'/admin', '/admin',
'/login', '/login',
'/register',
'/track/query', '/track/query',
'/wechat/register', '/wechat/register',
'/wechat/bind', '/wechat/bind',
@ -110,6 +112,8 @@ async function bootstrap() {
} }
app.mount('#app'); app.mount('#app');
;(window as any).__APP_MOUNTED__ = true;
installWeChatH5HistoryGuard(router);
initAuthKeepAlive(); initAuthKeepAlive();
if (light || isHomePath(entryPath)) { if (light || isHomePath(entryPath)) {

View File

@ -13,6 +13,19 @@ const FORCE_H5_KEY = 'oms_force_h5';
const MOBILE_UA_RE = const MOBILE_UA_RE =
/Android|webOS|iPhone|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|Windows Phone|HarmonyOS|OpenHarmony/i; /Android|webOS|iPhone|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|Windows Phone|HarmonyOS|OpenHarmony/i;
/** 微信内这些路径用 replace,避免历史栈叠高触发底部 ← → */
const WECHAT_SPA_PATH_RE = /^\/(h5|wechat|register|track\/query)(\/|$)/;
type WeixinJSBridgeLike = {
call: (name: string, ...args: unknown[]) => void;
};
declare global {
interface Window {
WeixinJSBridge?: WeixinJSBridgeLike;
}
}
/** iPadOS 13+ 桌面 UA 伪装,用触控点辅助识别 */ /** iPadOS 13+ 桌面 UA 伪装,用触控点辅助识别 */
function isIpadDesktopUa(ua: string): boolean { function isIpadDesktopUa(ua: string): boolean {
return /Macintosh/i.test(ua) && typeof navigator !== 'undefined' && navigator.maxTouchPoints > 1; return /Macintosh/i.test(ua) && typeof navigator !== 'undefined' && navigator.maxTouchPoints > 1;
@ -85,6 +98,44 @@ export function preferH5Portal(): boolean {
return false; return false;
} }
function resolveRoutePath(to: unknown): string {
if (typeof to === 'string') {
return to.split('?')[0].split('#')[0] || '';
}
if (to && typeof to === 'object' && 'path' in to) {
const path = String((to as { path?: unknown }).path || '');
return path.split('?')[0].split('#')[0] || '';
}
return '';
}
export function isWeChatSpaPath(path: string): boolean {
return WECHAT_SPA_PATH_RE.test(path || '');
}
/**
* 尝试隐藏微信内置工具栏(老版本微信 / 部分安卓、旧 iOS 仍会显示底部 ← →)。
* 新版微信多数已无此栏,调用无副作用。
*/
export function hideWeChatBrowserNav(): void {
if (typeof window === 'undefined' || !isWeChatBrowser()) return;
const invoke = () => {
try {
window.WeixinJSBridge?.call('hideToolbar');
} catch {
// ignore
}
};
if (window.WeixinJSBridge) {
invoke();
return;
}
document.addEventListener('WeixinJSBridgeReady', invoke, { once: true });
}
/** /**
* H5 站内跳转:微信内用 replace,避免历史栈变长触发底部左右箭头栏。 * H5 站内跳转:微信内用 replace,避免历史栈变长触发底部左右箭头栏。
* 非微信仍可用 push,保留系统返回。 * 非微信仍可用 push,保留系统返回。
@ -98,3 +149,33 @@ export function h5Navigate(
} }
return router.push(to); return router.push(to);
} }
let weChatHistoryGuardInstalled = false;
/**
* 微信内将 H5/注册相关 router.push 统一改为 replace。
* 覆盖漏网的 push(顾问跳转、登录后跳转等),从根上压住历史栈。
*/
export function installWeChatH5HistoryGuard(router: {
push: (to: unknown) => unknown;
replace: (to: unknown) => unknown;
}): void {
if (weChatHistoryGuardInstalled || typeof window === 'undefined' || !isWeChatBrowser()) {
return;
}
weChatHistoryGuardInstalled = true;
const originalPush = router.push.bind(router);
router.push = ((to: unknown) => {
const path = resolveRoutePath(to);
if (isWeChatSpaPath(path) || isWeChatSpaPath(window.location.pathname)) {
return router.replace(to);
}
return originalPush(to);
}) as typeof router.push;
hideWeChatBrowserNav();
// 部分机型 Bridge 就绪较晚,再补一次
window.setTimeout(hideWeChatBrowserNav, 800);
window.setTimeout(hideWeChatBrowserNav, 2000);
}

View File

@ -13,6 +13,8 @@ const QR_SIZE = 340;
const QR_CENTER_X = INVITE_POSTER_WIDTH / 2; const QR_CENTER_X = INVITE_POSTER_WIDTH / 2;
const QR_CENTER_Y = 1120; const QR_CENTER_Y = 1120;
const QR_PAD = 12; const QR_PAD = 12;
/** JPEG 对照片底图编码远快于 PNG,0.9 对二维码识别足够稳 */
const POSTER_JPEG_QUALITY = 0.9;
export interface InvitePosterOptions { export interface InvitePosterOptions {
inviteUrl: string; inviteUrl: string;
@ -21,6 +23,9 @@ export interface InvitePosterOptions {
captionLines?: string[]; captionLines?: string[];
} }
let bgImagePromise: Promise<HTMLImageElement> | null = null;
const posterCache = new Map<string, string>();
function loadImage(src: string): Promise<HTMLImageElement> { function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
@ -31,6 +36,21 @@ function loadImage(src: string): Promise<HTMLImageElement> {
}); });
} }
/** 预热海报底图,打开「邀请好友」前调用可显著缩短首帧等待 */
export function preloadInvitePosterBg(): Promise<HTMLImageElement> {
if (!bgImagePromise) {
bgImagePromise = loadImage(posterBgUrl).catch((err) => {
bgImagePromise = null;
throw err;
});
}
return bgImagePromise;
}
function cacheKey(options: InvitePosterOptions, captions: string[]): string {
return [options.inviteUrl.trim(), options.inviteCode ?? '', captions.join('\n')].join('|');
}
function defaultCaptionLines(inviteCode?: string): string[] { function defaultCaptionLines(inviteCode?: string): string[] {
void inviteCode; void inviteCode;
return ['微信扫码注册,双方有礼']; return ['微信扫码注册,双方有礼'];
@ -50,8 +70,36 @@ function drawCenteredText(
ctx.fillText(text, centerX, y, options.maxWidth); ctx.fillText(text, centerX, y, options.maxWidth);
} }
function canvasToJpegDataUrl(
canvas: HTMLCanvasElement,
quality = POSTER_JPEG_QUALITY,
): Promise<string> {
return new Promise((resolve, reject) => {
// toBlob 异步编码,避免 toDataURL 同步阻塞主线程
if (typeof canvas.toBlob === 'function') {
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('海报导出失败'));
return;
}
resolve(URL.createObjectURL(blob));
},
'image/jpeg',
quality,
);
return;
}
try {
resolve(canvas.toDataURL('image/jpeg', quality));
} catch (err) {
reject(err);
}
});
}
/** /**
* 将邀请链接二维码叠到推广海报空白处,并在下方绘制邀请话术,返回 PNG data URL。 * 将邀请链接二维码叠到推广海报空白处,并在下方绘制邀请话术,返回 JPEG blob URL(或 data URL)。
*/ */
export async function buildInvitePosterDataUrl( export async function buildInvitePosterDataUrl(
inviteUrlOrOptions: string | InvitePosterOptions, inviteUrlOrOptions: string | InvitePosterOptions,
@ -70,12 +118,18 @@ export async function buildInvitePosterDataUrl(
options.captionLines?.map((x) => x.trim()).filter(Boolean) ?? options.captionLines?.map((x) => x.trim()).filter(Boolean) ??
defaultCaptionLines(options.inviteCode); defaultCaptionLines(options.inviteCode);
const key = cacheKey(options, captionLines);
const cached = posterCache.get(key);
if (cached) {
return cached;
}
const [bg, qrDataUrl] = await Promise.all([ const [bg, qrDataUrl] = await Promise.all([
loadImage(posterBgUrl), preloadInvitePosterBg(),
QRCode.toDataURL(inviteUrl, { QRCode.toDataURL(inviteUrl, {
width: QR_SIZE, width: QR_SIZE,
margin: 3, margin: 2,
errorCorrectionLevel: 'H', errorCorrectionLevel: 'M',
color: { dark: '#0a3373', light: '#ffffff' }, color: { dark: '#0a3373', light: '#ffffff' },
}), }),
]); ]);
@ -84,7 +138,7 @@ export async function buildInvitePosterDataUrl(
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = INVITE_POSTER_WIDTH; canvas.width = INVITE_POSTER_WIDTH;
canvas.height = INVITE_POSTER_HEIGHT; canvas.height = INVITE_POSTER_HEIGHT;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d', { alpha: false });
if (!ctx) { if (!ctx) {
throw new Error('Canvas 不可用'); throw new Error('Canvas 不可用');
} }
@ -124,14 +178,16 @@ export async function buildInvitePosterDataUrl(
}); });
}); });
return canvas.toDataURL('image/png'); const url = await canvasToJpegDataUrl(canvas);
posterCache.set(key, url);
return url;
} }
export async function invitePosterToFile( export async function invitePosterToFile(
dataUrl: string, dataUrl: string,
filename = 'invite-poster.png', filename = 'invite-poster.jpg',
): Promise<File> { ): Promise<File> {
const res = await fetch(dataUrl); const res = await fetch(dataUrl);
const blob = await res.blob(); const blob = await res.blob();
return new File([blob], filename, { type: blob.type || 'image/png' }); return new File([blob], filename, { type: blob.type || 'image/jpeg' });
} }

View File

@ -14,6 +14,8 @@ export async function loginCustomerAfterWechatAuth(options: {
account: string; account: string;
password: string; password: string;
redirectPath?: string; redirectPath?: string;
/** 仅换登录态,不跳转(先展示关注公众号引导) */
skipRedirect?: boolean;
}): Promise<boolean> { }): Promise<boolean> {
const account = options.account.trim(); const account = options.account.trim();
const password = options.password; const password = options.password;
@ -30,6 +32,10 @@ export async function loginCustomerAfterWechatAuth(options: {
await ensureFullLocaleMessages(); await ensureFullLocaleMessages();
await nextTick(); await nextTick();
if (options.skipRedirect) {
return true;
}
const target = options.redirectPath || '/h5/me'; const target = options.redirectPath || '/h5/me';
try { try {
await router.replace(target); await router.replace(target);

View File

@ -0,0 +1,16 @@
import { getFollowQrcode } from '@/api/wechat';
/** 注册/绑定成功后跳转公众号(无需长按二维码) */
export async function jumpToWechatOfficialAccount(): Promise<boolean> {
try {
const res = await getFollowQrcode();
const url = String(
res?.jumpUrl || (res as any)?.JumpUrl || res?.qrCodeUrl || (res as any)?.QrCodeUrl || '',
).trim();
if (!url) return false;
window.location.href = url;
return true;
} catch {
return false;
}
}

24
src/utils/withTimeout.ts Normal file
View File

@ -0,0 +1,24 @@
/** Promise 超时包装:超时后 reject,不取消原任务。 */
export function withTimeout<T>(
promise: Promise<T>,
ms: number,
message = 'TIMEOUT',
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = window.setTimeout(() => reject(new Error(message)), ms);
promise.then(
(value) => {
window.clearTimeout(timer);
resolve(value);
},
(err) => {
window.clearTimeout(timer);
reject(err);
},
);
});
}
export function isTimeoutError(error: unknown): boolean {
return error instanceof Error && error.message === 'TIMEOUT';
}

View File

@ -125,7 +125,7 @@ import { Message } from '@arco-design/web-vue';
import { IconClose, IconShareAlt, IconUserGroup } from '@arco-design/web-vue/es/icon'; import { IconClose, IconShareAlt, IconUserGroup } from '@arco-design/web-vue/es/icon';
import { getMyInvite, getReferees } from '@/api/marketing/portal'; import { getMyInvite, getReferees } from '@/api/marketing/portal';
import { buildInviteRegisterUrl, buildInviteShareText } from '@/utils/inviteLink'; import { buildInviteRegisterUrl, buildInviteShareText } from '@/utils/inviteLink';
import { invitePosterToFile } from '@/utils/invitePoster'; import { invitePosterToFile, preloadInvitePosterBg } from '@/utils/invitePoster';
import InvitePoster from '@/components/InvitePoster.vue'; import InvitePoster from '@/components/InvitePoster.vue';
import H5EmptyState from './H5EmptyState.vue'; import H5EmptyState from './H5EmptyState.vue';
@ -240,6 +240,7 @@ watch(
(visible) => { (visible) => {
if (visible) { if (visible) {
activeTab.value = 'share'; activeTab.value = 'share';
void preloadInvitePosterBg();
void load(); void load();
} }
}, },

View File

@ -29,7 +29,7 @@ import { useI18n } from 'vue-i18n';
import { isLogin } from '@/utils/auth'; import { isLogin } from '@/utils/auth';
import { getOrderStatusCounts } from '@/api/order'; import { getOrderStatusCounts } from '@/api/order';
import { ensureFullLocaleMessages } from '@/locale'; import { ensureFullLocaleMessages } from '@/locale';
import { h5Navigate } from '@/utils/device'; import { h5Navigate, hideWeChatBrowserNav } from '@/utils/device';
import { H5_ORDER_GROUPS, sumStatusCounts } from '@/constants/h5OrderStatus'; import { H5_ORDER_GROUPS, sumStatusCounts } from '@/constants/h5OrderStatus';
import LoginLangToggle from '@/views/login/components/LoginLangToggle.vue'; import LoginLangToggle from '@/views/login/components/LoginLangToggle.vue';
import AppTabBar from './components/AppTabBar.vue'; import AppTabBar from './components/AppTabBar.vue';
@ -80,6 +80,7 @@ async function refreshOrderBadge() {
} }
onMounted(() => { onMounted(() => {
hideWeChatBrowserNav();
// 登录后补全量语言包(订单状态名、支付弹窗等) // 登录后补全量语言包(订单状态名、支付弹窗等)
if (isLogin()) { if (isLogin()) {
void ensureFullLocaleMessages(locale.value); void ensureFullLocaleMessages(locale.value);

View File

@ -47,6 +47,7 @@ export default {
'h5.price.recommendTag': 'Best', 'h5.price.recommendTag': 'Best',
'h5.price.tag.priceOptimal': 'Best price', 'h5.price.tag.priceOptimal': 'Best price',
'h5.price.tag.timeOptimal': 'Fastest', 'h5.price.tag.timeOptimal': 'Fastest',
'h5.price.tag.best': 'Best',
'h5.price.dimensionsToggle': 'Size (optional)', 'h5.price.dimensionsToggle': 'Size (optional)',
'h5.price.dimensionsHint': 'L×W×H for volumetric weight', 'h5.price.dimensionsHint': 'L×W×H for volumetric weight',
'h5.price.accuracy.postalCta': 'Estimate only — add a postal code for better accuracy', 'h5.price.accuracy.postalCta': 'Estimate only — add a postal code for better accuracy',
@ -54,7 +55,7 @@ export default {
'h5.price.accuracy.dismiss': 'Dismiss', 'h5.price.accuracy.dismiss': 'Dismiss',
'h5.price.ai.title': 'AI route pick', 'h5.price.ai.title': 'AI route pick',
'h5.price.ai.placeholder': 'e.g. lipstick, apparel, power bank', 'h5.price.ai.placeholder': 'e.g. lipstick, apparel, power bank',
'h5.price.ai.action': 'Recommend', 'h5.price.ai.action': 'AI Recommend',
'h5.price.ai.needQuotes': 'Run a quote search first, then use AI recommend', 'h5.price.ai.needQuotes': 'Run a quote search first, then use AI recommend',
'h5.price.ai.needName': 'Please enter a product name', 'h5.price.ai.needName': 'Please enter a product name',
'h5.price.hint.estimated': 'Indicative only; final after inbound', 'h5.price.hint.estimated': 'Indicative only; final after inbound',
@ -76,6 +77,8 @@ export default {
'h5.price.cargo': 'Cargo type', 'h5.price.cargo': 'Cargo type',
'h5.price.warehouse': 'Warehouse', 'h5.price.warehouse': 'Warehouse',
'h5.price.tradeTerms': 'Trade terms', 'h5.price.tradeTerms': 'Trade terms',
'h5.price.tradeTerms.ddp': 'DDP (duties included)',
'h5.price.tradeTerms.ddu': 'DDU (duties excluded)',
'h5.price.viewDetail': 'Route details', 'h5.price.viewDetail': 'Route details',
'h5.price.detailTitle': 'Route details', 'h5.price.detailTitle': 'Route details',
'h5.price.detailTitleNamed': '{name} · Details', 'h5.price.detailTitleNamed': '{name} · Details',
@ -467,6 +470,7 @@ export default {
'h5.invite.posterAlt': 'Invite poster', 'h5.invite.posterAlt': 'Invite poster',
'h5.invite.posterLoading': 'Generating poster…', 'h5.invite.posterLoading': 'Generating poster…',
'h5.invite.posterFailed': 'Failed to generate poster. Please retry.', 'h5.invite.posterFailed': 'Failed to generate poster. Please retry.',
'h5.invite.posterRetry': 'Retry',
'h5.invite.savePoster': 'Save poster', 'h5.invite.savePoster': 'Save poster',
'h5.invite.posterSaved': 'Poster saved', 'h5.invite.posterSaved': 'Poster saved',
'h5.invite.posterTip': 'Long-press to recognize the QR, or save and share with friends', 'h5.invite.posterTip': 'Long-press to recognize the QR, or save and share with friends',

View File

@ -47,6 +47,7 @@ export default {
'h5.price.recommendTag': '推荐', 'h5.price.recommendTag': '推荐',
'h5.price.tag.priceOptimal': '价格最优', 'h5.price.tag.priceOptimal': '价格最优',
'h5.price.tag.timeOptimal': '时效最优', 'h5.price.tag.timeOptimal': '时效最优',
'h5.price.tag.best': '最优',
'h5.price.dimensionsToggle': '尺寸(选填)', 'h5.price.dimensionsToggle': '尺寸(选填)',
'h5.price.dimensionsHint': '填齐长宽高可算体积重', 'h5.price.dimensionsHint': '填齐长宽高可算体积重',
'h5.price.accuracy.postalCta': '当前为估算报价,补上邮编可提高准确性', 'h5.price.accuracy.postalCta': '当前为估算报价,补上邮编可提高准确性',
@ -54,7 +55,7 @@ export default {
'h5.price.accuracy.dismiss': '关闭', 'h5.price.accuracy.dismiss': '关闭',
'h5.price.ai.title': 'AI 荐线', 'h5.price.ai.title': 'AI 荐线',
'h5.price.ai.placeholder': '品名,如口红、衣服、充电宝', 'h5.price.ai.placeholder': '品名,如口红、衣服、充电宝',
'h5.price.ai.action': '荐线', 'h5.price.ai.action': 'AI推荐',
'h5.price.ai.needQuotes': '请先查价,再使用 AI 荐线', 'h5.price.ai.needQuotes': '请先查价,再使用 AI 荐线',
'h5.price.ai.needName': '请输入品名', 'h5.price.ai.needName': '请输入品名',
'h5.price.hint.estimated': '参考价,入库实测为准', 'h5.price.hint.estimated': '参考价,入库实测为准',
@ -76,6 +77,8 @@ export default {
'h5.price.cargo': '货物属性', 'h5.price.cargo': '货物属性',
'h5.price.warehouse': '仓库', 'h5.price.warehouse': '仓库',
'h5.price.tradeTerms': '贸易条款', 'h5.price.tradeTerms': '贸易条款',
'h5.price.tradeTerms.ddp': 'DDP(包税)',
'h5.price.tradeTerms.ddu': 'DDU(不包税)',
'h5.price.viewDetail': '线路详情', 'h5.price.viewDetail': '线路详情',
'h5.price.detailTitle': '线路详情', 'h5.price.detailTitle': '线路详情',
'h5.price.detailTitleNamed': '{name} · 线路详情', 'h5.price.detailTitleNamed': '{name} · 线路详情',
@ -465,6 +468,7 @@ export default {
'h5.invite.posterAlt': '邀请推广海报', 'h5.invite.posterAlt': '邀请推广海报',
'h5.invite.posterLoading': '海报生成中…', 'h5.invite.posterLoading': '海报生成中…',
'h5.invite.posterFailed': '海报生成失败,请稍后重试', 'h5.invite.posterFailed': '海报生成失败,请稍后重试',
'h5.invite.posterRetry': '重新生成',
'h5.invite.savePoster': '保存海报', 'h5.invite.savePoster': '保存海报',
'h5.invite.posterSaved': '海报已保存', 'h5.invite.posterSaved': '海报已保存',
'h5.invite.posterTip': '长按海报识别二维码,或保存后发给好友', 'h5.invite.posterTip': '长按海报识别二维码,或保存后发给好友',

View File

@ -215,6 +215,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { preloadInvitePosterBg } from '@/utils/invitePoster';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Message, Modal } from '@arco-design/web-vue'; import { Message, Modal } from '@arco-design/web-vue';
@ -556,6 +557,7 @@ async function loadProfile() {
onMounted(() => { onMounted(() => {
loggedIn.value = isLogin(); loggedIn.value = isLogin();
void loadProfile(); void loadProfile();
void preloadInvitePosterBg();
}); });
</script> </script>
@ -1033,7 +1035,7 @@ onMounted(() => {
.h5-me-services__label { .h5-me-services__label {
min-width: 0; min-width: 0;
flex: 1; flex: 1;
font-size: 12px; font-size: 10px;
font-weight: 700; font-weight: 700;
color: var(--me-ink); color: var(--me-ink);
line-height: 1.2; line-height: 1.2;

View File

@ -30,7 +30,7 @@
</button> </button>
</div> </div>
<div ref="postalFieldRef" class="line-field"> <div class="line-field">
<span class="line-field__label">{{ t('h5.price.postalCode') }}</span> <span class="line-field__label">{{ t('h5.price.postalCode') }}</span>
<input <input
v-model="form.postalCode" v-model="form.postalCode"
@ -100,16 +100,11 @@
<div class="quote-foot"> <div class="quote-foot">
<span v-if="summaryText" class="quote-summary">{{ summaryText }}</span> <span v-if="summaryText" class="quote-summary">{{ summaryText }}</span>
<p class="quote-ref">{{ t('h5.price.referenceTip') }}</p>
</div> </div>
</section> </section>
<section v-if="queried" class="h5-price-results"> <section v-if="queried" class="h5-price-results">
<div v-if="sortedResults.length" class="h5-ai-bar"> <div v-if="sortedResults.length" class="h5-ai-bar">
<div class="h5-ai-bar__head">
<strong>{{ t('h5.price.ai.title') }}</strong>
</div>
<div class="h5-ai-bar__row">
<input <input
v-model="aiProductName" v-model="aiProductName"
class="h5-ai-bar__input" class="h5-ai-bar__input"
@ -127,7 +122,6 @@
{{ aiRecommendLoading ? '…' : t('h5.price.ai.action') }} {{ aiRecommendLoading ? '…' : t('h5.price.ai.action') }}
</button> </button>
</div> </div>
</div>
<div <div
v-if="recommendVerdict" v-if="recommendVerdict"
@ -160,31 +154,11 @@
</div> </div>
</div> </div>
<div
v-if="needPostalHint && !hintBannerDismissed"
class="h5-accuracy"
>
<span>{{ t('h5.price.accuracy.postalCta') }}</span>
<div class="h5-accuracy__actions">
<button type="button" class="h5-accuracy__fill" @click="focusPostal">
{{ t('h5.price.accuracy.fillPostal') }}
</button>
<button
type="button"
class="h5-accuracy__dismiss"
:aria-label="t('h5.price.accuracy.dismiss')"
@click="hintBannerDismissed = true"
>
×
</button>
</div>
</div>
<div v-if="sortedResults.length" class="h5-results-bar"> <div v-if="sortedResults.length" class="h5-results-bar">
<p v-if="showQueryHintBanner && !needPostalHint" class="h5-disclaimer"> <p v-if="showQueryHintBanner" class="h5-disclaimer">
{{ queryHintSummary }} {{ queryHintSummary }}
</p> </p>
<p v-else-if="!needPostalHint" class="h5-disclaimer"> <p v-else class="h5-disclaimer">
{{ t('h5.price.referenceBanner') }} {{ t('h5.price.referenceBanner') }}
</p> </p>
<div class="h5-sort"> <div class="h5-sort">
@ -238,7 +212,35 @@
<div class="h5-quote__head"> <div class="h5-quote__head">
<div class="h5-quote__title"> <div class="h5-quote__title">
<h3>{{ item.shippingMethod || item.productCode || '-' }}</h3> <h3>{{ item.shippingMethod || item.productCode || '-' }}</h3>
<p class="h5-quote__sub"> </div>
<div class="h5-quote__eta">
<span
v-if="item.isTimeOptimal"
class="h5-quote__corner-tag is-time"
>
{{ t('h5.price.tag.best') }}
</span>
<strong class="h5-quote__eta-value">{{ item.deliveryTime || '-' }}</strong>
<span class="h5-quote__eta-label">{{ t('h5.price.workingDays') }}</span>
</div>
<div class="h5-quote__price-block">
<span
v-if="item.isPriceOptimal"
class="h5-quote__corner-tag is-price"
>
{{ t('h5.price.tag.best') }}
</span>
<div class="h5-quote__price">
<small>¥</small>{{ formatMoney(item.totalCost) }}
</div>
<span class="ref-label">
{{ tradeTermsLabel(item.tradeTerms) || t('h5.price.referencePrice') }}
</span>
</div>
</div>
<div class="h5-quote__actions" @click.stop>
<div class="h5-quote__meta">
<span <span
v-if="item.productGroupName" v-if="item.productGroupName"
class="h5-quote__chip h5-quote__chip--group" class="h5-quote__chip h5-quote__chip--group"
@ -248,43 +250,11 @@
<span class="h5-quote__chip h5-quote__chip--cargo"> <span class="h5-quote__chip h5-quote__chip--cargo">
{{ item.cargoTypeName || item.cargoType || '-' }} {{ item.cargoTypeName || item.cargoType || '-' }}
</span> </span>
<span
v-if="item.tradeTerms === 'DDP' || item.tradeTerms === 'DDU'"
class="h5-quote__chip h5-quote__chip--trade"
>
{{ item.tradeTerms }}
</span>
<span v-if="item.isPriceOptimal" class="h5-quote__tag is-price">
{{ t('h5.price.tag.priceOptimal') }}
</span>
<span v-if="item.isTimeOptimal" class="h5-quote__tag is-time">
{{ t('h5.price.tag.timeOptimal') }}
</span>
<span v-if="item.chargeableWeight" class="h5-quote__weight">
{{ item.chargeableWeight }}kg
</span>
</p>
</div>
<div class="h5-quote__eta">
<strong class="h5-quote__eta-value">{{ item.deliveryTime || '-' }}</strong>
<span class="h5-quote__eta-label">{{ t('h5.price.workingDays') }}</span>
</div>
<div class="h5-quote__price-block">
<div class="h5-quote__price">
<small>¥</small>{{ formatMoney(item.totalCost) }}
</div>
<span class="ref-label">{{ t('h5.price.referencePrice') }}</span>
</div>
</div>
<div class="h5-quote__actions" @click.stop>
<span class="h5-quote__chip h5-quote__chip--wh"> <span class="h5-quote__chip h5-quote__chip--wh">
{{ item.warehouse || '-' }} {{ item.warehouse || '-' }}
</span> </span>
</div>
<div class="h5-quote__action-btns"> <div class="h5-quote__action-btns">
<button type="button" class="link-btn" @click="openDetail(item)">
{{ t('h5.price.viewDetail') }}
</button>
<a-dropdown trigger="click" position="top"> <a-dropdown trigger="click" position="top">
<button type="button" class="link-btn"> <button type="button" class="link-btn">
{{ t('h5.price.askAdvisor') }} {{ t('h5.price.askAdvisor') }}
@ -442,9 +412,9 @@
<label>{{ t('h5.price.warehouse') }}</label> <label>{{ t('h5.price.warehouse') }}</label>
<p>{{ detailItem.warehouse || '-' }}</p> <p>{{ detailItem.warehouse || '-' }}</p>
</div> </div>
<div v-if="detailItem.tradeTerms === 'DDP' || detailItem.tradeTerms === 'DDU'"> <div v-if="tradeTermsLabel(detailItem.tradeTerms)">
<label>{{ t('h5.price.tradeTerms') }}</label> <label>{{ t('h5.price.tradeTerms') }}</label>
<p>{{ detailItem.tradeTerms }}</p> <p>{{ tradeTermsLabel(detailItem.tradeTerms) }}</p>
</div> </div>
<div> <div>
<label>{{ t('h5.price.delivery') }}</label> <label>{{ t('h5.price.delivery') }}</label>
@ -557,7 +527,6 @@ const form = reactive({
}); });
const showDimensions = ref(false); const showDimensions = ref(false);
const postalFieldRef = ref<HTMLElement | null>(null);
const quoteElMap = new Map<string, HTMLElement>(); const quoteElMap = new Map<string, HTMLElement>();
type Opt = { value: string; label: string; name?: string }; type Opt = { value: string; label: string; name?: string };
@ -573,7 +542,6 @@ const queried = ref(false);
const queryFailureText = ref(''); const queryFailureText = ref('');
const results = ref<any[]>([]); const results = ref<any[]>([]);
const queryHint = ref<PriceCheckQueryHint | null>(null); const queryHint = ref<PriceCheckQueryHint | null>(null);
const hintBannerDismissed = ref(false);
const sortMode = ref<'recommend' | 'price' | 'time'>('recommend'); const sortMode = ref<'recommend' | 'price' | 'time'>('recommend');
const sortModes = ['recommend', 'price', 'time'] as const; const sortModes = ['recommend', 'price', 'time'] as const;
@ -736,17 +704,6 @@ const detailTitle = computed(() => {
return name ? t('h5.price.detailTitleNamed', { name }) : t('h5.price.detailTitle'); return name ? t('h5.price.detailTitleNamed', { name }) : t('h5.price.detailTitle');
}); });
const needPostalHint = computed(() => {
if (!queried.value) return false;
if (String(form.postalCode || '').trim()) return false;
const missing = queryHint.value?.missingFieldCodes ?? [];
return (
queryHint.value?.quoteLevel === 'estimated' ||
missing.includes('postalCode') ||
missing.includes('PostalCode')
);
});
const showQueryHintBanner = computed( const showQueryHintBanner = computed(
() => () =>
Boolean(queryHint.value?.disclaimers?.length) || Boolean(queryHint.value?.disclaimers?.length) ||
@ -764,6 +721,15 @@ function formatMoney(val: unknown) {
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
} }
function tradeTermsLabel(raw: unknown) {
const value = String(raw ?? '')
.trim()
.toUpperCase();
if (value === 'DDP') return t('h5.price.tradeTerms.ddp');
if (value === 'DDU') return t('h5.price.tradeTerms.ddu');
return '';
}
function parseTotalCost(cost: unknown) { function parseTotalCost(cost: unknown) {
if (cost == null || cost === '') return Infinity; if (cost == null || cost === '') return Infinity;
if (typeof cost === 'number') return cost; if (typeof cost === 'number') return cost;
@ -899,13 +865,6 @@ function onPickCargo(code: string) {
pickerVisible.value = false; pickerVisible.value = false;
} }
function focusPostal() {
hintBannerDismissed.value = false;
postalFieldRef.value?.scrollIntoView?.({ behavior: 'smooth', block: 'center' });
const input = postalFieldRef.value?.querySelector?.('input') as HTMLInputElement | null;
input?.focus?.();
}
async function loadOptions() { async function loadOptions() {
optionsLoading.value = true; optionsLoading.value = true;
try { try {
@ -979,7 +938,6 @@ async function handleQuery() {
queryLoading.value = true; queryLoading.value = true;
queried.value = true; queried.value = true;
queryFailureText.value = ''; queryFailureText.value = '';
hintBannerDismissed.value = false;
clearRecommendInsight(); clearRecommendInsight();
try { try {
const length = parseOptionalDimension(form.length); const length = parseOptionalDimension(form.length);
@ -1407,32 +1365,20 @@ onMounted(() => {
} }
.h5-ai-bar { .h5-ai-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px; margin-bottom: 10px;
padding: 12px; padding: 10px 12px;
border-radius: 14px; border-radius: 14px;
background: #fff; background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
} }
.h5-ai-bar__head {
margin-bottom: 8px;
strong {
font-size: 13px;
font-weight: 700;
color: #1d2129;
}
}
.h5-ai-bar__row {
display: flex;
gap: 8px;
}
.h5-ai-bar__input { .h5-ai-bar__input {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
height: 40px; height: 36px;
border: 1px solid #e5e6eb; border: 1px solid #e5e6eb;
border-radius: 10px; border-radius: 10px;
padding: 0 12px; padding: 0 12px;
@ -1448,7 +1394,7 @@ onMounted(() => {
.h5-ai-bar__btn { .h5-ai-bar__btn {
flex-shrink: 0; flex-shrink: 0;
height: 40px; height: 36px;
padding: 0 14px; padding: 0 14px;
border: none; border: none;
border-radius: 10px; border-radius: 10px;
@ -1553,50 +1499,6 @@ onMounted(() => {
white-space: nowrap; white-space: nowrap;
} }
.h5-accuracy {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
padding: 10px 12px;
border-radius: 12px;
background: #fff7e8;
color: #ad6800;
font-size: 12px;
line-height: 1.45;
text-align: left;
}
.h5-accuracy__actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.h5-accuracy__fill {
border: none;
border-radius: 8px;
padding: 6px 10px;
background: var(--app-primary-color);
color: #fff;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
}
.h5-accuracy__dismiss {
border: none;
background: transparent;
color: #86909c;
font-size: 18px;
line-height: 1;
padding: 2px 4px;
cursor: pointer;
}
.picker-shortcuts { .picker-shortcuts {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -1833,13 +1735,6 @@ onMounted(() => {
text-align: center; text-align: center;
} }
.quote-ref {
margin: 0;
font-size: 12px;
color: #86909c;
text-align: center;
}
.picker { .picker {
padding: 0 4px 16px; padding: 0 4px 16px;
} }
@ -2055,15 +1950,6 @@ onMounted(() => {
} }
} }
.h5-quote__sub {
margin: 6px 0 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 5px;
min-width: 0;
}
.h5-quote__chip { .h5-quote__chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -2078,54 +1964,59 @@ onMounted(() => {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
&--group {
color: #4e5969;
background: #f2f3f5;
}
&--cargo { &--cargo {
color: var(--app-primary-color); color: #86909c;
background: color-mix(in srgb, var(--app-primary-color) 12%, #fff); background: #f7f8fa;
} }
&--wh { &--wh {
color: #ff7d00;
background: rgba(255, 125, 0, 0.1);
}
}
.h5-quote__weight {
font-size: 11px;
color: #86909c; color: #86909c;
line-height: 1.3; background: #f7f8fa;
white-space: nowrap; }
} }
.h5-quote__tag { .h5-quote__corner-tag {
display: inline-flex; position: absolute;
align-items: center; z-index: 2;
height: 18px; top: 0;
padding: 0 6px; right: 0;
border-radius: 4px; max-width: calc(100% - 2px);
font-size: 10px; padding: 1px 4px;
border-radius: 3px;
font-size: 9px;
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1.2;
white-space: nowrap; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
&.is-time {
color: #007979;
background: rgba(0, 121, 121, 0.1);
}
&.is-price { &.is-price {
color: #f53f3f; color: #f53f3f;
background: rgba(245, 63, 63, 0.1); background: rgba(245, 63, 63, 0.1);
} }
&.is-time {
color: #165dff;
background: rgba(22, 93, 255, 0.1);
}
} }
.h5-quote__eta { .h5-quote__eta {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 2px; gap: 2px;
min-width: 56px; min-width: 56px;
padding: 0 6px; min-height: 42px;
padding: 10px 6px 0;
text-align: center; text-align: center;
justify-self: center; justify-self: center;
} }
@ -2133,7 +2024,7 @@ onMounted(() => {
.h5-quote__eta-value { .h5-quote__eta-value {
font-size: 18px; font-size: 18px;
font-weight: 800; font-weight: 800;
color: var(--app-primary-color); color: #007979;
line-height: 1.1; line-height: 1.1;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
letter-spacing: -0.02em; letter-spacing: -0.02em;
@ -2147,9 +2038,12 @@ onMounted(() => {
} }
.h5-quote__price-block { .h5-quote__price-block {
position: relative;
text-align: right; text-align: right;
justify-self: end; justify-self: end;
min-width: 0; min-width: 0;
min-height: 42px;
padding-top: 10px;
} }
.ref-label { .ref-label {
@ -2181,11 +2075,15 @@ onMounted(() => {
margin-top: 10px; margin-top: 10px;
padding-top: 8px; padding-top: 8px;
border-top: 1px solid rgba(242, 243, 245, 0.85); border-top: 1px solid rgba(242, 243, 245, 0.85);
.h5-quote__chip--wh {
flex-shrink: 1;
min-width: 0;
} }
.h5-quote__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 5px;
min-width: 0;
flex: 1;
} }
.h5-quote__action-btns { .h5-quote__action-btns {

View File

@ -113,10 +113,6 @@
<span class="ml-preview__stat-label">{{ t('marketing.memberLevel.form.minPoints') }}</span> <span class="ml-preview__stat-label">{{ t('marketing.memberLevel.form.minPoints') }}</span>
<span class="ml-preview__stat-value">{{ formState.minPoints ?? 0 }}</span> <span class="ml-preview__stat-value">{{ formState.minPoints ?? 0 }}</span>
</div> </div>
<div class="ml-preview__stat">
<span class="ml-preview__stat-label">{{ t('marketing.memberLevel.form.priceTierKey') }}</span>
<span class="ml-preview__stat-value">{{ formState.priceTierKey || '—' }}</span>
</div>
<div class="ml-preview__stat"> <div class="ml-preview__stat">
<span class="ml-preview__stat-label">{{ t('marketing.memberLevel.form.extraDiscountRate') }}</span> <span class="ml-preview__stat-label">{{ t('marketing.memberLevel.form.extraDiscountRate') }}</span>
<span class="ml-preview__stat-value">{{ previewDiscountText }}</span> <span class="ml-preview__stat-value">{{ previewDiscountText }}</span>
@ -234,22 +230,6 @@
/> />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :span="24">
<a-form-item
:label="t('marketing.memberLevel.form.priceTierKey')"
field="priceTierKey"
:tooltip="t('marketing.memberLevel.form.priceTierKeyExtra')"
>
<a-select
v-model="formState.priceTierKey"
:options="priceTierOptions"
allow-search
allow-create
allow-clear
:placeholder="t('marketing.memberLevel.form.priceTierKeyPlaceholder')"
/>
</a-form-item>
</a-col>
</a-row> </a-row>
</section> </section>
@ -326,7 +306,6 @@ interface MemberLevelRow {
levelCode: string levelCode: string
levelName: string levelName: string
minPoints: number minPoints: number
priceTierKey: string
extraDiscountRate: number extraDiscountRate: number
sort: number sort: number
iconUrl: string iconUrl: string
@ -340,7 +319,6 @@ interface MemberLevelFormState {
levelCode: string levelCode: string
levelName: string levelName: string
minPoints: number minPoints: number
priceTierKey: string
extraDiscountRate: number extraDiscountRate: number
sort: number sort: number
iconUrl: string iconUrl: string
@ -360,20 +338,12 @@ const drawerLoading = ref(false)
const editingId = ref<number | undefined>(undefined) const editingId = ref<number | undefined>(undefined)
const formRef = ref() const formRef = ref()
const priceTierOptions = [
{ label: '普通客户', value: '普通客户' },
{ label: 'VIP1', value: 'VIP1' },
{ label: 'VIP2', value: 'VIP2' },
{ label: 'VIP3', value: 'VIP3' },
]
const formState = reactive<MemberLevelFormState>(createEmptyForm()) const formState = reactive<MemberLevelFormState>(createEmptyForm())
const formRules = computed(() => ({ const formRules = computed(() => ({
levelCode: [{ required: true, message: t('marketing.memberLevel.form.levelCodePlaceholder') }], levelCode: [{ required: true, message: t('marketing.memberLevel.form.levelCodePlaceholder') }],
levelName: [{ required: true, message: t('marketing.memberLevel.form.levelNamePlaceholder') }], levelName: [{ required: true, message: t('marketing.memberLevel.form.levelNamePlaceholder') }],
minPoints: [{ required: true, message: t('marketing.memberLevel.form.minPointsExtra') }], minPoints: [{ required: true, message: t('marketing.memberLevel.form.minPointsExtra') }],
priceTierKey: [{ required: true, message: t('marketing.memberLevel.form.priceTierKeyPlaceholder') }],
status: [{ required: true, message: t('marketing.memberLevel.form.status') }], status: [{ required: true, message: t('marketing.memberLevel.form.status') }],
})) }))
@ -394,7 +364,6 @@ function createEmptyForm(): MemberLevelFormState {
levelCode: '', levelCode: '',
levelName: '', levelName: '',
minPoints: 0, minPoints: 0,
priceTierKey: '普通客户',
extraDiscountRate: 0, extraDiscountRate: 0,
sort: 0, sort: 0,
iconUrl: '', iconUrl: '',
@ -419,7 +388,6 @@ function mapRow(item: any): MemberLevelRow {
levelCode: item.levelCode ?? item.LevelCode ?? '', levelCode: item.levelCode ?? item.LevelCode ?? '',
levelName: item.levelName ?? item.LevelName ?? '', levelName: item.levelName ?? item.LevelName ?? '',
minPoints: Number(item.minPoints ?? item.MinPoints ?? 0), minPoints: Number(item.minPoints ?? item.MinPoints ?? 0),
priceTierKey: item.priceTierKey ?? item.PriceTierKey ?? '',
extraDiscountRate: Number(item.extraDiscountRate ?? item.ExtraDiscountRate ?? 0), extraDiscountRate: Number(item.extraDiscountRate ?? item.ExtraDiscountRate ?? 0),
sort: Number(item.sort ?? item.Sort ?? 0), sort: Number(item.sort ?? item.Sort ?? 0),
iconUrl: String(item.iconUrl ?? item.IconUrl ?? ''), iconUrl: String(item.iconUrl ?? item.IconUrl ?? ''),
@ -434,7 +402,6 @@ const hotColumns = computed(() => [
{ key: 'levelCode', label: t('marketing.memberLevel.form.levelCode'), width: 100 }, { key: 'levelCode', label: t('marketing.memberLevel.form.levelCode'), width: 100 },
{ key: 'levelName', label: t('marketing.memberLevel.form.levelName'), width: 120 }, { key: 'levelName', label: t('marketing.memberLevel.form.levelName'), width: 120 },
{ key: 'minPoints', label: t('marketing.memberLevel.form.minPoints'), width: 100 }, { key: 'minPoints', label: t('marketing.memberLevel.form.minPoints'), width: 100 },
{ key: 'priceTierKey', label: t('marketing.memberLevel.form.priceTierKey'), width: 120 },
{ {
key: 'extraDiscountRate', key: 'extraDiscountRate',
label: t('marketing.memberLevel.form.extraDiscountRate'), label: t('marketing.memberLevel.form.extraDiscountRate'),
@ -493,7 +460,6 @@ function openDrawer(row?: MemberLevelRow) {
levelCode: row.levelCode, levelCode: row.levelCode,
levelName: row.levelName, levelName: row.levelName,
minPoints: row.minPoints, minPoints: row.minPoints,
priceTierKey: row.priceTierKey,
extraDiscountRate: row.extraDiscountRate, extraDiscountRate: row.extraDiscountRate,
sort: row.sort, sort: row.sort,
iconUrl: row.iconUrl || '', iconUrl: row.iconUrl || '',
@ -540,8 +506,7 @@ async function handleDrawerOk() {
} }
const levelCode = String(formState.levelCode || '').trim() const levelCode = String(formState.levelCode || '').trim()
const levelName = String(formState.levelName || '').trim() const levelName = String(formState.levelName || '').trim()
const priceTierKey = String(formState.priceTierKey || '').trim() if (!levelCode || !levelName) {
if (!levelCode || !levelName || !priceTierKey) {
Message.warning(t('marketing.memberLevel.form.requiredHint')) Message.warning(t('marketing.memberLevel.form.requiredHint'))
return return
} }
@ -552,7 +517,6 @@ async function handleDrawerOk() {
levelCode, levelCode,
levelName, levelName,
minPoints: Number(formState.minPoints ?? 0), minPoints: Number(formState.minPoints ?? 0),
priceTierKey,
extraDiscountRate: Number(formState.extraDiscountRate ?? 0), extraDiscountRate: Number(formState.extraDiscountRate ?? 0),
sort: Number(formState.sort ?? 0), sort: Number(formState.sort ?? 0),
iconUrl: String(formState.iconUrl || '').trim() || undefined, iconUrl: String(formState.iconUrl || '').trim() || undefined,

View File

@ -18,20 +18,14 @@
postOrderDelete, postOrderDelete,
postOrderPrint, postOrderPrint,
postOrderExport, postOrderExport,
postOrderTrackUpsertSmallBag,
postOrderDownstreamForwardCreateOne,
postOrderBatchJob, postOrderBatchJob,
waitOrderBatchJob, waitOrderBatchJob,
formatBatchJobTrackUpsertResultText,
formatBatchJobDownstreamForwardResultText,
isOrderBatchQueueDisabledError, isOrderBatchQueueDisabledError,
formatBatchJobFailureMessage, formatBatchJobFailureMessage,
formatBatchJobSummaryMessage, formatBatchJobSummaryMessage,
resolveBatchJobItemRowLabel, resolveBatchJobItemRowLabel,
parseBatchJobPrintUrl, parseBatchJobPrintUrl,
postOrderHandleStatusUpdate, postOrderHandleStatusUpdate,
type OrderTrackUpsertResult,
type OrderDownstreamForwardResult,
} from '@/api/order'; } from '@/api/order';
import { notifyCustomerBalanceChanged } from '@/api/customer/accountFlow'; import { notifyCustomerBalanceChanged } from '@/api/customer/accountFlow';
import { buildFailGuideFromReason } from '@/utils/parcelCreateFailGuide'; import { buildFailGuideFromReason } from '@/utils/parcelCreateFailGuide';
@ -227,17 +221,6 @@
/** 按当前状态收敛工具栏,避免客户在无关 Tab 看到一排无效按钮 */ /** 按当前状态收敛工具栏,避免客户在无关 Tab 看到一排无效按钮 */
const showPayAction = computed(() => isActiveTab('all', 'pendingPayment')); const showPayAction = computed(() => isActiveTab('all', 'pendingPayment'));
const showPredeclareAction = computed(() => isActiveTab('all', 'draft', 'predeclared')); const showPredeclareAction = computed(() => isActiveTab('all', 'draft', 'predeclared'));
const showDownstreamAction = computed(() =>
isActiveTab(
'all',
'draft',
'predeclared',
'received',
'pendingPayment',
'pendingShip',
'pendingOnline',
),
);
const showPrintAction = computed(() => const showPrintAction = computed(() =>
isActiveTab( isActiveTab(
'all', 'all',
@ -258,9 +241,6 @@
isActiveTab('all', 'pendingShip', 'pendingOnline', 'shipping'), isActiveTab('all', 'pendingShip', 'pendingOnline', 'shipping'),
); );
const showDeleteAction = computed(() => isActiveTab('all', 'draft', 'predeclared')); const showDeleteAction = computed(() => isActiveTab('all', 'draft', 'predeclared'));
const showCreateTrackAction = computed(() =>
isActiveTab('all', 'pendingOnline', 'shipping', 'deliverySuccess', 'deliveryFailed'),
);
const problematicModalVisible = ref(false); const problematicModalVisible = ref(false);
const claimedModalVisible = ref(false); const claimedModalVisible = ref(false);
@ -394,31 +374,6 @@
const batchPrintVisible = ref(false); const batchPrintVisible = ref(false);
const singlePrintItems = ref<Array<{ id: number | string; label: string; url: string }>>([]); const singlePrintItems = ref<Array<{ id: number | string; label: string; url: string }>>([]);
const batchPrintItems = ref<Array<{ id: number | string; label: string; url: string }>>([]); const batchPrintItems = ref<Array<{ id: number | string; label: string; url: string }>>([]);
const trackUpsertModalVisible = ref(false);
const trackUpsertResultText = ref('');
const downstreamForwardModalVisible = ref(false);
const downstreamForwardResultText = ref('');
function formatTrackUpsertResult(waybillNo: string, result: OrderTrackUpsertResult): string {
const lines = [`【${waybillNo || '-'}】`];
if (result.skipped) {
lines.push(`跳过:${result.skipReason || '未知原因'}`);
return lines.join('\n');
}
lines.push(`HTTP:${result.statusCode ?? '-'}`);
lines.push(`成功:${result.success ? '是' : '否'}`);
if (result.responseBody) {
lines.push(`响应:${result.responseBody}`);
}
if (result.curlCommand) {
lines.push('curl:');
lines.push(result.curlCommand);
} else if (result.requestJson) {
lines.push(`请求:${result.requestJson}`);
}
return lines.join('\n');
}
async function runSelectedOrderBatch(options: { async function runSelectedOrderBatch(options: {
action: string; action: string;
rows: any[]; rows: any[];
@ -466,121 +421,6 @@
await options.syncRunner(); await options.syncRunner();
} }
async function runTrackUpsertBatch(rows: any[]) {
const orderIds = rows.map((row) => Number(getOrderRowId(row)!));
try {
const job = await postOrderBatchJob({ action: 'track-upsert-small-bag', orderIds });
const result = await waitOrderBatchJob(job.jobId);
if (result.timedOut) {
trackUpsertResultText.value = `${formatBatchJobSummaryMessage(result.job, { timedOut: true, jobId: job.jobId })}\n\n${formatBatchJobTrackUpsertResultText('创建轨迹', result.job, result.items, rows)}`;
trackUpsertModalVisible.value = true;
return;
}
const { job: finishedJob, items } = result;
trackUpsertResultText.value = formatBatchJobTrackUpsertResultText('创建轨迹', finishedJob, items, rows);
trackUpsertModalVisible.value = true;
return;
} catch (e: any) {
if (!isOrderBatchQueueDisabledError(e)) {
throw e;
}
}
const resultBlocks: string[] = [];
for (const row of rows) {
const id = getOrderRowId(row)!;
const waybillNo = row.waybillNo || row.serviceNo || String(id);
try {
const result = await postOrderTrackUpsertSmallBag({ id });
resultBlocks.push(formatTrackUpsertResult(waybillNo, result));
} catch (err: any) {
resultBlocks.push(`【${waybillNo}】失败:${err?.message || '请求异常'}`);
}
}
trackUpsertResultText.value = resultBlocks.join('\n\n');
trackUpsertModalVisible.value = true;
}
const handleCreateTrackOrder = async () => {
const rows = requireSelectedRows();
if (!rows.length) return;
await withOrderActionLoading('track', async () => {
await runTrackUpsertBatch(rows);
}).catch(() => undefined);
};
function formatDownstreamForwardResult(
waybillNo: string,
result: OrderDownstreamForwardResult
): string {
const lines = [
`【${waybillNo || '-'}】`,
result.success ? '成功' : '失败',
`standardCode:createOrderOne`,
];
if (result.supplierCode) {
lines.push(`supplierCode:${result.supplierCode}`);
}
if (result.downstreamServiceNo) {
lines.push(`服务商单号:${result.downstreamServiceNo}`);
}
if (result.trackNo) {
lines.push(`跟踪号:${result.trackNo}`);
}
lines.push(`请求:${result.url || '-'}`);
if (result.requestJson) {
lines.push(`请求体:\n${result.requestJson}`);
}
lines.push(`HTTP:${result.statusCode ?? '-'}`);
if (result.responseBody) {
lines.push(`响应:\n${result.responseBody}`);
}
return lines.join('\n');
}
async function runDownstreamForwardBatch(rows: any[]) {
const orderIds = rows.map((row) => Number(getOrderRowId(row)!));
try {
const job = await postOrderBatchJob({ action: 'downstream-forward-create-one', orderIds });
const result = await waitOrderBatchJob(job.jobId);
if (result.timedOut) {
downstreamForwardResultText.value = `${formatBatchJobSummaryMessage(result.job, { timedOut: true, jobId: job.jobId })}\n\n${formatBatchJobDownstreamForwardResultText('预报下游', result.job, result.items, rows)}`;
downstreamForwardModalVisible.value = true;
return;
}
const { job: finishedJob, items } = result;
downstreamForwardResultText.value = formatBatchJobDownstreamForwardResultText('预报下游', finishedJob, items, rows);
downstreamForwardModalVisible.value = true;
return;
} catch (e: any) {
if (!isOrderBatchQueueDisabledError(e)) {
throw e;
}
}
const resultBlocks: string[] = [];
for (const row of rows) {
const id = getOrderRowId(row)!;
const waybillNo = row.waybillNo || row.serviceNo || String(id);
try {
const result = await postOrderDownstreamForwardCreateOne({ id });
resultBlocks.push(formatDownstreamForwardResult(waybillNo, result));
} catch (err: any) {
resultBlocks.push(`【${waybillNo}】失败:${err?.message || '请求异常'}`);
}
}
downstreamForwardResultText.value = resultBlocks.join('\n\n');
downstreamForwardModalVisible.value = true;
}
const handleForwardDownstreamCreate = async () => {
const rows = requireSelectedRows();
if (!rows.length) return;
await withOrderActionLoading('downstream', async () => {
await runDownstreamForwardBatch(rows);
}).catch(() => undefined);
};
function getOrderRowId(row: any): number | string | undefined { function getOrderRowId(row: any): number | string | undefined {
if (row == null) return undefined; if (row == null) return undefined;
const id = row.id ?? row.Id; const id = row.id ?? row.Id;
@ -1053,7 +893,6 @@
if (key === 'stockBind') return openStockBindModal(); if (key === 'stockBind') return openStockBindModal();
if (key === 'createProblematic') return openProblematicModal(); if (key === 'createProblematic') return openProblematicModal();
if (key === 'createClaimed') return openClaimedModal(); if (key === 'createClaimed') return openClaimedModal();
if (key === 'createTrackOrder') return handleCreateTrackOrder();
if (key === 'intercept') return handleIntercept(); if (key === 'intercept') return handleIntercept();
if (key === 'cancelIntercept') return handleCancelIntercept(); if (key === 'cancelIntercept') return handleCancelIntercept();
if (key === 'delete') return handleDelete(); if (key === 'delete') return handleDelete();
@ -1331,13 +1170,6 @@
:disabled="isActionBusy('predeclare')" :disabled="isActionBusy('predeclare')"
@click="handlePreDeclare" @click="handlePreDeclare"
>{{ t('order.myOrders.actions.predeclare') }}</a-button> >{{ t('order.myOrders.actions.predeclare') }}</a-button>
<a-button
v-if="showDownstreamAction"
class="btn-downstream"
:loading="isActionLoading('downstream')"
:disabled="isActionBusy('downstream')"
@click="handleForwardDownstreamCreate"
>{{ t('order.myOrders.actions.forwardDownstreamCreate') }}</a-button>
<a-button <a-button
v-if="showPrintAction" v-if="showPrintAction"
type="primary" type="primary"
@ -1372,11 +1204,6 @@
:disabled="isActionBusy('markClaimed')" :disabled="isActionBusy('markClaimed')"
@click="onMoreActionClick('createClaimed')" @click="onMoreActionClick('createClaimed')"
>{{ t('order.myOrders.actions.createClaimed') }}</a-doption> >{{ t('order.myOrders.actions.createClaimed') }}</a-doption>
<a-doption
v-if="showCreateTrackAction"
:disabled="isActionBusy('track')"
@click="onMoreActionClick('createTrackOrder')"
>{{ t('order.myOrders.actions.createTrackOrder') }}</a-doption>
<a-doption <a-doption
v-if="showInterceptActions" v-if="showInterceptActions"
@click="onMoreActionClick('intercept')" @click="onMoreActionClick('intercept')"
@ -1534,24 +1361,6 @@
<OrderPrintModal v-model:visible="singlePrintVisible" mode="single" :items="singlePrintItems" /> <OrderPrintModal v-model:visible="singlePrintVisible" mode="single" :items="singlePrintItems" />
<OrderPrintModal v-model:visible="batchPrintVisible" mode="batch" :items="batchPrintItems" /> <OrderPrintModal v-model:visible="batchPrintVisible" mode="batch" :items="batchPrintItems" />
<a-modal
v-model:visible="trackUpsertModalVisible"
title="轨迹同步结果"
:width="720"
:footer="false"
>
<pre class="track-upsert-result">{{ trackUpsertResultText }}</pre>
</a-modal>
<a-modal
v-model:visible="downstreamForwardModalVisible"
:title="t('order.myOrders.modal.downstreamForwardTitle')"
:width="800"
:footer="false"
>
<pre class="track-upsert-result">{{ downstreamForwardResultText }}</pre>
</a-modal>
<MarkProblematicModal <MarkProblematicModal
v-model:visible="problematicModalVisible" v-model:visible="problematicModalVisible"
:order-ids="exceptionOrderIds" :order-ids="exceptionOrderIds"
@ -1802,25 +1611,6 @@
} }
} }
:deep(.arco-btn.btn-track),
:deep(.arco-btn.btn-downstream) {
color: #fff;
background: #000;
border-color: #000;
&:hover:not(:disabled) {
color: #fff;
background: #262626;
border-color: #262626;
}
&:active:not(:disabled) {
color: #fff;
background: #141414;
border-color: #141414;
}
}
:deep(.arco-btn.btn-more) { :deep(.arco-btn.btn-more) {
height: 32px; height: 32px;
padding: 0 4px; padding: 0 4px;
@ -2138,17 +1928,4 @@
height: 28px; height: 28px;
} }
} }
.track-upsert-result {
margin: 0;
max-height: 480px;
overflow: auto;
padding: 12px;
background: #f7f8fa;
border-radius: 4px;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
}
</style> </style>

View File

@ -1,10 +1,6 @@
<template> <template>
<div class="ai-rec" :class="[`ai-rec--${variant || 'bar'}`, { 'has-hint': showPostalHint }]"> <div class="ai-rec" :class="[`ai-rec--${variant || 'bar'}`, { 'has-hint': showPostalHint }]">
<div class="ai-rec__left"> <div class="ai-rec__left">
<div class="ai-rec__label">
<span class="ai-rec__title">{{ t('order.priceCheck.ai.title') }}</span>
<span class="ai-rec__desc">{{ t('order.priceCheck.ai.descShort') }}</span>
</div>
<div class="ai-rec__controls"> <div class="ai-rec__controls">
<a-input <a-input
v-model="productName" v-model="productName"
@ -96,31 +92,6 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.ai-rec__label {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
flex-shrink: 0;
}
.ai-rec__title {
color: #0f172a;
font-size: 13px;
font-weight: 600;
flex-shrink: 0;
}
.ai-rec__desc {
color: #94a3b8;
font-size: 12px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 140px;
}
.ai-rec__controls { .ai-rec__controls {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -1,13 +1,14 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { IconDown, IconUp } from '@arco-design/web-vue/es/icon' import { IconDown } from '@arco-design/web-vue/es/icon'
defineOptions({ name: 'PriceCheckQuoteResultCard' }) defineOptions({ name: 'PriceCheckQuoteResultCard' })
const props = defineProps<{ const props = defineProps<{
record: Record<string, any> record: Record<string, any>
selected?: boolean selected?: boolean
recommended?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@ -17,7 +18,6 @@
}>() }>()
const { t } = useI18n() const { t } = useI18n()
const feeExpanded = ref(false)
function formatMoney(value: string | number | null | undefined) { function formatMoney(value: string | number | null | undefined) {
if (value == null || value === '') return '' if (value == null || value === '') return ''
@ -27,28 +27,36 @@
return num.toFixed(2) return num.toFixed(2)
} }
const isEstimated = computed(() => { const productGroupLabel = computed(() =>
const level = String(props.record.quoteLevel ?? props.record.QuoteLevel ?? '').toLowerCase() String(props.record.productGroupName || props.record.productGroup || '').trim(),
return level === 'estimated' )
const cargoLabel = computed(() => String(props.record.cargoTypeName || '').trim())
const warehouseLabel = computed(() => String(props.record.warehouse || '').trim())
const tradeTermsLabel = computed(() => {
const tradeTerms = String(props.record.tradeTerms || '').trim().toUpperCase()
if (tradeTerms === 'DDP') return t('order.priceCheck.tradeTerms.ddp')
if (tradeTerms === 'DDU') return t('order.priceCheck.tradeTerms.ddu')
return ''
}) })
const metaLine = computed(() => { const hasFeeDetail = computed(() => {
const group = String(props.record.productGroupName || props.record.productGroup || '').trim() const r = props.record
const cargo = String(props.record.cargoTypeName || '').trim() return (
const warehouse = String(props.record.warehouse || '').trim() (r.freight != null && r.freight !== '') ||
const tradeTerms = String(props.record.tradeTerms || '').trim().toUpperCase() (r.otherFees != null && r.otherFees !== '') ||
const parts = [ (r.chargeableWeight != null && r.chargeableWeight !== '') ||
group, (r.volumetricWeight != null && r.volumetricWeight !== '') ||
cargo, !!r.costFormula ||
warehouse, (Array.isArray(r.otherFeeList) && r.otherFeeList.length > 0)
tradeTerms === 'DDP' || tradeTerms === 'DDU' ? tradeTerms : '', )
].filter(Boolean)
return parts.join(' · ')
}) })
function onCardClick(e: MouseEvent) { function onCardClick(e: MouseEvent) {
const target = e.target as HTMLElement | null const target = e.target as HTMLElement | null
if (target?.closest('.quote-card__actions, .quote-card__fee-toggle, .quote-card__fee')) return if (target?.closest('.quote-card__actions, .quote-card__price-trigger')) return
emit('select') emit('select')
} }
</script> </script>
@ -56,75 +64,71 @@
<template> <template>
<article <article
class="quote-card" class="quote-card"
:class="{ 'is-selected': selected }" :class="{ 'is-selected': selected, 'is-recommended': recommended }"
role="button" role="button"
tabindex="0" tabindex="0"
@click="onCardClick" @click="onCardClick"
@keydown.enter.prevent="emit('select')" @keydown.enter.prevent="emit('select')"
> >
<span v-if="recommended" class="quote-card__stamp" aria-hidden="true">
{{ t('order.priceCheck.card.recommendTag') }}
</span>
<div class="quote-card__main"> <div class="quote-card__main">
<div class="quote-card__left"> <div class="quote-card__left">
<div class="quote-card__name-row"> <div class="quote-card__name-row">
<h3 class="quote-card__name">{{ record.shippingMethod || '-' }}</h3> <h3 class="quote-card__name">{{ record.shippingMethod || '-' }}</h3>
<div class="quote-card__tags">
<span v-if="isEstimated" class="quote-tag is-warn">{{ t('order.priceCheck.tag.estimated') }}</span>
<span v-if="record.isPriceOptimal" class="quote-tag is-price">{{
t('order.priceCheck.tag.priceOptimal')
}}</span>
<span v-if="record.isTimeOptimal" class="quote-tag is-time">{{
t('order.priceCheck.tag.timeOptimal')
}}</span>
</div> </div>
</div> </div>
<p v-if="metaLine" class="quote-card__meta">{{ metaLine }}</p>
<button <div class="quote-card__cols">
type="button" <div class="quote-card__col">
class="quote-card__fee-toggle" <strong class="quote-card__col-value">{{ productGroupLabel || '-' }}</strong>
@click.stop="feeExpanded = !feeExpanded" <span class="quote-card__col-label">{{ t('order.priceCheck.card.productGroup') }}</span>
</div>
<div class="quote-card__col">
<strong class="quote-card__col-value">{{ cargoLabel || '-' }}</strong>
<span class="quote-card__col-label">{{ t('order.priceCheck.card.cargoType') }}</span>
</div>
<div class="quote-card__col">
<strong class="quote-card__col-value">{{ warehouseLabel || '-' }}</strong>
<span class="quote-card__col-label">{{ t('order.priceCheck.card.warehouse') }}</span>
</div>
<div class="quote-card__col is-metric">
<span
v-if="record.isTimeOptimal"
class="quote-card__corner-tag is-time"
> >
{{ feeExpanded ? t('order.priceCheck.card.hideFee') : t('order.priceCheck.card.showFee') }} {{ t('order.priceCheck.tag.timeOptimal') }}
<icon-up v-if="feeExpanded" /> </span>
<icon-down v-else /> <strong class="quote-card__col-value is-time">{{ record.deliveryTime || '-' }}</strong>
</button> <span class="quote-card__col-label">{{ t('order.priceCheck.card.eta') }}</span>
</div> </div>
<div class="quote-card__col">
<div class="quote-card__time"> <strong class="quote-card__col-value">{{ tradeTermsLabel || '-' }}</strong>
<div class="quote-card__metric"> <span class="quote-card__col-label">{{ t('order.priceCheck.card.tradeTerms') }}</span>
<span class="quote-card__metric-value is-time">{{ record.deliveryTime || '-' }}</span>
</div> </div>
<span class="quote-card__metric-label">{{ t('order.priceCheck.card.eta') }}</span> <div class="quote-card__col is-price">
<a-popover
position="top"
trigger="hover"
:content-style="{ padding: '10px 12px', minWidth: '200px' }"
:disabled="!hasFeeDetail"
>
<div
class="quote-card__price-trigger"
:class="{ 'has-tip': hasFeeDetail }"
@click.stop
>
<span
v-if="record.isPriceOptimal"
class="quote-card__corner-tag is-price"
>
{{ t('order.priceCheck.tag.priceOptimal') }}
</span>
<strong class="quote-card__col-value is-price">¥{{ formatMoney(record.totalCost) }}</strong>
</div> </div>
<div class="quote-card__right">
<div class="quote-card__price">
<div class="quote-card__metric">
<span class="quote-card__metric-value is-price">¥{{ formatMoney(record.totalCost) }}</span>
</div>
<span class="quote-card__metric-label">{{ t('order.priceCheck.card.total') }}</span>
</div>
<div class="quote-card__actions" @click.stop>
<a-dropdown trigger="hover" position="br">
<button type="button" class="ops-btn ops-btn--ghost">
{{ t('order.priceCheck.table.askAi') }}
<icon-down style="margin-left: 2px" />
</button>
<template #content> <template #content>
<a-doption @click="emit('ask', 'canShip')"> <div class="quote-card__fee-pop" @click.stop>
{{ t('order.priceCheck.table.askAiCanShip') }}
</a-doption>
<a-doption @click="emit('ask', 'cheaper')">
{{ t('order.priceCheck.table.askAiCheaper') }}
</a-doption>
</template>
</a-dropdown>
<button type="button" class="ops-btn ops-btn--primary" @click="emit('order')">
{{ t('order.priceCheck.table.orderNow') }}
</button>
</div>
</div>
</div>
<div v-if="feeExpanded" class="quote-card__fee" @click.stop>
<div class="quote-card__fee-row"> <div class="quote-card__fee-row">
<span>{{ t('order.priceCheck.table.freight') }}</span> <span>{{ t('order.priceCheck.table.freight') }}</span>
<span>¥{{ formatMoney(record.freight) || '0' }}</span> <span>¥{{ formatMoney(record.freight) || '0' }}</span>
@ -151,45 +155,146 @@
{{ record.costFormula }} {{ record.costFormula }}
</div> </div>
<div v-if="record.otherFeeList?.length" class="quote-card__fee-list"> <div v-if="record.otherFeeList?.length" class="quote-card__fee-list">
<div v-for="(fee, idx) in record.otherFeeList" :key="idx" class="quote-card__fee-row is-sub"> <div
v-for="(fee, idx) in record.otherFeeList"
:key="idx"
class="quote-card__fee-row is-sub"
>
<span>{{ fee.name || fee.costItem }}</span> <span>{{ fee.name || fee.costItem }}</span>
<span>¥{{ formatMoney(fee.amount) }}</span> <span>¥{{ formatMoney(fee.amount) }}</span>
</div> </div>
</div> </div>
</div> </div>
</template>
</a-popover>
</div>
</div>
<div class="quote-card__actions" @click.stop>
<a-dropdown trigger="hover" position="br">
<button type="button" class="ops-btn ops-btn--ghost">
{{ t('order.priceCheck.table.askAi') }}
<icon-down style="margin-left: 2px" />
</button>
<template #content>
<a-doption @click="emit('ask', 'canShip')">
{{ t('order.priceCheck.table.askAiCanShip') }}
</a-doption>
<a-doption @click="emit('ask', 'cheaper')">
{{ t('order.priceCheck.table.askAiCheaper') }}
</a-doption>
</template>
</a-dropdown>
<button type="button" class="ops-btn ops-btn--primary" @click="emit('order')">
{{ t('order.priceCheck.table.orderNow') }}
</button>
</div>
</div>
</article> </article>
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
.quote-card { .quote-card {
position: relative;
overflow: visible;
padding: 14px 16px; padding: 14px 16px;
border: 1px solid #eef2f6; border: 1px solid #eef2f6;
border-radius: 10px; border-radius: 10px;
background: #fff; background: #fff;
cursor: pointer; cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease; transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
&:hover { &:hover {
border-color: #d7e3e3; border-color: #d7e3e3;
background: #fbfdfe; background: #fbfdfe;
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.02);
} }
&.is-selected { &.is-selected {
border-color: var(--app-primary-color, #007979); border-color: var(--app-primary-color, #007979);
background: rgba(0, 121, 121, 0.03); background: rgba(0, 121, 121, 0.03);
box-shadow: inset 3px 0 0 var(--app-primary-color, #007979);
}
&.is-recommended {
border-color: color-mix(in srgb, var(--app-primary-color, #007979) 55%, #eef2f6);
box-shadow:
0 0 0 1px color-mix(in srgb, var(--app-primary-color, #007979) 18%, transparent),
0 6px 16px color-mix(in srgb, var(--app-primary-color, #007979) 10%, transparent);
}
}
.quote-card__stamp {
position: absolute;
z-index: 4;
top: -10px;
left: -10px;
padding: 6px 12px;
border: 2px solid var(--app-primary-color, #007979);
border-radius: 4px;
background: color-mix(in srgb, var(--app-primary-color, #007979) 14%, #fff);
color: var(--app-primary-color, #007979);
font-size: 13px;
font-weight: 800;
letter-spacing: 0.14em;
line-height: 1;
white-space: nowrap;
transform: rotate(-18deg);
transform-origin: center center;
box-shadow:
0 0 0 1px color-mix(in srgb, var(--app-primary-color, #007979) 40%, transparent) inset,
0 4px 12px color-mix(in srgb, var(--app-primary-color, #007979) 22%, transparent);
pointer-events: none;
opacity: 1;
&::after {
content: '';
position: absolute;
inset: 2px;
border: 1px dashed color-mix(in srgb, var(--app-primary-color, #007979) 55%, transparent);
border-radius: 2px;
pointer-events: none;
}
}
.quote-card__corner-tag {
position: absolute;
z-index: 3;
top: 2px;
right: 2px;
max-width: calc(100% - 4px);
padding: 2px 5px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
&.is-time {
background: rgba(0, 121, 121, 0.1);
color: var(--app-primary-color, #007979);
}
&.is-price {
background: rgba(245, 63, 63, 0.1);
color: #f53f3f;
} }
} }
.quote-card__main { .quote-card__main {
position: relative;
z-index: 1;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); grid-template-columns: minmax(180px, 1.1fr) minmax(0, 2fr) auto;
gap: 16px 24px; gap: 16px 20px;
align-items: center; align-items: center;
} }
.quote-card__left { .quote-card__left {
min-width: 0; min-width: 0;
justify-self: start;
} }
.quote-card__name-row { .quote-card__name-row {
@ -209,93 +314,122 @@
word-break: break-word; word-break: break-word;
} }
.quote-card__tags { .quote-card__cols {
display: inline-flex; display: grid;
flex-wrap: wrap; grid-template-columns:
gap: 6px; minmax(68px, 1.15fr)
minmax(52px, 0.85fr)
minmax(64px, 1fr)
minmax(56px, 0.75fr)
minmax(80px, 1.1fr)
minmax(84px, 0.95fr);
gap: 0;
align-items: stretch;
min-width: 0;
padding: 2px 0;
border-radius: 8px;
background: #f8fafc;
overflow: visible;
} }
.quote-tag { .quote-card__col {
font-size: 11px; display: flex;
font-weight: 500; flex-direction: column;
line-height: 1; align-items: center;
padding: 3px 6px; justify-content: center;
border-radius: 3px; gap: 4px;
background: #f1f5f9; min-width: 0;
color: #64748b; padding: 8px 10px;
position: relative;
overflow: visible;
&:not(:last-child)::after {
content: '';
position: absolute;
top: 10px;
right: 0;
bottom: 10px;
width: 1px;
background: #e8edf2;
}
&.is-metric,
&.is-price {
z-index: 2;
}
&.is-price { &.is-price {
background: rgba(245, 63, 63, 0.08); background: rgba(245, 63, 63, 0.03);
color: #f53f3f; border-radius: 0 8px 8px 0;
padding: 0;
} }
}
.quote-card__price-trigger {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
width: 100%;
min-height: 100%;
padding: 8px 10px;
border-radius: 0 8px 8px 0;
cursor: default;
overflow: visible;
&.has-tip {
cursor: help;
.quote-card__col-value.is-price {
border-bottom: 1px dashed rgba(245, 63, 63, 0.35);
}
&:hover {
background: rgba(245, 63, 63, 0.06);
.quote-card__col-value.is-price {
border-bottom-color: rgba(245, 63, 63, 0.7);
}
}
}
}
.quote-card__col-value {
margin: 0;
max-width: 100%;
color: #1d2129;
font-size: 13px;
font-weight: 600;
line-height: 1.25;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.is-time { &.is-time {
background: rgba(0, 121, 121, 0.08); color: #007979;
color: var(--app-primary-color, #007979); font-size: 18px;
}
&.is-warn {
background: #fff7ed;
color: #c2410c;
}
}
.quote-card__meta {
margin: 6px 0 0;
color: #94a3b8;
font-size: 12px;
line-height: 1.4;
}
.quote-card__time {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
min-width: 72px;
justify-self: center;
}
.quote-card__right {
display: flex;
align-items: center;
gap: 20px;
justify-self: end;
}
.quote-card__price {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
min-width: 96px;
}
.quote-card__metric {
display: inline-flex;
align-items: baseline;
gap: 2px;
}
.quote-card__metric-value {
font-size: 22px;
font-weight: 700; font-weight: 700;
letter-spacing: -0.03em; letter-spacing: -0.03em;
line-height: 1.1;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
&.is-time {
color: #1d2129;
} }
&.is-price { &.is-price {
color: #f53f3f; color: #f53f3f;
font-size: 20px;
font-weight: 700;
letter-spacing: -0.03em;
font-variant-numeric: tabular-nums;
} }
} }
.quote-card__metric-label { .quote-card__col-label {
color: #94a3b8; color: #94a3b8;
font-size: 11px; font-size: 11px;
line-height: 1;
white-space: nowrap;
} }
.quote-card__actions { .quote-card__actions {
@ -303,6 +437,7 @@
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: 8px; gap: 8px;
flex-shrink: 0;
} }
.ops-btn { .ops-btn {
@ -337,36 +472,17 @@
} }
} }
.quote-card__fee-toggle { .quote-card__fee-pop {
display: inline-flex;
align-items: center;
gap: 2px;
margin-top: 8px;
padding: 0;
border: none;
background: transparent;
color: #94a3b8;
font-size: 12px;
cursor: pointer;
&:hover {
color: var(--app-primary-color, #007979);
}
}
.quote-card__fee {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f1f5f9;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
min-width: 180px;
} }
.quote-card__fee-row { .quote-card__fee-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 20px;
color: #475569; color: #475569;
font-size: 13px; font-size: 13px;
@ -378,7 +494,7 @@
} }
.quote-card__fee-formula { .quote-card__fee-formula {
margin-top: 4px; margin-top: 2px;
color: #94a3b8; color: #94a3b8;
font-size: 12px; font-size: 12px;
line-height: 1.5; line-height: 1.5;
@ -389,7 +505,44 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
margin-top: 4px; margin-top: 2px;
padding-top: 6px;
border-top: 1px solid #f1f5f9;
}
@media (max-width: 1280px) {
.quote-card__main {
grid-template-columns: minmax(160px, 1fr) minmax(0, 1.6fr) auto;
gap: 12px 14px;
}
.quote-card__cols {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.quote-card__col {
padding: 8px 6px;
&.is-price {
padding: 0;
}
}
.quote-card__price-trigger {
padding: 8px 6px;
}
.quote-card__col-value {
font-size: 12px;
&.is-time {
font-size: 16px;
}
&.is-price {
font-size: 18px;
}
}
} }
@media (max-width: 1100px) { @media (max-width: 1100px) {
@ -398,17 +551,35 @@
gap: 12px; gap: 12px;
} }
.quote-card__time { .quote-card__cols {
justify-self: start; width: 100%;
align-items: flex-start;
} }
.quote-card__right { .quote-card__actions {
justify-self: stretch; justify-content: flex-end;
justify-content: space-between;
width: 100%; width: 100%;
padding-top: 10px; padding-top: 10px;
border-top: 1px dashed #eef2f6; border-top: 1px dashed #eef2f6;
} }
} }
@media (max-width: 720px) {
.quote-card__cols {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.quote-card__col {
&:nth-child(3n)::after {
display: none;
}
&.is-price {
border-radius: 0 0 8px 0;
}
}
.quote-card__price-trigger {
border-radius: 0 0 8px 0;
}
}
</style> </style>

View File

@ -63,6 +63,7 @@
const showAdvanced = ref(false); const showAdvanced = ref(false);
const sortMode = ref<'recommend' | 'price' | 'time'>('recommend'); const sortMode = ref<'recommend' | 'price' | 'time'>('recommend');
const selectedGuideRecord = ref<Record<string, any> | null>(null); const selectedGuideRecord = ref<Record<string, any> | null>(null);
const recommendedRowKey = ref('');
const recommendReason = ref(''); const recommendReason = ref('');
const recommendVerdict = ref<ShipVerdict | ''>(''); const recommendVerdict = ref<ShipVerdict | ''>('');
const recommendEvidence = ref<string[]>([]); const recommendEvidence = ref<string[]>([]);
@ -492,7 +493,7 @@
hintBannerDismissed.value = false; hintBannerDismissed.value = false;
queryResults.value = processQueryResults(items || []); queryResults.value = processQueryResults(items || []);
selectedGuideRecord.value = null; selectedGuideRecord.value = null;
clearRecommendInsight(); resetRecommendState();
sortMode.value = 'recommend'; sortMode.value = 'recommend';
currentPage.value = 1; currentPage.value = 1;
if (!queryResults.value.length) { if (!queryResults.value.length) {
@ -506,7 +507,7 @@
queryFailureText.value = errText; queryFailureText.value = errText;
queryResults.value = []; queryResults.value = [];
selectedGuideRecord.value = null; selectedGuideRecord.value = null;
clearRecommendInsight(); resetRecommendState();
} finally { } finally {
queryLoading.value = false; queryLoading.value = false;
} }
@ -530,7 +531,7 @@
queryHint.value = null; queryHint.value = null;
hintBannerDismissed.value = false; hintBannerDismissed.value = false;
selectedGuideRecord.value = null; selectedGuideRecord.value = null;
clearRecommendInsight(); resetRecommendState();
currentPage.value = 1; currentPage.value = 1;
queryFailureText.value = ''; queryFailureText.value = '';
sortMode.value = 'recommend'; sortMode.value = 'recommend';
@ -613,13 +614,20 @@
recommendAlternatives.value = []; recommendAlternatives.value = [];
} }
function resetRecommendState() {
recommendedRowKey.value = '';
clearRecommendInsight();
}
function applyRecommendInsight(result: { function applyRecommendInsight(result: {
rowKey?: string;
reason: string; reason: string;
verdict: ShipVerdict; verdict: ShipVerdict;
evidence: string[]; evidence: string[];
cargoName: string; cargoName: string;
alternatives: RecommendAlternative[]; alternatives: RecommendAlternative[];
}) { }) {
recommendedRowKey.value = result.rowKey || '';
recommendReason.value = result.reason; recommendReason.value = result.reason;
recommendVerdict.value = result.verdict; recommendVerdict.value = result.verdict;
recommendEvidence.value = result.evidence || []; recommendEvidence.value = result.evidence || [];
@ -627,6 +635,11 @@
recommendAlternatives.value = result.alternatives || []; recommendAlternatives.value = result.alternatives || [];
} }
function isRecommendStamp(record: Record<string, any>) {
if (recommendedRowKey.value) return record.rowKey === recommendedRowKey.value;
return sortMode.value === 'recommend' && !!record.isPriceOptimal;
}
const handleSelectGuide = (record: any) => { const handleSelectGuide = (record: any) => {
if (!record) return; if (!record) return;
selectedGuideRecord.value = record; selectedGuideRecord.value = record;
@ -657,6 +670,7 @@
return; return;
} }
const alt = recommendAlternatives.value.find((a) => a.rowKey === rowKey); const alt = recommendAlternatives.value.find((a) => a.rowKey === rowKey);
recommendedRowKey.value = rowKey;
recommendVerdict.value = alt?.verdict || recommendVerdict.value || 'unknown'; recommendVerdict.value = alt?.verdict || recommendVerdict.value || 'unknown';
recommendReason.value = alt?.blurb recommendReason.value = alt?.blurb
? t('order.priceCheck.ai.altSelected', { blurb: alt.blurb }) ? t('order.priceCheck.ai.altSelected', { blurb: alt.blurb })
@ -713,7 +727,7 @@
})); }));
const result = await recommendWithAi(name, candidates); const result = await recommendWithAi(name, candidates);
if (!result) { if (!result) {
clearRecommendInsight(); resetRecommendState();
selectedGuideRecord.value = null; selectedGuideRecord.value = null;
queryFailureText.value = t('order.priceCheck.ai.noMatch', { name }); queryFailureText.value = t('order.priceCheck.ai.noMatch', { name });
Message.warning(t('order.priceCheck.ai.noMatch', { name })); Message.warning(t('order.priceCheck.ai.noMatch', { name }));
@ -1072,6 +1086,7 @@
:key="record.rowKey" :key="record.rowKey"
:record="record" :record="record"
:selected="selectedGuideRecord?.rowKey === record.rowKey" :selected="selectedGuideRecord?.rowKey === record.rowKey"
:recommended="isRecommendStamp(record)"
@select="handleSelectGuide(record)" @select="handleSelectGuide(record)"
@order="handleOrder(record)" @order="handleOrder(record)"
@ask="(intent) => handleAskAi(record, intent)" @ask="(intent) => handleAskAi(record, intent)"
@ -1365,8 +1380,10 @@
.quote-card-list { .quote-card-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 12px;
padding: 0 16px 16px; /* 顶部略留白,推荐章不被裁切;列表本身可滚动 */
padding: 14px 16px 16px 18px;
box-sizing: border-box;
} }
.quote-empty { .quote-empty {
display: flex; display: flex;
@ -1467,18 +1484,37 @@
line-height: 1.5; line-height: 1.5;
} }
.list-wrap { .list-wrap {
width: 100%; flex: 1; min-height: 0; padding: 0; box-sizing: border-box; width: 100%;
display: flex; flex-direction: column; overflow: hidden;
}
.price-check-spin {
width: 100%; flex: 1; min-height: 0; display: flex; flex-direction: column;
:deep(.arco-spin) { width: 100%; flex: 1; min-height: 0; display: flex; flex-direction: column; }
:deep(.arco-spin-children) {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
padding: 0;
box-sizing: border-box;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: auto; overflow: auto;
-webkit-overflow-scrolling: touch;
}
.price-check-spin {
width: 100%;
flex: 1 0 auto;
min-height: 100%;
display: flex;
flex-direction: column;
:deep(.arco-spin) {
width: 100%;
flex: 1 0 auto;
min-height: 100%;
display: flex;
flex-direction: column;
}
:deep(.arco-spin-children) {
flex: 1 0 auto;
min-height: 100%;
display: flex;
flex-direction: column;
overflow: visible;
} }
} }
.price-check-table { .price-check-table {

View File

@ -1389,7 +1389,7 @@
>{{ t('order.myOrders.actions.predeclare') }}</a-button> >{{ t('order.myOrders.actions.predeclare') }}</a-button>
<a-button <a-button
v-if="showDownstreamAction" v-if="showDownstreamAction"
class="btn-downstream" type="primary"
:loading="isActionLoading('downstream')" :loading="isActionLoading('downstream')"
:disabled="isActionBusy('downstream')" :disabled="isActionBusy('downstream')"
@click="handleForwardDownstreamCreate" @click="handleForwardDownstreamCreate"
@ -1851,8 +1851,7 @@
} }
} }
:deep(.arco-btn.btn-track), :deep(.arco-btn.btn-track) {
:deep(.arco-btn.btn-downstream) {
color: #fff; color: #fff;
background: #000; background: #000;
border-color: #000; border-color: #000;

View File

@ -7,7 +7,15 @@
</div> </div>
<a-alert <a-alert
v-if="referrerInfo" v-if="referrerLoading"
type="info"
:show-icon="true"
class="referrer-alert"
>
{{ t('marketing.register.referrerLoading') }}
</a-alert>
<a-alert
v-else-if="referrerInfo"
type="success" type="success"
:show-icon="true" :show-icon="true"
class="referrer-alert" class="referrer-alert"
@ -17,6 +25,14 @@
code: referrerInfo.inviteCode ?? referrerInfo.InviteCode ?? inviteCode, code: referrerInfo.inviteCode ?? referrerInfo.InviteCode ?? inviteCode,
}) }} }) }}
</a-alert> </a-alert>
<a-alert
v-else-if="referrerTimedOut"
type="warning"
:show-icon="true"
class="referrer-alert"
>
{{ t('marketing.register.referrerTimeout') }}
</a-alert>
<a-alert <a-alert
v-else-if="inviteCode && referrerValidated === false" v-else-if="inviteCode && referrerValidated === false"
type="warning" type="warning"
@ -66,9 +82,12 @@ import { CustomerService } from '@/api/customer'
import { validateInviteCode } from '@/api/marketing/portal' import { validateInviteCode } from '@/api/marketing/portal'
import { isLetterAndDigitPassword } from '@/utils/customerPassword' import { isLetterAndDigitPassword } from '@/utils/customerPassword'
import { stashInviteRef } from '@/utils/inviteRefStorage' import { stashInviteRef } from '@/utils/inviteRefStorage'
import { isTimeoutError, withTimeout } from '@/utils/withTimeout'
defineOptions({ name: 'CustomerRegister' }) defineOptions({ name: 'CustomerRegister' })
const REFERRER_TIMEOUT_MS = 15000
const { t } = useI18n() const { t } = useI18n()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -77,6 +96,8 @@ const submitting = ref(false)
const inviteCode = ref('') const inviteCode = ref('')
const referrerInfo = ref<Record<string, unknown> | null>(null) const referrerInfo = ref<Record<string, unknown> | null>(null)
const referrerValidated = ref<boolean | null>(null) const referrerValidated = ref<boolean | null>(null)
const referrerLoading = ref(false)
const referrerTimedOut = ref(false)
const formState = reactive({ const formState = reactive({
customerName: '', customerName: '',
@ -116,8 +137,15 @@ const formRules: Record<string, FieldRule[]> = {
async function loadReferrer(code: string) { async function loadReferrer(code: string) {
inviteCode.value = code inviteCode.value = code
stashInviteRef(code) stashInviteRef(code)
referrerLoading.value = true
referrerTimedOut.value = false
referrerValidated.value = null
referrerInfo.value = null
try { try {
const data = (await validateInviteCode(code)) as Record<string, unknown> const data = (await withTimeout(
validateInviteCode(code) as Promise<Record<string, unknown>>,
REFERRER_TIMEOUT_MS,
)) as Record<string, unknown>
const valid = data.valid ?? data.Valid const valid = data.valid ?? data.Valid
if (valid === false) { if (valid === false) {
referrerInfo.value = null referrerInfo.value = null
@ -132,9 +160,16 @@ async function loadReferrer(code: string) {
inviteCode: data.inviterCustomerCode ?? data.InviterCustomerCode ?? code, inviteCode: data.inviterCustomerCode ?? data.InviterCustomerCode ?? code,
} }
referrerValidated.value = true referrerValidated.value = true
} catch { } catch (error) {
referrerInfo.value = null referrerInfo.value = null
if (isTimeoutError(error)) {
referrerTimedOut.value = true
referrerValidated.value = null
return
}
referrerValidated.value = false referrerValidated.value = false
} finally {
referrerLoading.value = false
} }
} }

View File

@ -138,7 +138,7 @@
style="width: 100%" style="width: 100%"
/> />
</a-form-item> </a-form-item>
<a-form-item :label="fieldLabels.remark" field="remark"> <a-form-item v-if="showRemark" :label="fieldLabels.remark" field="remark">
<a-textarea <a-textarea
v-model="formState.remark" v-model="formState.remark"
:placeholder="fieldPlaceholders.remark" :placeholder="fieldPlaceholders.remark"
@ -162,6 +162,7 @@ import {
PLATFORM_SHOP_CONFIG_CODES, PLATFORM_SHOP_CONFIG_CODES,
TRACK_SERVICE_CONFIG_CODE, TRACK_SERVICE_CONFIG_CODE,
WECHAT_MP_CONFIG_CODE, WECHAT_MP_CONFIG_CODE,
WECHAT_MP_FOLLOW_CONFIG_CODE,
createSystemOtherConfig, createSystemOtherConfig,
deleteSystemOtherConfig, deleteSystemOtherConfig,
getSystemOtherConfigDetail, getSystemOtherConfigDetail,
@ -192,6 +193,7 @@ type FieldMeta = {
hint?: string hint?: string
showAppKey?: boolean showAppKey?: boolean
showAppSecret?: boolean showAppSecret?: boolean
showRemark?: boolean
requireAppKey?: boolean requireAppKey?: boolean
requireAppSecret?: boolean requireAppSecret?: boolean
requireBaseUrl?: boolean requireBaseUrl?: boolean
@ -242,6 +244,26 @@ const PRESET_DEFS: Array<{
requireRemark: true, requireRemark: true,
}, },
}, },
{
code: WECHAT_MP_FOLLOW_CONFIG_CODE,
nameKey: 'system.otherConfig.defaults.wechatMpFollowName',
defaults: {
configCode: WECHAT_MP_FOLLOW_CONFIG_CODE,
enabled: true,
timeoutSeconds: 10,
},
meta: {
baseUrl: 'system.otherConfig.fields.wechatFollow.baseUrl',
appKey: 'system.otherConfig.form.appKey',
appSecret: 'system.otherConfig.form.appSecret',
remark: 'system.otherConfig.form.remark',
hint: 'system.otherConfig.hint.wechatFollow',
showAppKey: false,
showAppSecret: false,
showRemark: false,
requireBaseUrl: true,
},
},
{ {
code: PLATFORM_SHOP_CONFIG_CODES.common, code: PLATFORM_SHOP_CONFIG_CODES.common,
nameKey: 'system.otherConfig.defaults.platformShopName', nameKey: 'system.otherConfig.defaults.platformShopName',
@ -376,6 +398,7 @@ const fieldPlaceholders = computed(() => ({
const showAppKey = computed(() => activeMeta.value.showAppKey !== false) const showAppKey = computed(() => activeMeta.value.showAppKey !== false)
const showAppSecret = computed(() => activeMeta.value.showAppSecret !== false) const showAppSecret = computed(() => activeMeta.value.showAppSecret !== false)
const showRemark = computed(() => activeMeta.value.showRemark !== false)
const fieldHint = computed(() => (activeMeta.value.hint ? t(activeMeta.value.hint) : '')) const fieldHint = computed(() => (activeMeta.value.hint ? t(activeMeta.value.hint) : ''))
function resolveFieldMeta(configCode?: string): FieldMeta { function resolveFieldMeta(configCode?: string): FieldMeta {
@ -411,6 +434,7 @@ function platformTag(configCode?: string) {
if (code === PLATFORM_SHOP_CONFIG_CODES.shopee) return 'Shopee' if (code === PLATFORM_SHOP_CONFIG_CODES.shopee) return 'Shopee'
if (code === PLATFORM_SHOP_CONFIG_CODES.lazada) return 'Lazada' if (code === PLATFORM_SHOP_CONFIG_CODES.lazada) return 'Lazada'
if (code === WECHAT_MP_CONFIG_CODE) return 'WeChat' if (code === WECHAT_MP_CONFIG_CODE) return 'WeChat'
if (code === WECHAT_MP_FOLLOW_CONFIG_CODE) return 'WeChat'
if (code === TRACK_SERVICE_CONFIG_CODE) return 'Track' if (code === TRACK_SERVICE_CONFIG_CODE) return 'Track'
return '' return ''
} }

View File

@ -179,6 +179,16 @@
</a-option> </a-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item :label="t('user.customer.fields.priceTierKey')" field="priceTierKey">
<a-select
v-model="formState.priceTierKey"
:options="priceTierOptions"
allow-search
allow-create
allow-clear
:placeholder="t('user.customer.placeholder.select')"
/>
</a-form-item>
<a-form-item :label="t('user.customer.fields.remark')" field="remark"> <a-form-item :label="t('user.customer.fields.remark')" field="remark">
<a-textarea <a-textarea
v-model="formState.remark" v-model="formState.remark"
@ -434,6 +444,7 @@ function mapCustomerRow(r: any): CustomerRow {
salesPerson: r.salesPerson ?? r.salesman ?? r.Salesman, salesPerson: r.salesPerson ?? r.salesman ?? r.Salesman,
memberLevelId: r.memberLevelId ?? r.MemberLevelId, memberLevelId: r.memberLevelId ?? r.MemberLevelId,
memberLevelName: r.memberLevelName ?? r.MemberLevelName, memberLevelName: r.memberLevelName ?? r.MemberLevelName,
priceTierKey: r.priceTierKey ?? r.PriceTierKey ?? '',
} }
} }
@ -485,6 +496,7 @@ const hotColumns = computed(() => [
{ key: 'usedQuota', label: t('user.customer.fields.usedQuota'), width: 95 }, { key: 'usedQuota', label: t('user.customer.fields.usedQuota'), width: 95 },
{ key: 'status', label: t('user.customer.fields.status'), width: 75 }, { key: 'status', label: t('user.customer.fields.status'), width: 75 },
{ key: 'memberLevelName', label: t('user.customer.fields.memberLevel'), width: 100 }, { key: 'memberLevelName', label: t('user.customer.fields.memberLevel'), width: 100 },
{ key: 'priceTierKey', label: t('user.customer.fields.priceTierKey'), width: 100 },
{ key: 'remark', label: t('user.customer.fields.remark'), width: 100 }, { key: 'remark', label: t('user.customer.fields.remark'), width: 100 },
{ key: 'salesPerson', label: t('user.customer.fields.salesPerson'), width: 90 }, { key: 'salesPerson', label: t('user.customer.fields.salesPerson'), width: 90 },
{ key: 'customerService', label: t('user.customer.fields.customerService'), width: 90 }, { key: 'customerService', label: t('user.customer.fields.customerService'), width: 90 },
@ -649,11 +661,20 @@ const formState = reactive<Partial<CustomerRow>>({
usedQuota: undefined, usedQuota: undefined,
status: CUSTOMER_STATUS.normal, status: CUSTOMER_STATUS.normal,
memberLevelId: undefined, memberLevelId: undefined,
priceTierKey: '普通会员',
remark: '', remark: '',
salesPerson: '', salesPerson: '',
customerService: '', customerService: '',
}) })
const priceTierOptions = [
{ label: '普通会员', value: '普通会员' },
{ label: '普通客户', value: '普通客户' },
{ label: 'VIP1', value: 'VIP1' },
{ label: 'VIP2', value: 'VIP2' },
{ label: 'VIP3', value: 'VIP3' },
]
const formRules = computed(() => ({ const formRules = computed(() => ({
customerName: [{ required: true, message: t('user.customer.rules.customerNameRequired') }], customerName: [{ required: true, message: t('user.customer.rules.customerNameRequired') }],
email: [{ required: true, message: t('user.customer.rules.emailRequired') }], email: [{ required: true, message: t('user.customer.rules.emailRequired') }],
@ -690,6 +711,7 @@ function handleAdd() {
usedQuota: undefined, usedQuota: undefined,
status: CUSTOMER_STATUS.normal, status: CUSTOMER_STATUS.normal,
memberLevelId: undefined, memberLevelId: undefined,
priceTierKey: '普通会员',
remark: '', remark: '',
salesPerson: '', salesPerson: '',
customerService: '', customerService: '',
@ -712,6 +734,7 @@ function handleEdit(record: CustomerRow) {
usedQuota: record.usedQuota, usedQuota: record.usedQuota,
status: record.status ?? CUSTOMER_STATUS.normal, status: record.status ?? CUSTOMER_STATUS.normal,
memberLevelId: record.memberLevelId ?? undefined, memberLevelId: record.memberLevelId ?? undefined,
priceTierKey: record.priceTierKey || '普通客户',
remark: record.remark, remark: record.remark,
salesPerson: resolveStaffNameForForm(record.salesPerson ?? record.salesman), salesPerson: resolveStaffNameForForm(record.salesPerson ?? record.salesman),
customerService: resolveStaffNameForForm(record.customerService), customerService: resolveStaffNameForForm(record.customerService),

View File

@ -158,6 +158,7 @@ import { IconCopy } from '@arco-design/web-vue/es/icon'
import PaginationPro from '@/components/PaginationPro/index.vue' import PaginationPro from '@/components/PaginationPro/index.vue'
import { getMyInvite, getReferees } from '@/api/marketing/portal' import { getMyInvite, getReferees } from '@/api/marketing/portal'
import { buildInviteRegisterUrl, buildInviteShareText } from '@/utils/inviteLink' import { buildInviteRegisterUrl, buildInviteShareText } from '@/utils/inviteLink'
import { preloadInvitePosterBg } from '@/utils/invitePoster'
import InvitePoster from '@/components/InvitePoster.vue' import InvitePoster from '@/components/InvitePoster.vue'
import PortalPageHeader from '../components/PortalPageHeader.vue' import PortalPageHeader from '../components/PortalPageHeader.vue'
import PortalHowTo from '../components/PortalHowTo.vue' import PortalHowTo from '../components/PortalHowTo.vue'
@ -288,7 +289,10 @@ async function loadAll() {
} }
} }
onMounted(() => loadAll()) onMounted(() => {
void preloadInvitePosterBg()
loadAll()
})
</script> </script>
<style scoped lang="less"> <style scoped lang="less">

View File

@ -14,12 +14,16 @@
<icon-check-circle-fill /> <icon-check-circle-fill />
</div> </div>
<h1 class="wechat-h5-success__title">{{ t('wechat.bind.success') }}</h1> <h1 class="wechat-h5-success__title">{{ t('wechat.bind.success') }}</h1>
<p class="wechat-h5-success__desc">{{ t('wechat.bind.successDesc') }}</p> <p class="wechat-h5-success__desc">{{ t('wechat.follow.redirecting') }}</p>
<div class="wechat-h5-success__actions"> <div class="wechat-h5-success__actions">
<a-button type="primary" long size="large" class="wechat-h5-submit" @click="goCustomerCenter"> <a-button type="primary" long size="large" class="wechat-h5-submit" @click="finishAfterFollow">
{{ t('wechat.bind.goCenter') }} {{
loggedInAfterBind ? t('wechat.follow.continue') : t('wechat.follow.continueLogin')
}}
</a-button>
<a-button long size="large" :loading="followJumping" @click="openOfficialAccount">
{{ t('wechat.follow.openAccount') }}
</a-button> </a-button>
<a-button long size="large" @click="goLogin">{{ t('wechat.bind.goLogin') }}</a-button>
</div> </div>
</div> </div>
@ -54,6 +58,18 @@
</div> </div>
</div> </div>
<div v-else-if="statusCode === 'network-error'" class="wechat-h5-state">
<a-result
status="error"
:title="t('wechat.common.networkError')"
:subtitle="t('wechat.common.networkErrorTip')"
/>
<div class="wechat-h5-state-actions">
<a-button type="primary" long @click="retryLoad">{{ t('wechat.common.retryLoad') }}</a-button>
<a-button long @click="goLogin">{{ t('wechat.bind.goLogin') }}</a-button>
</div>
</div>
<template v-else-if="statusCode === 'ready' && wxUserInfo"> <template v-else-if="statusCode === 'ready' && wxUserInfo">
<section class="wechat-h5-hero"> <section class="wechat-h5-hero">
<img <img
@ -127,7 +143,7 @@
<div v-else class="wechat-h5-loading"> <div v-else class="wechat-h5-loading">
<a-spin :size="32" /> <a-spin :size="32" />
<p class="wechat-h5-hint">{{ t('wechat.common.loading') }}</p> <p class="wechat-h5-hint">{{ loadingHint }}</p>
</div> </div>
</template> </template>
</div> </div>
@ -149,7 +165,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onMounted, reactive, ref } from 'vue'; import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue'; import { Message } from '@arco-design/web-vue';
@ -163,22 +179,49 @@ import {
import { handleApiError } from '@/utils/rbac-error-handler'; import { handleApiError } from '@/utils/rbac-error-handler';
import { redirectToWeChatOAuth } from '@/utils/wechatOAuth'; import { redirectToWeChatOAuth } from '@/utils/wechatOAuth';
import { loginCustomerAfterWechatAuth } from '@/utils/wechatAuthLogin'; import { loginCustomerAfterWechatAuth } from '@/utils/wechatAuthLogin';
import { isTimeoutError, withTimeout } from '@/utils/withTimeout';
import { hideWeChatBrowserNav } from '@/utils/device';
import { jumpToWechatOfficialAccount } from '@/utils/wechatFollowJump';
import LoginLangToggle from '@/views/login/components/LoginLangToggle.vue'; import LoginLangToggle from '@/views/login/components/LoginLangToggle.vue';
defineOptions({ name: 'WechatBind' }); defineOptions({ name: 'WechatBind' });
type PageStatus = 'loading' | 'ready' | 'oauth-error' | 'already-bound' | 'need-auth'; type PageStatus = 'loading' | 'ready' | 'oauth-error' | 'already-bound' | 'need-auth' | 'network-error';
const OAUTH_LOAD_TIMEOUT_MS = 15000;
const LOADING_SLOW_HINT_MS = 3500;
const { t } = useI18n(); const { t } = useI18n();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
function goCustomerCenter() { function goCustomerCenter() {
router.push('/h5/me'); router.replace('/h5/me');
} }
function goLogin() { function goLogin() {
router.push('/h5/login'); router.replace('/h5/login');
}
function finishAfterFollow() {
if (loggedInAfterBind.value) {
goCustomerCenter();
return;
}
goLogin();
}
async function openOfficialAccount() {
if (followJumping.value) return;
followJumping.value = true;
try {
const ok = await jumpToWechatOfficialAccount();
if (!ok) {
Message.warning(t('wechat.follow.configMissing'));
}
} finally {
followJumping.value = false;
}
} }
function goRegister() { function goRegister() {
@ -194,12 +237,25 @@ function retryOAuth() {
startOAuth(); startOAuth();
} }
function retryLoad() {
const code = route.query.code;
if (typeof code === 'string' && code) {
void loadOAuthUserInfo(code);
return;
}
startOAuth();
}
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
const success = ref(false); const success = ref(false);
const loggedInAfterBind = ref(false);
const followJumping = ref(false);
const submitting = ref(false); const submitting = ref(false);
const statusCode = ref<PageStatus>('loading'); const statusCode = ref<PageStatus>('loading');
const loadingHint = ref('');
const authModalVisible = ref(false); const authModalVisible = ref(false);
const wxUserInfo = ref<WeChatOAuthUserInfo | null>(null); const wxUserInfo = ref<WeChatOAuthUserInfo | null>(null);
let loadingSlowTimer: number | undefined;
const formState = reactive({ const formState = reactive({
account: '', account: '',
@ -216,9 +272,28 @@ const formRules: Record<string, FieldRule | FieldRule[]> = {
password: [{ required: true, message: t('wechat.bind.passwordPlaceholder') }], password: [{ required: true, message: t('wechat.bind.passwordPlaceholder') }],
}; };
function clearLoadingTimers() {
if (loadingSlowTimer != null) {
window.clearTimeout(loadingSlowTimer);
loadingSlowTimer = undefined;
}
}
function beginLoading() {
clearLoadingTimers();
statusCode.value = 'loading';
loadingHint.value = t('wechat.common.loading');
loadingSlowTimer = window.setTimeout(() => {
if (statusCode.value === 'loading') {
loadingHint.value = t('wechat.common.loadingSlow');
}
}, LOADING_SLOW_HINT_MS);
}
async function loadOAuthUserInfo(code: string) { async function loadOAuthUserInfo(code: string) {
beginLoading();
try { try {
const info = await getOAuthUserInfo(code); const info = await withTimeout(getOAuthUserInfo(code), OAUTH_LOAD_TIMEOUT_MS);
if (!info?.openid) { if (!info?.openid) {
statusCode.value = 'oauth-error'; statusCode.value = 'oauth-error';
return; return;
@ -230,8 +305,14 @@ async function loadOAuthUserInfo(code: string) {
wxUserInfo.value = info; wxUserInfo.value = info;
statusCode.value = 'ready'; statusCode.value = 'ready';
} catch (error) { } catch (error) {
if (isTimeoutError(error)) {
statusCode.value = 'network-error';
return;
}
handleApiError(error); handleApiError(error);
statusCode.value = 'oauth-error'; statusCode.value = 'network-error';
} finally {
clearLoadingTimers();
} }
} }
@ -254,14 +335,13 @@ async function handleSubmit() {
}, },
}); });
Message.success(t('wechat.bind.success')); Message.success(t('wechat.bind.success'));
const loggedIn = await loginCustomerAfterWechatAuth({ loggedInAfterBind.value = await loginCustomerAfterWechatAuth({
account: formState.account.trim(), account: formState.account.trim(),
password: formState.password, password: formState.password,
redirectPath: '/h5/me', skipRedirect: true,
}); });
if (!loggedIn) {
success.value = true; success.value = true;
} void openOfficialAccount();
} catch (error) { } catch (error) {
handleApiError(error); handleApiError(error);
} finally { } finally {
@ -270,6 +350,8 @@ async function handleSubmit() {
} }
onMounted(() => { onMounted(() => {
hideWeChatBrowserNav();
loadingHint.value = t('wechat.common.loading');
const code = route.query.code; const code = route.query.code;
if (typeof code !== 'string' || !code) { if (typeof code !== 'string' || !code) {
statusCode.value = 'need-auth'; statusCode.value = 'need-auth';
@ -278,6 +360,10 @@ onMounted(() => {
} }
void loadOAuthUserInfo(code); void loadOAuthUserInfo(code);
}); });
onBeforeUnmount(() => {
clearLoadingTimers();
});
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>

View File

@ -1,5 +1,10 @@
export default { export default {
'wechat.common.loading': 'Getting WeChat authorization…', 'wechat.common.loading': 'Getting WeChat authorization…',
'wechat.common.loadingSlow': 'Network is busy. Still loading, please wait…',
'wechat.common.networkError': 'Failed to load',
'wechat.common.networkErrorTip':
'Please check your network and retry. If it keeps failing, switch network or try again later.',
'wechat.common.retryLoad': 'Reload',
'wechat.common.retry': 'Retry authorization', 'wechat.common.retry': 'Retry authorization',
'wechat.common.authTitle': 'WeChat authorization', 'wechat.common.authTitle': 'WeChat authorization',
'wechat.common.authDesc': 'wechat.common.authDesc':
@ -11,6 +16,15 @@ export default {
'wechat.common.authBindGateTitle': 'Bind WeChat', 'wechat.common.authBindGateTitle': 'Bind WeChat',
'wechat.common.authBindGateTip': 'Authorize to verify your account and bind', 'wechat.common.authBindGateTip': 'Authorize to verify your account and bind',
'wechat.follow.title': 'Follow the official account',
'wechat.follow.redirecting':
'Opening the official account. After following, you can reopen the customer center from the menu anytime.',
'wechat.follow.openAccount': 'Open official account',
'wechat.follow.configMissing':
'Follow link is not configured. Contact admin — you can still enter the customer center.',
'wechat.follow.continue': 'Enter customer center',
'wechat.follow.continueLogin': 'Sign in',
'wechat.register.title': 'Confirm registration', 'wechat.register.title': 'Confirm registration',
'wechat.register.subtitle': 'WeChat name and a default password are ready — confirm to finish', 'wechat.register.subtitle': 'WeChat name and a default password are ready — confirm to finish',
'wechat.register.success': 'Registered', 'wechat.register.success': 'Registered',

View File

@ -1,5 +1,9 @@
export default { export default {
'wechat.common.loading': '正在获取微信授权…', 'wechat.common.loading': '正在获取微信授权…',
'wechat.common.loadingSlow': '网络较忙,仍在加载中,请稍候…',
'wechat.common.networkError': '加载失败',
'wechat.common.networkErrorTip': '请检查网络后重试;若持续失败,可切换网络或稍后再打开',
'wechat.common.retryLoad': '重新加载',
'wechat.common.retry': '重新授权', 'wechat.common.retry': '重新授权',
'wechat.common.authTitle': '微信授权', 'wechat.common.authTitle': '微信授权',
'wechat.common.authDesc': 'wechat.common.authDesc':
@ -11,6 +15,13 @@ export default {
'wechat.common.authBindGateTitle': '开始绑定微信', 'wechat.common.authBindGateTitle': '开始绑定微信',
'wechat.common.authBindGateTip': '授权后即可验证系统账号并绑定', 'wechat.common.authBindGateTip': '授权后即可验证系统账号并绑定',
'wechat.follow.title': '请关注公众号',
'wechat.follow.redirecting': '正在打开公众号,关注后可随时从菜单进入客户中心',
'wechat.follow.openAccount': '打开公众号',
'wechat.follow.configMissing': '暂未配置公众号跳转链接,请联系管理员;您仍可进入客户中心',
'wechat.follow.continue': '进入客户中心',
'wechat.follow.continueLogin': '去登录',
'wechat.register.title': '确认注册', 'wechat.register.title': '确认注册',
'wechat.register.subtitle': '已带入微信昵称与初始密码,确认后即可完成', 'wechat.register.subtitle': '已带入微信昵称与初始密码,确认后即可完成',
'wechat.register.success': '注册成功', 'wechat.register.success': '注册成功',

View File

@ -14,12 +14,18 @@
<icon-check-circle-fill /> <icon-check-circle-fill />
</div> </div>
<h1 class="wechat-h5-success__title">{{ t('wechat.register.success') }}</h1> <h1 class="wechat-h5-success__title">{{ t('wechat.register.success') }}</h1>
<p class="wechat-h5-success__desc">{{ t('wechat.register.successDesc') }}</p> <p class="wechat-h5-success__desc">{{ t('wechat.follow.redirecting') }}</p>
<div class="wechat-h5-success__actions"> <div class="wechat-h5-success__actions">
<a-button type="primary" long size="large" class="wechat-h5-submit" @click="goCustomerCenter"> <a-button type="primary" long size="large" class="wechat-h5-submit" @click="finishAfterFollow">
{{ t('wechat.register.goCenter') }} {{
loggedInAfterRegister
? t('wechat.follow.continue')
: t('wechat.follow.continueLogin')
}}
</a-button>
<a-button long size="large" :loading="followJumping" @click="openOfficialAccount">
{{ t('wechat.follow.openAccount') }}
</a-button> </a-button>
<a-button long size="large" @click="goLogin">{{ t('wechat.register.goLogin') }}</a-button>
</div> </div>
</div> </div>
@ -74,6 +80,18 @@
</div> </div>
</div> </div>
<div v-else-if="statusCode === 'network-error'" class="wechat-h5-state">
<a-result
status="error"
:title="t('wechat.common.networkError')"
:subtitle="t('wechat.common.networkErrorTip')"
/>
<div class="wechat-h5-state-actions">
<a-button type="primary" long @click="retryLoad">{{ t('wechat.common.retryLoad') }}</a-button>
<a-button long @click="goLogin">{{ t('wechat.register.goLogin') }}</a-button>
</div>
</div>
<template v-else-if="statusCode === 'ready' && wxUserInfo"> <template v-else-if="statusCode === 'ready' && wxUserInfo">
<section class="wechat-h5-hero"> <section class="wechat-h5-hero">
<img <img
@ -177,7 +195,7 @@
<div v-else class="wechat-h5-loading"> <div v-else class="wechat-h5-loading">
<a-spin :size="32" /> <a-spin :size="32" />
<p class="wechat-h5-hint">{{ t('wechat.common.loading') }}</p> <p class="wechat-h5-hint">{{ loadingHint }}</p>
</div> </div>
</template> </template>
</div> </div>
@ -199,7 +217,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onMounted, reactive, ref } from 'vue'; import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue'; import { Message } from '@arco-design/web-vue';
@ -223,24 +241,57 @@ import {
stashWeChatInviteRef, stashWeChatInviteRef,
} from '@/utils/wechatOAuth'; } from '@/utils/wechatOAuth';
import { buildInviteRegisterUrl } from '@/utils/inviteLink'; import { buildInviteRegisterUrl } from '@/utils/inviteLink';
import { isWeChatBrowser } from '@/utils/device'; import { isTimeoutError, withTimeout } from '@/utils/withTimeout';
import { isWeChatBrowser, hideWeChatBrowserNav } from '@/utils/device';
import { loginCustomerAfterWechatAuth } from '@/utils/wechatAuthLogin'; import { loginCustomerAfterWechatAuth } from '@/utils/wechatAuthLogin';
import { jumpToWechatOfficialAccount } from '@/utils/wechatFollowJump';
import LoginLangToggle from '@/views/login/components/LoginLangToggle.vue'; import LoginLangToggle from '@/views/login/components/LoginLangToggle.vue';
defineOptions({ name: 'WechatRegister' }); defineOptions({ name: 'WechatRegister' });
type PageStatus = 'loading' | 'ready' | 'oauth-error' | 'already-bound' | 'open-in-wechat' | 'need-auth'; type PageStatus =
| 'loading'
| 'ready'
| 'oauth-error'
| 'already-bound'
| 'open-in-wechat'
| 'need-auth'
| 'network-error';
const OAUTH_LOAD_TIMEOUT_MS = 15000;
const LOADING_SLOW_HINT_MS = 3500;
const { t } = useI18n(); const { t } = useI18n();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
function goCustomerCenter() { function goCustomerCenter() {
router.push('/h5/me'); router.replace('/h5/me');
} }
function goLogin() { function goLogin() {
router.push('/h5/login'); router.replace('/h5/login');
}
function finishAfterFollow() {
if (loggedInAfterRegister.value) {
goCustomerCenter();
return;
}
goLogin();
}
async function openOfficialAccount() {
if (followJumping.value) return;
followJumping.value = true;
try {
const ok = await jumpToWechatOfficialAccount();
if (!ok) {
Message.warning(t('wechat.follow.configMissing'));
}
} finally {
followJumping.value = false;
}
} }
function goBind() { function goBind() {
@ -256,6 +307,15 @@ function retryOAuth() {
startOAuth(); startOAuth();
} }
function retryLoad() {
const code = route.query.code;
if (typeof code === 'string' && code) {
void loadOAuthUserInfo(code);
return;
}
startOAuth();
}
async function copyInviteLink() { async function copyInviteLink() {
const url = buildInviteRegisterUrl(inviteCode.value) || window.location.href; const url = buildInviteRegisterUrl(inviteCode.value) || window.location.href;
try { try {
@ -268,13 +328,17 @@ async function copyInviteLink() {
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
const success = ref(false); const success = ref(false);
const loggedInAfterRegister = ref(false);
const followJumping = ref(false);
const submitting = ref(false); const submitting = ref(false);
const statusCode = ref<PageStatus>('loading'); const statusCode = ref<PageStatus>('loading');
const loadingHint = ref('');
const authModalVisible = ref(false); const authModalVisible = ref(false);
const wxUserInfo = ref<WeChatOAuthUserInfo | null>(null); const wxUserInfo = ref<WeChatOAuthUserInfo | null>(null);
const inviteCode = ref(''); const inviteCode = ref('');
const referrerValidated = ref<boolean | null>(null); const referrerValidated = ref<boolean | null>(null);
const referrerInfo = ref<{ name: string; code: string } | null>(null); const referrerInfo = ref<{ name: string; code: string } | null>(null);
let loadingSlowTimer: number | undefined;
const formState = reactive({ const formState = reactive({
customerName: '', customerName: '',
@ -303,11 +367,32 @@ const formRules: Record<string, FieldRule | FieldRule[]> = {
], ],
}; };
function clearLoadingTimers() {
if (loadingSlowTimer != null) {
window.clearTimeout(loadingSlowTimer);
loadingSlowTimer = undefined;
}
}
function beginLoading() {
clearLoadingTimers();
statusCode.value = 'loading';
loadingHint.value = t('wechat.common.loading');
loadingSlowTimer = window.setTimeout(() => {
if (statusCode.value === 'loading') {
loadingHint.value = t('wechat.common.loadingSlow');
}
}, LOADING_SLOW_HINT_MS);
}
async function loadReferrer(code: string) { async function loadReferrer(code: string) {
inviteCode.value = code; inviteCode.value = code;
stashWeChatInviteRef(code); stashWeChatInviteRef(code);
try { try {
const data = (await validateInviteCode(code)) as Record<string, unknown>; const data = (await withTimeout(
validateInviteCode(code) as Promise<Record<string, unknown>>,
OAUTH_LOAD_TIMEOUT_MS,
)) as Record<string, unknown>;
const valid = data.valid ?? data.Valid; const valid = data.valid ?? data.Valid;
if (valid === false) { if (valid === false) {
referrerInfo.value = null; referrerInfo.value = null;
@ -332,8 +417,9 @@ async function loadReferrer(code: string) {
} }
async function loadOAuthUserInfo(code: string) { async function loadOAuthUserInfo(code: string) {
beginLoading();
try { try {
const info = await getOAuthUserInfo(code); const info = await withTimeout(getOAuthUserInfo(code), OAUTH_LOAD_TIMEOUT_MS);
if (!info?.openid) { if (!info?.openid) {
statusCode.value = 'oauth-error'; statusCode.value = 'oauth-error';
return; return;
@ -347,8 +433,14 @@ async function loadOAuthUserInfo(code: string) {
formState.password = DEFAULT_CUSTOMER_PASSWORD; formState.password = DEFAULT_CUSTOMER_PASSWORD;
statusCode.value = 'ready'; statusCode.value = 'ready';
} catch (error) { } catch (error) {
if (isTimeoutError(error)) {
statusCode.value = 'network-error';
return;
}
handleApiError(error); handleApiError(error);
statusCode.value = 'oauth-error'; statusCode.value = 'network-error';
} finally {
clearLoadingTimers();
} }
} }
@ -376,16 +468,16 @@ async function handleSubmit() {
clearStashedWeChatInviteRef(); clearStashedWeChatInviteRef();
Message.success(t('wechat.register.success')); Message.success(t('wechat.register.success'));
const account = String(result?.customerCode || '').trim(); const account = String(result?.customerCode || '').trim();
const loggedIn = account loggedInAfterRegister.value = account
? await loginCustomerAfterWechatAuth({ ? await loginCustomerAfterWechatAuth({
account, account,
password: formState.password, password: formState.password,
redirectPath: '/h5/me', skipRedirect: true,
}) })
: false; : false;
if (!loggedIn) {
success.value = true; success.value = true;
} // 注册成功后直接跳转公众号,返回可用下方按钮进客户中心
void openOfficialAccount();
} catch (error) { } catch (error) {
handleApiError(error); handleApiError(error);
} finally { } finally {
@ -394,6 +486,8 @@ async function handleSubmit() {
} }
onMounted(() => { onMounted(() => {
hideWeChatBrowserNav();
loadingHint.value = t('wechat.common.loading');
const refCode = resolveWeChatInviteRef(route.query as Record<string, unknown>); const refCode = resolveWeChatInviteRef(route.query as Record<string, unknown>);
if (refCode) { if (refCode) {
void loadReferrer(refCode); void loadReferrer(refCode);
@ -412,6 +506,10 @@ onMounted(() => {
} }
void loadOAuthUserInfo(code); void loadOAuthUserInfo(code);
}); });
onBeforeUnmount(() => {
clearLoadingTimers();
});
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>