From 2dd3e656a3f767791e1d34d97234665b3a027786 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Mon, 26 Oct 2026 10:22:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20H5=20=E8=AE=BF=E5=AE=A2=E5=8F=AF?= =?UTF-8?q?=E9=80=9B=E5=9B=9B=20Tab=EF=BC=8C=E7=99=BB=E5=BD=95=E9=97=A8?= =?UTF-8?q?=E7=A6=81=E4=B8=8E=E5=BE=AE=E4=BF=A1=E6=B3=A8=E5=86=8C=E5=BC=95?= =?UTF-8?q?=E5=AF=BC=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .dockerignore | 1 + .gitea/workflows/deploy.yml | 1 + Dockerfile.k3s | 30 +- src/App.vue | 179 ++++------- src/api/order/index.ts | 44 +++ src/api/rbac/axios-config.ts | 48 +-- .../NanaAssistant/AiAdvisorShell.vue | 33 +- src/locale/bootstrap-en-US.ts | 3 + src/locale/bootstrap-zh-CN.ts | 3 + src/locale/en-US.ts | 113 +------ src/locale/nana/en-US.ts | 114 +++++++ src/locale/nana/zh-CN.ts | 113 +++++++ src/locale/zh-CN.ts | 112 +------ src/router/guard/userLoginInfo.ts | 15 +- src/router/index.ts | 14 +- src/store/modules/rbac-user.ts | 7 + src/utils/auth-keepalive.ts | 25 +- src/utils/auth-session-expired.ts | 98 ++++++ src/utils/auth-session-guard.ts | 16 + src/utils/h5-redirect.ts | 24 ++ src/utils/page-transition-flag.ts | 4 +- src/utils/request.ts | 67 +--- src/views/h5/admin-center/login/index.vue | 13 + .../h5/customer-center/assistant/index.vue | 51 ++++ .../customer-center/components/AppTabBar.vue | 6 +- .../components/H5LoginGateSheet.vue | 134 ++++++++ .../composables/useH5LoginGate.ts | 57 ++++ .../composables/useH5WeChatRegister.ts | 26 ++ src/views/h5/customer-center/layout.vue | 47 ++- src/views/h5/customer-center/locale/en-US.ts | 33 ++ src/views/h5/customer-center/locale/zh-CN.ts | 33 ++ src/views/h5/customer-center/login/index.vue | 18 +- src/views/h5/customer-center/me/index.vue | 248 ++++++++++++++- src/views/h5/customer-center/orders/index.vue | 139 ++++++--- src/views/h5/customer-center/price/index.vue | 286 +++++++++++++++--- .../workplace/components/AdminWorkplace.vue | 15 +- .../components/CustomerWorkplace.vue | 22 +- .../login/components/pwd-login/index.vue | 14 + src/views/register/index.vue | 27 +- 39 files changed, 1601 insertions(+), 632 deletions(-) create mode 100644 src/locale/nana/en-US.ts create mode 100644 src/locale/nana/zh-CN.ts create mode 100644 src/utils/auth-session-expired.ts create mode 100644 src/utils/auth-session-guard.ts create mode 100644 src/utils/h5-redirect.ts create mode 100644 src/views/h5/customer-center/components/H5LoginGateSheet.vue create mode 100644 src/views/h5/customer-center/composables/useH5LoginGate.ts create mode 100644 src/views/h5/customer-center/composables/useH5WeChatRegister.ts diff --git a/.dockerignore b/.dockerignore index 5674289..332da2e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -25,6 +25,7 @@ xhs-agent-desktop .env* !.env.production +!.env.ideship-test # 本地简易镜像另用 Dockerfile;k3s 构建用 Dockerfile.k3s Dockerfile diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index a1cc650..0906f6e 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -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: diff --git a/Dockerfile.k3s b/Dockerfile.k3s index 88fa85e..97e3896 100644 --- a/Dockerfile.k3s +++ b/Dockerfile.k3s @@ -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"] diff --git a/src/App.vue b/src/App.vue index 2d6db0f..075f1e1 100644 --- a/src/App.vue +++ b/src/App.vue @@ -2,7 +2,7 @@
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); } diff --git a/src/api/order/index.ts b/src/api/order/index.ts index 9938966..e5a68a9 100644 --- a/src/api/order/index.ts +++ b/src/api/order/index.ts @@ -1742,6 +1742,50 @@ export async function getOrderPriceCheckShippingOptions(): Promise 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 { + 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[] diff --git a/src/api/rbac/axios-config.ts b/src/api/rbac/axios-config.ts index 6486337..191b803 100644 --- a/src/api/rbac/axios-config.ts +++ b/src/api/rbac/axios-config.ts @@ -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, - 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, 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('没有权限访问') diff --git a/src/components/NanaAssistant/AiAdvisorShell.vue b/src/components/NanaAssistant/AiAdvisorShell.vue index 14f2a53..4517835 100644 --- a/src/components/NanaAssistant/AiAdvisorShell.vue +++ b/src/components/NanaAssistant/AiAdvisorShell.vue @@ -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); // 手机/微信:禁止进页自动聚焦,避免整页被放大 diff --git a/src/locale/bootstrap-en-US.ts b/src/locale/bootstrap-en-US.ts index 1d10882..09e3981 100644 --- a/src/locale/bootstrap-en-US.ts +++ b/src/locale/bootstrap-en-US.ts @@ -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, }; diff --git a/src/locale/bootstrap-zh-CN.ts b/src/locale/bootstrap-zh-CN.ts index fa2cbff..bdfad40 100644 --- a/src/locale/bootstrap-zh-CN.ts +++ b/src/locale/bootstrap-zh-CN.ts @@ -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, }; diff --git a/src/locale/en-US.ts b/src/locale/en-US.ts index c058b02..71b29b6 100644 --- a/src/locale/en-US.ts +++ b/src/locale/en-US.ts @@ -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', diff --git a/src/locale/nana/en-US.ts b/src/locale/nana/en-US.ts new file mode 100644 index 0000000..d5549ea --- /dev/null +++ b/src/locale/nana/en-US.ts @@ -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.', +} diff --git a/src/locale/nana/zh-CN.ts b/src/locale/nana/zh-CN.ts new file mode 100644 index 0000000..65d1de9 --- /dev/null +++ b/src/locale/nana/zh-CN.ts @@ -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': '发送失败,请稍后重试', +} diff --git a/src/locale/zh-CN.ts b/src/locale/zh-CN.ts index 070d747..7d2e96a 100644 --- a/src/locale/zh-CN.ts +++ b/src/locale/zh-CN.ts @@ -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': '余额已更新', diff --git a/src/router/guard/userLoginInfo.ts b/src/router/guard/userLoginInfo.ts index 1b3299b..7556148 100644 --- a/src/router/guard/userLoginInfo.ts +++ b/src/router/guard/userLoginInfo.ts @@ -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', diff --git a/src/router/index.ts b/src/router/index.ts index cd8bf70..5d023cb 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -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'; }, }, ], diff --git a/src/store/modules/rbac-user.ts b/src/store/modules/rbac-user.ts index 7bb4d53..e0414c8 100644 --- a/src/store/modules/rbac-user.ts +++ b/src/store/modules/rbac-user.ts @@ -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 diff --git a/src/utils/auth-keepalive.ts b/src/utils/auth-keepalive.ts index 4b9a971..bd4f010 100644 --- a/src/utils/auth-keepalive.ts +++ b/src/utils/auth-keepalive.ts @@ -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) { diff --git a/src/utils/auth-session-expired.ts b/src/utils/auth-session-expired.ts new file mode 100644 index 0000000..2432f8c --- /dev/null +++ b/src/utils/auth-session-expired.ts @@ -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 +} diff --git a/src/utils/auth-session-guard.ts b/src/utils/auth-session-guard.ts new file mode 100644 index 0000000..766ebc4 --- /dev/null +++ b/src/utils/auth-session-guard.ts @@ -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 +} diff --git a/src/utils/h5-redirect.ts b/src/utils/h5-redirect.ts new file mode 100644 index 0000000..81a5658 --- /dev/null +++ b/src/utils/h5-redirect.ts @@ -0,0 +1,24 @@ +/** + * 解析 H5 登录/注册后的 redirect(可能含 query),供 router.replace 使用。 + */ +export function parseH5RedirectLocation(redirect: string | null | undefined): { + path: string + query?: Record +} { + 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 = {} + new URLSearchParams(withoutHash.slice(qIdx + 1)).forEach((value, key) => { + if (key) query[key] = value + }) + return Object.keys(query).length ? { path, query } : { path } +} diff --git a/src/utils/page-transition-flag.ts b/src/utils/page-transition-flag.ts index 94ca5d1..7739f12 100644 --- a/src/utils/page-transition-flag.ts +++ b/src/utils/page-transition-flag.ts @@ -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; diff --git a/src/utils/request.ts b/src/utils/request.ts index 6354643..3e2c8de 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -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; diff --git a/src/views/h5/admin-center/login/index.vue b/src/views/h5/admin-center/login/index.vue index aa26077..f21345a 100644 --- a/src/views/h5/admin-center/login/index.vue +++ b/src/views/h5/admin-center/login/index.vue @@ -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; diff --git a/src/views/h5/customer-center/assistant/index.vue b/src/views/h5/customer-center/assistant/index.vue index fdc9aa4..ebc465e 100644 --- a/src/views/h5/customer-center/assistant/index.vue +++ b/src/views/h5/customer-center/assistant/index.vue @@ -1,5 +1,17 @@ @@ -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 | 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(); diff --git a/src/views/h5/customer-center/locale/en-US.ts b/src/views/h5/customer-center/locale/en-US.ts index 9d920a8..29db460 100644 --- a/src/views/h5/customer-center/locale/en-US.ts +++ b/src/views/h5/customer-center/locale/en-US.ts @@ -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', diff --git a/src/views/h5/customer-center/locale/zh-CN.ts b/src/views/h5/customer-center/locale/zh-CN.ts index 90ab3f5..7702899 100644 --- a/src/views/h5/customer-center/locale/zh-CN.ts +++ b/src/views/h5/customer-center/locale/zh-CN.ts @@ -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} 条未命中', diff --git a/src/views/h5/customer-center/login/index.vue b/src/views/h5/customer-center/login/index.vue index b64cb6d..f859192 100644 --- a/src/views/h5/customer-center/login/index.vue +++ b/src/views/h5/customer-center/login/index.vue @@ -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; diff --git a/src/views/h5/customer-center/me/index.vue b/src/views/h5/customer-center/me/index.vue index 4f0c74a..a55824f 100644 --- a/src/views/h5/customer-center/me/index.vue +++ b/src/views/h5/customer-center/me/index.vue @@ -1,6 +1,47 @@ @@ -450,7 +468,7 @@
-
+