feat: ?????????????????/???????
??????????????????????????????? H5/????? Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
c6a4484448
commit
bd97ed76fd
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@ -0,0 +1,5 @@
|
||||
# 镜像只需要 dist/ 与 nginx-docker.conf,其余一律不进构建上下文
|
||||
*
|
||||
!dist/
|
||||
!dist/**
|
||||
!nginx-docker.conf
|
||||
97
index.html
97
index.html
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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', {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '页面跳转失败,请刷新后重试',
|
||||
|
||||
@ -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': '创建时间',
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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' });
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
16
src/utils/wechatFollowJump.ts
Normal file
16
src/utils/wechatFollowJump.ts
Normal 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
24
src/utils/withTimeout.ts
Normal 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';
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
},
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '长按海报识别二维码,或保存后发给好友',
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 ''
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '注册成功',
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user