feat: H5 访客可逛四 Tab,登录门禁与微信注册引导优化
All checks were successful
Build and Deploy Front / deploy (push) Successful in 6m58s
All checks were successful
Build and Deploy Front / deploy (push) Successful in 6m58s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
88909258cc
commit
2dd3e656a3
@ -25,6 +25,7 @@ xhs-agent-desktop
|
||||
|
||||
.env*
|
||||
!.env.production
|
||||
!.env.ideship-test
|
||||
|
||||
# 本地简易镜像另用 Dockerfile;k3s 构建用 Dockerfile.k3s
|
||||
Dockerfile
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
# push master → prod;push test → test
|
||||
# 构建始终用 prod kubeconfig(Kaniko);仅 deploy 切集群
|
||||
# 前端镜像:test 分支须传 FRONT_BUILD_SCRIPT=build:test,否则会打进正式 API 域名
|
||||
# 构建变慢时:确认宿主机 ci-publish-front.sh 已给 Kaniko --cache=true(lock 不变时应跳过 pnpm install)
|
||||
name: Build and Deploy Front
|
||||
|
||||
on:
|
||||
|
||||
@ -5,23 +5,39 @@
|
||||
# prod 默认 build(.env.production → api.ideship.com)
|
||||
# test 传 build:test(.env.ideship-test → api.test.ideship.com)
|
||||
# 即使误用 production 构建,运行时也会按 hostname 纠正到测试 API。
|
||||
#
|
||||
# 构建耗时提示(宿主机 ci-publish-front.sh):
|
||||
# 务必给 Kaniko 开 --cache=true(及 cache-repo / cache-ttl),
|
||||
# 否则每次都会全量 pnpm install + vite build(本地 vite alone 约 3~4 分钟)。
|
||||
|
||||
# ---------- 运行时 serve:与源码无关,Kaniko 有 cache 时几乎秒过 ----------
|
||||
FROM docker.m.daocloud.io/library/node:20-alpine AS serve-runtime
|
||||
RUN npm config set registry https://registry.npmmirror.com \
|
||||
&& npm install -g serve@14.2.4
|
||||
|
||||
# ---------- 前端构建 ----------
|
||||
FROM docker.m.daocloud.io/library/node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
ARG FRONT_BUILD_SCRIPT=build
|
||||
RUN npm config set registry https://registry.npmmirror.com \
|
||||
&& npm install -g pnpm@9.15.9
|
||||
|
||||
# corepack 启用内置 pnpm,避免每次 npm install -g
|
||||
RUN corepack enable \
|
||||
&& corepack prepare pnpm@9.15.9 --activate \
|
||||
&& pnpm config set registry https://registry.npmmirror.com
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
# --ignore-scripts:跳过 gifsicle/optipng 等原生编译(需 autoreconf;vite 未启用 imagemin)
|
||||
RUN pnpm config set registry https://registry.npmmirror.com \
|
||||
&& pnpm install --frozen-lockfile --ignore-scripts
|
||||
# 先只装依赖,lock 不变时可命中 Kaniko 层缓存(最大收益)
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
COPY . .
|
||||
RUN pnpm run ${FRONT_BUILD_SCRIPT}
|
||||
|
||||
# 仅静态托管 + SPA fallback;不再内置 nginx(网关层 Traefik 负责 HTTPS)
|
||||
# ---------- 最终镜像:只拷 serve + dist ----------
|
||||
FROM docker.m.daocloud.io/library/node:20-alpine
|
||||
WORKDIR /app
|
||||
RUN npm config set registry https://registry.npmmirror.com \
|
||||
&& npm install -g serve@14.2.4
|
||||
COPY --from=serve-runtime /usr/local/lib/node_modules/serve /usr/local/lib/node_modules/serve
|
||||
COPY --from=serve-runtime /usr/local/bin/serve /usr/local/bin/serve
|
||||
COPY --from=build /app/dist ./dist
|
||||
EXPOSE 80
|
||||
CMD ["serve", "-s", "dist", "-l", "80"]
|
||||
|
||||
179
src/App.vue
179
src/App.vue
@ -2,7 +2,7 @@
|
||||
<a-config-provider :locale="locale">
|
||||
<div
|
||||
class="app-portal-stage"
|
||||
:class="{ 'app-portal-stage--flip': isPageFlip }"
|
||||
:class="{ 'app-portal-stage--iris': isPageIris }"
|
||||
>
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<Transition
|
||||
@ -36,9 +36,12 @@
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
/** '' | 'oms-page-flip' */
|
||||
/** '' | 'oms-iris' — 中心虹膜散开(官网→登录 / 登录互切) */
|
||||
const portalTransition = ref('');
|
||||
const isPageFlip = computed(() => portalTransition.value === 'oms-page-flip');
|
||||
const isPageIris = computed(() => portalTransition.value === 'oms-iris');
|
||||
|
||||
const IRIS_MS = 1000;
|
||||
const IRIS_EASE = 'cubic-bezier(0.22, 1, 0.36, 1)';
|
||||
|
||||
function clearPortalTransition() {
|
||||
portalTransition.value = '';
|
||||
@ -57,9 +60,7 @@
|
||||
node.style.width = '100%';
|
||||
node.style.minHeight = '100%';
|
||||
node.style.zIndex = String(z);
|
||||
node.style.transformStyle = 'preserve-3d';
|
||||
node.style.backfaceVisibility = 'hidden';
|
||||
node.style.willChange = 'transform, opacity, filter';
|
||||
node.style.willChange = 'clip-path, opacity, filter';
|
||||
}
|
||||
|
||||
function clearSheetStyles(node: HTMLElement) {
|
||||
@ -68,122 +69,68 @@
|
||||
node.style.width = '';
|
||||
node.style.minHeight = '';
|
||||
node.style.zIndex = '';
|
||||
node.style.transformStyle = '';
|
||||
node.style.backfaceVisibility = '';
|
||||
node.style.willChange = '';
|
||||
node.style.transform = '';
|
||||
node.style.transformOrigin = '';
|
||||
node.style.filter = '';
|
||||
node.style.opacity = '';
|
||||
node.style.boxShadow = '';
|
||||
node.style.clipPath = '';
|
||||
node.style.removeProperty('-webkit-clip-path');
|
||||
}
|
||||
|
||||
/**
|
||||
* 真翻书:书脊在左固定,右上角往左掀。
|
||||
* 多段缓动;新页在底下同步亮起。
|
||||
* 中心虹膜:旧页留底层轻压暗;新页 clip-path 从中心向外展开。
|
||||
*/
|
||||
function playBookFlipLeave(node: HTMLElement, done: () => void) {
|
||||
prepareSheet(node, 4);
|
||||
node.style.transformOrigin = '0% 22%';
|
||||
node.style.boxShadow = '-8px 12px 28px rgba(8, 18, 28, 0.16)';
|
||||
|
||||
const shade = document.createElement('div');
|
||||
shade.className = 'oms-flip-shade';
|
||||
node.appendChild(shade);
|
||||
|
||||
function playIrisLeave(node: HTMLElement, done: () => void) {
|
||||
prepareSheet(node, 1);
|
||||
const anim = node.animate(
|
||||
[
|
||||
{
|
||||
transform: 'rotateY(0deg)',
|
||||
filter: 'brightness(1)',
|
||||
opacity: 1,
|
||||
boxShadow: '-6px 10px 24px rgba(8, 18, 28, 0.12)',
|
||||
offset: 0,
|
||||
},
|
||||
{
|
||||
transform: 'rotateY(-8deg)',
|
||||
filter: 'brightness(0.99)',
|
||||
opacity: 1,
|
||||
boxShadow: '-10px 12px 28px rgba(8, 18, 28, 0.16)',
|
||||
offset: 0.18,
|
||||
},
|
||||
{
|
||||
transform: 'rotateY(-28deg)',
|
||||
filter: 'brightness(0.97)',
|
||||
opacity: 1,
|
||||
boxShadow: '-16px 14px 34px rgba(8, 18, 28, 0.2)',
|
||||
offset: 0.36,
|
||||
},
|
||||
{
|
||||
transform: 'rotateY(-58deg)',
|
||||
filter: 'brightness(0.94)',
|
||||
opacity: 1,
|
||||
boxShadow: '-22px 12px 40px rgba(8, 18, 28, 0.24)',
|
||||
offset: 0.55,
|
||||
},
|
||||
{
|
||||
transform: 'rotateY(-98deg)',
|
||||
filter: 'brightness(0.9)',
|
||||
opacity: 0.92,
|
||||
boxShadow: '-26px 10px 44px rgba(8, 18, 28, 0.26)',
|
||||
offset: 0.74,
|
||||
},
|
||||
{
|
||||
transform: 'rotateY(-138deg)',
|
||||
filter: 'brightness(0.84)',
|
||||
opacity: 0.7,
|
||||
boxShadow: '-18px 8px 32px rgba(8, 18, 28, 0.18)',
|
||||
offset: 0.9,
|
||||
},
|
||||
{
|
||||
transform: 'rotateY(-162deg)',
|
||||
filter: 'brightness(0.78)',
|
||||
opacity: 0.28,
|
||||
boxShadow: '-6px 6px 18px rgba(8, 18, 28, 0.1)',
|
||||
offset: 1,
|
||||
},
|
||||
{ opacity: 1, filter: 'brightness(1)', offset: 0 },
|
||||
{ opacity: 0.92, filter: 'brightness(0.94)', offset: 0.45 },
|
||||
{ opacity: 0.82, filter: 'brightness(0.88)', offset: 1 },
|
||||
],
|
||||
{
|
||||
duration: 1480,
|
||||
easing: 'cubic-bezier(0.33, 0.0, 0.2, 1)',
|
||||
duration: IRIS_MS,
|
||||
easing: IRIS_EASE,
|
||||
fill: 'forwards',
|
||||
},
|
||||
);
|
||||
|
||||
const shadeAnim = shade.animate(
|
||||
[
|
||||
{ opacity: 0, offset: 0 },
|
||||
{ opacity: 0.25, offset: 0.18 },
|
||||
{ opacity: 0.55, offset: 0.45 },
|
||||
{ opacity: 0.7, offset: 0.7 },
|
||||
{ opacity: 0.2, offset: 1 },
|
||||
],
|
||||
{
|
||||
duration: 1480,
|
||||
easing: 'cubic-bezier(0.33, 0.0, 0.2, 1)',
|
||||
fill: 'forwards',
|
||||
},
|
||||
);
|
||||
|
||||
Promise.all([anim.finished, shadeAnim.finished])
|
||||
anim.finished
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
shade.remove();
|
||||
done();
|
||||
});
|
||||
}
|
||||
|
||||
function playBookFlipEnter(node: HTMLElement, done: () => void) {
|
||||
prepareSheet(node, 1);
|
||||
function playIrisEnter(node: HTMLElement, done: () => void) {
|
||||
prepareSheet(node, 4);
|
||||
node.style.clipPath = 'circle(0% at 50% 50%)';
|
||||
node.style.setProperty('-webkit-clip-path', 'circle(0% at 50% 50%)');
|
||||
|
||||
const anim = node.animate(
|
||||
[
|
||||
{ opacity: 0.88, filter: 'brightness(0.9)', offset: 0 },
|
||||
{ opacity: 0.94, filter: 'brightness(0.94)', offset: 0.45 },
|
||||
{ opacity: 1, filter: 'brightness(1)', offset: 1 },
|
||||
{
|
||||
clipPath: 'circle(0% at 50% 50%)',
|
||||
opacity: 1,
|
||||
filter: 'brightness(0.98)',
|
||||
offset: 0,
|
||||
},
|
||||
{
|
||||
clipPath: 'circle(42% at 50% 50%)',
|
||||
opacity: 1,
|
||||
filter: 'brightness(1)',
|
||||
offset: 0.42,
|
||||
},
|
||||
{
|
||||
clipPath: 'circle(150% at 50% 50%)',
|
||||
opacity: 1,
|
||||
filter: 'brightness(1)',
|
||||
offset: 1,
|
||||
},
|
||||
],
|
||||
{
|
||||
duration: 1480,
|
||||
easing: 'cubic-bezier(0.33, 0.0, 0.2, 1)',
|
||||
duration: IRIS_MS,
|
||||
easing: IRIS_EASE,
|
||||
fill: 'forwards',
|
||||
},
|
||||
);
|
||||
@ -197,19 +144,19 @@
|
||||
}
|
||||
|
||||
function onPortalLeave(el: Element, done: () => void) {
|
||||
if (portalTransition.value !== 'oms-page-flip') {
|
||||
if (portalTransition.value !== 'oms-iris') {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
playBookFlipLeave(el as HTMLElement, done);
|
||||
playIrisLeave(el as HTMLElement, done);
|
||||
}
|
||||
|
||||
function onPortalEnter(el: Element, done: () => void) {
|
||||
if (portalTransition.value !== 'oms-page-flip') {
|
||||
if (portalTransition.value !== 'oms-iris') {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
playBookFlipEnter(el as HTMLElement, done);
|
||||
playIrisEnter(el as HTMLElement, done);
|
||||
}
|
||||
|
||||
function isStandalonePortalPath(path: string) {
|
||||
@ -252,7 +199,7 @@
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if ((armed || siteToLogin || loginPortalSwitch) && !reduceMotion) {
|
||||
portalTransition.value = 'oms-page-flip';
|
||||
portalTransition.value = 'oms-iris';
|
||||
} else {
|
||||
portalTransition.value = '';
|
||||
}
|
||||
@ -317,36 +264,12 @@ html body .main-menu .menu-text {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.app-portal-stage--flip {
|
||||
.app-portal-stage--iris {
|
||||
position: relative;
|
||||
perspective: 2600px;
|
||||
perspective-origin: 78% 16%;
|
||||
overflow: hidden;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
background: #0b1620;
|
||||
}
|
||||
|
||||
.oms-flip-shade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(0, 0, 0, 0.28) 0%,
|
||||
rgba(0, 0, 0, 0.1) 14%,
|
||||
rgba(0, 0, 0, 0.02) 28%,
|
||||
transparent 46%
|
||||
),
|
||||
linear-gradient(
|
||||
215deg,
|
||||
rgba(255, 255, 255, 0.22) 0%,
|
||||
rgba(255, 255, 255, 0.05) 18%,
|
||||
transparent 42%
|
||||
);
|
||||
mix-blend-mode: multiply;
|
||||
opacity: 0;
|
||||
/* 与登录页灰底一致,避免虹膜展开时露黑缝 */
|
||||
background: var(--app-page-bg, #f0f3f4);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1742,6 +1742,50 @@ export async function getOrderPriceCheckShippingOptions(): Promise<PriceCheckShi
|
||||
.filter((item) => item.products.length > 0)
|
||||
}
|
||||
|
||||
/** 查价表单下拉(匿名可读):国家 / 发货仓 / 货物属性 */
|
||||
export interface PriceCheckFormOptions {
|
||||
countries: Array<{ countryCode: string; chineseName: string; englishName: string }>
|
||||
warehouses: Array<{ id: number; warehouseName: string }>
|
||||
cargoAttributes: Array<{ dictCode: string; dictName: string }>
|
||||
}
|
||||
|
||||
/** GET /order/price-check/form-options */
|
||||
export async function getOrderPriceCheckFormOptions(): Promise<PriceCheckFormOptions> {
|
||||
const res = await axiosInstance.get(orderUrl('/order/price-check/form-options'))
|
||||
const body = res.data
|
||||
assertBusinessOk(body)
|
||||
const payload = body?.data ?? body?.Data ?? body ?? {}
|
||||
const countriesRaw = payload?.countries ?? payload?.Countries ?? []
|
||||
const warehousesRaw = payload?.warehouses ?? payload?.Warehouses ?? []
|
||||
const cargoRaw = payload?.cargoAttributes ?? payload?.CargoAttributes ?? []
|
||||
return {
|
||||
countries: (Array.isArray(countriesRaw) ? countriesRaw : [])
|
||||
.map((row: any) => {
|
||||
const countryCode = String(row?.countryCode ?? row?.CountryCode ?? '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
const chineseName = String(row?.chineseName ?? row?.ChineseName ?? '').trim()
|
||||
const englishName = String(row?.englishName ?? row?.EnglishName ?? '').trim()
|
||||
return { countryCode, chineseName, englishName }
|
||||
})
|
||||
.filter((c) => c.countryCode),
|
||||
warehouses: (Array.isArray(warehousesRaw) ? warehousesRaw : [])
|
||||
.map((row: any) => {
|
||||
const id = Number(row?.id ?? row?.Id)
|
||||
const warehouseName = String(row?.warehouseName ?? row?.WarehouseName ?? '').trim()
|
||||
return { id, warehouseName: warehouseName || String(id) }
|
||||
})
|
||||
.filter((w) => Number.isFinite(w.id) && w.id > 0),
|
||||
cargoAttributes: (Array.isArray(cargoRaw) ? cargoRaw : [])
|
||||
.map((row: any) => {
|
||||
const dictCode = String(row?.dictCode ?? row?.DictCode ?? '').trim()
|
||||
const dictName = String(row?.dictName ?? row?.DictName ?? '').trim()
|
||||
return { dictCode, dictName: dictName || dictCode }
|
||||
})
|
||||
.filter((c) => c.dictCode),
|
||||
}
|
||||
}
|
||||
|
||||
export async function postOrderPriceCheck(params: PriceCheckRequest): Promise<{
|
||||
items: any[]
|
||||
failures: PriceCheckFailure[]
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import axios, { AxiosError, AxiosRequestConfig } from 'axios'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import router from '@/router'
|
||||
import { OpenAPI, clearAuthToken, OmsLoginType } from './index'
|
||||
import { OpenAPI } from './index'
|
||||
import { useRbacUserStore } from '@/store/modules/rbac-user'
|
||||
import { resolvePostLogoutLogin } from '@/utils/login-portal'
|
||||
import { handleSessionExpired } from '@/utils/auth-session-expired'
|
||||
|
||||
// 创建自定义 axios 实例
|
||||
// baseURL 为空;远端由 OpenAPI.BASE / omsUrl 按 env 拼接(本地走 Vite 代理到测试环境)
|
||||
@ -31,35 +30,6 @@ function onTokenRefreshed(token: string) {
|
||||
refreshSubscribers = []
|
||||
}
|
||||
|
||||
function redirectToLoginPage(
|
||||
userStore: ReturnType<typeof useRbacUserStore>,
|
||||
loginType?: OmsLoginType | null
|
||||
) {
|
||||
const current = router.currentRoute.value
|
||||
const target = resolvePostLogoutLogin({
|
||||
loginType: loginType !== undefined ? loginType : userStore.resolveLoginTypeForRedirect(),
|
||||
path: current.fullPath,
|
||||
})
|
||||
if (target.path) {
|
||||
router.replace({
|
||||
path: target.path,
|
||||
query: target.query,
|
||||
})
|
||||
return
|
||||
}
|
||||
router.replace({
|
||||
name: target.name,
|
||||
query: target.query,
|
||||
})
|
||||
}
|
||||
|
||||
function logoutAndRedirectToLogin(userStore: ReturnType<typeof useRbacUserStore>, message: string) {
|
||||
const loginType = userStore.resolveLoginTypeForRedirect()
|
||||
userStore.logout()
|
||||
Message.error(message)
|
||||
redirectToLoginPage(userStore, loginType)
|
||||
}
|
||||
|
||||
// 请求拦截器
|
||||
axiosInstance.interceptors.request.use(
|
||||
async (config: AxiosRequestConfig) => {
|
||||
@ -152,14 +122,18 @@ axiosInstance.interceptors.response.use(
|
||||
|
||||
// 401:保活窗内(含 F5 从 lastActivity 恢复的 idle 窗)才允许续期
|
||||
if (status === 401 && !originalRequest._retry) {
|
||||
// 访客 / 已登出:不走会话过期跳转(避免 H5 可逛页被踢去登录)
|
||||
if (!userStore.isLoggedIn) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
if (!userStore.isKeepAliveActive()) {
|
||||
if (!silentError) logoutAndRedirectToLogin(userStore, '登录已过期,请重新登录')
|
||||
if (!silentError) handleSessionExpired('登录已过期,请重新登录')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 如果没有 refreshToken,直接登出
|
||||
if (!userStore.refreshToken) {
|
||||
if (!silentError) logoutAndRedirectToLogin(userStore, '未授权,请重新登录')
|
||||
if (!silentError) handleSessionExpired('未授权,请重新登录')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
@ -192,11 +166,11 @@ axiosInstance.interceptors.response.use(
|
||||
return axiosInstance(originalRequest)
|
||||
} else {
|
||||
// 刷新失败,登出
|
||||
if (!silentError) logoutAndRedirectToLogin(userStore, '登录已过期,请重新登录')
|
||||
if (!silentError) handleSessionExpired('登录已过期,请重新登录')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
} catch {
|
||||
if (!silentError) logoutAndRedirectToLogin(userStore, '登录已过期,请重新登录')
|
||||
if (!silentError) handleSessionExpired('登录已过期,请重新登录')
|
||||
return Promise.reject(error)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
@ -210,7 +184,7 @@ axiosInstance.interceptors.response.use(
|
||||
switch (status) {
|
||||
case 401:
|
||||
// 如果走到这里,说明401没有被上面的逻辑处理(可能是没有retry标记的情况)
|
||||
logoutAndRedirectToLogin(userStore, '未授权,请重新登录')
|
||||
handleSessionExpired('未授权,请重新登录')
|
||||
break
|
||||
case 403:
|
||||
Message.error('没有权限访问')
|
||||
|
||||
@ -341,7 +341,9 @@ import { getOrderDetail } from '@/api/order';
|
||||
import { CustomerService, type CustomerServiceAdvisorDto } from '@/api/customer';
|
||||
import { getOrderStatusMeta, resolveOrderStatusCode } from '@/constants/orderStatus';
|
||||
import { isValidAvatarUrl } from '@/utils/avatar';
|
||||
import { isLogin } from '@/utils/auth';
|
||||
import { rewriteBrowserApiPath } from '@/utils/rewriteBrowserApiPath';
|
||||
import { ensureH5Login } from '@/views/h5/customer-center/composables/useH5LoginGate';
|
||||
import {
|
||||
getWaybillContextKey,
|
||||
getWaybillDisplayNo,
|
||||
@ -916,14 +918,32 @@ async function loadProvider() {
|
||||
}
|
||||
}
|
||||
|
||||
function gateH5LoginBeforeChat(): boolean {
|
||||
// 仅客户 H5:未登录发消息前引导登录;PC / 管理端不走此门禁
|
||||
if (!String(route.path || '').startsWith('/h5')) return true;
|
||||
if (String(route.path || '').startsWith('/h5/admin')) return true;
|
||||
// 保留当前 fullPath(含 prefill/auto/运单上下文),登录后可续聊
|
||||
return ensureH5Login({
|
||||
reasonKey: 'h5.loginGate.reason.assistant',
|
||||
redirect: route.fullPath || '/h5/assistant',
|
||||
});
|
||||
}
|
||||
|
||||
function isCustomerH5Guest(): boolean {
|
||||
const path = String(route.path || '');
|
||||
return path.startsWith('/h5') && !path.startsWith('/h5/admin') && !isLogin();
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const text = inputText.value.trim();
|
||||
if (!text || chatLoading.value) return;
|
||||
if (!gateH5LoginBeforeChat()) return;
|
||||
await sendUserText(text);
|
||||
}
|
||||
|
||||
async function askWaybillTrack() {
|
||||
if (chatLoading.value || !waybillContext.value) return;
|
||||
if (!gateH5LoginBeforeChat()) return;
|
||||
activeCapability.value = 'track';
|
||||
const text = t('nanaAssistant.waybillAutoAsk', { no: waybillDisplayNo.value });
|
||||
await sendUserText(text);
|
||||
@ -932,6 +952,7 @@ async function askWaybillTrack() {
|
||||
/** 点选三大能力:先给引导话术 + 预填草稿,等客户补全再发 */
|
||||
async function startMcpCapability(id: McpCapabilityId) {
|
||||
if (chatLoading.value) return;
|
||||
if (!gateH5LoginBeforeChat()) return;
|
||||
const cap = getCapability(id);
|
||||
activeCapability.value = id;
|
||||
|
||||
@ -958,10 +979,12 @@ async function startMcpCapability(id: McpCapabilityId) {
|
||||
async function sendQuickTag(tag: { id: string; label: string }) {
|
||||
if (chatLoading.value) return;
|
||||
const def = quickTagDefs.find((item) => item.id === tag.id);
|
||||
// 人工客服不强制登录(与作曲器人工按钮一致)
|
||||
if (def?.human) {
|
||||
openAdvisorView();
|
||||
return;
|
||||
}
|
||||
if (!gateH5LoginBeforeChat()) return;
|
||||
|
||||
if (providerKey.value) {
|
||||
if (tag.id === 'price' || tag.id === 'quote-speed') {
|
||||
@ -1257,8 +1280,11 @@ async function applyPrefillFromRoute() {
|
||||
if (!shouldSkipAutoFocus()) focusInput();
|
||||
|
||||
if (shouldAuto && canSend.value) {
|
||||
await clearPrefillQueryFlags();
|
||||
// 先发送(访客门禁会带上当前 fullPath 含 prefill/auto);成功后再清 query
|
||||
await sendMessage();
|
||||
if (isLogin()) {
|
||||
await clearPrefillQueryFlags();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
prefillApplying = false;
|
||||
@ -1277,7 +1303,10 @@ watch(
|
||||
|
||||
onMounted(() => {
|
||||
loadThreads();
|
||||
loadProvider();
|
||||
// 访客不拉需登录的 provider 列表,避免无效 401
|
||||
if (!isCustomerH5Guest()) {
|
||||
loadProvider();
|
||||
}
|
||||
void applyWaybillContextFromRoute().then(() => applyPrefillFromRoute());
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
// 手机/微信:禁止进页自动聚焦,避免整页被放大
|
||||
|
||||
@ -3,9 +3,11 @@ import localeWechat from '@/views/wechat/locale/en-US';
|
||||
import localeH5Center from '@/views/h5/customer-center/locale/en-US';
|
||||
import localeH5AdminCenter from '@/views/h5/admin-center/locale/en-US';
|
||||
import localeH5Track from '@/views/h5/track-query/locale/en-US';
|
||||
import localeNana from '@/locale/nana/en-US';
|
||||
|
||||
/**
|
||||
* 登录/微信/H5 客户中心轻量英文包(仅在切换到英文或偏好英文时加载)
|
||||
* Includes nanaAssistant for guest H5 advisor tab.
|
||||
*/
|
||||
export default {
|
||||
'app.language': 'Language',
|
||||
@ -15,4 +17,5 @@ export default {
|
||||
...localeH5Track,
|
||||
...localeH5Center,
|
||||
...localeH5AdminCenter,
|
||||
...localeNana,
|
||||
};
|
||||
|
||||
@ -3,9 +3,11 @@ import localeWechat from '@/views/wechat/locale/zh-CN';
|
||||
import localeH5Center from '@/views/h5/customer-center/locale/zh-CN';
|
||||
import localeH5AdminCenter from '@/views/h5/admin-center/locale/zh-CN';
|
||||
import localeH5Track from '@/views/h5/track-query/locale/zh-CN';
|
||||
import localeNana from '@/locale/nana/zh-CN';
|
||||
|
||||
/**
|
||||
* 登录/微信/H5 客户中心轻量语言包(首屏只加载这份,进系统后再合并完整 zh-CN)
|
||||
* 含 nanaAssistant:访客可逛顾问 Tab 时不依赖全量包
|
||||
*/
|
||||
export default {
|
||||
'app.language': '语言',
|
||||
@ -15,4 +17,5 @@ export default {
|
||||
...localeH5Track,
|
||||
...localeH5Center,
|
||||
...localeH5AdminCenter,
|
||||
...localeNana,
|
||||
};
|
||||
|
||||
@ -11,6 +11,7 @@ import localeMonitor from '@/views/home/monitor/locale/en-US';
|
||||
import localeSettings from './en-US/settings';
|
||||
import marketingEn from './marketing/en-US';
|
||||
import kbEn from './kb/en-US';
|
||||
import localeNana from './nana/en-US';
|
||||
|
||||
export default {
|
||||
// Common
|
||||
@ -1694,117 +1695,7 @@ export default {
|
||||
'nav.contactService.phone': 'Contact phone',
|
||||
'nav.contactService.workingTime': 'Service hours: Mon-Sun 9:00-22:00',
|
||||
'nav.contactService.contactUs': 'Contact support',
|
||||
'nanaAssistant.title': 'Nana · Exclusive Advisor',
|
||||
'nanaAssistant.open': 'Open Nana exclusive advisor',
|
||||
'nanaAssistant.tabAi': 'AI Nana',
|
||||
'nanaAssistant.tabAdvisor': 'Exclusive Advisor',
|
||||
'nanaAssistant.badge': 'Logistics Advisor',
|
||||
'nanaAssistant.welcomeTitle': 'What do you want to check?',
|
||||
'nanaAssistant.welcomeHello': '{greet}, Xiao Ai at your service',
|
||||
'nanaAssistant.welcomeSub': 'What do you want to check? Tap below, or just type.',
|
||||
'nanaAssistant.greet.morning': 'Good morning',
|
||||
'nanaAssistant.greet.noon': 'Good afternoon',
|
||||
'nanaAssistant.greet.afternoon': 'Good afternoon',
|
||||
'nanaAssistant.greet.evening': 'Good evening',
|
||||
'nanaAssistant.greet.night': 'Still up',
|
||||
'nanaAssistant.quickLabel': 'Common questions',
|
||||
'nanaAssistant.welcomeMsg': "Hi — I'm your IDE logistics advisor. Tap below or type your own question.",
|
||||
'nanaAssistant.historyTitle': 'Chat history',
|
||||
'nanaAssistant.newChat': 'New chat',
|
||||
'nanaAssistant.emptyHistory': 'No conversations yet',
|
||||
'nanaAssistant.searchHistory': 'Search chats or waybill no.',
|
||||
'nanaAssistant.noSearchResults': 'No matching conversations',
|
||||
'nanaAssistant.deleteThread': 'Delete conversation',
|
||||
'nanaAssistant.hint': 'Type a question, or tap an option above.',
|
||||
'nanaAssistant.hintLive': 'Ask about rates, whether it can ship, or where a parcel is.',
|
||||
'nanaAssistant.inputPlaceholder': 'Ask a question…',
|
||||
'nanaAssistant.inputPlaceholderLive': 'Ask about price, channels, or tracking…',
|
||||
'nanaAssistant.inputPlaceholderTrack': 'Enter waybill / tracking no.…',
|
||||
'nanaAssistant.mcp.exampleHint': 'e.g. {sample}',
|
||||
'nanaAssistant.mcp.required': 'Required',
|
||||
'nanaAssistant.mcp.optional': 'Optional',
|
||||
'nanaAssistant.mcp.missingAsk': 'Please also provide: {fields}. Then I can look it up.',
|
||||
'nanaAssistant.mcp.priceTitle': 'Check rates',
|
||||
'nanaAssistant.mcp.priceDesc': 'Estimate freight by country and weight',
|
||||
'nanaAssistant.mcp.priceCoach':
|
||||
'To quote I need destination country and actual weight (kg). Postal code and size help accuracy.\nExample: US 0.5kg',
|
||||
'nanaAssistant.mcp.priceExample': 'Check US 0.5kg rates for me',
|
||||
'nanaAssistant.mcp.priceDraft':
|
||||
'Country:\nWeight (kg):\nPostal code (optional):\nDims LWH cm (optional):',
|
||||
'nanaAssistant.mcp.guideTitle': 'Can this cargo ship?',
|
||||
'nanaAssistant.mcp.guideDesc': 'See if this item can go on this service',
|
||||
'nanaAssistant.mcp.guideCoach':
|
||||
'I need the sales product (service) and the cargo name.\nExample: product XX, cargo lipstick — can it ship?',
|
||||
'nanaAssistant.mcp.guideExample': 'Can lipstick ship?',
|
||||
'nanaAssistant.mcp.guideDraft': 'Sales product code/name:\nCargo name:',
|
||||
'nanaAssistant.mcp.trackTitle': 'Where is my parcel?',
|
||||
'nanaAssistant.mcp.trackDesc': 'See where the parcel is now',
|
||||
'nanaAssistant.mcp.trackCoach':
|
||||
'Send a waybill number. If one is already loaded, tap “Track this shipment”.',
|
||||
'nanaAssistant.mcp.trackExample': 'Where is waybill NFXXXX',
|
||||
'nanaAssistant.mcp.trackDraft': 'Waybill No.:',
|
||||
'nanaAssistant.mcp.field.destCountry': 'Destination destCountry',
|
||||
'nanaAssistant.mcp.field.weight': 'Weight (kg)',
|
||||
'nanaAssistant.mcp.field.postal': 'Postal code',
|
||||
'nanaAssistant.mcp.field.dims': 'Dimensions (cm)',
|
||||
'nanaAssistant.mcp.field.cargoType': 'Cargo type',
|
||||
'nanaAssistant.mcp.field.product': 'Sales product code/name',
|
||||
'nanaAssistant.mcp.field.cargoName': 'Cargo name',
|
||||
'nanaAssistant.mcp.field.customsOrderNo': 'Waybill No.',
|
||||
'nanaAssistant.human': 'Talk to online advisor',
|
||||
'nanaAssistant.wecomKf': 'Chat on WeCom',
|
||||
'nanaAssistant.you': 'You',
|
||||
'nanaAssistant.backToChat': 'Back to chat',
|
||||
'nanaAssistant.quickSmall': 'Small or difficult orders?',
|
||||
'nanaAssistant.quickPrice': 'US / EU shipping cost?',
|
||||
'nanaAssistant.quickQuoteSpeed': 'How fast is a quote?',
|
||||
'nanaAssistant.quickTrack': 'Track my shipment',
|
||||
'nanaAssistant.quickVisibility': 'Real-time tracking?',
|
||||
'nanaAssistant.quickAfterSales': 'Something went wrong?',
|
||||
'nanaAssistant.quickHuman': 'Talk to online advisor',
|
||||
'nanaAssistant.replySmall': 'Yes. IDE focuses on SMB sellers and shipments others decline — special routes, complex cargo, and low-volume orders are in scope. Tell me origin, destination, and cargo type to get started.',
|
||||
'nanaAssistant.replyPrice': 'For a quote, destination destCountry + actual weight (kg) is enough — e.g. US 0.5kg. Postal code/dims help; origin city is not required.',
|
||||
'nanaAssistant.replyQuoteSpeed': 'With destCountry + weight we can return an indicative quote right away; final billing follows inbound weight and product rules.',
|
||||
'nanaAssistant.trackHint': 'Just send the waybill number to track, e.g. IDE2607281746002926',
|
||||
'nanaAssistant.replyVisibility': 'You get milestone updates from pickup to delivery — pickup, export, line-haul, customs, and final delivery. We proactively flag delays before they escalate.',
|
||||
'nanaAssistant.replyAfterSales': "Your dedicated advisor handles exceptions, claims, and after-sales follow-up — you won't be left chasing tickets alone. Describe the issue and tracking number if you have one.",
|
||||
'nanaAssistant.replyHuman': 'Reach us via the contact page — phone 18123959245 or email bd01@1st56.com. Mention your route and cargo so we assign the right advisor.',
|
||||
'nanaAssistant.fallback': 'Tell me destination destCountry + weight to quote, or paste a waybill number to track — or talk to a human advisor.',
|
||||
'nanaAssistant.emptyReply': 'No useful reply came back. Please try again with destination + weight, or a waybill number.',
|
||||
'nanaAssistant.aiUnavailable': 'AI advisor is not configured yet. Please contact a human advisor or try later.',
|
||||
'nanaAssistant.providerLoading': 'Connecting to AI advisor… please send again in a moment.',
|
||||
'nanaAssistant.toolDoneEmpty': 'Called {tools}, but the model returned no summary. Ask “summarize” or contact support.',
|
||||
'nanaAssistant.sendFailedDetail': 'Request failed: {msg}',
|
||||
'nanaAssistant.wechatId': 'WeChat ID',
|
||||
'nanaAssistant.noAdvisor': 'No exclusive advisor assigned yet',
|
||||
'nanaAssistant.noWechatQr': 'WeChat QR code not uploaded',
|
||||
'nanaAssistant.greeting': "Hi, I'm Nana, your AI assistant.\n\nAsk me about tracking, shipping times, declared values, customs clearance, and more. How can I help?",
|
||||
'nanaAssistant.send': 'Send',
|
||||
'nanaAssistant.close': 'Close',
|
||||
'nanaAssistant.back': 'Back',
|
||||
'nanaAssistant.askAboutWaybill': 'Ask logistics advisor',
|
||||
'nanaAssistant.actionAdvisor': 'Advisor',
|
||||
'nanaAssistant.menu.advisorHint': 'Rates, tracking, human help',
|
||||
'nanaAssistant.menu.batchJobs': 'Batch jobs',
|
||||
'nanaAssistant.menu.batchHint': 'Progress and results',
|
||||
'nanaAssistant.batchPanel.title': 'Batch jobs',
|
||||
'nanaAssistant.batchPanel.empty': 'No recent batch jobs',
|
||||
'nanaAssistant.batchPanel.viewAll': 'View all',
|
||||
'nanaAssistant.waybillContext': 'Current waybill: {no}',
|
||||
'nanaAssistant.viewWaybill': 'View waybill →',
|
||||
'nanaAssistant.queryTrack': 'Track shipment',
|
||||
'nanaAssistant.endWaybillConsult': 'End waybill consult',
|
||||
'nanaAssistant.waybillWelcomeTitle': 'How can I help with this shipment?',
|
||||
'nanaAssistant.waybillWelcomeSub': 'Waybill {no} is loaded — track it or ask about fees.',
|
||||
'nanaAssistant.waybillAskTrack': 'Track this shipment',
|
||||
'nanaAssistant.waybillAskFee': 'Ask about fees',
|
||||
'nanaAssistant.waybillAutoAsk': 'Please track waybill {no} for me',
|
||||
'nanaAssistant.trackWaybillDemo': 'Got your request for waybill {no}. When the smart service is configured, we prefer the live API; this is a reference reply: milestones usually include pickup, export, line-haul, customs, and delivery. Your advisor will follow up on exceptions.',
|
||||
'nanaAssistant.copyWechat': 'Copy WeChat ID',
|
||||
'nanaAssistant.copyWechatSuccess': 'WeChat ID copied',
|
||||
'nanaAssistant.copyWechatFailed': 'Copy failed — please copy manually',
|
||||
'nanaAssistant.noProvider': 'AI service is not configured yet. Please contact your administrator.',
|
||||
'nanaAssistant.sendFailed': 'Failed to send. Please try again later.',
|
||||
...localeNana,
|
||||
'nav.balance.label': 'Balance',
|
||||
'nav.balance.refresh': 'Refresh balance',
|
||||
'nav.balance.refreshed': 'Balance updated',
|
||||
|
||||
114
src/locale/nana/en-US.ts
Normal file
114
src/locale/nana/en-US.ts
Normal file
@ -0,0 +1,114 @@
|
||||
/** Nana / AI advisor copy (shared by H5 bootstrap + full locale) */
|
||||
export default {
|
||||
'nanaAssistant.title': 'Nana · Exclusive Advisor',
|
||||
'nanaAssistant.open': 'Open Nana exclusive advisor',
|
||||
'nanaAssistant.tabAi': 'AI Nana',
|
||||
'nanaAssistant.tabAdvisor': 'Exclusive Advisor',
|
||||
'nanaAssistant.badge': 'Logistics Advisor',
|
||||
'nanaAssistant.welcomeTitle': 'What do you want to check?',
|
||||
'nanaAssistant.welcomeHello': '{greet}, Xiao Ai at your service',
|
||||
'nanaAssistant.welcomeSub': 'What do you want to check? Tap below, or just type.',
|
||||
'nanaAssistant.greet.morning': 'Good morning',
|
||||
'nanaAssistant.greet.noon': 'Good afternoon',
|
||||
'nanaAssistant.greet.afternoon': 'Good afternoon',
|
||||
'nanaAssistant.greet.evening': 'Good evening',
|
||||
'nanaAssistant.greet.night': 'Still up',
|
||||
'nanaAssistant.quickLabel': 'Common questions',
|
||||
'nanaAssistant.welcomeMsg': "Hi — I'm your IDE logistics advisor. Tap below or type your own question.",
|
||||
'nanaAssistant.historyTitle': 'Chat history',
|
||||
'nanaAssistant.newChat': 'New chat',
|
||||
'nanaAssistant.emptyHistory': 'No conversations yet',
|
||||
'nanaAssistant.searchHistory': 'Search chats or waybill no.',
|
||||
'nanaAssistant.noSearchResults': 'No matching conversations',
|
||||
'nanaAssistant.deleteThread': 'Delete conversation',
|
||||
'nanaAssistant.hint': 'Type a question, or tap an option above.',
|
||||
'nanaAssistant.hintLive': 'Ask about rates, whether it can ship, or where a parcel is.',
|
||||
'nanaAssistant.inputPlaceholder': 'Ask a question…',
|
||||
'nanaAssistant.inputPlaceholderLive': 'Ask about price, channels, or tracking…',
|
||||
'nanaAssistant.inputPlaceholderTrack': 'Enter waybill / tracking no.…',
|
||||
'nanaAssistant.mcp.exampleHint': 'e.g. {sample}',
|
||||
'nanaAssistant.mcp.required': 'Required',
|
||||
'nanaAssistant.mcp.optional': 'Optional',
|
||||
'nanaAssistant.mcp.missingAsk': 'Please also provide: {fields}. Then I can look it up.',
|
||||
'nanaAssistant.mcp.priceTitle': 'Check rates',
|
||||
'nanaAssistant.mcp.priceDesc': 'Estimate freight by country and weight',
|
||||
'nanaAssistant.mcp.priceCoach':
|
||||
'To quote I need destination country and actual weight (kg). Postal code and size help accuracy.\nExample: US 0.5kg',
|
||||
'nanaAssistant.mcp.priceExample': 'Check US 0.5kg rates for me',
|
||||
'nanaAssistant.mcp.priceDraft':
|
||||
'Country:\nWeight (kg):\nPostal code (optional):\nDims LWH cm (optional):',
|
||||
'nanaAssistant.mcp.guideTitle': 'Can this cargo ship?',
|
||||
'nanaAssistant.mcp.guideDesc': 'See if this item can go on this service',
|
||||
'nanaAssistant.mcp.guideCoach':
|
||||
'I need the sales product (service) and the cargo name.\nExample: product XX, cargo lipstick — can it ship?',
|
||||
'nanaAssistant.mcp.guideExample': 'Can lipstick ship?',
|
||||
'nanaAssistant.mcp.guideDraft': 'Sales product code/name:\nCargo name:',
|
||||
'nanaAssistant.mcp.trackTitle': 'Where is my parcel?',
|
||||
'nanaAssistant.mcp.trackDesc': 'See where the parcel is now',
|
||||
'nanaAssistant.mcp.trackCoach':
|
||||
'Send a waybill number. If one is already loaded, tap “Track this shipment”.',
|
||||
'nanaAssistant.mcp.trackExample': 'Where is waybill NFXXXX',
|
||||
'nanaAssistant.mcp.trackDraft': 'Waybill No.:',
|
||||
'nanaAssistant.mcp.field.destCountry': 'Destination destCountry',
|
||||
'nanaAssistant.mcp.field.weight': 'Weight (kg)',
|
||||
'nanaAssistant.mcp.field.postal': 'Postal code',
|
||||
'nanaAssistant.mcp.field.dims': 'Dimensions (cm)',
|
||||
'nanaAssistant.mcp.field.cargoType': 'Cargo type',
|
||||
'nanaAssistant.mcp.field.product': 'Sales product code/name',
|
||||
'nanaAssistant.mcp.field.cargoName': 'Cargo name',
|
||||
'nanaAssistant.mcp.field.customsOrderNo': 'Waybill No.',
|
||||
'nanaAssistant.human': 'Talk to online advisor',
|
||||
'nanaAssistant.wecomKf': 'Chat on WeCom',
|
||||
'nanaAssistant.you': 'You',
|
||||
'nanaAssistant.backToChat': 'Back to chat',
|
||||
'nanaAssistant.quickSmall': 'Small or difficult orders?',
|
||||
'nanaAssistant.quickPrice': 'US / EU shipping cost?',
|
||||
'nanaAssistant.quickQuoteSpeed': 'How fast is a quote?',
|
||||
'nanaAssistant.quickTrack': 'Track my shipment',
|
||||
'nanaAssistant.quickVisibility': 'Real-time tracking?',
|
||||
'nanaAssistant.quickAfterSales': 'Something went wrong?',
|
||||
'nanaAssistant.quickHuman': 'Talk to online advisor',
|
||||
'nanaAssistant.replySmall': 'Yes. IDE focuses on SMB sellers and shipments others decline — special routes, complex cargo, and low-volume orders are in scope. Tell me origin, destination, and cargo type to get started.',
|
||||
'nanaAssistant.replyPrice': 'For a quote, destination destCountry + actual weight (kg) is enough — e.g. US 0.5kg. Postal code/dims help; origin city is not required.',
|
||||
'nanaAssistant.replyQuoteSpeed': 'With destCountry + weight we can return an indicative quote right away; final billing follows inbound weight and product rules.',
|
||||
'nanaAssistant.trackHint': 'Just send the waybill number to track, e.g. IDE2607281746002926',
|
||||
'nanaAssistant.replyVisibility': 'You get milestone updates from pickup to delivery — pickup, export, line-haul, customs, and final delivery. We proactively flag delays before they escalate.',
|
||||
'nanaAssistant.replyAfterSales': "Your dedicated advisor handles exceptions, claims, and after-sales follow-up — you won't be left chasing tickets alone. Describe the issue and tracking number if you have one.",
|
||||
'nanaAssistant.replyHuman': 'Reach us via the contact page — phone 18123959245 or email bd01@1st56.com. Mention your route and cargo so we assign the right advisor.',
|
||||
'nanaAssistant.fallback': 'Tell me destination destCountry + weight to quote, or paste a waybill number to track — or talk to a human advisor.',
|
||||
'nanaAssistant.emptyReply': 'No useful reply came back. Please try again with destination + weight, or a waybill number.',
|
||||
'nanaAssistant.aiUnavailable': 'AI advisor is not configured yet. Please contact a human advisor or try later.',
|
||||
'nanaAssistant.providerLoading': 'Connecting to AI advisor… please send again in a moment.',
|
||||
'nanaAssistant.toolDoneEmpty': 'Called {tools}, but the model returned no summary. Ask “summarize” or contact support.',
|
||||
'nanaAssistant.sendFailedDetail': 'Request failed: {msg}',
|
||||
'nanaAssistant.wechatId': 'WeChat ID',
|
||||
'nanaAssistant.noAdvisor': 'No exclusive advisor assigned yet',
|
||||
'nanaAssistant.noWechatQr': 'WeChat QR code not uploaded',
|
||||
'nanaAssistant.greeting': "Hi, I'm Nana, your AI assistant.\n\nAsk me about tracking, shipping times, declared values, customs clearance, and more. How can I help?",
|
||||
'nanaAssistant.send': 'Send',
|
||||
'nanaAssistant.close': 'Close',
|
||||
'nanaAssistant.back': 'Back',
|
||||
'nanaAssistant.askAboutWaybill': 'Ask logistics advisor',
|
||||
'nanaAssistant.actionAdvisor': 'Advisor',
|
||||
'nanaAssistant.menu.advisorHint': 'Rates, tracking, human help',
|
||||
'nanaAssistant.menu.batchJobs': 'Batch jobs',
|
||||
'nanaAssistant.menu.batchHint': 'Progress and results',
|
||||
'nanaAssistant.batchPanel.title': 'Batch jobs',
|
||||
'nanaAssistant.batchPanel.empty': 'No recent batch jobs',
|
||||
'nanaAssistant.batchPanel.viewAll': 'View all',
|
||||
'nanaAssistant.waybillContext': 'Current waybill: {no}',
|
||||
'nanaAssistant.viewWaybill': 'View waybill →',
|
||||
'nanaAssistant.queryTrack': 'Track shipment',
|
||||
'nanaAssistant.endWaybillConsult': 'End waybill consult',
|
||||
'nanaAssistant.waybillWelcomeTitle': 'How can I help with this shipment?',
|
||||
'nanaAssistant.waybillWelcomeSub': 'Waybill {no} is loaded — track it or ask about fees.',
|
||||
'nanaAssistant.waybillAskTrack': 'Track this shipment',
|
||||
'nanaAssistant.waybillAskFee': 'Ask about fees',
|
||||
'nanaAssistant.waybillAutoAsk': 'Please track waybill {no} for me',
|
||||
'nanaAssistant.trackWaybillDemo': 'Got your request for waybill {no}. When the smart service is configured, we prefer the live API; this is a reference reply: milestones usually include pickup, export, line-haul, customs, and delivery. Your advisor will follow up on exceptions.',
|
||||
'nanaAssistant.copyWechat': 'Copy WeChat ID',
|
||||
'nanaAssistant.copyWechatSuccess': 'WeChat ID copied',
|
||||
'nanaAssistant.copyWechatFailed': 'Copy failed — please copy manually',
|
||||
'nanaAssistant.noProvider': 'AI service is not configured yet. Please contact your administrator.',
|
||||
'nanaAssistant.sendFailed': 'Failed to send. Please try again later.',
|
||||
}
|
||||
113
src/locale/nana/zh-CN.ts
Normal file
113
src/locale/nana/zh-CN.ts
Normal file
@ -0,0 +1,113 @@
|
||||
/** Nana / AI 顾问文案(H5 bootstrap 与全量包共用) */
|
||||
export default {
|
||||
'nanaAssistant.title': 'Nana 专属顾问',
|
||||
'nanaAssistant.open': '打开 Nana 专属顾问',
|
||||
'nanaAssistant.tabAi': 'AI Nana',
|
||||
'nanaAssistant.tabAdvisor': '专属顾问',
|
||||
'nanaAssistant.badge': '物流顾问',
|
||||
'nanaAssistant.welcomeTitle': '今天想查点什么?',
|
||||
'nanaAssistant.welcomeHello': '{greet},小艾为您服务',
|
||||
'nanaAssistant.welcomeSub': '今天想查点什么?点下面,或自己打字问我。',
|
||||
'nanaAssistant.greet.morning': '早上好',
|
||||
'nanaAssistant.greet.noon': '中午好',
|
||||
'nanaAssistant.greet.afternoon': '下午好',
|
||||
'nanaAssistant.greet.evening': '晚上好',
|
||||
'nanaAssistant.greet.night': '夜深了',
|
||||
'nanaAssistant.quickLabel': '常见问题',
|
||||
'nanaAssistant.welcomeMsg': '你好,我是 IDE 物流顾问。可以直接问,也可以点下面的入口。',
|
||||
'nanaAssistant.historyTitle': '历史记录',
|
||||
'nanaAssistant.newChat': '新会话',
|
||||
'nanaAssistant.emptyHistory': '暂无会话',
|
||||
'nanaAssistant.searchHistory': '搜索会话或运单号',
|
||||
'nanaAssistant.noSearchResults': '未找到匹配的会话',
|
||||
'nanaAssistant.deleteThread': '删除会话',
|
||||
'nanaAssistant.hint': '直接提问,或点上面的入口。',
|
||||
'nanaAssistant.hintLive': '可以问运费、能不能寄、包裹到哪了。',
|
||||
'nanaAssistant.inputPlaceholder': '输入您的问题…',
|
||||
'nanaAssistant.inputPlaceholderLive': '',
|
||||
'nanaAssistant.inputPlaceholderTrack': '',
|
||||
'nanaAssistant.mcp.exampleHint': '例:{sample}',
|
||||
'nanaAssistant.mcp.required': '必填',
|
||||
'nanaAssistant.mcp.optional': '选填',
|
||||
'nanaAssistant.mcp.missingAsk': '还需要补充:{fields}。补全后我再帮你查。',
|
||||
'nanaAssistant.mcp.priceTitle': '查运费',
|
||||
'nanaAssistant.mcp.priceDesc': '按目的国和重量估算运费',
|
||||
'nanaAssistant.mcp.priceCoach':
|
||||
'查运费需要:目的国家和实重(kg)。有邮编、尺寸会更准。\n补全后发送即可,例如:美国 0.5kg',
|
||||
'nanaAssistant.mcp.priceExample': '帮我查下美国0.5kg价格',
|
||||
'nanaAssistant.mcp.priceDraft': '目的国:\n实重(kg):\n邮编(选填):\n尺寸长宽高cm(选填):',
|
||||
'nanaAssistant.mcp.guideTitle': '这个货能不能寄',
|
||||
'nanaAssistant.mcp.guideDesc': '看这个品名能不能走这条渠道',
|
||||
'nanaAssistant.mcp.guideCoach':
|
||||
'判断能不能寄需要:销售产品(运输方式)和品名。\n例如:产品 XX,品名 lipstick,能发吗?',
|
||||
'nanaAssistant.mcp.guideExample': 'lipstick能寄吗',
|
||||
'nanaAssistant.mcp.guideDraft': '销售产品代码/名称:\n客户品名:',
|
||||
'nanaAssistant.mcp.trackTitle': '包裹到哪了',
|
||||
'nanaAssistant.mcp.trackDesc': '看包裹现在到哪了',
|
||||
'nanaAssistant.mcp.trackCoach':
|
||||
'查包裹位置请发运单号。如果上面已经带入运单,直接点「查这票轨迹」即可。',
|
||||
'nanaAssistant.mcp.trackExample': '帮我查下运单NFXXXX到哪了',
|
||||
'nanaAssistant.mcp.trackDraft': '运单号:',
|
||||
'nanaAssistant.mcp.field.destCountry': '目的国家',
|
||||
'nanaAssistant.mcp.field.weight': '实重(kg)',
|
||||
'nanaAssistant.mcp.field.postal': '邮编',
|
||||
'nanaAssistant.mcp.field.dims': '长宽高(cm)',
|
||||
'nanaAssistant.mcp.field.cargoType': '货物属性',
|
||||
'nanaAssistant.mcp.field.product': '销售产品代码/名称',
|
||||
'nanaAssistant.mcp.field.cargoName': '客户品名',
|
||||
'nanaAssistant.mcp.field.customsOrderNo': '运单号',
|
||||
'nanaAssistant.human': '联系在线顾问',
|
||||
'nanaAssistant.wecomKf': '企业微信在线咨询',
|
||||
'nanaAssistant.you': '你',
|
||||
'nanaAssistant.backToChat': '返回对话',
|
||||
'nanaAssistant.quickSmall': '小批量 / 疑难单能发吗?',
|
||||
'nanaAssistant.quickPrice': '到美国 / 欧洲多少钱?',
|
||||
'nanaAssistant.quickQuoteSpeed': '多久出报价?',
|
||||
'nanaAssistant.quickTrack': '查物流单号',
|
||||
'nanaAssistant.quickVisibility': '能实时追踪吗?',
|
||||
'nanaAssistant.quickAfterSales': '出问题了怎么办?',
|
||||
'nanaAssistant.quickHuman': '联系在线顾问',
|
||||
'nanaAssistant.replySmall': '可以。IDE 专注中小卖家和别人不接的单 — 特殊线路、复杂货型、小批量都在服务范围内。告诉我起运地、目的地和货型即可开始。',
|
||||
'nanaAssistant.replyPrice': '查价只需目的国和实重(kg)。例如:美国 0.5kg。有邮编/尺寸会更准;不需要起运地。',
|
||||
'nanaAssistant.replyQuoteSpeed': '提交目的国和重量后可即时试算参考价;正式出账以入仓重与产品规则为准。',
|
||||
'nanaAssistant.trackHint': '直接发送运单号即可查轨迹,例如:IDE2607281746002926',
|
||||
'nanaAssistant.replyVisibility': '从揽收到签收有关键节点更新 — 揽收、出口、干线、清关、妥投。异常升级前我们会主动预警。',
|
||||
'nanaAssistant.replyAfterSales': '专属顾问负责异常、理赔与售后跟进,不用自己排队工单。请说明问题,如有单号一并提供。',
|
||||
'nanaAssistant.replyHuman': '请前往联系页 — 电话 18123959245 或邮箱 bd01@1st56.com。说明线路和货型,我们会安排对应顾问。',
|
||||
'nanaAssistant.fallback': '可以说目的国和重量让我查价,或直接发运单号查轨迹;也可以联系人工顾问。',
|
||||
'nanaAssistant.emptyReply': '刚才没有得到有效回复,请再发一次,或换个更具体的说法(目的国+重量 / 运单号)。',
|
||||
'nanaAssistant.aiUnavailable': '智能顾问暂未配置或未就绪。请联系人工顾问,或稍后再试。',
|
||||
'nanaAssistant.providerLoading': '正在连接智能顾问,请稍候再发…',
|
||||
'nanaAssistant.toolDoneEmpty': '已调用 {tools},但模型未返回说明。请再问一句「总结一下」或联系人工顾问。',
|
||||
'nanaAssistant.sendFailedDetail': '请求失败:{msg}',
|
||||
'nanaAssistant.wechatId': '微信号',
|
||||
'nanaAssistant.noAdvisor': '暂未分配专属顾问',
|
||||
'nanaAssistant.noWechatQr': '暂未上传微信二维码',
|
||||
'nanaAssistant.greeting': '嗨,我是 Nana~,我是你的AI智能客服\n\n无论是查轨迹、了解渠道时效,还是申报价值和清关问题,都可以问我。今天有什么可以帮你的吗?',
|
||||
'nanaAssistant.send': '发送',
|
||||
'nanaAssistant.close': '关闭',
|
||||
'nanaAssistant.back': '返回',
|
||||
'nanaAssistant.askAboutWaybill': '咨询物流顾问',
|
||||
'nanaAssistant.actionAdvisor': '问顾问',
|
||||
'nanaAssistant.menu.advisorHint': '查价、轨迹、人工咨询',
|
||||
'nanaAssistant.menu.batchJobs': '批量任务',
|
||||
'nanaAssistant.menu.batchHint': '查看进度与结果',
|
||||
'nanaAssistant.batchPanel.title': '批量任务',
|
||||
'nanaAssistant.batchPanel.empty': '暂无最近批量任务',
|
||||
'nanaAssistant.batchPanel.viewAll': '查看全部',
|
||||
'nanaAssistant.waybillContext': '当前运单:{no}',
|
||||
'nanaAssistant.viewWaybill': '查看运单 →',
|
||||
'nanaAssistant.queryTrack': '查轨迹',
|
||||
'nanaAssistant.endWaybillConsult': '结束运单咨询',
|
||||
'nanaAssistant.waybillWelcomeTitle': '这票需要什么帮助?',
|
||||
'nanaAssistant.waybillWelcomeSub': '已带入运单 {no},可以直接查轨迹或问费用。',
|
||||
'nanaAssistant.waybillAskTrack': '查这票轨迹',
|
||||
'nanaAssistant.waybillAskFee': '问这票费用',
|
||||
'nanaAssistant.waybillAutoAsk': '请帮我查询运单 {no} 的物流轨迹',
|
||||
'nanaAssistant.trackWaybillDemo': '已收到运单 {no} 的查询。若已配置智能服务,将优先走真实接口;当前为参考回复:一般可在揽收、出口、干线、清关、妥投等节点看到更新。如有异常,专属顾问会主动跟进。',
|
||||
'nanaAssistant.copyWechat': '复制微信号',
|
||||
'nanaAssistant.copyWechatSuccess': '微信号已复制',
|
||||
'nanaAssistant.copyWechatFailed': '复制失败,请手动复制',
|
||||
'nanaAssistant.noProvider': 'AI 服务暂未配置,请联系管理员开启',
|
||||
'nanaAssistant.sendFailed': '发送失败,请稍后重试',
|
||||
}
|
||||
@ -11,6 +11,7 @@ import localeMonitor from '@/views/home/monitor/locale/zh-CN';
|
||||
import localeSettings from './zh-CN/settings';
|
||||
import marketingZh from './marketing/zh-CN';
|
||||
import kbZh from './kb/zh-CN';
|
||||
import localeNana from './nana/zh-CN';
|
||||
|
||||
export default {
|
||||
// 通用
|
||||
@ -1450,116 +1451,7 @@ export default {
|
||||
'nav.contactService.phone': '联系电话',
|
||||
'nav.contactService.workingTime': '工作时间:周一至周日 9:00-22:00',
|
||||
'nav.contactService.contactUs': '联系客服',
|
||||
'nanaAssistant.title': 'Nana 专属顾问',
|
||||
'nanaAssistant.open': '打开 Nana 专属顾问',
|
||||
'nanaAssistant.tabAi': 'AI Nana',
|
||||
'nanaAssistant.tabAdvisor': '专属顾问',
|
||||
'nanaAssistant.badge': '物流顾问',
|
||||
'nanaAssistant.welcomeTitle': '今天想查点什么?',
|
||||
'nanaAssistant.welcomeHello': '{greet},小艾为您服务',
|
||||
'nanaAssistant.welcomeSub': '今天想查点什么?点下面,或自己打字问我。',
|
||||
'nanaAssistant.greet.morning': '早上好',
|
||||
'nanaAssistant.greet.noon': '中午好',
|
||||
'nanaAssistant.greet.afternoon': '下午好',
|
||||
'nanaAssistant.greet.evening': '晚上好',
|
||||
'nanaAssistant.greet.night': '夜深了',
|
||||
'nanaAssistant.quickLabel': '常见问题',
|
||||
'nanaAssistant.welcomeMsg': '你好,我是 IDE 物流顾问。可以直接问,也可以点下面的入口。',
|
||||
'nanaAssistant.historyTitle': '历史记录',
|
||||
'nanaAssistant.newChat': '新会话',
|
||||
'nanaAssistant.emptyHistory': '暂无会话',
|
||||
'nanaAssistant.searchHistory': '搜索会话或运单号',
|
||||
'nanaAssistant.noSearchResults': '未找到匹配的会话',
|
||||
'nanaAssistant.deleteThread': '删除会话',
|
||||
'nanaAssistant.hint': '直接提问,或点上面的入口。',
|
||||
'nanaAssistant.hintLive': '可以问运费、能不能寄、包裹到哪了。',
|
||||
'nanaAssistant.inputPlaceholder': '输入您的问题…',
|
||||
'nanaAssistant.inputPlaceholderLive': '',
|
||||
'nanaAssistant.inputPlaceholderTrack': '',
|
||||
'nanaAssistant.mcp.exampleHint': '例:{sample}',
|
||||
'nanaAssistant.mcp.required': '必填',
|
||||
'nanaAssistant.mcp.optional': '选填',
|
||||
'nanaAssistant.mcp.missingAsk': '还需要补充:{fields}。补全后我再帮你查。',
|
||||
'nanaAssistant.mcp.priceTitle': '查运费',
|
||||
'nanaAssistant.mcp.priceDesc': '按目的国和重量估算运费',
|
||||
'nanaAssistant.mcp.priceCoach':
|
||||
'查运费需要:目的国家和实重(kg)。有邮编、尺寸会更准。\n补全后发送即可,例如:美国 0.5kg',
|
||||
'nanaAssistant.mcp.priceExample': '帮我查下美国0.5kg价格',
|
||||
'nanaAssistant.mcp.priceDraft': '目的国:\n实重(kg):\n邮编(选填):\n尺寸长宽高cm(选填):',
|
||||
'nanaAssistant.mcp.guideTitle': '这个货能不能寄',
|
||||
'nanaAssistant.mcp.guideDesc': '看这个品名能不能走这条渠道',
|
||||
'nanaAssistant.mcp.guideCoach':
|
||||
'判断能不能寄需要:销售产品(运输方式)和品名。\n例如:产品 XX,品名 lipstick,能发吗?',
|
||||
'nanaAssistant.mcp.guideExample': 'lipstick能寄吗',
|
||||
'nanaAssistant.mcp.guideDraft': '销售产品代码/名称:\n客户品名:',
|
||||
'nanaAssistant.mcp.trackTitle': '包裹到哪了',
|
||||
'nanaAssistant.mcp.trackDesc': '看包裹现在到哪了',
|
||||
'nanaAssistant.mcp.trackCoach':
|
||||
'查包裹位置请发运单号。如果上面已经带入运单,直接点「查这票轨迹」即可。',
|
||||
'nanaAssistant.mcp.trackExample': '帮我查下运单NFXXXX到哪了',
|
||||
'nanaAssistant.mcp.trackDraft': '运单号:',
|
||||
'nanaAssistant.mcp.field.destCountry': '目的国家',
|
||||
'nanaAssistant.mcp.field.weight': '实重(kg)',
|
||||
'nanaAssistant.mcp.field.postal': '邮编',
|
||||
'nanaAssistant.mcp.field.dims': '长宽高(cm)',
|
||||
'nanaAssistant.mcp.field.cargoType': '货物属性',
|
||||
'nanaAssistant.mcp.field.product': '销售产品代码/名称',
|
||||
'nanaAssistant.mcp.field.cargoName': '客户品名',
|
||||
'nanaAssistant.mcp.field.customsOrderNo': '运单号',
|
||||
'nanaAssistant.human': '联系在线顾问',
|
||||
'nanaAssistant.wecomKf': '企业微信在线咨询',
|
||||
'nanaAssistant.you': '你',
|
||||
'nanaAssistant.backToChat': '返回对话',
|
||||
'nanaAssistant.quickSmall': '小批量 / 疑难单能发吗?',
|
||||
'nanaAssistant.quickPrice': '到美国 / 欧洲多少钱?',
|
||||
'nanaAssistant.quickQuoteSpeed': '多久出报价?',
|
||||
'nanaAssistant.quickTrack': '查物流单号',
|
||||
'nanaAssistant.quickVisibility': '能实时追踪吗?',
|
||||
'nanaAssistant.quickAfterSales': '出问题了怎么办?',
|
||||
'nanaAssistant.quickHuman': '联系在线顾问',
|
||||
'nanaAssistant.replySmall': '可以。IDE 专注中小卖家和别人不接的单 — 特殊线路、复杂货型、小批量都在服务范围内。告诉我起运地、目的地和货型即可开始。',
|
||||
'nanaAssistant.replyPrice': '查价只需目的国和实重(kg)。例如:美国 0.5kg。有邮编/尺寸会更准;不需要起运地。',
|
||||
'nanaAssistant.replyQuoteSpeed': '提交目的国和重量后可即时试算参考价;正式出账以入仓重与产品规则为准。',
|
||||
'nanaAssistant.trackHint': '直接发送运单号即可查轨迹,例如:IDE2607281746002926',
|
||||
'nanaAssistant.replyVisibility': '从揽收到签收有关键节点更新 — 揽收、出口、干线、清关、妥投。异常升级前我们会主动预警。',
|
||||
'nanaAssistant.replyAfterSales': '专属顾问负责异常、理赔与售后跟进,不用自己排队工单。请说明问题,如有单号一并提供。',
|
||||
'nanaAssistant.replyHuman': '请前往联系页 — 电话 18123959245 或邮箱 bd01@1st56.com。说明线路和货型,我们会安排对应顾问。',
|
||||
'nanaAssistant.fallback': '可以说目的国和重量让我查价,或直接发运单号查轨迹;也可以联系人工顾问。',
|
||||
'nanaAssistant.emptyReply': '刚才没有得到有效回复,请再发一次,或换个更具体的说法(目的国+重量 / 运单号)。',
|
||||
'nanaAssistant.aiUnavailable': '智能顾问暂未配置或未就绪。请联系人工顾问,或稍后再试。',
|
||||
'nanaAssistant.providerLoading': '正在连接智能顾问,请稍候再发…',
|
||||
'nanaAssistant.toolDoneEmpty': '已调用 {tools},但模型未返回说明。请再问一句「总结一下」或联系人工顾问。',
|
||||
'nanaAssistant.sendFailedDetail': '请求失败:{msg}',
|
||||
'nanaAssistant.wechatId': '微信号',
|
||||
'nanaAssistant.noAdvisor': '暂未分配专属顾问',
|
||||
'nanaAssistant.noWechatQr': '暂未上传微信二维码',
|
||||
'nanaAssistant.greeting': '嗨,我是 Nana~,我是你的AI智能客服\n\n无论是查轨迹、了解渠道时效,还是申报价值和清关问题,都可以问我。今天有什么可以帮你的吗?',
|
||||
'nanaAssistant.send': '发送',
|
||||
'nanaAssistant.close': '关闭',
|
||||
'nanaAssistant.back': '返回',
|
||||
'nanaAssistant.askAboutWaybill': '咨询物流顾问',
|
||||
'nanaAssistant.actionAdvisor': '问顾问',
|
||||
'nanaAssistant.menu.advisorHint': '查价、轨迹、人工咨询',
|
||||
'nanaAssistant.menu.batchJobs': '批量任务',
|
||||
'nanaAssistant.menu.batchHint': '查看进度与结果',
|
||||
'nanaAssistant.batchPanel.title': '批量任务',
|
||||
'nanaAssistant.batchPanel.empty': '暂无最近批量任务',
|
||||
'nanaAssistant.batchPanel.viewAll': '查看全部',
|
||||
'nanaAssistant.waybillContext': '当前运单:{no}',
|
||||
'nanaAssistant.viewWaybill': '查看运单 →',
|
||||
'nanaAssistant.queryTrack': '查轨迹',
|
||||
'nanaAssistant.endWaybillConsult': '结束运单咨询',
|
||||
'nanaAssistant.waybillWelcomeTitle': '这票需要什么帮助?',
|
||||
'nanaAssistant.waybillWelcomeSub': '已带入运单 {no},可以直接查轨迹或问费用。',
|
||||
'nanaAssistant.waybillAskTrack': '查这票轨迹',
|
||||
'nanaAssistant.waybillAskFee': '问这票费用',
|
||||
'nanaAssistant.waybillAutoAsk': '请帮我查询运单 {no} 的物流轨迹',
|
||||
'nanaAssistant.trackWaybillDemo': '已收到运单 {no} 的查询。若已配置智能服务,将优先走真实接口;当前为参考回复:一般可在揽收、出口、干线、清关、妥投等节点看到更新。如有异常,专属顾问会主动跟进。',
|
||||
'nanaAssistant.copyWechat': '复制微信号',
|
||||
'nanaAssistant.copyWechatSuccess': '微信号已复制',
|
||||
'nanaAssistant.copyWechatFailed': '复制失败,请手动复制',
|
||||
'nanaAssistant.noProvider': 'AI 服务暂未配置,请联系管理员开启',
|
||||
'nanaAssistant.sendFailed': '发送失败,请稍后重试',
|
||||
...localeNana,
|
||||
'nav.balance.label': '余额',
|
||||
'nav.balance.refresh': '刷新余额',
|
||||
'nav.balance.refreshed': '余额已更新',
|
||||
|
||||
@ -211,7 +211,7 @@ export default function setupUserLoginInfoGuard(router: Router) {
|
||||
return;
|
||||
}
|
||||
|
||||
// H5:未登录按门户引导到对应登录页
|
||||
// H5:未登录 — 客户四 Tab 可逛;深链仍引导登录
|
||||
if (!isLogin() && String(to.path || '').startsWith('/h5')) {
|
||||
const path = String(to.path || '');
|
||||
if (isAdminH5Path(path) && path !== ADMIN_H5_LOGIN_PATH) {
|
||||
@ -224,6 +224,19 @@ export default function setupUserLoginInfoGuard(router: Router) {
|
||||
return;
|
||||
}
|
||||
if (!isAdminH5Path(path) && path !== '/h5/login') {
|
||||
const guestOk =
|
||||
to.meta?.h5GuestOk === true ||
|
||||
path === '/h5' ||
|
||||
path === '/h5/' ||
|
||||
path === '/h5/price' ||
|
||||
path === '/h5/orders' ||
|
||||
path === '/h5/assistant' ||
|
||||
path === '/h5/me';
|
||||
if (guestOk) {
|
||||
NProgress.done();
|
||||
next();
|
||||
return;
|
||||
}
|
||||
NProgress.done();
|
||||
next({
|
||||
path: '/h5/login',
|
||||
|
||||
@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router';
|
||||
import NProgress from 'nprogress'; // progress bar
|
||||
import 'nprogress/nprogress.css';
|
||||
|
||||
import { isLogin } from '@/utils/auth';
|
||||
import { appRoutes } from './routes';
|
||||
import { REDIRECT_MAIN, NOT_FOUND_ROUTE } from './routes/base';
|
||||
import createRouteGuard from './guard';
|
||||
@ -188,12 +189,7 @@ const router = createRouter({
|
||||
{
|
||||
path: '',
|
||||
name: 'H5CustomerCenterIndex',
|
||||
redirect: () => {
|
||||
const token =
|
||||
localStorage.getItem('rbac_access_token') ||
|
||||
localStorage.getItem('token');
|
||||
return token ? '/h5/orders' : '/h5/login';
|
||||
},
|
||||
redirect: () => (isLogin() ? '/h5/orders' : '/h5/price'),
|
||||
},
|
||||
{
|
||||
path: 'assistant',
|
||||
@ -201,6 +197,7 @@ const router = createRouter({
|
||||
component: () => import('@/views/h5/customer-center/assistant/index.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
h5GuestOk: true,
|
||||
locale: 'h5.tab.assistant',
|
||||
h5HideHeader: true,
|
||||
},
|
||||
@ -211,6 +208,7 @@ const router = createRouter({
|
||||
component: () => import('@/views/h5/customer-center/price/index.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
h5GuestOk: true,
|
||||
locale: 'h5.tab.price',
|
||||
h5HideHeader: true,
|
||||
},
|
||||
@ -221,6 +219,7 @@ const router = createRouter({
|
||||
component: () => import('@/views/h5/customer-center/orders/index.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
h5GuestOk: true,
|
||||
locale: 'h5.tab.orders',
|
||||
h5HideHeader: true,
|
||||
},
|
||||
@ -242,6 +241,7 @@ const router = createRouter({
|
||||
component: () => import('@/views/h5/customer-center/me/index.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
h5GuestOk: true,
|
||||
locale: 'h5.tab.me',
|
||||
h5HideHeader: true,
|
||||
},
|
||||
@ -331,7 +331,7 @@ const router = createRouter({
|
||||
const token =
|
||||
localStorage.getItem('rbac_access_token') ||
|
||||
localStorage.getItem('token');
|
||||
return token ? '/h5/orders' : '/h5/login';
|
||||
return token ? '/h5/orders' : '/h5/price';
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@ -12,6 +12,10 @@ import {
|
||||
import { handleApiError } from '@/utils/rbac-error-handler'
|
||||
import { getLoginRouteNameByType } from '@/utils/login-portal'
|
||||
import { getLoginTypeFromAccessToken } from '@/utils/jwt'
|
||||
import {
|
||||
markSessionEndedByUser,
|
||||
resetSessionExpiredGuard,
|
||||
} from '@/utils/auth-session-guard'
|
||||
|
||||
// Token 存储 key
|
||||
const TOKEN_KEY = 'rbac_access_token'
|
||||
@ -174,6 +178,7 @@ export const useRbacUserStore = defineStore('rbacUser', () => {
|
||||
return
|
||||
}
|
||||
|
||||
resetSessionExpiredGuard()
|
||||
accessToken.value = result.accessToken
|
||||
refreshToken.value = result.refreshToken || ''
|
||||
const expiresInSeconds =
|
||||
@ -423,6 +428,8 @@ export const useRbacUserStore = defineStore('rbacUser', () => {
|
||||
|
||||
// 登出:保留 loginPortalType / LOGIN_TYPE,确保过期回跳仍进对应门户登录页
|
||||
function logout() {
|
||||
// 先标记会话结束,避免页面卸载前 in-flight 请求 401 连弹「登录已过期」
|
||||
markSessionEndedByUser()
|
||||
accessToken.value = ''
|
||||
refreshToken.value = ''
|
||||
tokenExpiresAt.value = 0
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import router from '@/router'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { useRbacUserStore } from '@/store/modules/rbac-user'
|
||||
import { resolvePostLogoutLogin } from '@/utils/login-portal'
|
||||
import { handleSessionExpired } from '@/utils/auth-session-expired'
|
||||
import { watch } from 'vue'
|
||||
|
||||
// 用户最后一次交互起,1小时后进入“过期/跳登录”
|
||||
@ -41,30 +40,10 @@ function scheduleLogoutAt(ts: number) {
|
||||
async function doLogout() {
|
||||
clearTimers()
|
||||
const userStore = useRbacUserStore()
|
||||
const loginType = userStore.resolveLoginTypeForRedirect()
|
||||
const currentPath = router.currentRoute.value.fullPath
|
||||
|
||||
if (userStore.isLoggedIn) {
|
||||
userStore.disableKeepAlive()
|
||||
userStore.logout()
|
||||
}
|
||||
|
||||
Message.error('登录已过期,请重新登录')
|
||||
const target = resolvePostLogoutLogin({
|
||||
loginType,
|
||||
path: currentPath,
|
||||
})
|
||||
if (target.path) {
|
||||
router.replace({
|
||||
path: target.path,
|
||||
query: target.query,
|
||||
})
|
||||
return
|
||||
}
|
||||
router.replace({
|
||||
name: target.name,
|
||||
query: target.query,
|
||||
})
|
||||
handleSessionExpired('登录已过期,请重新登录')
|
||||
}
|
||||
|
||||
function scheduleProactiveRefreshAt(expectedKeepAliveUntil: number) {
|
||||
|
||||
98
src/utils/auth-session-expired.ts
Normal file
98
src/utils/auth-session-expired.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import type { OmsLoginType } from '@/api/rbac'
|
||||
import router from '@/router'
|
||||
import { useUserStore } from '@/store'
|
||||
import { useRbacUserStore } from '@/store/modules/rbac-user'
|
||||
import { clearToken } from '@/utils/auth'
|
||||
import {
|
||||
isHandlingSessionExpired,
|
||||
markSessionEndedByUser,
|
||||
} from '@/utils/auth-session-guard'
|
||||
import {
|
||||
isLoginRouteName,
|
||||
resolvePostLogoutLogin,
|
||||
ADMIN_LOGIN_PATH,
|
||||
ADMIN_H5_LOGIN_PATH,
|
||||
CUSTOMER_LOGIN_PATH,
|
||||
} from '@/utils/login-portal'
|
||||
|
||||
export { resetSessionExpiredGuard, markSessionEndedByUser } from '@/utils/auth-session-guard'
|
||||
|
||||
function isOnLoginPage(): boolean {
|
||||
const route = router.currentRoute.value
|
||||
if (isLoginRouteName(route.name != null ? String(route.name) : null)) return true
|
||||
const path = route.path || ''
|
||||
return (
|
||||
path === CUSTOMER_LOGIN_PATH ||
|
||||
path === ADMIN_LOGIN_PATH ||
|
||||
path === ADMIN_H5_LOGIN_PATH ||
|
||||
path.endsWith('/login')
|
||||
)
|
||||
}
|
||||
|
||||
/** 客户 H5 访客可逛页:无 token 时接口 401 不应踢去登录 */
|
||||
function isCustomerH5GuestPath(path: string): boolean {
|
||||
const p = (path || '').split('?')[0]
|
||||
if (p === '/h5' || p === '/h5/') return true
|
||||
return (
|
||||
p === '/h5/price' ||
|
||||
p === '/h5/orders' ||
|
||||
p === '/h5/assistant' ||
|
||||
p === '/h5/me' ||
|
||||
Boolean(router.currentRoute.value.meta?.h5GuestOk)
|
||||
)
|
||||
}
|
||||
|
||||
function redirectToLogin(
|
||||
loginType: OmsLoginType | null | undefined,
|
||||
currentPath: string,
|
||||
) {
|
||||
if (isOnLoginPage()) return
|
||||
const pathOnly = (currentPath || '').split('?')[0]
|
||||
if (isCustomerH5GuestPath(pathOnly)) return
|
||||
const target = resolvePostLogoutLogin({ loginType, path: currentPath })
|
||||
if (target.path) {
|
||||
void router.replace({ path: target.path, query: target.query })
|
||||
return
|
||||
}
|
||||
void router.replace({ name: target.name, query: target.query })
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一处理登录失效:至多提示一次并跳转登录页。
|
||||
* 主动退出或已无 token 时不弹「登录已过期」。
|
||||
* 访客逛 H5 时接口 401 也不踢登录页。
|
||||
* @returns 是否实际执行了过期提示
|
||||
*/
|
||||
export function handleSessionExpired(
|
||||
message = '登录已过期,请重新登录',
|
||||
): boolean {
|
||||
if (isHandlingSessionExpired()) {
|
||||
return false
|
||||
}
|
||||
|
||||
const rbacUserStore = useRbacUserStore()
|
||||
const currentPath = router.currentRoute.value.fullPath
|
||||
const loginType = rbacUserStore.resolveLoginTypeForRedirect()
|
||||
|
||||
// 主动退出 / 访客无会话:静默;访客可逛页不跳登录
|
||||
if (!rbacUserStore.isLoggedIn) {
|
||||
markSessionEndedByUser()
|
||||
redirectToLogin(loginType, currentPath)
|
||||
return false
|
||||
}
|
||||
|
||||
markSessionEndedByUser()
|
||||
rbacUserStore.logout()
|
||||
|
||||
try {
|
||||
clearToken()
|
||||
useUserStore().resetInfo()
|
||||
} catch {
|
||||
// pinia 未就绪时忽略
|
||||
}
|
||||
|
||||
Message.error(message)
|
||||
redirectToLogin(loginType, currentPath)
|
||||
return true
|
||||
}
|
||||
16
src/utils/auth-session-guard.ts
Normal file
16
src/utils/auth-session-guard.ts
Normal file
@ -0,0 +1,16 @@
|
||||
/** 会话结束守卫(无业务依赖,避免与 rbac-user 循环引用) */
|
||||
|
||||
let handlingSessionExpired = false
|
||||
|
||||
export function isHandlingSessionExpired() {
|
||||
return handlingSessionExpired
|
||||
}
|
||||
|
||||
export function resetSessionExpiredGuard() {
|
||||
handlingSessionExpired = false
|
||||
}
|
||||
|
||||
/** 用户点击退出 / 正在处理过期:禁止后续 401 再弹「登录已过期」 */
|
||||
export function markSessionEndedByUser() {
|
||||
handlingSessionExpired = true
|
||||
}
|
||||
24
src/utils/h5-redirect.ts
Normal file
24
src/utils/h5-redirect.ts
Normal file
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 解析 H5 登录/注册后的 redirect(可能含 query),供 router.replace 使用。
|
||||
*/
|
||||
export function parseH5RedirectLocation(redirect: string | null | undefined): {
|
||||
path: string
|
||||
query?: Record<string, string>
|
||||
} {
|
||||
const raw = String(redirect || '').trim()
|
||||
if (!raw.startsWith('/h5') || raw.startsWith('/h5/admin')) {
|
||||
return { path: '/h5/price' }
|
||||
}
|
||||
const hashIdx = raw.indexOf('#')
|
||||
const withoutHash = hashIdx >= 0 ? raw.slice(0, hashIdx) : raw
|
||||
const qIdx = withoutHash.indexOf('?')
|
||||
if (qIdx < 0) {
|
||||
return { path: withoutHash || '/h5/price' }
|
||||
}
|
||||
const path = withoutHash.slice(0, qIdx) || '/h5/price'
|
||||
const query: Record<string, string> = {}
|
||||
new URLSearchParams(withoutHash.slice(qIdx + 1)).forEach((value, key) => {
|
||||
if (key) query[key] = value
|
||||
})
|
||||
return Object.keys(query).length ? { path, query } : { path }
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
/** 登录进系统后首屏跳过 page-fade */
|
||||
let skipOnce = false;
|
||||
|
||||
/** 门户翻页过渡(官网→登录 / 客户↔管理登录,单次消费) */
|
||||
/** 门户中心虹膜过渡(官网→登录 / 客户↔管理登录,单次消费) */
|
||||
let pageFlipOnce = false;
|
||||
|
||||
export function skipNextPageTransition() {
|
||||
@ -14,7 +14,7 @@ export function consumeSkipPageTransition(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 武装一次书页翻页过渡 */
|
||||
/** 武装一次中心虹膜过渡 */
|
||||
export function armPageFlip() {
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
pageFlipOnce = true;
|
||||
|
||||
@ -6,11 +6,10 @@ import type {
|
||||
} from 'axios';
|
||||
import axios from 'axios';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import router from '@/router';
|
||||
import { clearToken } from '@/utils/auth';
|
||||
import { useUserStore } from '@/store';
|
||||
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
||||
import { resolvePostLogoutLogin } from '@/utils/login-portal';
|
||||
import { isLogin } from '@/utils/auth';
|
||||
import { handleSessionExpired } from '@/utils/auth-session-expired';
|
||||
import { rewriteBrowserApiPath } from '@/utils/rewriteBrowserApiPath';
|
||||
// 安装全局 Message 默认(可关闭 / 错误加长),须在本文件 Message.* 调用前生效
|
||||
import '@/utils/message';
|
||||
@ -98,20 +97,11 @@ service.interceptors.response.use(
|
||||
// 将字符串类型的 code 转换为数字进行比较
|
||||
const codeNum = typeof code === 'string' ? parseInt(code, 10) : code;
|
||||
|
||||
// 登录过期(code 为 401)
|
||||
// 登录过期(code 为 401)— 与 RBAC 拦截器共用去重;访客无会话不跳登录
|
||||
if (codeNum === 401 || code === '401') {
|
||||
const errorMessage = res.message || res.msg || '登录已过期,请重新登录';
|
||||
Message.error(errorMessage);
|
||||
const rbacUserStore = useRbacUserStore();
|
||||
const loginType = rbacUserStore.resolveLoginTypeForRedirect();
|
||||
const currentPath = router.currentRoute.value.fullPath;
|
||||
clearToken();
|
||||
userStore.resetInfo();
|
||||
const target = resolvePostLogoutLogin({ loginType, path: currentPath });
|
||||
if (target.path) {
|
||||
router.replace({ path: target.path, query: target.query });
|
||||
} else {
|
||||
router.replace({ name: target.name, query: target.query });
|
||||
if (isLogin()) {
|
||||
handleSessionExpired(errorMessage);
|
||||
}
|
||||
return Promise.reject(new Error(errorMessage));
|
||||
}
|
||||
@ -141,8 +131,6 @@ service.interceptors.response.use(
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
const userStore = useUserStore();
|
||||
|
||||
// 获取错误请求的响应数据
|
||||
if (error.response) {
|
||||
// 服务器返回了错误状态码 (4xx, 5xx)
|
||||
@ -153,47 +141,14 @@ service.interceptors.response.use(
|
||||
case 400:
|
||||
Message.error(`请求参数错误: ${data?.message || statusText}`);
|
||||
break;
|
||||
case 401:
|
||||
// 401 未授权,登录过期
|
||||
// 检查是否是RBAC API的401,如果是则登出;否则可能是权限问题
|
||||
const isRbacApi = error.config?.url?.includes('/rbac/');
|
||||
const errorMessage = data?.message || data?.msg || '登录已过期,请重新登录';
|
||||
|
||||
if (isRbacApi) {
|
||||
// RBAC API的401,说明token过期,需要重新登录
|
||||
Message.error(errorMessage);
|
||||
const rbacUserStore = useRbacUserStore();
|
||||
const loginType = rbacUserStore.resolveLoginTypeForRedirect();
|
||||
const currentPath = router.currentRoute.value.fullPath;
|
||||
clearToken();
|
||||
userStore.resetInfo();
|
||||
localStorage.removeItem('rbac_token');
|
||||
localStorage.removeItem('rbac_access_token');
|
||||
localStorage.removeItem('rbac_refresh_token');
|
||||
localStorage.removeItem('rbac_token_expires_at');
|
||||
localStorage.removeItem('rbac_userInfo');
|
||||
const target = resolvePostLogoutLogin({ loginType, path: currentPath });
|
||||
if (target.path) {
|
||||
router.replace({ path: target.path, query: target.query });
|
||||
} else {
|
||||
router.replace({ name: target.name, query: target.query });
|
||||
}
|
||||
} else {
|
||||
// 非RBAC API的401,统一处理为登录失效,跳转到登录页
|
||||
Message.error(errorMessage || '登录已过期,请重新登录');
|
||||
const rbacUserStore = useRbacUserStore();
|
||||
const loginType = rbacUserStore.resolveLoginTypeForRedirect();
|
||||
const currentPath = router.currentRoute.value.fullPath;
|
||||
clearToken();
|
||||
userStore.resetInfo();
|
||||
const target = resolvePostLogoutLogin({ loginType, path: currentPath });
|
||||
if (target.path) {
|
||||
router.replace({ path: target.path, query: target.query });
|
||||
} else {
|
||||
router.replace({ name: target.name, query: target.query });
|
||||
}
|
||||
case 401: {
|
||||
// 401:已登录才走会话过期;访客可逛页的公开接口失败不踢登录
|
||||
if (isLogin()) {
|
||||
const errorMessage = data?.message || data?.msg || '登录已过期,请重新登录';
|
||||
handleSessionExpired(errorMessage);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 403:
|
||||
Message.error(`禁止访问: ${data?.message || statusText}`);
|
||||
break;
|
||||
|
||||
@ -288,6 +288,19 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 覆盖浏览器自动填充天蓝色底 */
|
||||
.h5-login-form :deep(input.arco-input):-webkit-autofill,
|
||||
.h5-login-form :deep(input.arco-input):-webkit-autofill:hover,
|
||||
.h5-login-form :deep(input.arco-input):-webkit-autofill:focus,
|
||||
.h5-login-form :deep(input.arco-input):-webkit-autofill:active {
|
||||
-webkit-text-fill-color: #1e293b;
|
||||
caret-color: #1e293b;
|
||||
border: none;
|
||||
-webkit-box-shadow: 0 0 0 1000px #f0f3f4 inset !important;
|
||||
box-shadow: 0 0 0 1000px #f0f3f4 inset !important;
|
||||
transition: background-color 99999s ease-out 0s;
|
||||
}
|
||||
|
||||
.h5-login-error {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
|
||||
@ -1,5 +1,17 @@
|
||||
<template>
|
||||
<div class="h5-assistant-page">
|
||||
<div v-if="!isLoggedIn" class="h5-assistant-guest h5-enter">
|
||||
<p>{{ t('h5.assistant.guestTip') }}</p>
|
||||
<div class="h5-assistant-guest__actions">
|
||||
<a-button class="app-btn-primary-soft" size="small" @click="promptGuestLogin">
|
||||
{{ t('h5.assistant.guestLogin') }}
|
||||
</a-button>
|
||||
<a-button size="small" @click="promptGuestRegister">
|
||||
{{ t('h5.assistant.guestRegister') }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 查价预填上下文(运单上下文由 Shell 自带条展示) -->
|
||||
<div v-if="priceContext" class="h5-ctx h5-enter">
|
||||
<span class="h5-ctx__blob" aria-hidden="true" />
|
||||
@ -24,6 +36,9 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import AiAdvisorShell from '@/components/NanaAssistant/AiAdvisorShell.vue';
|
||||
import { parseWaybillContextFromQuery } from '@/composables/useAskAdvisor';
|
||||
import { isLogin } from '@/utils/auth';
|
||||
import { ensureH5Login } from '../composables/useH5LoginGate';
|
||||
import { startH5WeChatRegister } from '../composables/useH5WeChatRegister';
|
||||
|
||||
defineOptions({ name: 'H5Assistant' });
|
||||
|
||||
@ -32,6 +47,8 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
const shellRef = ref<{ setInputText?: (text: string) => void } | null>(null);
|
||||
|
||||
const isLoggedIn = computed(() => isLogin());
|
||||
|
||||
const waybillCtx = computed(() => parseWaybillContextFromQuery(route.query));
|
||||
|
||||
const prefillText = computed(() => {
|
||||
@ -51,6 +68,17 @@ function clearPriceContext() {
|
||||
router.replace({ path: '/h5/assistant', query });
|
||||
shellRef.value?.setInputText?.('');
|
||||
}
|
||||
|
||||
function promptGuestLogin() {
|
||||
ensureH5Login({
|
||||
reasonKey: 'h5.loginGate.reason.assistant',
|
||||
redirect: route.fullPath || '/h5/assistant',
|
||||
});
|
||||
}
|
||||
|
||||
function promptGuestRegister() {
|
||||
startH5WeChatRegister({ redirect: route.fullPath || '/h5/assistant' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@ -65,6 +93,29 @@ function clearPriceContext() {
|
||||
background: #f5f6f8;
|
||||
}
|
||||
|
||||
.h5-assistant-guest {
|
||||
flex-shrink: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
box-shadow:
|
||||
inset 0 2px 8px rgba(15, 23, 42, 0.07),
|
||||
inset 0 1px 1px rgba(15, 23, 42, 0.04);
|
||||
|
||||
p {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: var(--h5-text-3, #86909c);
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.h5-ctx {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
|
||||
@ -61,6 +61,7 @@ import IconTabCalculator from '@/assets/icons/h5/tab-calculator.svg';
|
||||
import IconTabPackage from '@/assets/icons/h5/tab-package.svg';
|
||||
import IconTabService from '@/assets/icons/h5/tab-service.svg';
|
||||
import IconTabMe from '@/assets/icons/h5/tab-me.svg';
|
||||
import { isLogin } from '@/utils/auth';
|
||||
|
||||
defineOptions({ name: 'H5AppTabBar' });
|
||||
|
||||
@ -156,13 +157,14 @@ function dismissFabHint() {
|
||||
|
||||
function scheduleFabHint() {
|
||||
clearHintTimers();
|
||||
if (props.hideCreate || hasSeenFabHint()) {
|
||||
// 访客点下单会先弹登录,不主动推「下单」气泡
|
||||
if (props.hideCreate || hasSeenFabHint() || !isLogin()) {
|
||||
showFabHint.value = false;
|
||||
return;
|
||||
}
|
||||
hintShowTimer = setTimeout(() => {
|
||||
hintShowTimer = null;
|
||||
if (props.hideCreate || hasSeenFabHint()) return;
|
||||
if (props.hideCreate || hasSeenFabHint() || !isLogin()) return;
|
||||
showFabHint.value = true;
|
||||
hintHideTimer = setTimeout(() => {
|
||||
hintHideTimer = null;
|
||||
|
||||
134
src/views/h5/customer-center/components/H5LoginGateSheet.vue
Normal file
134
src/views/h5/customer-center/components/H5LoginGateSheet.vue
Normal file
@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<a-drawer
|
||||
:visible="visible"
|
||||
placement="bottom"
|
||||
height="auto"
|
||||
:footer="false"
|
||||
:header="false"
|
||||
unmount-on-close
|
||||
mask-closable
|
||||
:drawer-style="drawerStyle"
|
||||
popup-container="body"
|
||||
class="h5-login-gate-drawer"
|
||||
@cancel="dismiss"
|
||||
>
|
||||
<div class="h5-login-gate">
|
||||
<div class="h5-login-gate__handle" aria-hidden="true" />
|
||||
<header class="h5-login-gate__head">
|
||||
<h3>{{ t('h5.loginGate.title') }}</h3>
|
||||
<button type="button" class="h5-login-gate__close" @click="dismiss">
|
||||
<icon-close />
|
||||
</button>
|
||||
</header>
|
||||
<p class="h5-login-gate__desc">{{ t(reasonKey) }}</p>
|
||||
<footer class="h5-login-gate__foot">
|
||||
<button type="button" class="h5-login-gate__btn h5-login-gate__btn--ghost" @click="dismiss">
|
||||
{{ t('h5.loginGate.later') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h5-login-gate__btn h5-login-gate__btn--ghost"
|
||||
@click="goRegister"
|
||||
>
|
||||
{{ t('h5.loginGate.register') }}
|
||||
</button>
|
||||
<button type="button" class="h5-login-gate__btn h5-login-gate__btn--primary" @click="goLogin">
|
||||
{{ t('h5.loginGate.goLogin') }}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { IconClose } from '@arco-design/web-vue/es/icon'
|
||||
import { useH5LoginGateState } from '../composables/useH5LoginGate'
|
||||
|
||||
defineOptions({ name: 'H5LoginGateSheet' })
|
||||
|
||||
const { t } = useI18n()
|
||||
const { visible, reasonKey, dismiss, goLogin, goRegister } = useH5LoginGateState()
|
||||
|
||||
const drawerStyle = {
|
||||
borderRadius: '20px 20px 0 0',
|
||||
overflow: 'hidden',
|
||||
maxHeight: 'min(70vh, 420px)',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.h5-login-gate {
|
||||
padding: 8px 16px calc(16px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.h5-login-gate__handle {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
margin: 4px auto 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.h5-login-gate__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--h5-text, #1d2129);
|
||||
}
|
||||
}
|
||||
|
||||
.h5-login-gate__close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(15, 23, 42, 0.06);
|
||||
color: #64748b;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.h5-login-gate__desc {
|
||||
margin: 0 0 20px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--h5-text-3, #86909c);
|
||||
}
|
||||
|
||||
.h5-login-gate__foot {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.h5-login-gate__btn {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
padding: 0 8px;
|
||||
|
||||
&--ghost {
|
||||
background: #f0f3f4;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: var(--app-primary-color);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
57
src/views/h5/customer-center/composables/useH5LoginGate.ts
Normal file
57
src/views/h5/customer-center/composables/useH5LoginGate.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { ref } from 'vue'
|
||||
import router from '@/router'
|
||||
import { isLogin } from '@/utils/auth'
|
||||
import { h5Navigate } from '@/utils/device'
|
||||
import { startH5WeChatRegister } from './useH5WeChatRegister'
|
||||
|
||||
const visible = ref(false)
|
||||
const reasonKey = ref('h5.loginGate.reason.default')
|
||||
const redirectPath = ref('/h5/price')
|
||||
|
||||
/**
|
||||
* 访客可逛 H5;关键动作前调用。
|
||||
* @returns true = 已登录可继续;false = 已弹出登录引导
|
||||
*/
|
||||
export function ensureH5Login(options?: {
|
||||
reasonKey?: string
|
||||
redirect?: string
|
||||
}): boolean {
|
||||
if (isLogin()) return true
|
||||
reasonKey.value = options?.reasonKey || 'h5.loginGate.reason.default'
|
||||
redirectPath.value =
|
||||
options?.redirect ||
|
||||
router.currentRoute.value.fullPath ||
|
||||
'/h5/price'
|
||||
visible.value = true
|
||||
return false
|
||||
}
|
||||
|
||||
export function useH5LoginGateState() {
|
||||
function dismiss() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function goLogin() {
|
||||
const redirect = redirectPath.value || '/h5/price'
|
||||
visible.value = false
|
||||
h5Navigate(router, {
|
||||
path: '/h5/login',
|
||||
query: { redirect },
|
||||
})
|
||||
}
|
||||
|
||||
function goRegister() {
|
||||
const redirect = redirectPath.value || '/h5/price'
|
||||
visible.value = false
|
||||
startH5WeChatRegister({ redirect })
|
||||
}
|
||||
|
||||
return {
|
||||
visible,
|
||||
reasonKey,
|
||||
redirectPath,
|
||||
dismiss,
|
||||
goLogin,
|
||||
goRegister,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import i18n from '@/locale'
|
||||
import router from '@/router'
|
||||
import { h5Navigate, isWeChatBrowser } from '@/utils/device'
|
||||
import { redirectToWeChatOAuth } from '@/utils/wechatOAuth'
|
||||
|
||||
/**
|
||||
* H5 访客「注册」:微信内直接公众号 OAuth 注册;否则提示并落到 H5 登录页。
|
||||
*/
|
||||
export function startH5WeChatRegister(options?: { redirect?: string }) {
|
||||
const redirect = options?.redirect || router.currentRoute.value.fullPath || '/h5/me'
|
||||
const t = i18n.global.t.bind(i18n.global)
|
||||
|
||||
if (!isWeChatBrowser()) {
|
||||
Message.warning(String(t('h5.login.openInWechat')))
|
||||
h5Navigate(router, {
|
||||
path: '/h5/login',
|
||||
query: { redirect },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
void redirectToWeChatOAuth('register').catch((e: any) => {
|
||||
Message.error(e?.message || String(t('h5.login.oauthFailed')))
|
||||
})
|
||||
}
|
||||
@ -26,6 +26,7 @@
|
||||
/>
|
||||
<H5FollowOfficialModal v-if="followModalReady" />
|
||||
<H5NoticePopup v-if="noticePopupReady" />
|
||||
<H5LoginGateSheet />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -40,15 +41,17 @@ import {
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { isLogin } from '@/utils/auth';
|
||||
import { getOrderStatusCounts } from '@/api/order';
|
||||
import { ensureFullLocaleMessages } from '@/locale';
|
||||
import { h5Navigate, hideWeChatBrowserNav } from '@/utils/device';
|
||||
import { 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';
|
||||
import H5LoginGateSheet from './components/H5LoginGateSheet.vue';
|
||||
import { ensureH5Login } from './composables/useH5LoginGate';
|
||||
import { H5_HIDE_CREATE_FAB_KEY } from './composables/h5TabBarUi';
|
||||
|
||||
defineOptions({ name: 'H5CustomerCenterLayout' });
|
||||
@ -65,7 +68,6 @@ const H5NoticePopup = defineAsyncComponent(
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const orderBadge = ref(0);
|
||||
const placeOrderVisible = ref(false);
|
||||
const createFlowReady = ref(false);
|
||||
@ -77,11 +79,12 @@ let idlePreloadHandle: number | null = null;
|
||||
let badgeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async function openPlaceOrder() {
|
||||
if (!isLogin()) {
|
||||
h5Navigate(router, {
|
||||
path: '/h5/login',
|
||||
query: { redirect: '/h5/place-order' },
|
||||
});
|
||||
if (
|
||||
!ensureH5Login({
|
||||
reasonKey: 'h5.loginGate.reason.placeOrder',
|
||||
redirect: '/h5/place-order',
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
createFlowReady.value = true;
|
||||
@ -131,23 +134,17 @@ function preloadCreateFlow() {
|
||||
|
||||
onMounted(() => {
|
||||
hideWeChatBrowserNav();
|
||||
// 全量语言包空闲再补,不挡首屏(bootstrap 已含 H5 + 运单状态)
|
||||
if (isLogin()) {
|
||||
const run = () => void ensureFullLocaleMessages(locale.value);
|
||||
if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
|
||||
idlePreloadHandle = window.requestIdleCallback(
|
||||
() => {
|
||||
run();
|
||||
preloadCreateFlow();
|
||||
},
|
||||
{ timeout: 2500 },
|
||||
) as unknown as number;
|
||||
} else {
|
||||
idlePreloadHandle = window.setTimeout(() => {
|
||||
run();
|
||||
preloadCreateFlow();
|
||||
}, 800) as unknown as number;
|
||||
}
|
||||
// 访客也可逛顾问等页:空闲补全量语言包(bootstrap 已含 H5 + nanaAssistant)
|
||||
const run = () => {
|
||||
void ensureFullLocaleMessages(locale.value);
|
||||
preloadCreateFlow();
|
||||
};
|
||||
if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
|
||||
idlePreloadHandle = window.requestIdleCallback(run, {
|
||||
timeout: 2500,
|
||||
}) as unknown as number;
|
||||
} else {
|
||||
idlePreloadHandle = window.setTimeout(run, 800) as unknown as number;
|
||||
}
|
||||
if (showTabBar.value) {
|
||||
scheduleBadgeRefresh();
|
||||
|
||||
@ -12,6 +12,34 @@ export default {
|
||||
'h5.tab.orders': 'Orders',
|
||||
'h5.tab.me': 'Me',
|
||||
|
||||
'h5.loginGate.title': 'Sign in to continue',
|
||||
'h5.loginGate.goLogin': 'Sign in',
|
||||
'h5.loginGate.register': 'WeChat register',
|
||||
'h5.loginGate.later': 'Not now',
|
||||
'h5.loginGate.reason.default': 'Sign in to use the full customer center',
|
||||
'h5.loginGate.reason.price': 'Sign in to check shipping rates',
|
||||
'h5.loginGate.reason.orders': 'Sign in to view your orders',
|
||||
'h5.loginGate.reason.assistant': 'Sign in to chat with the advisor',
|
||||
'h5.loginGate.reason.me': 'Sign in to manage your account',
|
||||
'h5.loginGate.reason.placeOrder': 'Sign in to place an order',
|
||||
'h5.orders.guestEmpty': 'Sign in to sync shipment progress and tracking',
|
||||
'h5.orders.guestLogin': 'Sign in',
|
||||
'h5.orders.guestRegister': 'WeChat register',
|
||||
'h5.me.guestName': 'Guest',
|
||||
'h5.me.guestHint': 'Sign in to unlock member rates, coupons, and invite rewards',
|
||||
'h5.me.guestLogin': 'Sign in',
|
||||
'h5.me.guestRegister': 'Register',
|
||||
'h5.me.guestBenefitsAria': 'Member benefits',
|
||||
'h5.me.guestBenefit.member.title': 'Member rates',
|
||||
'h5.me.guestBenefit.member.desc': 'Earn points from shipments — Bronze to Crown discounts on base freight',
|
||||
'h5.me.guestBenefit.coupon.title': 'Coupons',
|
||||
'h5.me.guestBenefit.coupon.desc': 'New-user & promo coupons offset base freight; we pick the better deal',
|
||||
'h5.me.guestBenefit.invite.title': 'Invite & earn',
|
||||
'h5.me.guestBenefit.invite.desc': 'Reach Bronze to invite friends — coupons and freight rewards for both',
|
||||
'h5.assistant.guestTip': 'Sign in to chat with AI; human support is available anytime',
|
||||
'h5.assistant.guestLogin': 'Sign in',
|
||||
'h5.assistant.guestRegister': 'WeChat register',
|
||||
|
||||
'h5.assistant.quick.price': 'How do I check shipping rates?',
|
||||
'h5.assistant.quick.pay': 'How do I pay a pending order?',
|
||||
'h5.assistant.quick.track': 'How often is tracking updated?',
|
||||
@ -86,6 +114,11 @@ export default {
|
||||
'h5.price.goOrder': 'Place order',
|
||||
'h5.price.goOrderHint': 'Opening place-order…',
|
||||
'h5.price.empty': 'Enter country and weight to get quotes',
|
||||
'h5.price.guestTip': 'Fill in details first; sign in to get quotes — we will query with your inputs',
|
||||
'h5.price.guestLogin': 'Sign in',
|
||||
'h5.price.guestRegister': 'WeChat register',
|
||||
'h5.price.optionsFailed': 'Failed to load options',
|
||||
'h5.price.optionsRetry': 'Retry',
|
||||
'h5.price.noResult': 'No quotes found. Adjust filters or ask the advisor.',
|
||||
'h5.price.noResultTitle': 'No quotes found',
|
||||
'h5.price.noResultWithFailures': '{count} misses',
|
||||
|
||||
@ -12,6 +12,34 @@ export default {
|
||||
'h5.tab.orders': '运单',
|
||||
'h5.tab.me': '我的',
|
||||
|
||||
'h5.loginGate.title': '登录后继续',
|
||||
'h5.loginGate.goLogin': '去登录',
|
||||
'h5.loginGate.register': '微信注册',
|
||||
'h5.loginGate.later': '暂不',
|
||||
'h5.loginGate.reason.default': '登录后即可使用完整功能',
|
||||
'h5.loginGate.reason.price': '登录后即可查询报价',
|
||||
'h5.loginGate.reason.orders': '登录后即可查看运单',
|
||||
'h5.loginGate.reason.assistant': '登录后即可咨询顾问',
|
||||
'h5.loginGate.reason.me': '登录后即可管理账户与资产',
|
||||
'h5.loginGate.reason.placeOrder': '登录后即可下单发货',
|
||||
'h5.orders.guestEmpty': '登录后同步运单进度与物流轨迹',
|
||||
'h5.orders.guestLogin': '去登录',
|
||||
'h5.orders.guestRegister': '微信注册',
|
||||
'h5.me.guestName': '未登录',
|
||||
'h5.me.guestHint': '登录后解锁会员折扣、优惠券与邀请奖励',
|
||||
'h5.me.guestLogin': '去登录',
|
||||
'h5.me.guestRegister': '注册账号',
|
||||
'h5.me.guestBenefitsAria': '会员权益',
|
||||
'h5.me.guestBenefit.member.title': '会员折扣',
|
||||
'h5.me.guestBenefit.member.desc': '发货攒积分升铜卡~皇冠,基础运费可享折扣',
|
||||
'h5.me.guestBenefit.coupon.title': '优惠券',
|
||||
'h5.me.guestBenefit.coupon.desc': '新人券、活动券可抵基础运费,与会员折取更省',
|
||||
'h5.me.guestBenefit.invite.title': '邀请有礼',
|
||||
'h5.me.guestBenefit.invite.desc': '升级铜卡后可邀请好友,双方得券与运费奖励',
|
||||
'h5.assistant.guestTip': '登录后即可咨询 AI 顾问;人工客服可直接联系',
|
||||
'h5.assistant.guestLogin': '去登录',
|
||||
'h5.assistant.guestRegister': '微信注册',
|
||||
|
||||
'h5.assistant.quick.price': '怎么查运费?',
|
||||
'h5.assistant.quick.pay': '待支付订单怎么付?',
|
||||
'h5.assistant.quick.track': '轨迹多久更新?',
|
||||
@ -86,6 +114,11 @@ export default {
|
||||
'h5.price.goOrder': '去下单',
|
||||
'h5.price.goOrderHint': '正在跳转下单…',
|
||||
'h5.price.empty': '输入国家与重量后查询报价',
|
||||
'h5.price.guestTip': '可先填写条件;查询报价需登录,登录后自动按当前条件查询',
|
||||
'h5.price.guestLogin': '去登录',
|
||||
'h5.price.guestRegister': '微信注册',
|
||||
'h5.price.optionsFailed': '选项加载失败',
|
||||
'h5.price.optionsRetry': '重试',
|
||||
'h5.price.noResult': '暂无报价,可调整条件或问问顾问',
|
||||
'h5.price.noResultTitle': '查价未出结果',
|
||||
'h5.price.noResultWithFailures': '共 {count} 条未命中',
|
||||
|
||||
@ -119,6 +119,7 @@ import { IconLeft } from '@arco-design/web-vue/es/icon';
|
||||
import { OmsLoginType } from '@/api/rbac';
|
||||
import { isLogin, setToken } from '@/utils/auth';
|
||||
import { applyRbacUserToUserStore } from '@/utils/enter-system';
|
||||
import { parseH5RedirectLocation } from '@/utils/h5-redirect';
|
||||
import { ensureFullLocaleMessages } from '@/locale';
|
||||
import { isWeChatBrowser } from '@/utils/device';
|
||||
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
||||
@ -219,9 +220,9 @@ async function finishLogin() {
|
||||
// 不阻塞跳转:H5 bootstrap 已含业务文案;全量语言包后台补
|
||||
void ensureFullLocaleMessages();
|
||||
await nextTick();
|
||||
const target = resolveRedirectPath();
|
||||
const target = parseH5RedirectLocation(resolveRedirectPath());
|
||||
try {
|
||||
await router.replace({ path: target, query: {} });
|
||||
await router.replace(target);
|
||||
} catch (navErr) {
|
||||
console.error('H5 登录跳转失败', navErr);
|
||||
await router.replace('/h5/orders').catch(() => undefined);
|
||||
@ -530,6 +531,19 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 覆盖浏览器自动填充天蓝色底 */
|
||||
.h5-login-form :deep(input.arco-input):-webkit-autofill,
|
||||
.h5-login-form :deep(input.arco-input):-webkit-autofill:hover,
|
||||
.h5-login-form :deep(input.arco-input):-webkit-autofill:focus,
|
||||
.h5-login-form :deep(input.arco-input):-webkit-autofill:active {
|
||||
-webkit-text-fill-color: #1e293b;
|
||||
caret-color: #1e293b;
|
||||
border: none;
|
||||
-webkit-box-shadow: 0 0 0 1000px #f0f3f4 inset !important;
|
||||
box-shadow: 0 0 0 1000px #f0f3f4 inset !important;
|
||||
transition: background-color 99999s ease-out 0s;
|
||||
}
|
||||
|
||||
.h5-login-error {
|
||||
margin: 0 0 8px;
|
||||
padding: 10px 12px;
|
||||
|
||||
@ -1,6 +1,47 @@
|
||||
<template>
|
||||
<div class="h5-me-page">
|
||||
<a-spin :loading="loading" class="h5-me-spin">
|
||||
<template v-if="!isLoggedIn">
|
||||
<section class="h5-me-hero h5-me-hero--guest h5-enter">
|
||||
<div class="h5-me-hero__top">
|
||||
<img :src="displayAvatar" alt="" class="h5-me-avatar" />
|
||||
<div class="h5-me-hero__text">
|
||||
<div class="h5-me-hero__name-row">
|
||||
<h2 class="h5-me-name">{{ t('h5.me.guestName') }}</h2>
|
||||
</div>
|
||||
<p class="h5-me-guest-hint">{{ t('h5.me.guestHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="h5-me-guest-benefits" :aria-label="t('h5.me.guestBenefitsAria')">
|
||||
<li
|
||||
v-for="(item, index) in guestBenefits"
|
||||
:key="item.key"
|
||||
class="h5-me-guest-benefit"
|
||||
:class="item.toneClass"
|
||||
:style="{ animationDelay: `${80 + index * 70}ms` }"
|
||||
>
|
||||
<div class="h5-me-guest-benefit__head">
|
||||
<span class="h5-me-guest-benefit__icon" aria-hidden="true">
|
||||
<img :src="item.icon" alt="" />
|
||||
</span>
|
||||
<strong class="h5-me-guest-benefit__title">{{ t(item.titleKey) }}</strong>
|
||||
</div>
|
||||
<p class="h5-me-guest-benefit__desc">{{ t(item.descKey) }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="h5-me-guest-actions">
|
||||
<button type="button" class="h5-me-guest-login" @click="goGuestLogin">
|
||||
{{ t('h5.me.guestLogin') }}
|
||||
</button>
|
||||
<button type="button" class="h5-me-guest-register" @click="goGuestRegister">
|
||||
{{ t('h5.me.register') }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<a-spin v-else :loading="loading" class="h5-me-spin">
|
||||
<div v-if="loading && !profile.id" class="h5-me-skel">
|
||||
<div class="h5-me-skel__hero" />
|
||||
<div class="h5-me-skel__card" />
|
||||
@ -267,6 +308,7 @@ import { getMapIdFromAccessToken } from '@/utils/jwt';
|
||||
import { h5Navigate, isWeChatBrowser } from '@/utils/device';
|
||||
import { openWecomKf } from '@/utils/wecomKf';
|
||||
import { redirectToWeChatOAuth } from '@/utils/wechatOAuth';
|
||||
import { startH5WeChatRegister } from '../composables/useH5WeChatRegister';
|
||||
import { useUserStore } from '@/store';
|
||||
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
||||
import useLocale from '@/hooks/locale';
|
||||
@ -275,6 +317,7 @@ import iconServiceInvite from '@/assets/icons/h5/service-invite.svg?url';
|
||||
import iconServiceCommission from '@/assets/icons/h5/service-commission.svg?url';
|
||||
import iconServiceStoreOrder from '@/assets/icons/h5/service-store-order.svg?url';
|
||||
import iconOrderPickup from '@/assets/icons/h5/order-pickup.svg?url';
|
||||
import iconTabMe from '@/assets/icons/h5/tab-me.svg?url';
|
||||
|
||||
defineOptions({ name: 'H5Me' });
|
||||
|
||||
@ -706,11 +749,48 @@ async function loadProfile() {
|
||||
}
|
||||
}
|
||||
|
||||
const isLoggedIn = computed(() => isLogin());
|
||||
|
||||
const guestBenefits = [
|
||||
{
|
||||
key: 'member',
|
||||
icon: iconTabMe,
|
||||
toneClass: 'is-teal',
|
||||
titleKey: 'h5.me.guestBenefit.member.title',
|
||||
descKey: 'h5.me.guestBenefit.member.desc',
|
||||
},
|
||||
{
|
||||
key: 'coupon',
|
||||
icon: iconServiceCoupon,
|
||||
toneClass: 'is-accent',
|
||||
titleKey: 'h5.me.guestBenefit.coupon.title',
|
||||
descKey: 'h5.me.guestBenefit.coupon.desc',
|
||||
},
|
||||
{
|
||||
key: 'invite',
|
||||
icon: iconServiceInvite,
|
||||
toneClass: 'is-ink',
|
||||
titleKey: 'h5.me.guestBenefit.invite.title',
|
||||
descKey: 'h5.me.guestBenefit.invite.desc',
|
||||
},
|
||||
] as const;
|
||||
|
||||
function goGuestLogin() {
|
||||
h5Navigate(router, {
|
||||
path: '/h5/login',
|
||||
query: { redirect: '/h5/me' },
|
||||
});
|
||||
}
|
||||
|
||||
function goGuestRegister() {
|
||||
startH5WeChatRegister({ redirect: '/h5/me' });
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 清掉可能残留的全局 loading toast
|
||||
Message.clear();
|
||||
if (!isLogin()) {
|
||||
router.replace({ path: '/h5/login', query: { redirect: '/h5/me' } });
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
void loadProfile();
|
||||
@ -791,6 +871,170 @@ onMounted(() => {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.h5-me-hero--guest {
|
||||
padding: 18px 14px 16px;
|
||||
gap: 16px;
|
||||
background: #fff;
|
||||
|
||||
.h5-me-hero__top {
|
||||
align-items: center;
|
||||
padding: 0 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.h5-me-guest-hint {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: var(--h5-text-3, #86909c);
|
||||
}
|
||||
|
||||
.h5-me-guest-benefits {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.h5-me-guest-benefit {
|
||||
--benefit-tone: var(--me-teal);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
min-height: 108px;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
background: #f7f8fa;
|
||||
border: none;
|
||||
overflow: hidden;
|
||||
animation: h5-rise-in 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
|
||||
&.is-teal {
|
||||
--benefit-tone: var(--me-teal);
|
||||
}
|
||||
|
||||
&.is-accent {
|
||||
--benefit-tone: var(--me-accent, #e85d4c);
|
||||
}
|
||||
|
||||
&.is-ink {
|
||||
--benefit-tone: var(--me-ink);
|
||||
}
|
||||
}
|
||||
|
||||
.h5-me-guest-benefit__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.h5-me-guest-benefit__icon {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
|
||||
img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
}
|
||||
|
||||
.h5-me-guest-benefit__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding-top: 2px;
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--me-ink);
|
||||
line-height: 1.25;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.h5-me-guest-benefit__desc {
|
||||
margin: 0;
|
||||
margin-top: auto;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.45;
|
||||
color: #86909c;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.h5-me-guest-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.h5-me-guest-login,
|
||||
.h5-me-guest-register {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.h5-me-guest-login {
|
||||
border: none;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--app-primary-color) 0%,
|
||||
color-mix(in srgb, var(--app-primary-color) 82%, #0f766e) 100%
|
||||
);
|
||||
color: #fff;
|
||||
box-shadow: 0 6px 16px color-mix(in srgb, var(--app-primary-color) 28%, transparent);
|
||||
}
|
||||
|
||||
.h5-me-guest-register {
|
||||
border: 1px solid color-mix(in srgb, var(--app-primary-color) 22%, #e5e6eb);
|
||||
background: color-mix(in srgb, var(--app-primary-color) 6%, #fff);
|
||||
color: var(--app-primary-color);
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.h5-me-guest-benefits {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.h5-me-guest-benefit {
|
||||
min-height: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.h5-me-guest-benefit__desc {
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
}
|
||||
|
||||
/* 右上角错落等级角标:底色跟等级色走,不再被墨色冲淡 */
|
||||
.h5-me-vip {
|
||||
position: absolute;
|
||||
|
||||
@ -52,54 +52,56 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="h5-orders-toolbar" :class="{ 'is-pickup-mode': selectionMode }">
|
||||
<div class="h5-orders-search">
|
||||
<icon-search class="h5-orders-search__icon" />
|
||||
<input
|
||||
v-model="keyword"
|
||||
class="h5-orders-search__input"
|
||||
type="search"
|
||||
enterkeyhint="search"
|
||||
:placeholder="t('h5.orders.searchPlaceholder')"
|
||||
@keyup.enter="reload"
|
||||
/>
|
||||
<template v-if="isLoggedIn">
|
||||
<div class="h5-orders-toolbar" :class="{ 'is-pickup-mode': selectionMode }">
|
||||
<div class="h5-orders-search">
|
||||
<icon-search class="h5-orders-search__icon" />
|
||||
<input
|
||||
v-model="keyword"
|
||||
class="h5-orders-search__input"
|
||||
type="search"
|
||||
enterkeyhint="search"
|
||||
:placeholder="t('h5.orders.searchPlaceholder')"
|
||||
@keyup.enter="reload"
|
||||
/>
|
||||
<button
|
||||
v-if="keyword"
|
||||
type="button"
|
||||
class="h5-orders-search__clear"
|
||||
aria-label="clear"
|
||||
@click="keyword = ''; reload()"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="h5-orders-search-btn" @click="reload">
|
||||
{{ t('h5.orders.search') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="keyword"
|
||||
v-if="!isPickupSelection"
|
||||
type="button"
|
||||
class="h5-orders-search__clear"
|
||||
aria-label="clear"
|
||||
@click="keyword = ''; reload()"
|
||||
class="h5-orders-batch-entry"
|
||||
:class="{ 'is-on': isBatchSelection }"
|
||||
@click="isBatchSelection ? exitSelectionMode() : enterBatchMode()"
|
||||
>
|
||||
×
|
||||
{{ isBatchSelection ? t('h5.orders.batch.done') : t('h5.orders.batch.entry') }}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="h5-orders-search-btn" @click="reload">
|
||||
{{ t('h5.orders.search') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!isPickupSelection"
|
||||
type="button"
|
||||
class="h5-orders-batch-entry"
|
||||
:class="{ 'is-on': isBatchSelection }"
|
||||
@click="isBatchSelection ? exitSelectionMode() : enterBatchMode()"
|
||||
>
|
||||
{{ isBatchSelection ? t('h5.orders.batch.done') : t('h5.orders.batch.entry') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!isPickupSelection" class="h5-orders-tabs">
|
||||
<button
|
||||
v-for="group in groups"
|
||||
:key="group.key"
|
||||
type="button"
|
||||
class="h5-orders-tab"
|
||||
:class="{ 'is-active': activeGroup === group.key }"
|
||||
@click="activeGroup = group.key"
|
||||
>
|
||||
{{ t(group.labelKey) }}
|
||||
<em v-if="groupCount(group) > 0">{{ groupCount(group) }}</em>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="!isPickupSelection" class="h5-orders-tabs">
|
||||
<button
|
||||
v-for="group in groups"
|
||||
:key="group.key"
|
||||
type="button"
|
||||
class="h5-orders-tab"
|
||||
:class="{ 'is-active': activeGroup === group.key }"
|
||||
@click="activeGroup = group.key"
|
||||
>
|
||||
{{ t(group.labelKey) }}
|
||||
<em v-if="groupCount(group) > 0">{{ groupCount(group) }}</em>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="isBatchSelection" class="h5-batch-hint h5-enter">
|
||||
<p>{{ t('h5.orders.batch.hint') }}</p>
|
||||
@ -115,15 +117,28 @@
|
||||
<a-spin :loading="loading && orders.length > 0" class="h5-orders-spin">
|
||||
<H5EmptyState
|
||||
v-if="!loading && !orders.length"
|
||||
:icon="IconTabPackage"
|
||||
:icon="isLoggedIn ? IconTabPackage : IconLock"
|
||||
:text="
|
||||
isPickupSelection
|
||||
? t('h5.orders.mergePickup.emptyPredeclared')
|
||||
: t('h5.orders.empty')
|
||||
!isLoggedIn
|
||||
? t('h5.orders.guestEmpty')
|
||||
: isPickupSelection
|
||||
? t('h5.orders.mergePickup.emptyPredeclared')
|
||||
: t('h5.orders.empty')
|
||||
"
|
||||
>
|
||||
<template v-if="!isLoggedIn">
|
||||
<a-button class="app-btn-primary-soft" @click="promptGuestLogin">
|
||||
{{ t('h5.orders.guestLogin') }}
|
||||
</a-button>
|
||||
<a-button @click="promptGuestRegister">
|
||||
{{ t('h5.orders.guestRegister') }}
|
||||
</a-button>
|
||||
<a-button @click="h5Navigate(router, '/h5/price')">
|
||||
{{ t('h5.orders.emptyGoPrice') }}
|
||||
</a-button>
|
||||
</template>
|
||||
<a-button
|
||||
v-if="!selectionMode"
|
||||
v-else-if="!selectionMode"
|
||||
class="app-btn-primary-soft"
|
||||
@click="h5Navigate(router, '/h5/price')"
|
||||
>
|
||||
@ -411,11 +426,14 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import dayjs from 'dayjs';
|
||||
import { Message, Modal } from '@arco-design/web-vue';
|
||||
import { IconCheck, IconCopy, IconLeft, IconSearch } from '@arco-design/web-vue/es/icon';
|
||||
import { IconCheck, IconCopy, IconLeft, IconLock, IconSearch } from '@arco-design/web-vue/es/icon';
|
||||
import { h5Navigate } from '@/utils/device';
|
||||
import { isLogin } from '@/utils/auth';
|
||||
import iconOrderPickup from '@/assets/icons/h5/order-pickup.svg?url';
|
||||
import IconTabPackage from '@/assets/icons/h5/tab-package.svg';
|
||||
import H5EmptyState from '../components/H5EmptyState.vue';
|
||||
import { ensureH5Login } from '../composables/useH5LoginGate';
|
||||
import { startH5WeChatRegister } from '../composables/useH5WeChatRegister';
|
||||
import { H5_HIDE_CREATE_FAB_KEY } from '../composables/h5TabBarUi';
|
||||
import {
|
||||
formatBatchJobFailureMessage,
|
||||
@ -1368,6 +1386,12 @@ async function fetchPage(pageNo: number, append: boolean) {
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
if (!isLogin()) {
|
||||
// 访客:仅展示空态营销,不因 Tab/搜索自动弹登录
|
||||
orders.value = [];
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
await Promise.all([fetchCounts(), fetchPage(1, false)]);
|
||||
@ -1496,7 +1520,26 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
const isLoggedIn = computed(() => isLogin());
|
||||
|
||||
function promptGuestLogin() {
|
||||
ensureH5Login({
|
||||
reasonKey: 'h5.loginGate.reason.orders',
|
||||
redirect: '/h5/orders',
|
||||
});
|
||||
}
|
||||
|
||||
function promptGuestRegister() {
|
||||
startH5WeChatRegister({ redirect: '/h5/orders' });
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
if (!isLogin()) {
|
||||
bootstrapping = false;
|
||||
loading.value = false;
|
||||
orders.value = [];
|
||||
return;
|
||||
}
|
||||
await syncSelectionModeFromQuery();
|
||||
await resolveEntryGroup();
|
||||
bootstrapping = false;
|
||||
|
||||
@ -184,6 +184,24 @@
|
||||
<template v-else>{{ t('h5.price.query') }}</template>
|
||||
<template v-if="queryLoading">{{ t('h5.price.querying') }}</template>
|
||||
</button>
|
||||
|
||||
<div v-if="!isLoggedIn" class="h5-price-guest-tip">
|
||||
<p>{{ t('h5.price.guestTip') }}</p>
|
||||
<div class="h5-price-guest-tip__actions">
|
||||
<a-button class="app-btn-primary-soft" size="small" @click="promptGuestLogin">
|
||||
{{ t('h5.price.guestLogin') }}
|
||||
</a-button>
|
||||
<a-button size="small" @click="promptGuestRegister">
|
||||
{{ t('h5.price.guestRegister') }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="optionsLoadFailed" class="h5-price-guest-tip h5-price-guest-tip--warn">
|
||||
<p>{{ t('h5.price.optionsFailed') }}</p>
|
||||
<a-button size="small" class="app-btn-primary-soft" @click="loadOptions">
|
||||
{{ t('h5.price.optionsRetry') }}
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
@ -450,7 +468,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-else class="h5-empty h5-price-empty">
|
||||
<div v-else-if="isLoggedIn" class="h5-empty h5-price-empty">
|
||||
<div class="h5-empty__icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="10" y="22" width="44" height="28" rx="6" fill="currentColor" opacity="0.12" />
|
||||
@ -649,7 +667,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch, type ComponentPublicInstance } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { IconDown } from '@arco-design/web-vue/es/icon';
|
||||
@ -657,13 +675,17 @@ import IconRoutePackage from '@/assets/icons/h5/route-package.svg';
|
||||
import IconTabService from '@/assets/icons/h5/tab-service.svg';
|
||||
import MarkdownViewer from '@/components/MarkdownViewer/index.vue';
|
||||
import type { CustomerProfileCompleteness } from '@/api/customer';
|
||||
import { postOrderPriceCheck, type PriceCheckQueryHint } from '@/api/order';
|
||||
import { getCountryList } from '@/api/system/country';
|
||||
import { getWarehouseList } from '@/api/system/warehouse';
|
||||
import { getDictListAll } from '@/api/system/dict';
|
||||
import {
|
||||
getOrderPriceCheckFormOptions,
|
||||
postOrderPriceCheck,
|
||||
type PriceCheckQueryHint,
|
||||
} from '@/api/order';
|
||||
import { getSalesProductDetail } from '@/api/channel/salesProduct';
|
||||
import { h5Navigate } from '@/utils/device';
|
||||
import { isLogin } from '@/utils/auth';
|
||||
import { checkCustomerContactRequired } from '@/utils/customerContactGate';
|
||||
import { ensureH5Login } from '../composables/useH5LoginGate';
|
||||
import { startH5WeChatRegister } from '../composables/useH5WeChatRegister';
|
||||
import {
|
||||
getPriceAskLocation,
|
||||
type PriceAskIntent,
|
||||
@ -685,9 +707,11 @@ import H5CompleteContactSheet from '../components/H5CompleteContactSheet.vue';
|
||||
defineOptions({ name: 'H5Price' });
|
||||
|
||||
const PRICE_CHECK_ORDER_DRAFT_KEY = 'oms_price_check_order_draft';
|
||||
const PRICE_FORM_DRAFT_KEY = 'oms_h5_price_form_draft';
|
||||
const countryShortcutCodes = ['US', 'GB', 'DE', 'CA', 'FR', 'ES'];
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const placeOrderVisible = ref(false);
|
||||
const placeOrderDraft = ref<Record<string, any> | null>(null);
|
||||
@ -742,6 +766,7 @@ const warehouseOptions = ref<WhOpt[]>([]);
|
||||
const cargoOptions = ref<Opt[]>([]);
|
||||
const countryKeyword = ref('');
|
||||
const optionsLoading = ref(false);
|
||||
const optionsLoadFailed = ref(false);
|
||||
const queryLoading = ref(false);
|
||||
const queried = ref(false);
|
||||
const queryFailureText = ref('');
|
||||
@ -1091,56 +1116,99 @@ function onPickCargo(code: string) {
|
||||
pickerVisible.value = false;
|
||||
}
|
||||
|
||||
function snapshotPriceForm(extra?: { autoQuery?: boolean; resumeOrder?: boolean }) {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
PRICE_FORM_DRAFT_KEY,
|
||||
JSON.stringify({
|
||||
...form,
|
||||
autoQuery: Boolean(extra?.autoQuery),
|
||||
resumeOrder: Boolean(extra?.resumeOrder),
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function restorePriceFormSnapshot(): { autoQuery: boolean; resumeOrder: boolean } {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(PRICE_FORM_DRAFT_KEY);
|
||||
if (!raw) return { autoQuery: false, resumeOrder: false };
|
||||
sessionStorage.removeItem(PRICE_FORM_DRAFT_KEY);
|
||||
const data = JSON.parse(raw) as Record<string, unknown>;
|
||||
const keys: Array<keyof typeof form> = [
|
||||
'destinationCountry',
|
||||
'cargoWeight',
|
||||
'postalCode',
|
||||
'destinationProvince',
|
||||
'destinationCity',
|
||||
'cargoType',
|
||||
'warehouseId',
|
||||
'length',
|
||||
'width',
|
||||
'height',
|
||||
];
|
||||
for (const key of keys) {
|
||||
if (data[key] !== undefined) {
|
||||
;(form as any)[key] = data[key]
|
||||
}
|
||||
}
|
||||
return {
|
||||
autoQuery: Boolean(data.autoQuery),
|
||||
resumeOrder: Boolean(data.resumeOrder),
|
||||
};
|
||||
} catch {
|
||||
return { autoQuery: false, resumeOrder: false };
|
||||
}
|
||||
}
|
||||
|
||||
function clearResumeQueryFlags() {
|
||||
const q = { ...route.query } as Record<string, any>;
|
||||
let dirty = false;
|
||||
if (q.autoQuery != null) {
|
||||
delete q.autoQuery;
|
||||
dirty = true;
|
||||
}
|
||||
if (q.resumeOrder != null) {
|
||||
delete q.resumeOrder;
|
||||
dirty = true;
|
||||
}
|
||||
if (dirty) {
|
||||
void router.replace({ path: '/h5/price', query: q }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
optionsLoading.value = true;
|
||||
optionsLoadFailed.value = false;
|
||||
try {
|
||||
const [countryRes, warehouseRes, dictRows] = await Promise.all([
|
||||
getCountryList(),
|
||||
getWarehouseList({ page: 1, pageSize: 500 }),
|
||||
getDictListAll({ dictType: 'cargo_attribute' }),
|
||||
]);
|
||||
// 匿名可读:访客可先选仓/国家/属性,点「查询报价」再引导登录
|
||||
const opts = await getOrderPriceCheckFormOptions();
|
||||
|
||||
countryOptions.value = (countryRes.items ?? [])
|
||||
.map((row: any) => {
|
||||
const code = String(row?.countryCode ?? row?.CountryCode ?? row?.code ?? '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
const name = String(
|
||||
row?.chineseName ??
|
||||
row?.ChineseName ??
|
||||
row?.countryNameCn ??
|
||||
row?.countryName ??
|
||||
row?.CountryName ??
|
||||
row?.name ??
|
||||
'',
|
||||
).trim();
|
||||
return {
|
||||
value: code,
|
||||
name,
|
||||
label: name ? `${name} ${code}` : code,
|
||||
};
|
||||
})
|
||||
.filter((c) => c.value);
|
||||
countryOptions.value = opts.countries.map((row) => {
|
||||
const code = row.countryCode;
|
||||
const name = row.chineseName || row.englishName || code;
|
||||
return {
|
||||
value: code,
|
||||
name,
|
||||
label: name ? `${name} ${code}` : code,
|
||||
};
|
||||
});
|
||||
|
||||
warehouseOptions.value = (warehouseRes.items ?? [])
|
||||
.map((row: any) => {
|
||||
const id = Number(row?.id ?? row?.Id);
|
||||
const name = String(row?.warehouseName ?? row?.WarehouseName ?? '').trim();
|
||||
return { value: id, label: name || String(id) };
|
||||
})
|
||||
.filter((w) => Number.isFinite(w.value) && w.value > 0);
|
||||
warehouseOptions.value = opts.warehouses.map((row) => ({
|
||||
value: row.id,
|
||||
label: row.warehouseName || String(row.id),
|
||||
}));
|
||||
|
||||
if (!form.warehouseId && warehouseOptions.value.length) {
|
||||
form.warehouseId = warehouseOptions.value[0].value;
|
||||
}
|
||||
|
||||
cargoOptions.value = (dictRows ?? [])
|
||||
.map((row: any) => {
|
||||
const code = String(row?.dictCode ?? row?.DictCode ?? row?.code ?? '').trim();
|
||||
const name = String(row?.dictName ?? row?.DictName ?? row?.name ?? '').trim();
|
||||
return { value: code, label: name || code };
|
||||
})
|
||||
.filter((c) => c.value);
|
||||
cargoOptions.value = opts.cargoAttributes.map((row) => ({
|
||||
value: row.dictCode,
|
||||
label: row.dictName || row.dictCode,
|
||||
}));
|
||||
|
||||
// 默认普货:名称含「普」或首项
|
||||
if (!form.cargoType && cargoOptions.value.length) {
|
||||
@ -1151,6 +1219,7 @@ async function loadOptions() {
|
||||
countryOptions.value = [];
|
||||
warehouseOptions.value = [];
|
||||
cargoOptions.value = [];
|
||||
optionsLoadFailed.value = true;
|
||||
} finally {
|
||||
optionsLoading.value = false;
|
||||
}
|
||||
@ -1161,6 +1230,20 @@ async function handleQuery() {
|
||||
Message.warning(t('h5.price.required'));
|
||||
return;
|
||||
}
|
||||
snapshotPriceForm({ autoQuery: true });
|
||||
if (
|
||||
!ensureH5Login({
|
||||
reasonKey: 'h5.loginGate.reason.price',
|
||||
redirect: '/h5/price?autoQuery=1',
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
sessionStorage.removeItem(PRICE_FORM_DRAFT_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
queryLoading.value = true;
|
||||
queried.value = true;
|
||||
formCollapsed.value = false;
|
||||
@ -1210,6 +1293,15 @@ async function handleQuery() {
|
||||
}
|
||||
|
||||
async function handleAiRecommend() {
|
||||
snapshotPriceForm();
|
||||
if (
|
||||
!ensureH5Login({
|
||||
reasonKey: 'h5.loginGate.reason.price',
|
||||
redirect: '/h5/price',
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const name = aiProductName.value.trim();
|
||||
if (!name) {
|
||||
Message.warning(t('h5.price.ai.needName'));
|
||||
@ -1368,9 +1460,25 @@ function openPlaceOrderWithDraft(draft: Record<string, any>) {
|
||||
|
||||
async function goOrder(item: any) {
|
||||
const draft = buildOrderDraft(item);
|
||||
// 先落草稿,登录后可从 ?resumeOrder=1 恢复下单
|
||||
try {
|
||||
sessionStorage.setItem(PRICE_CHECK_ORDER_DRAFT_KEY, JSON.stringify(draft));
|
||||
localStorage.setItem(PRICE_CHECK_ORDER_DRAFT_KEY, JSON.stringify(draft));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
placeOrderDraft.value = draft;
|
||||
snapshotPriceForm({ resumeOrder: true });
|
||||
if (
|
||||
!ensureH5Login({
|
||||
reasonKey: 'h5.loginGate.reason.placeOrder',
|
||||
redirect: '/h5/price?resumeOrder=1',
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const { needContact, profile } = await checkCustomerContactRequired();
|
||||
if (needContact) {
|
||||
placeOrderDraft.value = draft;
|
||||
pendingOpenOrder = true;
|
||||
contactProfile.value = profile;
|
||||
contactVisible.value = true;
|
||||
@ -1390,8 +1498,61 @@ function onContactCancel() {
|
||||
pendingOpenOrder = false;
|
||||
}
|
||||
|
||||
const isLoggedIn = computed(() => isLogin());
|
||||
|
||||
function promptGuestLogin() {
|
||||
ensureH5Login({
|
||||
reasonKey: 'h5.loginGate.reason.price',
|
||||
redirect: '/h5/price',
|
||||
});
|
||||
}
|
||||
|
||||
function promptGuestRegister() {
|
||||
startH5WeChatRegister({ redirect: '/h5/price' });
|
||||
}
|
||||
|
||||
async function resumeAfterLogin() {
|
||||
if (!isLogin()) return;
|
||||
const fromQueryAuto = String(route.query.autoQuery || '') === '1';
|
||||
const fromQueryResume = String(route.query.resumeOrder || '') === '1';
|
||||
const snap = restorePriceFormSnapshot();
|
||||
const shouldAutoQuery = fromQueryAuto || snap.autoQuery;
|
||||
const shouldResumeOrder = fromQueryResume || snap.resumeOrder;
|
||||
clearResumeQueryFlags();
|
||||
|
||||
if (shouldResumeOrder) {
|
||||
let draft = placeOrderDraft.value;
|
||||
if (!draft) {
|
||||
try {
|
||||
const raw =
|
||||
sessionStorage.getItem(PRICE_CHECK_ORDER_DRAFT_KEY) ||
|
||||
localStorage.getItem(PRICE_CHECK_ORDER_DRAFT_KEY);
|
||||
if (raw) draft = JSON.parse(raw);
|
||||
} catch {
|
||||
draft = null;
|
||||
}
|
||||
}
|
||||
if (draft) {
|
||||
const { needContact, profile } = await checkCustomerContactRequired();
|
||||
if (needContact) {
|
||||
placeOrderDraft.value = draft;
|
||||
pendingOpenOrder = true;
|
||||
contactProfile.value = profile;
|
||||
contactVisible.value = true;
|
||||
return;
|
||||
}
|
||||
openPlaceOrderWithDraft(draft);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldAutoQuery && form.destinationCountry && String(form.cargoWeight).trim()) {
|
||||
await handleQuery();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadOptions();
|
||||
void loadOptions().then(() => resumeAfterLogin());
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -2744,6 +2905,35 @@ onMounted(() => {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.h5-price-guest-tip {
|
||||
margin-top: 12px;
|
||||
padding: 12px 12px 10px;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--app-primary-color) 8%, #fff);
|
||||
text-align: left;
|
||||
|
||||
p {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: var(--h5-text-3, #86909c);
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: #fff7e8;
|
||||
|
||||
p {
|
||||
color: #ad6800;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.h5-detail-spin {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
|
||||
@ -194,10 +194,12 @@ function go(path: string, query?: Record<string, string>) {
|
||||
align-items: stretch;
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
width: min(1480px, 94%);
|
||||
max-width: 94vw;
|
||||
margin: 0 auto;
|
||||
padding: 16px 0;
|
||||
/* 与其它页面 *-container / 客户工作台一致:满宽 + 四边 16px */
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@ -538,8 +540,9 @@ function go(path: string, query?: Record<string, string>) {
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.admin-workplace__container {
|
||||
width: 96%;
|
||||
padding: 12px 0 20px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -68,14 +68,15 @@ defineOptions({ name: 'CustomerWorkplace' })
|
||||
.container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
padding: 16px 20px;
|
||||
padding-bottom: 16px;
|
||||
/* 与其它页面 *-container 一致:相对主区左右 16px,勿 94% 居中造成两侧偏大留白 */
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
width: min(1480px, 94%);
|
||||
max-width: 94vw;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.left-side {
|
||||
@ -371,11 +372,6 @@ defineOptions({ name: 'CustomerWorkplace' })
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.container {
|
||||
width: min(1280px, 96%);
|
||||
max-width: 96vw;
|
||||
}
|
||||
|
||||
.right-side {
|
||||
max-width: 340px;
|
||||
}
|
||||
@ -384,10 +380,10 @@ defineOptions({ name: 'CustomerWorkplace' })
|
||||
@media (max-width: 1200px) {
|
||||
.container {
|
||||
flex-direction: column;
|
||||
padding: 12px 16px;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
width: 96%;
|
||||
max-width: 96vw;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
|
||||
@ -456,6 +456,20 @@ async function handleSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
/* 覆盖 Chrome/Edge 自动填充的天蓝色底,保持与输入井 #f0f3f4 一致 */
|
||||
.nova-login__form :deep(input.arco-input):-webkit-autofill,
|
||||
.nova-login__form :deep(input.arco-input):-webkit-autofill:hover,
|
||||
.nova-login__form :deep(input.arco-input):-webkit-autofill:focus,
|
||||
.nova-login__form :deep(input.arco-input):-webkit-autofill:active {
|
||||
-webkit-text-fill-color: #1e293b;
|
||||
caret-color: #1e293b;
|
||||
border: none;
|
||||
/* 用大 inset 阴影盖住浏览器默认 autofill 背景 */
|
||||
-webkit-box-shadow: 0 0 0 1000px #f0f3f4 inset !important;
|
||||
box-shadow: 0 0 0 1000px #f0f3f4 inset !important;
|
||||
transition: background-color 99999s ease-out 0s;
|
||||
}
|
||||
|
||||
.nova-login__arco-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@ -88,6 +88,8 @@ import {
|
||||
} from '@/utils/advisorInviteStorage'
|
||||
import { stashInviteRef } from '@/utils/inviteRefStorage'
|
||||
import { isTimeoutError, withTimeout } from '@/utils/withTimeout'
|
||||
import { preferH5Portal } from '@/utils/device'
|
||||
import { isAdminH5Path } from '@/utils/login-portal'
|
||||
|
||||
defineOptions({ name: 'CustomerRegister' })
|
||||
|
||||
@ -96,6 +98,27 @@ const REFERRER_TIMEOUT_MS = 15000
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
function resolvePostRegisterLogin() {
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect.trim() : ''
|
||||
const wantH5 =
|
||||
preferH5Portal() ||
|
||||
(redirect.startsWith('/h5') && !isAdminH5Path(redirect))
|
||||
if (wantH5) {
|
||||
return {
|
||||
path: '/h5/login',
|
||||
query: redirect ? { redirect } : undefined,
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: 'login' as const,
|
||||
query: redirect
|
||||
? { redirect }
|
||||
: inviteCode.value
|
||||
? {}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const inviteCode = ref('')
|
||||
@ -201,7 +224,7 @@ async function handleSubmit() {
|
||||
clearStashedInviteRef()
|
||||
clearStashedAdvisorInvite()
|
||||
Message.success(t('marketing.register.success'))
|
||||
router.replace({ name: 'login', query: inviteCode.value ? {} : undefined })
|
||||
router.replace(resolvePostRegisterLogin())
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || t('marketing.register.failed'))
|
||||
} finally {
|
||||
@ -210,7 +233,7 @@ async function handleSubmit() {
|
||||
}
|
||||
|
||||
function goLogin() {
|
||||
router.push({ name: 'login' })
|
||||
router.push(resolvePostRegisterLogin())
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user