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="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>
<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>
</body>
</html>

View File

@ -37,6 +37,7 @@ export interface CustomerDto {
customerGroup?: CustomerGroup
memberLevelId?: number | null
memberLevelName?: string
priceTierKey?: string | null
lastLoginIp?: string
lastOnlineIp?: string
lastLoginTime?: string
@ -87,6 +88,7 @@ function mapUpdateBody(id: string | number, data: Partial<CustomerDto>) {
customerService: data.customerService,
customerGroup: data.customerGroup,
memberLevelId: data.memberLevelId,
priceTierKey: data.priceTierKey,
}
}
@ -132,6 +134,7 @@ export const CustomerService = {
customerService: data.customerService,
customerGroup: data.customerGroup ?? 0,
memberLevelId: data.memberLevelId,
priceTierKey: data.priceTierKey,
permissions: data.permissions ?? [],
}
const r = await axiosInstance.post(omsUrl('/customer/sub/create'), body)
@ -157,6 +160,7 @@ export const CustomerService = {
customerService: data.customerService,
customerGroup: data.customerGroup ?? 0,
memberLevelId: data.memberLevelId,
priceTierKey: data.priceTierKey,
inviteCode: data.inviteCode?.trim() || undefined,
}
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_FOLLOW_CONFIG_CODE = 'wechat_mp_follow'
/** 店铺授权平台配置编码 */
export const PLATFORM_SHOP_CONFIG_CODES = {
common: 'platform_shop',

View File

@ -6,6 +6,7 @@ import type {
WeChatCustomerRegisterInput,
WeChatCustomerRegisterResult,
WeChatOAuthUserInfo,
WxFollowQrcodeResult,
WxLoginQrcodeResult,
WxLoginResult,
} from './types';
@ -17,7 +18,12 @@ function unwrapOrSelf<T>(body: unknown): T {
return body as T;
}
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 unwrapData<T>(body);
@ -31,6 +37,12 @@ export async function getLoginQrcode(loginUuid?: string): Promise<WxLoginQrcodeR
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 是否已关联账号(轮询用,无值时不抛错) */
export async function getWxLoginStatus(loginUuid: string): Promise<WxLoginStatusResult> {
const res = await axiosInstance.get('/rbac/auth/wx-login/status', {

View File

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

View File

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { onMounted, ref, watch } from 'vue';
import { onMounted, onBeforeUnmount, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue';
import { buildInvitePosterDataUrl } from '@/utils/invitePoster';
@ -32,8 +32,10 @@ const { t } = useI18n();
const dataUrl = ref('');
const loading = ref(false);
const failed = ref(false);
let renderSeq = 0;
async function render() {
const seq = ++renderSeq;
failed.value = false;
dataUrl.value = '';
if (!props.value) return;
@ -43,13 +45,17 @@ async function render() {
inviteUrl: props.value,
inviteCode: props.inviteCode,
});
if (seq !== renderSeq) return;
dataUrl.value = url;
emit('ready', url);
} catch {
if (seq !== renderSeq) return;
failed.value = true;
emit('fail');
} finally {
loading.value = false;
if (seq === renderSeq) {
loading.value = false;
}
}
}
@ -57,7 +63,7 @@ function downloadPoster() {
if (!dataUrl.value) return;
const a = document.createElement('a');
a.href = dataUrl.value;
a.download = 'invite-poster.png';
a.download = 'invite-poster.jpg';
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
@ -69,6 +75,10 @@ onMounted(() => {
void render();
});
onBeforeUnmount(() => {
renderSeq += 1;
});
watch(
() => [props.value, props.inviteCode] as const,
() => {
@ -85,7 +95,10 @@ defineExpose({
<template>
<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">
<img
:src="dataUrl"
@ -103,7 +116,12 @@ defineExpose({
</button>
<p class="invite-poster__tip">{{ t('h5.invite.posterTip') }}</p>
</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>
</template>
@ -124,11 +142,34 @@ defineExpose({
.invite-poster__loading,
.invite-poster__fail {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 160px;
padding: 28px 12px;
font-size: 13px;
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 {
margin-top: 12px;
height: 36px;

View File

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

View File

@ -63,10 +63,10 @@ export default {
'menu.marketing': 'Marketing',
'menu.marketing.memberLevel': 'Member Levels',
'marketing.memberLevel.pageTip':
'Member levels control price tier keys and optional extra discounts. Changes apply to future quotes only.',
'marketing.memberLevel.searchPlaceholder': 'Search code / name / tier key',
'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',
'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.edit': 'Edit level',
'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.minPoints': 'Min points',
'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.extraDiscountRateExtra': 'e.g. 0.01 = extra 1% off after tier price',
'marketing.memberLevel.form.sort': 'Sort',
@ -91,11 +88,11 @@ export default {
'marketing.memberLevel.form.benefitDesc': 'Benefits',
'marketing.memberLevel.form.benefitDescPlaceholder': 'Benefit copy shown to 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.basicHint': 'Code cannot change after create; name and status can',
'marketing.memberLevel.section.pricing': 'Points & pricing',
'marketing.memberLevel.section.pricingHint': 'Upgrade threshold, price tier and extra discount',
'marketing.memberLevel.section.pricing': 'Points & discount',
'marketing.memberLevel.section.pricingHint': 'Upgrade threshold and extra discount',
'marketing.memberLevel.section.display': 'Display & benefits',
'marketing.memberLevel.section.displayHint': 'Portal display only; does not affect pricing',
'marketing.memberLevel.preview.nameFallback': 'Untitled level',
@ -1682,14 +1679,21 @@ export default {
'order.priceCheck.empty.step3': 'Search, then AI-pick by cargo',
'order.priceCheck.card.eta': 'Business days',
'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.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.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.descShort': 'Pick a route by cargo name',
'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.needQuotes': 'Run a quote search first, then use AI recommend',
'order.priceCheck.ai.noMatch': 'No suitable channel found for "{name}"',
@ -2688,6 +2692,7 @@ export default {
'system.otherConfig.message.deleteSuccess': 'Deleted successfully',
'system.otherConfig.defaults.trackServiceName': 'Small Bag Track Service',
'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.platformShopTiktokName': 'Store Auth - TikTok',
'system.otherConfig.defaults.platformShopTemuName': 'Store Auth - Temu',
@ -2701,6 +2706,9 @@ export default {
'system.otherConfig.fields.wechat.appSecret': 'AppSecret',
'system.otherConfig.fields.wechat.remark': '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.remark': 'StateSecret (optional)',
'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.customerGroup': 'Customer Group',
'user.customer.fields.memberLevel': 'Member Level',
'user.customer.fields.priceTierKey': 'Price Tier Key',
'user.customer.fields.remark': 'Remark',
'user.customer.fields.createTime': 'Create Time',
'user.customer.fields.createdAt': 'Created At',

View File

@ -304,7 +304,7 @@ export default {
'marketing.portal.invite.earnTitle': 'View earnings',
'marketing.portal.invite.earnDesc': 'Commission settles after delivery success',
'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.refereeHint': 'Level-1 only; personal info is masked',
'marketing.portal.invite.emptyTitle': 'No friends have joined yet',
@ -314,15 +314,15 @@ export default {
'marketing.portal.invite.tag.delivered': 'Delivered',
'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.howHint': 'More orders, better rates',
'marketing.portal.member.howHint': 'More orders, more points',
'marketing.portal.member.how1Title': 'Place and pay',
'marketing.portal.member.how1Desc': 'Points are granted automatically after successful payment',
'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.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.defaultLevel': 'Standard',
'marketing.portal.member.priceTier': 'Price tier',
@ -435,6 +435,9 @@ export default {
'marketing.register.subtitle': 'Member pricing and welcome benefits',
'marketing.register.referrerHint': 'Invited by {name} ({code})',
'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.phone': 'Phone',
'marketing.register.email': 'Email',
@ -460,7 +463,7 @@ export default {
'profile.marketing.coupons': 'My coupons',
'profile.marketing.commission': 'Referral earnings',
'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.commissionDesc': 'Commission, transfer, withdraw',
'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.earnDesc': '派送成功后可结算到佣金账户',
'marketing.portal.invite.levelTitle': '我的会员等级',
'marketing.portal.invite.levelDesc': '下单累计积分,解锁更优价区',
'marketing.portal.invite.levelDesc': '下单累计积分,升级会员等级',
'marketing.portal.invite.refereeTitle': '我的下级',
'marketing.portal.invite.refereeHint': '仅展示一级引荐,保护隐私已脱敏',
'marketing.portal.invite.emptyTitle': '还没有好友通过您的邀请注册',
@ -313,15 +313,15 @@ export default {
'marketing.portal.invite.tag.delivered': '已派送',
'marketing.portal.member.title': '我的等级',
'marketing.portal.member.subtitle': '支付成功后累计积分,积分越高会员价越优惠。',
'marketing.portal.member.subtitle': '支付成功后累计积分,积分越高额外折扣越优惠。',
'marketing.portal.member.howTitle': '等级怎么升级',
'marketing.portal.member.howHint': '下单越多,价区越好',
'marketing.portal.member.howHint': '下单越多,积分越高',
'marketing.portal.member.how1Title': '下单并支付',
'marketing.portal.member.how1Desc': '运单支付成功后,系统自动发放积分',
'marketing.portal.member.how2Title': '积分累计升级',
'marketing.portal.member.how2Desc': '达到下一等级门槛后,自动解锁新价区',
'marketing.portal.member.how2Desc': '达到下一等级门槛后,自动升级会员等级',
'marketing.portal.member.how3Title': '享受更优运价',
'marketing.portal.member.how3Desc': '等级越高,下单报价越优惠',
'marketing.portal.member.how3Desc': '等级越高,可叠加的额外折扣越优惠',
'marketing.portal.member.currentLevel': '当前等级',
'marketing.portal.member.defaultLevel': '普通会员',
'marketing.portal.member.priceTier': '价区',
@ -434,6 +434,8 @@ export default {
'marketing.register.subtitle': '注册即享会员价与新人福利',
'marketing.register.referrerHint': '您正通过 {name}({code})的邀请注册',
'marketing.register.invalidRef': '邀请码无效或已失效,您仍可正常注册',
'marketing.register.referrerLoading': '正在校验邀请码…',
'marketing.register.referrerTimeout': '邀请码校验超时,请检查网络后刷新;您仍可继续注册',
'marketing.register.customerName': '客户名称',
'marketing.register.phone': '手机号',
'marketing.register.email': '邮箱',
@ -459,7 +461,7 @@ export default {
'profile.marketing.coupons': '我的优惠券',
'profile.marketing.commission': '推荐收益',
'profile.marketing.inviteDesc': '分享链接,积累下级',
'profile.marketing.memberDesc': '积分与会员价区',
'profile.marketing.memberDesc': 'Points and membership level',
'profile.marketing.couponsDesc': '查看可用券包',
'profile.marketing.commissionDesc': '分成、转余额、提现',
'profile.marketing.navigateFailed': '页面跳转失败,请刷新后重试',

View File

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

View File

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

View File

@ -13,6 +13,19 @@ const FORCE_H5_KEY = 'oms_force_h5';
const MOBILE_UA_RE =
/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 伪装,用触控点辅助识别 */
function isIpadDesktopUa(ua: string): boolean {
return /Macintosh/i.test(ua) && typeof navigator !== 'undefined' && navigator.maxTouchPoints > 1;
@ -85,6 +98,44 @@ export function preferH5Portal(): boolean {
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,避免历史栈变长触发底部左右箭头栏。
* 非微信仍可用 push,保留系统返回。
@ -98,3 +149,33 @@ export function h5Navigate(
}
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_Y = 1120;
const QR_PAD = 12;
/** JPEG 对照片底图编码远快于 PNG,0.9 对二维码识别足够稳 */
const POSTER_JPEG_QUALITY = 0.9;
export interface InvitePosterOptions {
inviteUrl: string;
@ -21,6 +23,9 @@ export interface InvitePosterOptions {
captionLines?: string[];
}
let bgImagePromise: Promise<HTMLImageElement> | null = null;
const posterCache = new Map<string, string>();
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
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[] {
void inviteCode;
return ['微信扫码注册,双方有礼'];
@ -50,8 +70,36 @@ function drawCenteredText(
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(
inviteUrlOrOptions: string | InvitePosterOptions,
@ -70,12 +118,18 @@ export async function buildInvitePosterDataUrl(
options.captionLines?.map((x) => x.trim()).filter(Boolean) ??
defaultCaptionLines(options.inviteCode);
const key = cacheKey(options, captionLines);
const cached = posterCache.get(key);
if (cached) {
return cached;
}
const [bg, qrDataUrl] = await Promise.all([
loadImage(posterBgUrl),
preloadInvitePosterBg(),
QRCode.toDataURL(inviteUrl, {
width: QR_SIZE,
margin: 3,
errorCorrectionLevel: 'H',
margin: 2,
errorCorrectionLevel: 'M',
color: { dark: '#0a3373', light: '#ffffff' },
}),
]);
@ -84,7 +138,7 @@ export async function buildInvitePosterDataUrl(
const canvas = document.createElement('canvas');
canvas.width = INVITE_POSTER_WIDTH;
canvas.height = INVITE_POSTER_HEIGHT;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d', { alpha: false });
if (!ctx) {
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(
dataUrl: string,
filename = 'invite-poster.png',
filename = 'invite-poster.jpg',
): Promise<File> {
const res = await fetch(dataUrl);
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;
password: string;
redirectPath?: string;
/** 仅换登录态,不跳转(先展示关注公众号引导) */
skipRedirect?: boolean;
}): Promise<boolean> {
const account = options.account.trim();
const password = options.password;
@ -30,6 +32,10 @@ export async function loginCustomerAfterWechatAuth(options: {
await ensureFullLocaleMessages();
await nextTick();
if (options.skipRedirect) {
return true;
}
const target = options.redirectPath || '/h5/me';
try {
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 { getMyInvite, getReferees } from '@/api/marketing/portal';
import { buildInviteRegisterUrl, buildInviteShareText } from '@/utils/inviteLink';
import { invitePosterToFile } from '@/utils/invitePoster';
import { invitePosterToFile, preloadInvitePosterBg } from '@/utils/invitePoster';
import InvitePoster from '@/components/InvitePoster.vue';
import H5EmptyState from './H5EmptyState.vue';
@ -240,6 +240,7 @@ watch(
(visible) => {
if (visible) {
activeTab.value = 'share';
void preloadInvitePosterBg();
void load();
}
},

View File

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

View File

@ -47,6 +47,7 @@ export default {
'h5.price.recommendTag': 'Best',
'h5.price.tag.priceOptimal': 'Best price',
'h5.price.tag.timeOptimal': 'Fastest',
'h5.price.tag.best': 'Best',
'h5.price.dimensionsToggle': 'Size (optional)',
'h5.price.dimensionsHint': 'L×W×H for volumetric weight',
'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.ai.title': 'AI route pick',
'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.needName': 'Please enter a product name',
'h5.price.hint.estimated': 'Indicative only; final after inbound',
@ -76,6 +77,8 @@ export default {
'h5.price.cargo': 'Cargo type',
'h5.price.warehouse': 'Warehouse',
'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.detailTitle': 'Route details',
'h5.price.detailTitleNamed': '{name} · Details',
@ -467,6 +470,7 @@ export default {
'h5.invite.posterAlt': 'Invite poster',
'h5.invite.posterLoading': 'Generating poster…',
'h5.invite.posterFailed': 'Failed to generate poster. Please retry.',
'h5.invite.posterRetry': 'Retry',
'h5.invite.savePoster': 'Save poster',
'h5.invite.posterSaved': 'Poster saved',
'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.tag.priceOptimal': '价格最优',
'h5.price.tag.timeOptimal': '时效最优',
'h5.price.tag.best': '最优',
'h5.price.dimensionsToggle': '尺寸(选填)',
'h5.price.dimensionsHint': '填齐长宽高可算体积重',
'h5.price.accuracy.postalCta': '当前为估算报价,补上邮编可提高准确性',
@ -54,7 +55,7 @@ export default {
'h5.price.accuracy.dismiss': '关闭',
'h5.price.ai.title': 'AI 荐线',
'h5.price.ai.placeholder': '品名,如口红、衣服、充电宝',
'h5.price.ai.action': '荐线',
'h5.price.ai.action': 'AI推荐',
'h5.price.ai.needQuotes': '请先查价,再使用 AI 荐线',
'h5.price.ai.needName': '请输入品名',
'h5.price.hint.estimated': '参考价,入库实测为准',
@ -76,6 +77,8 @@ export default {
'h5.price.cargo': '货物属性',
'h5.price.warehouse': '仓库',
'h5.price.tradeTerms': '贸易条款',
'h5.price.tradeTerms.ddp': 'DDP(包税)',
'h5.price.tradeTerms.ddu': 'DDU(不包税)',
'h5.price.viewDetail': '线路详情',
'h5.price.detailTitle': '线路详情',
'h5.price.detailTitleNamed': '{name} · 线路详情',
@ -465,6 +468,7 @@ export default {
'h5.invite.posterAlt': '邀请推广海报',
'h5.invite.posterLoading': '海报生成中…',
'h5.invite.posterFailed': '海报生成失败,请稍后重试',
'h5.invite.posterRetry': '重新生成',
'h5.invite.savePoster': '保存海报',
'h5.invite.posterSaved': '海报已保存',
'h5.invite.posterTip': '长按海报识别二维码,或保存后发给好友',

View File

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

View File

@ -30,7 +30,7 @@
</button>
</div>
<div ref="postalFieldRef" class="line-field">
<div class="line-field">
<span class="line-field__label">{{ t('h5.price.postalCode') }}</span>
<input
v-model="form.postalCode"
@ -100,33 +100,27 @@
<div class="quote-foot">
<span v-if="summaryText" class="quote-summary">{{ summaryText }}</span>
<p class="quote-ref">{{ t('h5.price.referenceTip') }}</p>
</div>
</section>
<section v-if="queried" class="h5-price-results">
<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
v-model="aiProductName"
class="h5-ai-bar__input"
type="text"
:placeholder="t('h5.price.ai.placeholder')"
maxlength="80"
@keyup.enter="handleAiRecommend"
/>
<button
type="button"
class="h5-ai-bar__btn"
:disabled="aiRecommendLoading || !aiProductName.trim()"
@click="handleAiRecommend"
>
{{ aiRecommendLoading ? '…' : t('h5.price.ai.action') }}
</button>
</div>
<input
v-model="aiProductName"
class="h5-ai-bar__input"
type="text"
:placeholder="t('h5.price.ai.placeholder')"
maxlength="80"
@keyup.enter="handleAiRecommend"
/>
<button
type="button"
class="h5-ai-bar__btn"
:disabled="aiRecommendLoading || !aiProductName.trim()"
@click="handleAiRecommend"
>
{{ aiRecommendLoading ? '…' : t('h5.price.ai.action') }}
</button>
</div>
<div
@ -160,31 +154,11 @@
</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">
<p v-if="showQueryHintBanner && !needPostalHint" class="h5-disclaimer">
<p v-if="showQueryHintBanner" class="h5-disclaimer">
{{ queryHintSummary }}
</p>
<p v-else-if="!needPostalHint" class="h5-disclaimer">
<p v-else class="h5-disclaimer">
{{ t('h5.price.referenceBanner') }}
</p>
<div class="h5-sort">
@ -238,53 +212,49 @@
<div class="h5-quote__head">
<div class="h5-quote__title">
<h3>{{ item.shippingMethod || item.productCode || '-' }}</h3>
<p class="h5-quote__sub">
<span
v-if="item.productGroupName"
class="h5-quote__chip h5-quote__chip--group"
>
{{ item.productGroupName }}
</span>
<span class="h5-quote__chip h5-quote__chip--cargo">
{{ item.cargoTypeName || item.cargoType || '-' }}
</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">
<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">{{ t('h5.price.referencePrice') }}</span>
<span class="ref-label">
{{ tradeTermsLabel(item.tradeTerms) || t('h5.price.referencePrice') }}
</span>
</div>
</div>
<div class="h5-quote__actions" @click.stop>
<span class="h5-quote__chip h5-quote__chip--wh">
{{ item.warehouse || '-' }}
</span>
<div class="h5-quote__meta">
<span
v-if="item.productGroupName"
class="h5-quote__chip h5-quote__chip--group"
>
{{ item.productGroupName }}
</span>
<span class="h5-quote__chip h5-quote__chip--cargo">
{{ item.cargoTypeName || item.cargoType || '-' }}
</span>
<span class="h5-quote__chip h5-quote__chip--wh">
{{ item.warehouse || '-' }}
</span>
</div>
<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">
<button type="button" class="link-btn">
{{ t('h5.price.askAdvisor') }}
@ -442,9 +412,9 @@
<label>{{ t('h5.price.warehouse') }}</label>
<p>{{ detailItem.warehouse || '-' }}</p>
</div>
<div v-if="detailItem.tradeTerms === 'DDP' || detailItem.tradeTerms === 'DDU'">
<div v-if="tradeTermsLabel(detailItem.tradeTerms)">
<label>{{ t('h5.price.tradeTerms') }}</label>
<p>{{ detailItem.tradeTerms }}</p>
<p>{{ tradeTermsLabel(detailItem.tradeTerms) }}</p>
</div>
<div>
<label>{{ t('h5.price.delivery') }}</label>
@ -557,7 +527,6 @@ const form = reactive({
});
const showDimensions = ref(false);
const postalFieldRef = ref<HTMLElement | null>(null);
const quoteElMap = new Map<string, HTMLElement>();
type Opt = { value: string; label: string; name?: string };
@ -573,7 +542,6 @@ const queried = ref(false);
const queryFailureText = ref('');
const results = ref<any[]>([]);
const queryHint = ref<PriceCheckQueryHint | null>(null);
const hintBannerDismissed = ref(false);
const sortMode = ref<'recommend' | 'price' | 'time'>('recommend');
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');
});
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(
() =>
Boolean(queryHint.value?.disclaimers?.length) ||
@ -764,6 +721,15 @@ function formatMoney(val: unknown) {
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) {
if (cost == null || cost === '') return Infinity;
if (typeof cost === 'number') return cost;
@ -899,13 +865,6 @@ function onPickCargo(code: string) {
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() {
optionsLoading.value = true;
try {
@ -979,7 +938,6 @@ async function handleQuery() {
queryLoading.value = true;
queried.value = true;
queryFailureText.value = '';
hintBannerDismissed.value = false;
clearRecommendInsight();
try {
const length = parseOptionalDimension(form.length);
@ -1407,32 +1365,20 @@ onMounted(() => {
}
.h5-ai-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
padding: 12px;
padding: 10px 12px;
border-radius: 14px;
background: #fff;
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 {
flex: 1;
min-width: 0;
height: 40px;
height: 36px;
border: 1px solid #e5e6eb;
border-radius: 10px;
padding: 0 12px;
@ -1448,7 +1394,7 @@ onMounted(() => {
.h5-ai-bar__btn {
flex-shrink: 0;
height: 40px;
height: 36px;
padding: 0 14px;
border: none;
border-radius: 10px;
@ -1553,50 +1499,6 @@ onMounted(() => {
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 {
display: flex;
flex-wrap: wrap;
@ -1833,13 +1735,6 @@ onMounted(() => {
text-align: center;
}
.quote-ref {
margin: 0;
font-size: 12px;
color: #86909c;
text-align: center;
}
.picker {
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 {
display: inline-flex;
align-items: center;
@ -2078,54 +1964,59 @@ onMounted(() => {
overflow: hidden;
text-overflow: ellipsis;
&--group {
color: #4e5969;
background: #f2f3f5;
}
&--cargo {
color: var(--app-primary-color);
background: color-mix(in srgb, var(--app-primary-color) 12%, #fff);
color: #86909c;
background: #f7f8fa;
}
&--wh {
color: #ff7d00;
background: rgba(255, 125, 0, 0.1);
color: #86909c;
background: #f7f8fa;
}
}
.h5-quote__weight {
font-size: 11px;
color: #86909c;
line-height: 1.3;
white-space: nowrap;
}
.h5-quote__tag {
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 6px;
border-radius: 4px;
font-size: 10px;
.h5-quote__corner-tag {
position: absolute;
z-index: 2;
top: 0;
right: 0;
max-width: calc(100% - 2px);
padding: 1px 4px;
border-radius: 3px;
font-size: 9px;
font-weight: 700;
line-height: 1;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
&.is-time {
color: #007979;
background: rgba(0, 121, 121, 0.1);
}
&.is-price {
color: #f53f3f;
background: rgba(245, 63, 63, 0.1);
}
&.is-time {
color: #165dff;
background: rgba(22, 93, 255, 0.1);
}
}
.h5-quote__eta {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
min-width: 56px;
padding: 0 6px;
min-height: 42px;
padding: 10px 6px 0;
text-align: center;
justify-self: center;
}
@ -2133,7 +2024,7 @@ onMounted(() => {
.h5-quote__eta-value {
font-size: 18px;
font-weight: 800;
color: var(--app-primary-color);
color: #007979;
line-height: 1.1;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
@ -2147,9 +2038,12 @@ onMounted(() => {
}
.h5-quote__price-block {
position: relative;
text-align: right;
justify-self: end;
min-width: 0;
min-height: 42px;
padding-top: 10px;
}
.ref-label {
@ -2181,11 +2075,15 @@ onMounted(() => {
margin-top: 10px;
padding-top: 8px;
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 {

View File

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

View File

@ -18,20 +18,14 @@
postOrderDelete,
postOrderPrint,
postOrderExport,
postOrderTrackUpsertSmallBag,
postOrderDownstreamForwardCreateOne,
postOrderBatchJob,
waitOrderBatchJob,
formatBatchJobTrackUpsertResultText,
formatBatchJobDownstreamForwardResultText,
isOrderBatchQueueDisabledError,
formatBatchJobFailureMessage,
formatBatchJobSummaryMessage,
resolveBatchJobItemRowLabel,
parseBatchJobPrintUrl,
postOrderHandleStatusUpdate,
type OrderTrackUpsertResult,
type OrderDownstreamForwardResult,
} from '@/api/order';
import { notifyCustomerBalanceChanged } from '@/api/customer/accountFlow';
import { buildFailGuideFromReason } from '@/utils/parcelCreateFailGuide';
@ -227,17 +221,6 @@
/** 按当前状态收敛工具栏,避免客户在无关 Tab 看到一排无效按钮 */
const showPayAction = computed(() => isActiveTab('all', 'pendingPayment'));
const showPredeclareAction = computed(() => isActiveTab('all', 'draft', 'predeclared'));
const showDownstreamAction = computed(() =>
isActiveTab(
'all',
'draft',
'predeclared',
'received',
'pendingPayment',
'pendingShip',
'pendingOnline',
),
);
const showPrintAction = computed(() =>
isActiveTab(
'all',
@ -258,9 +241,6 @@
isActiveTab('all', 'pendingShip', 'pendingOnline', 'shipping'),
);
const showDeleteAction = computed(() => isActiveTab('all', 'draft', 'predeclared'));
const showCreateTrackAction = computed(() =>
isActiveTab('all', 'pendingOnline', 'shipping', 'deliverySuccess', 'deliveryFailed'),
);
const problematicModalVisible = ref(false);
const claimedModalVisible = ref(false);
@ -394,31 +374,6 @@
const batchPrintVisible = ref(false);
const singlePrintItems = 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: {
action: string;
rows: any[];
@ -466,121 +421,6 @@
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 {
if (row == null) return undefined;
const id = row.id ?? row.Id;
@ -1053,7 +893,6 @@
if (key === 'stockBind') return openStockBindModal();
if (key === 'createProblematic') return openProblematicModal();
if (key === 'createClaimed') return openClaimedModal();
if (key === 'createTrackOrder') return handleCreateTrackOrder();
if (key === 'intercept') return handleIntercept();
if (key === 'cancelIntercept') return handleCancelIntercept();
if (key === 'delete') return handleDelete();
@ -1331,13 +1170,6 @@
:disabled="isActionBusy('predeclare')"
@click="handlePreDeclare"
>{{ 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
v-if="showPrintAction"
type="primary"
@ -1372,11 +1204,6 @@
:disabled="isActionBusy('markClaimed')"
@click="onMoreActionClick('createClaimed')"
>{{ 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
v-if="showInterceptActions"
@click="onMoreActionClick('intercept')"
@ -1534,24 +1361,6 @@
<OrderPrintModal v-model:visible="singlePrintVisible" mode="single" :items="singlePrintItems" />
<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
v-model:visible="problematicModalVisible"
: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) {
height: 32px;
padding: 0 4px;
@ -2138,17 +1928,4 @@
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>

View File

@ -1,10 +1,6 @@
<template>
<div class="ai-rec" :class="[`ai-rec--${variant || 'bar'}`, { 'has-hint': showPostalHint }]">
<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">
<a-input
v-model="productName"
@ -96,31 +92,6 @@
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 {
display: flex;
align-items: center;

View File

@ -1,13 +1,14 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { computed } from 'vue'
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' })
const props = defineProps<{
record: Record<string, any>
selected?: boolean
recommended?: boolean
}>()
const emit = defineEmits<{
@ -17,7 +18,6 @@
}>()
const { t } = useI18n()
const feeExpanded = ref(false)
function formatMoney(value: string | number | null | undefined) {
if (value == null || value === '') return ''
@ -27,28 +27,36 @@
return num.toFixed(2)
}
const isEstimated = computed(() => {
const level = String(props.record.quoteLevel ?? props.record.QuoteLevel ?? '').toLowerCase()
return level === 'estimated'
const productGroupLabel = computed(() =>
String(props.record.productGroupName || props.record.productGroup || '').trim(),
)
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 group = String(props.record.productGroupName || props.record.productGroup || '').trim()
const cargo = String(props.record.cargoTypeName || '').trim()
const warehouse = String(props.record.warehouse || '').trim()
const tradeTerms = String(props.record.tradeTerms || '').trim().toUpperCase()
const parts = [
group,
cargo,
warehouse,
tradeTerms === 'DDP' || tradeTerms === 'DDU' ? tradeTerms : '',
].filter(Boolean)
return parts.join(' · ')
const hasFeeDetail = computed(() => {
const r = props.record
return (
(r.freight != null && r.freight !== '') ||
(r.otherFees != null && r.otherFees !== '') ||
(r.chargeableWeight != null && r.chargeableWeight !== '') ||
(r.volumetricWeight != null && r.volumetricWeight !== '') ||
!!r.costFormula ||
(Array.isArray(r.otherFeeList) && r.otherFeeList.length > 0)
)
})
function onCardClick(e: MouseEvent) {
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')
}
</script>
@ -56,105 +64,130 @@
<template>
<article
class="quote-card"
:class="{ 'is-selected': selected }"
:class="{ 'is-selected': selected, 'is-recommended': recommended }"
role="button"
tabindex="0"
@click="onCardClick"
@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__left">
<div class="quote-card__name-row">
<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>
<p v-if="metaLine" class="quote-card__meta">{{ metaLine }}</p>
<button
type="button"
class="quote-card__fee-toggle"
@click.stop="feeExpanded = !feeExpanded"
>
{{ feeExpanded ? t('order.priceCheck.card.hideFee') : t('order.priceCheck.card.showFee') }}
<icon-up v-if="feeExpanded" />
<icon-down v-else />
</button>
</div>
<div class="quote-card__time">
<div class="quote-card__metric">
<span class="quote-card__metric-value is-time">{{ record.deliveryTime || '-' }}</span>
<div class="quote-card__cols">
<div class="quote-card__col">
<strong class="quote-card__col-value">{{ productGroupLabel || '-' }}</strong>
<span class="quote-card__col-label">{{ t('order.priceCheck.card.productGroup') }}</span>
</div>
<span class="quote-card__metric-label">{{ t('order.priceCheck.card.eta') }}</span>
</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 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__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>
<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"
>
{{ t('order.priceCheck.tag.timeOptimal') }}
</span>
<strong class="quote-card__col-value is-time">{{ record.deliveryTime || '-' }}</strong>
<span class="quote-card__col-label">{{ t('order.priceCheck.card.eta') }}</span>
</div>
<div class="quote-card__col">
<strong class="quote-card__col-value">{{ tradeTermsLabel || '-' }}</strong>
<span class="quote-card__col-label">{{ t('order.priceCheck.card.tradeTerms') }}</span>
</div>
<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>
<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>
<div class="quote-card__fee-pop" @click.stop>
<div class="quote-card__fee-row">
<span>{{ t('order.priceCheck.table.freight') }}</span>
<span>¥{{ formatMoney(record.freight) || '0' }}</span>
</div>
<div class="quote-card__fee-row">
<span>{{ t('order.priceCheck.table.otherFees') }}</span>
<span>¥{{ formatMoney(record.otherFees) || '0' }}</span>
</div>
<div
v-if="record.chargeableWeight != null && record.chargeableWeight !== ''"
class="quote-card__fee-row"
>
<span>{{ t('order.priceCheck.table.chargeableWeightShort') }}</span>
<span>{{ record.chargeableWeight }} kg</span>
</div>
<div
v-if="record.volumetricWeight != null && record.volumetricWeight !== ''"
class="quote-card__fee-row"
>
<span>{{ t('order.priceCheck.table.volumetricWeightShort') }}</span>
<span>{{ record.volumetricWeight }} kg</span>
</div>
<div v-if="record.costFormula" class="quote-card__fee-formula">
{{ record.costFormula }}
</div>
<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"
>
<span>{{ fee.name || fee.costItem }}</span>
<span>¥{{ formatMoney(fee.amount) }}</span>
</div>
</div>
</div>
</template>
</a-dropdown>
<button type="button" class="ops-btn ops-btn--primary" @click="emit('order')">
{{ t('order.priceCheck.table.orderNow') }}
</button>
</a-popover>
</div>
</div>
</div>
<div v-if="feeExpanded" class="quote-card__fee" @click.stop>
<div class="quote-card__fee-row">
<span>{{ t('order.priceCheck.table.freight') }}</span>
<span>¥{{ formatMoney(record.freight) || '0' }}</span>
</div>
<div class="quote-card__fee-row">
<span>{{ t('order.priceCheck.table.otherFees') }}</span>
<span>¥{{ formatMoney(record.otherFees) || '0' }}</span>
</div>
<div
v-if="record.chargeableWeight != null && record.chargeableWeight !== ''"
class="quote-card__fee-row"
>
<span>{{ t('order.priceCheck.table.chargeableWeightShort') }}</span>
<span>{{ record.chargeableWeight }} kg</span>
</div>
<div
v-if="record.volumetricWeight != null && record.volumetricWeight !== ''"
class="quote-card__fee-row"
>
<span>{{ t('order.priceCheck.table.volumetricWeightShort') }}</span>
<span>{{ record.volumetricWeight }} kg</span>
</div>
<div v-if="record.costFormula" class="quote-card__fee-formula">
{{ record.costFormula }}
</div>
<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">
<span>{{ fee.name || fee.costItem }}</span>
<span>¥{{ formatMoney(fee.amount) }}</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>
<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>
@ -162,34 +195,106 @@
<style lang="less" scoped>
.quote-card {
position: relative;
overflow: visible;
padding: 14px 16px;
border: 1px solid #eef2f6;
border-radius: 10px;
background: #fff;
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 {
border-color: #d7e3e3;
background: #fbfdfe;
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.02);
}
&.is-selected {
border-color: var(--app-primary-color, #007979);
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 {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
gap: 16px 24px;
grid-template-columns: minmax(180px, 1.1fr) minmax(0, 2fr) auto;
gap: 16px 20px;
align-items: center;
}
.quote-card__left {
min-width: 0;
justify-self: start;
}
.quote-card__name-row {
@ -209,93 +314,122 @@
word-break: break-word;
}
.quote-card__tags {
display: inline-flex;
flex-wrap: wrap;
gap: 6px;
.quote-card__cols {
display: grid;
grid-template-columns:
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 {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 0;
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 {
background: rgba(245, 63, 63, 0.03);
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 {
color: #007979;
font-size: 18px;
font-weight: 700;
letter-spacing: -0.03em;
font-variant-numeric: tabular-nums;
}
&.is-price {
color: #f53f3f;
font-size: 20px;
font-weight: 700;
letter-spacing: -0.03em;
font-variant-numeric: tabular-nums;
}
}
.quote-card__col-label {
color: #94a3b8;
font-size: 11px;
font-weight: 500;
line-height: 1;
padding: 3px 6px;
border-radius: 3px;
background: #f1f5f9;
color: #64748b;
&.is-price {
background: rgba(245, 63, 63, 0.08);
color: #f53f3f;
}
&.is-time {
background: rgba(0, 121, 121, 0.08);
color: var(--app-primary-color, #007979);
}
&.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;
letter-spacing: -0.03em;
line-height: 1.1;
font-variant-numeric: tabular-nums;
&.is-time {
color: #1d2129;
}
&.is-price {
color: #f53f3f;
}
}
.quote-card__metric-label {
color: #94a3b8;
font-size: 11px;
white-space: nowrap;
}
.quote-card__actions {
@ -303,6 +437,7 @@
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-shrink: 0;
}
.ops-btn {
@ -337,36 +472,17 @@
}
}
.quote-card__fee-toggle {
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;
.quote-card__fee-pop {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 180px;
}
.quote-card__fee-row {
display: flex;
justify-content: space-between;
gap: 16px;
gap: 20px;
color: #475569;
font-size: 13px;
@ -378,7 +494,7 @@
}
.quote-card__fee-formula {
margin-top: 4px;
margin-top: 2px;
color: #94a3b8;
font-size: 12px;
line-height: 1.5;
@ -389,7 +505,44 @@
display: flex;
flex-direction: column;
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) {
@ -398,17 +551,35 @@
gap: 12px;
}
.quote-card__time {
justify-self: start;
align-items: flex-start;
.quote-card__cols {
width: 100%;
}
.quote-card__right {
justify-self: stretch;
justify-content: space-between;
.quote-card__actions {
justify-content: flex-end;
width: 100%;
padding-top: 10px;
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>

View File

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

View File

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

View File

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

View File

@ -138,7 +138,7 @@
style="width: 100%"
/>
</a-form-item>
<a-form-item :label="fieldLabels.remark" field="remark">
<a-form-item v-if="showRemark" :label="fieldLabels.remark" field="remark">
<a-textarea
v-model="formState.remark"
:placeholder="fieldPlaceholders.remark"
@ -162,6 +162,7 @@ import {
PLATFORM_SHOP_CONFIG_CODES,
TRACK_SERVICE_CONFIG_CODE,
WECHAT_MP_CONFIG_CODE,
WECHAT_MP_FOLLOW_CONFIG_CODE,
createSystemOtherConfig,
deleteSystemOtherConfig,
getSystemOtherConfigDetail,
@ -192,6 +193,7 @@ type FieldMeta = {
hint?: string
showAppKey?: boolean
showAppSecret?: boolean
showRemark?: boolean
requireAppKey?: boolean
requireAppSecret?: boolean
requireBaseUrl?: boolean
@ -242,6 +244,26 @@ const PRESET_DEFS: Array<{
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,
nameKey: 'system.otherConfig.defaults.platformShopName',
@ -376,6 +398,7 @@ const fieldPlaceholders = computed(() => ({
const showAppKey = computed(() => activeMeta.value.showAppKey !== 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) : ''))
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.lazada) return 'Lazada'
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'
return ''
}

View File

@ -179,6 +179,16 @@
</a-option>
</a-select>
</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-textarea
v-model="formState.remark"
@ -434,6 +444,7 @@ function mapCustomerRow(r: any): CustomerRow {
salesPerson: r.salesPerson ?? r.salesman ?? r.Salesman,
memberLevelId: r.memberLevelId ?? r.MemberLevelId,
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: 'status', label: t('user.customer.fields.status'), width: 75 },
{ 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: 'salesPerson', label: t('user.customer.fields.salesPerson'), width: 90 },
{ key: 'customerService', label: t('user.customer.fields.customerService'), width: 90 },
@ -649,11 +661,20 @@ const formState = reactive<Partial<CustomerRow>>({
usedQuota: undefined,
status: CUSTOMER_STATUS.normal,
memberLevelId: undefined,
priceTierKey: '普通会员',
remark: '',
salesPerson: '',
customerService: '',
})
const priceTierOptions = [
{ label: '普通会员', value: '普通会员' },
{ label: '普通客户', value: '普通客户' },
{ label: 'VIP1', value: 'VIP1' },
{ label: 'VIP2', value: 'VIP2' },
{ label: 'VIP3', value: 'VIP3' },
]
const formRules = computed(() => ({
customerName: [{ required: true, message: t('user.customer.rules.customerNameRequired') }],
email: [{ required: true, message: t('user.customer.rules.emailRequired') }],
@ -690,6 +711,7 @@ function handleAdd() {
usedQuota: undefined,
status: CUSTOMER_STATUS.normal,
memberLevelId: undefined,
priceTierKey: '普通会员',
remark: '',
salesPerson: '',
customerService: '',
@ -712,6 +734,7 @@ function handleEdit(record: CustomerRow) {
usedQuota: record.usedQuota,
status: record.status ?? CUSTOMER_STATUS.normal,
memberLevelId: record.memberLevelId ?? undefined,
priceTierKey: record.priceTierKey || '普通客户',
remark: record.remark,
salesPerson: resolveStaffNameForForm(record.salesPerson ?? record.salesman),
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 { getMyInvite, getReferees } from '@/api/marketing/portal'
import { buildInviteRegisterUrl, buildInviteShareText } from '@/utils/inviteLink'
import { preloadInvitePosterBg } from '@/utils/invitePoster'
import InvitePoster from '@/components/InvitePoster.vue'
import PortalPageHeader from '../components/PortalPageHeader.vue'
import PortalHowTo from '../components/PortalHowTo.vue'
@ -288,7 +289,10 @@ async function loadAll() {
}
}
onMounted(() => loadAll())
onMounted(() => {
void preloadInvitePosterBg()
loadAll()
})
</script>
<style scoped lang="less">

View File

@ -14,12 +14,16 @@
<icon-check-circle-fill />
</div>
<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">
<a-button type="primary" long size="large" class="wechat-h5-submit" @click="goCustomerCenter">
{{ t('wechat.bind.goCenter') }}
<a-button type="primary" long size="large" class="wechat-h5-submit" @click="finishAfterFollow">
{{
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 long size="large" @click="goLogin">{{ t('wechat.bind.goLogin') }}</a-button>
</div>
</div>
@ -54,6 +58,18 @@
</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">
<section class="wechat-h5-hero">
<img
@ -127,7 +143,7 @@
<div v-else class="wechat-h5-loading">
<a-spin :size="32" />
<p class="wechat-h5-hint">{{ t('wechat.common.loading') }}</p>
<p class="wechat-h5-hint">{{ loadingHint }}</p>
</div>
</template>
</div>
@ -149,7 +165,7 @@
</template>
<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 { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue';
@ -163,22 +179,49 @@ import {
import { handleApiError } from '@/utils/rbac-error-handler';
import { redirectToWeChatOAuth } from '@/utils/wechatOAuth';
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';
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 route = useRoute();
const router = useRouter();
function goCustomerCenter() {
router.push('/h5/me');
router.replace('/h5/me');
}
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() {
@ -194,12 +237,25 @@ function retryOAuth() {
startOAuth();
}
function retryLoad() {
const code = route.query.code;
if (typeof code === 'string' && code) {
void loadOAuthUserInfo(code);
return;
}
startOAuth();
}
const formRef = ref<FormInstance>();
const success = ref(false);
const loggedInAfterBind = ref(false);
const followJumping = ref(false);
const submitting = ref(false);
const statusCode = ref<PageStatus>('loading');
const loadingHint = ref('');
const authModalVisible = ref(false);
const wxUserInfo = ref<WeChatOAuthUserInfo | null>(null);
let loadingSlowTimer: number | undefined;
const formState = reactive({
account: '',
@ -216,9 +272,28 @@ const formRules: Record<string, FieldRule | FieldRule[]> = {
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) {
beginLoading();
try {
const info = await getOAuthUserInfo(code);
const info = await withTimeout(getOAuthUserInfo(code), OAUTH_LOAD_TIMEOUT_MS);
if (!info?.openid) {
statusCode.value = 'oauth-error';
return;
@ -230,8 +305,14 @@ async function loadOAuthUserInfo(code: string) {
wxUserInfo.value = info;
statusCode.value = 'ready';
} catch (error) {
if (isTimeoutError(error)) {
statusCode.value = 'network-error';
return;
}
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'));
const loggedIn = await loginCustomerAfterWechatAuth({
loggedInAfterBind.value = await loginCustomerAfterWechatAuth({
account: formState.account.trim(),
password: formState.password,
redirectPath: '/h5/me',
skipRedirect: true,
});
if (!loggedIn) {
success.value = true;
}
success.value = true;
void openOfficialAccount();
} catch (error) {
handleApiError(error);
} finally {
@ -270,6 +350,8 @@ async function handleSubmit() {
}
onMounted(() => {
hideWeChatBrowserNav();
loadingHint.value = t('wechat.common.loading');
const code = route.query.code;
if (typeof code !== 'string' || !code) {
statusCode.value = 'need-auth';
@ -278,6 +360,10 @@ onMounted(() => {
}
void loadOAuthUserInfo(code);
});
onBeforeUnmount(() => {
clearLoadingTimers();
});
</script>
<style lang="less" scoped>

View File

@ -1,5 +1,10 @@
export default {
'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.authTitle': 'WeChat authorization',
'wechat.common.authDesc':
@ -11,6 +16,15 @@ export default {
'wechat.common.authBindGateTitle': 'Bind WeChat',
'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.subtitle': 'WeChat name and a default password are ready — confirm to finish',
'wechat.register.success': 'Registered',

View File

@ -1,5 +1,9 @@
export default {
'wechat.common.loading': '正在获取微信授权…',
'wechat.common.loadingSlow': '网络较忙,仍在加载中,请稍候…',
'wechat.common.networkError': '加载失败',
'wechat.common.networkErrorTip': '请检查网络后重试;若持续失败,可切换网络或稍后再打开',
'wechat.common.retryLoad': '重新加载',
'wechat.common.retry': '重新授权',
'wechat.common.authTitle': '微信授权',
'wechat.common.authDesc':
@ -11,6 +15,13 @@ export default {
'wechat.common.authBindGateTitle': '开始绑定微信',
'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.subtitle': '已带入微信昵称与初始密码,确认后即可完成',
'wechat.register.success': '注册成功',

View File

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