20261026 #12

Merged
zzh merged 3 commits from test into master 2026-10-26 12:27:55 +08:00
55 changed files with 3272 additions and 920 deletions

View File

@ -25,6 +25,7 @@ xhs-agent-desktop
.env*
!.env.production
!.env.ideship-test
# 本地简易镜像另用 Dockerfile;k3s 构建用 Dockerfile.k3s
Dockerfile

View File

@ -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:

View File

@ -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"]

View File

@ -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>

View File

@ -396,6 +396,25 @@ export function postOrderDelete(body: OrderIdRequest) {
return postOrderById('/order/delete', body)
}
export interface OrderChangePostShipStatusRequest extends OrderIdRequest {
/** pendingOnline / shipping / deliverySuccess / deliveryFailed */
targetStatus: string
/** 必填备注 */
remark: string
}
/** POST /order/change-post-ship-status — 出库后四态校正(派送成功只进不出) */
export async function postOrderChangePostShipStatus(
body: OrderChangePostShipStatusRequest,
): Promise<void> {
const res = await axiosInstance.post(orderUrl('/order/change-post-ship-status'), {
id: Number(body.id),
targetStatus: String(body.targetStatus || '').trim(),
remark: String(body.remark || '').trim(),
})
assertBusinessOk(res.data)
}
export interface OrderTrackUpsertResult {
skipped?: boolean
skipReason?: string
@ -1742,6 +1761,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[]

View File

@ -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('没有权限访问')

1
src/components.d.ts vendored
View File

@ -70,5 +70,6 @@ declare module 'vue' {
TopNav: typeof import('./components/top-nav/index.vue')['default']
UserEditDrawer: typeof import('./components/UserEditDrawer/index.vue')['default']
UserProfileFields: typeof import('./components/UserProfileFields/index.vue')['default']
WarehouseAddressPeekModal: typeof import('./components/customer-side-rail/WarehouseAddressPeekModal.vue')['default']
}
}

View File

@ -11,11 +11,19 @@ const props = withDefaults(
dropdownPosition?: 'br' | 'bl' | 'tr' | 'tl' | 'rt' | 'rb'
/** 侧栏收起:窄宽展示缩略文案(无图标) */
compact?: boolean
/** 下拉触发;侧栏默认 click,顶栏 hover */
trigger?: 'hover' | 'click'
/** 侧栏展开:显示「发货仓库」语义前缀 */
showCaption?: boolean
contentClass?: string
}>(),
{
variant: 'navbar',
dropdownPosition: 'br',
compact: false,
trigger: undefined,
showCaption: false,
contentClass: '',
},
)
@ -39,6 +47,22 @@ const labelText = computed(() => {
const useDropdown = computed(() => props.variant === 'navbar' || props.variant === 'sider')
const dropdownTrigger = computed(() => {
if (props.trigger) return props.trigger
return props.variant === 'sider' ? 'click' : 'hover'
})
const showSiderCaption = computed(
() => props.showCaption && props.variant === 'sider' && !props.compact,
)
const contentClass = computed(() => props.contentClass || undefined)
const popupTranslate = computed<[number, number] | undefined>(() => {
const pos = props.dropdownPosition
return pos === 'tl' || pos === 'tr' ? [0, -6] : undefined
})
async function loadServiceDataList() {
try {
const { items } = await getWarehouseList({
@ -77,13 +101,23 @@ onMounted(() => {
>
<a-dropdown
v-if="useDropdown"
trigger="hover"
:trigger="dropdownTrigger"
:position="dropdownPosition"
:content-class="contentClass"
:popup-translate="popupTranslate"
:popup-max-height="320"
>
<span class="contact-service__label" :title="displayName">
<span class="contact-service__text">{{ labelText }}</span>
<button
type="button"
class="contact-service__label"
:title="displayName"
:aria-label="`${t('nav.contactService.shipWarehouse')}: ${displayName}`"
>
<span v-if="showSiderCaption" class="contact-service__caption">
{{ t('nav.contactService.shipWarehouse') }}
</span>
<span class="contact-service__text">{{ labelText }}</span>
</button>
<template #content>
<a-doption
v-for="opt in warehouseStore.options"
@ -142,7 +176,9 @@ onMounted(() => {
max-width: 180px;
height: 28px;
padding: 0 8px;
border: none;
border-radius: 6px;
background: transparent;
font-size: 13px;
line-height: 28px;
color: #fff;
@ -163,30 +199,72 @@ onMounted(() => {
.contact-service__label {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 8px;
gap: 4px;
width: 100%;
min-height: 32px;
height: 30px;
min-height: 30px;
padding: 0 8px;
border-radius: 8px;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.55);
font-size: 12px;
color: var(--color-text-2);
font-weight: 400;
color: var(--color-text-1);
cursor: pointer;
text-align: left;
transition: background-color 0.14s ease, color 0.14s ease;
&:hover {
color: var(--color-text-1);
background: rgba(255, 255, 255, 0.72);
background: rgba(255, 255, 255, 0.92);
color: var(--app-primary-color, rgb(var(--primary-6)));
}
}
.contact-service__caption {
flex-shrink: 0;
font-size: 11px;
font-weight: 500;
color: var(--color-text-3);
&::after {
content: '·';
margin-left: 4px;
color: var(--color-text-4, #c9cdd4);
}
}
.contact-service__text {
min-width: 0;
flex: 1;
line-height: 1.2;
}
&.is-compact .contact-service__label {
justify-content: center;
padding: 0 2px;
min-width: 28px;
align-items: center;
gap: 0;
padding: 0;
min-width: 36px;
width: 36px;
height: 28px;
min-height: 28px;
font-size: 12px;
font-weight: 600;
background: transparent;
font-size: 14px;
font-weight: 400;
text-align: center;
&:hover {
background: rgba(255, 255, 255, 0.85);
}
}
&.is-compact .contact-service__text {
flex: none;
width: 100%;
text-align: center;
line-height: 1;
}
}

View File

@ -223,6 +223,7 @@
<template>
<a-form
ref="formRef"
v-bind="$attrs"
v-model="formValue"
:label-col-props="labelColProps"
:layout="formLayout"
@ -230,7 +231,6 @@
:size="size"
:wrapper-col-props="wrapperColProps"
class="form-pro-surface"
v-bind="$attrs"
>
<a-row
v-if="layout === 'default' || formLayout === 'vertical'"

View File

@ -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();
// 访客不拉需登录的 provider 列表,避免无效 401
if (!isCustomerH5Guest()) {
loadProvider();
}
void applyWaybillContextFromRoute().then(() => applyPrefillFromRoute());
window.addEventListener('keydown', onKeydown);
// 手机/微信:禁止进页自动聚焦,避免整页被放大

View File

@ -0,0 +1,483 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue'
import ModalPro from '@/components/ModalPro/index.vue'
import useModal from '@/components/ModalPro/useModal'
import { getWarehouseList, type WarehouseDto } from '@/api/system/warehouse'
import { resolveCurrentCustomer } from '@/api/customer/resolve'
import {
compareWarehouseSort,
formatWarehouseShipAddress,
formatWarehouseShipRecipient,
} from '@/utils/warehouseShipAddress'
import { getMapIdFromAccessToken } from '@/utils/jwt'
import { useRbacUserStore } from '@/store/modules/rbac-user'
import { useCurrentWarehouseStore } from '@/store'
const props = defineProps<{
visible: boolean
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
}>()
const { t } = useI18n()
const [registerModal] = useModal()
const rbacUserStore = useRbacUserStore()
const warehouseStore = useCurrentWarehouseStore()
const loading = ref(false)
const list = ref<WarehouseDto[]>([])
const customerCode = ref('')
const currentId = computed(() => warehouseStore.warehouseId)
const orderedList = computed(() => {
const rows = [...list.value]
const cur = currentId.value
if (!cur) return rows
return rows.sort((a, b) => {
const aCur = Number(a.id) === cur ? 0 : 1
const bCur = Number(b.id) === cur ? 0 : 1
if (aCur !== bCur) return aCur - bCur
return compareWarehouseSort(a, b)
})
})
function pick(raw: any, ...keys: string[]) {
for (const k of keys) {
const v = raw?.[k]
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v)
}
return ''
}
function normalize(row: any): WarehouseDto {
const sortRaw = row?.sort ?? row?.Sort
const sortNum = sortRaw == null || sortRaw === '' ? null : Number(sortRaw)
return {
id: row?.id ?? row?.Id,
warehouseName: pick(row, 'warehouseName', 'WarehouseName'),
warehouseType: pick(row, 'warehouseType', 'WarehouseType') || undefined,
warehouseTypeName: pick(row, 'warehouseTypeName', 'WarehouseTypeName') || undefined,
recipient: pick(row, 'recipient', 'Recipient'),
contactPhone: pick(row, 'contactPhone', 'ContactPhone'),
shippingAddress: pick(row, 'shippingAddress', 'ShippingAddress'),
sort: sortNum != null && Number.isFinite(sortNum) ? sortNum : null,
remark: pick(row, 'remark', 'Remark') || undefined,
}
}
function displayRecipient(item: WarehouseDto) {
return formatWarehouseShipRecipient(item.recipient, customerCode.value)
}
function displayAddress(item: WarehouseDto) {
return formatWarehouseShipAddress(item.shippingAddress, customerCode.value)
}
function buildCopyText(item: WarehouseDto) {
return [
item.warehouseName || '-',
`${t('system.warehouseAddress.fields.recipient')}: ${displayRecipient(item)}`,
`${t('system.warehouseAddress.fields.contactPhone')}: ${item.contactPhone || '-'}`,
`${t('system.warehouseAddress.fields.shippingAddress')}: ${displayAddress(item)}`,
].join('\n')
}
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text)
Message.success(t('system.warehouseAddress.message.copySuccess'))
} catch {
Message.error(t('system.warehouseAddress.message.copyFailed'))
}
}
function copyItem(item: WarehouseDto) {
void copyText(buildCopyText(item))
}
function copyField(value: string) {
const text = String(value || '').trim()
if (!text || text === '-') {
Message.warning(t('nav.warehousePeek.emptyField'))
return
}
void copyText(text)
}
async function loadCustomerCode() {
try {
const mapId = getMapIdFromAccessToken(rbacUserStore.accessToken)
const account = rbacUserStore.userInfo?.account?.trim() || ''
const data = await resolveCurrentCustomer({ mapId, account })
customerCode.value = String(data?.customerCode ?? '').trim()
} catch {
customerCode.value = ''
}
}
function syncWarehouseOptions(rows: WarehouseDto[]) {
warehouseStore.setOptions(
rows
.filter((row) => row.id != null)
.map((row) => ({
label: row.warehouseName || String(row.id),
value: Number(row.id),
})),
)
}
function selectWarehouse(item: WarehouseDto) {
const id = Number(item.id)
if (!id) return
if (id === currentId.value) return
syncWarehouseOptions(list.value)
warehouseStore.setWarehouseId(id)
Message.success(
t('nav.warehousePeek.switched', {
name: item.warehouseName || String(id),
}),
)
}
async function loadList() {
loading.value = true
try {
const [res] = await Promise.all([
getWarehouseList({ page: 1, pageSize: 100 }),
loadCustomerCode(),
])
list.value = (res.items ?? []).map(normalize).sort(compareWarehouseSort)
syncWarehouseOptions(list.value)
} catch {
list.value = []
} finally {
loading.value = false
}
}
function onUpdateVisible(val: boolean) {
emit('update:visible', val)
}
function handleClose() {
emit('update:visible', false)
}
watch(
() => props.visible,
(open) => {
if (open) void loadList()
},
)
</script>
<template>
<ModalPro
:visible="visible"
:title="t('nav.warehousePeek.title')"
:width="420"
:mask-closable="true"
:footer="false"
unmount-on-close
@register="registerModal"
@update:visible="onUpdateVisible"
@cancel="handleClose"
>
<template #footer>
<span />
</template>
<p class="peek-tip">
{{ t('nav.warehousePeek.tip') }}
<span v-if="customerCode" class="peek-tip__code">{{ customerCode }}</span>
</p>
<p class="peek-hint">{{ t('nav.warehousePeek.selectHint') }}</p>
<a-spin :loading="loading" class="peek-spin">
<div v-if="!orderedList.length && !loading" class="peek-empty">
{{ t('nav.warehousePeek.empty') }}
</div>
<article
v-for="item in orderedList"
:key="String(item.id)"
class="peek-card"
:class="{ 'is-current': Number(item.id) === currentId }"
role="button"
tabindex="0"
:aria-pressed="Number(item.id) === currentId"
@click="selectWarehouse(item)"
@keydown.enter.prevent="selectWarehouse(item)"
@keydown.space.prevent="selectWarehouse(item)"
>
<header class="peek-card__head">
<div class="peek-card__title">
<icon-home class="peek-card__icon" />
<strong>{{ item.warehouseName || '-' }}</strong>
<span v-if="Number(item.id) === currentId" class="peek-card__badge">
{{ t('nav.warehousePeek.current') }}
</span>
</div>
<div class="peek-card__actions" @click.stop>
<button
v-if="Number(item.id) !== currentId"
type="button"
class="peek-card__use"
@click="selectWarehouse(item)"
>
{{ t('nav.warehousePeek.useThis') }}
</button>
<button
type="button"
class="peek-card__copy-all"
:title="t('system.warehouseAddress.actions.copy')"
@click="copyItem(item)"
>
<icon-copy />
<span>{{ t('nav.warehousePeek.copyAll') }}</span>
</button>
</div>
</header>
<div class="peek-card__rows">
<div class="peek-row">
<span class="peek-row__label">{{ t('system.warehouseAddress.fields.recipient') }}</span>
<span class="peek-row__value" :title="displayRecipient(item)">{{ displayRecipient(item) }}</span>
<button type="button" class="peek-row__copy" @click.stop="copyField(displayRecipient(item))">
<icon-copy />
</button>
</div>
<div class="peek-row">
<span class="peek-row__label">{{ t('system.warehouseAddress.fields.contactPhone') }}</span>
<span class="peek-row__value" :title="item.contactPhone || '-'">{{ item.contactPhone || '-' }}</span>
<button type="button" class="peek-row__copy" @click.stop="copyField(item.contactPhone || '')">
<icon-copy />
</button>
</div>
<div class="peek-row peek-row--address">
<span class="peek-row__label">{{ t('system.warehouseAddress.fields.shippingAddress') }}</span>
<span class="peek-row__value" :title="displayAddress(item)">{{ displayAddress(item) }}</span>
<button type="button" class="peek-row__copy" @click.stop="copyField(displayAddress(item))">
<icon-copy />
</button>
</div>
<div v-if="item.remark" class="peek-row">
<span class="peek-row__label">{{ t('system.warehouseAddress.fields.remark') }}</span>
<span class="peek-row__value" :title="item.remark">{{ item.remark }}</span>
<button type="button" class="peek-row__copy" @click.stop="copyField(item.remark || '')">
<icon-copy />
</button>
</div>
</div>
</article>
</a-spin>
</ModalPro>
</template>
<style scoped lang="less">
.peek-tip {
margin: 0 0 6px;
padding: 8px 10px;
border-radius: 6px;
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 8%, #fff);
border: 1px solid color-mix(in srgb, var(--app-primary-color, #1d7281) 16%, transparent);
color: var(--color-text-2);
font-size: 12px;
line-height: 1.5;
&__code {
margin-left: 4px;
font-weight: 700;
color: var(--app-primary-color, #1d7281);
font-variant-numeric: tabular-nums;
}
}
.peek-hint {
margin: 0 0 12px;
color: var(--color-text-3);
font-size: 12px;
line-height: 1.4;
}
.peek-spin {
display: block;
width: 100%;
max-height: min(60vh, 480px);
overflow: auto;
}
.peek-empty {
padding: 28px 12px;
text-align: center;
color: var(--color-text-3);
font-size: 13px;
}
.peek-card {
margin-bottom: 10px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--color-border-2, #e5e6eb);
background: #fff;
cursor: pointer;
transition: border-color 0.14s ease, background-color 0.14s ease, box-shadow 0.14s ease;
&:last-child {
margin-bottom: 0;
}
&:hover {
border-color: color-mix(in srgb, var(--app-primary-color, #1d7281) 28%, transparent);
}
&.is-current {
border-color: color-mix(in srgb, var(--app-primary-color, #1d7281) 35%, transparent);
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 5%, #fff);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--app-primary-color, #1d7281) 12%, transparent);
cursor: default;
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
&__actions {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
&__use {
height: 26px;
padding: 0 8px;
border: none;
border-radius: 6px;
background: var(--app-primary-color, #1d7281);
color: #fff;
font-size: 12px;
font-weight: 600;
cursor: pointer;
&:hover {
filter: brightness(1.05);
}
}
&__title {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
font-size: 14px;
color: var(--color-text-1);
strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 650;
}
}
&__icon {
flex-shrink: 0;
color: var(--app-primary-color, #1d7281);
font-size: 15px;
}
&__badge {
flex-shrink: 0;
padding: 1px 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 14%, #fff);
color: var(--app-primary-color, #1d7281);
font-size: 11px;
font-weight: 600;
line-height: 1.4;
}
&__copy-all {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
height: 26px;
padding: 0 8px;
border: none;
border-radius: 6px;
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 10%, #fff);
color: var(--app-primary-color, #1d7281);
font-size: 12px;
font-weight: 600;
cursor: pointer;
&:hover {
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 16%, #fff);
}
}
}
.peek-row {
display: grid;
grid-template-columns: 64px minmax(0, 1fr) 24px;
align-items: start;
gap: 6px;
padding: 5px 0;
border-top: 1px dashed var(--color-border-2, #e5e6eb);
&:first-child {
border-top: none;
padding-top: 0;
}
&__label {
color: var(--color-text-3);
font-size: 12px;
line-height: 1.45;
}
&__value {
min-width: 0;
color: var(--color-text-1);
font-size: 13px;
line-height: 1.45;
word-break: break-word;
}
&__copy {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
margin-top: -1px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--color-text-4, #c9cdd4);
cursor: pointer;
font-size: 13px;
&:hover {
color: var(--app-primary-color, #1d7281);
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 8%, #fff);
}
}
&--address .peek-row__value {
font-weight: 500;
}
}
</style>

View File

@ -71,55 +71,39 @@
</section>
</div>
<!-- 底栏:用户+余额 → 工具一行 → 仓库+收起 -->
<!-- 底栏:头像+消息+打印 → 仓库+收起(昵称/余额进头像菜单) -->
<div class="customer-side-rail__dock">
<div
class="customer-side-rail__user-row"
:class="{ 'is-account-active': isAccountRouteActive }"
class="customer-side-rail__toolbar"
:class="{ 'is-account-active': isAccountRouteActive || isBalanceRouteActive }"
>
<div class="customer-side-rail__user">
<RightTopUser
:compact="compactLayout"
dropdown-position="rt"
variant="sider"
compact
dropdown-position="tl"
:account-menus="accountMenus"
show-language-menu
show-recharge-cta
:balance-text="balanceDisplay"
/>
</div>
<div
class="customer-side-rail__user-balance customer-side-rail__fade"
:class="{ 'is-balance-active': isBalanceRouteActive }"
<div class="customer-side-rail__message-slot">
<RightTopMessage
dropdown-position="tl"
trigger="click"
content-class="csr-dock-dropdown"
/>
</div>
<a-dropdown
trigger="click"
position="tl"
content-class="csr-dock-dropdown"
:popup-translate="[0, -6]"
>
<a-dropdown trigger="hover" position="rt">
<div
class="customer-side-rail__balance-trigger"
:title="`${t('nav.balance.label')}: ¥${balanceDisplay}`"
>
<span class="customer-side-rail__balance-currency">¥</span>
<span class="customer-side-rail__balance-value">{{ balanceDisplay }}</span>
</div>
<template #content>
<a-doption @click="() => onBalanceMenuClick('recharge')">
{{ t('nav.balance.recharge') }}
</a-doption>
<a-doption @click="() => onBalanceMenuClick('rechargeRecord')">
{{ t('nav.balance.rechargeRecord') }}
</a-doption>
<a-doption @click="() => onBalanceMenuClick('accountFlow')">
{{ t('nav.balance.accountFlow') }}
</a-doption>
<a-doption :disabled="balanceRefreshing" @click="onRefreshBalance">
{{ t('nav.balance.refresh') }}
</a-doption>
</template>
</a-dropdown>
</div>
</div>
<div class="customer-side-rail__utils">
<a-dropdown trigger="hover" position="rt">
<button
type="button"
class="customer-side-rail__tool"
class="customer-side-rail__tool customer-side-rail__tool--print"
:class="{
'is-enabled': printConnectionState.enabled,
'is-loading': printConnectionState.connecting,
@ -142,21 +126,24 @@
</a-doption>
</template>
</a-dropdown>
<div class="customer-side-rail__tool-slot">
<RightTopMessage dropdown-position="rt" />
</div>
</div>
<div class="customer-side-rail__footer-row">
<div class="customer-side-rail__warehouse">
<ContactService variant="sider" dropdown-position="rt" :compact="compactLayout" />
</div>
<button
type="button"
class="customer-side-rail__warehouse-btn"
:class="{ 'is-compact': compactLayout }"
:title="warehouseDockTitle"
:aria-label="warehouseDockTitle"
@click="openWarehouseAddressPeek"
>
<span class="customer-side-rail__warehouse-btn-text">{{ warehouseDockLabel }}</span>
</button>
<button
type="button"
class="customer-side-rail__toggle"
:aria-label="collapsed ? '展开菜单' : '收起菜单'"
:title="collapsed ? '展开菜单' : '收起菜单'"
:aria-label="collapsed ? t('nav.sideRail.expand') : t('nav.sideRail.collapse')"
:title="collapsed ? t('nav.sideRail.expand') : t('nav.sideRail.collapse')"
@click="toggleCollapsed"
>
<icon-double-right v-if="collapsed" class="customer-side-rail__toggle-icon" />
@ -165,6 +152,8 @@
</div>
</div>
</div>
<WarehouseAddressPeekModal v-model:visible="warehousePeekVisible" />
</aside>
</template>
@ -178,7 +167,9 @@ import {
IconDoubleRight,
IconPrinter,
} from '@arco-design/web-vue/es/icon'
import { useCurrentWarehouseStore } from '@/store'
import { useRbacUserStore } from '@/store/modules/rbac-user'
import { getWarehouseList } from '@/api/system/warehouse'
import { getSubMenuIcon } from '@/utils/menu-icon'
import { MENU_NAME_TO_LOCALE_KEY, translateMenuTitle } from '@/utils/menu-i18n'
import { openWindow, regexUrl } from '@/utils'
@ -209,14 +200,15 @@ import {
} from '@/utils/customer-portal-nav'
import { resolvePortalDrawerPanel } from '@/views/user/portal-drawer/types'
import { openPortalSideDrawer } from '@/views/user/portal-drawer/usePortalSideDrawer'
import ContactService from '@/components/ContactService/index.vue'
import RightTopMessage from '@/components/top-nav/RightTopMessage.vue'
import RightTopUser from '@/components/top-nav/RightTopUser.vue'
import WarehouseAddressPeekModal from '@/components/customer-side-rail/WarehouseAddressPeekModal.vue'
const { t, te } = useI18n()
const { t, te, locale } = useI18n()
const route = useRoute()
const router = useRouter()
const rbacUserStore = useRbacUserStore()
const warehouseStore = useCurrentWarehouseStore()
const printConnectionState = useHiprintClientState()
/** 与 CSS transition 时长一致(Linear 风格约 300ms) */
@ -275,7 +267,6 @@ const printConnectionTitle = computed(() => {
})
const customerBalance = ref<number | null>(null)
const balanceRefreshing = ref(false)
let balanceInflight: Promise<void> | null = null
const balanceDisplay = computed(() => {
@ -311,38 +302,49 @@ function loadCustomerBalance() {
return balanceInflight
}
async function onRefreshBalance() {
if (balanceRefreshing.value) return
balanceRefreshing.value = true
const previous = customerBalance.value
try {
await loadCustomerBalance()
Message.success(t('nav.balance.refreshed'))
} catch (error) {
console.error('Failed to load customer balance', error)
customerBalance.value = previous
Message.error(t('nav.balance.refreshFailed'))
} finally {
balanceRefreshing.value = false
}
}
function onBalanceMenuClick(key: string) {
if (key === 'recharge') {
router.push({ name: 'VipRechargeDeposit' })
} else if (key === 'rechargeRecord') {
router.push({ name: 'FinanceRechargeRecord' })
} else if (key === 'accountFlow') {
router.push({ name: 'FinanceAccountFlow' })
}
}
function handleCustomerBalanceChanged() {
void loadCustomerBalance().catch((error) => {
console.error('Failed to load customer balance', error)
})
}
const warehousePeekVisible = ref(false)
const warehouseDockName = computed(
() =>
warehouseStore.warehouseName ||
t('nav.contactService.selectWarehouse'),
)
const warehouseDockLabel = computed(() => {
const name = warehouseDockName.value
if (compactLayout.value) return name.slice(0, 1) || '仓'
return name
})
const warehouseDockTitle = computed(
() => `${t('nav.warehousePeek.title')}: ${warehouseDockName.value}`,
)
function openWarehouseAddressPeek() {
warehousePeekVisible.value = true
}
async function ensureWarehouseOptions() {
try {
const { items } = await getWarehouseList({ page: 1, pageSize: 100 })
const list = (items ?? [])
.filter((row) => row.id != null)
.map((row) => ({
label: row.warehouseName || String(row.id),
value: Number(row.id),
}))
warehouseStore.setOptions(list)
} catch {
/* 侧栏仅展示已缓存仓名,打开弹窗时再拉地址 */
}
}
function groupLabel(group: CustomerNavGroup) {
if (te(group.labelKey)) return t(group.labelKey)
return group.labelFallback
@ -353,6 +355,7 @@ function navKey(item: CustomerNavItem) {
}
function formatTitle(item: CustomerNavItem) {
void locale.value
return translateMenuTitle(t, {
name: item.name,
route: item.route || item.path,
@ -488,6 +491,7 @@ watch(activePath, () => {
onMounted(() => {
initHiprintClientState().catch(() => undefined)
void ensureWarehouseOptions()
window.addEventListener(CUSTOMER_BALANCE_CHANGED_EVENT, handleCustomerBalanceChanged)
})
@ -576,23 +580,76 @@ onUnmounted(() => {
&__footer-row {
display: flex;
align-items: center;
gap: 6px;
gap: 2px;
min-width: 0;
}
&__warehouse {
&__warehouse-btn {
flex: 1;
display: inline-flex;
align-items: center;
min-width: 0;
height: 30px;
padding: 0 8px;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.55);
color: var(--color-text-2);
font-size: 12px;
font-weight: 500;
line-height: 1.2;
cursor: pointer;
text-align: left;
transition: color 0.14s ease, background-color 0.14s ease;
&:hover {
color: var(--app-primary-color, #1d7281);
background: rgba(255, 255, 255, 0.92);
}
&.is-compact {
flex: 0 0 auto;
width: 36px;
min-width: 36px;
height: 28px;
padding: 0;
justify-content: center;
text-align: center;
background: transparent;
}
}
&__warehouse-btn-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__message-slot {
flex: 1;
min-width: 0;
display: flex;
justify-content: center;
:deep(.contact-service--sider) {
:deep(.message-dropdown-trigger) {
width: 100%;
max-width: 56px;
height: 30px;
justify-content: center;
padding: 0 4px;
border-radius: 6px;
color: var(--color-text-2);
gap: 2px;
}
:deep(.contact-service--sider .contact-service__label) {
min-height: 32px;
padding: 0 8px;
font-size: 12px;
justify-content: flex-start;
:deep(.message-dropdown-trigger:hover) {
color: var(--color-text-1);
background: rgba(255, 255, 255, 0.85);
}
:deep(.message-label) {
display: none;
}
}
@ -751,18 +808,19 @@ onUnmounted(() => {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px var(--csr-pad-x) 12px;
gap: 4px;
padding: 8px var(--csr-pad-x) 8px;
border-top: 1px solid var(--color-border-2, rgba(0, 0, 0, 0.06));
}
&__user-row {
/* 工具条:头像 + 消息 + 打印 */
&__toolbar {
display: flex;
align-items: center;
gap: 4px;
gap: 2px;
min-width: 0;
padding: 2px;
border-radius: 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.45);
transition:
background-color 0.14s ease,
@ -778,85 +836,26 @@ onUnmounted(() => {
}
}
&__user-balance {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: flex-end;
max-width: 72px;
min-width: 0;
height: 36px;
padding-right: 4px;
border-radius: 8px;
&.is-balance-active {
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 12%, #fff);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--app-primary-color, #1d7281) 18%, transparent);
}
}
&__balance-trigger {
display: flex;
align-items: center;
justify-content: flex-end;
max-width: 100%;
min-width: 0;
height: 32px;
padding: 0 4px;
border-radius: 6px;
cursor: pointer;
color: var(--app-primary-color);
font-weight: 600;
font-size: 12px;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
&:hover {
background: rgba(255, 255, 255, 0.85);
}
}
&__balance-currency {
flex-shrink: 0;
margin-right: 1px;
opacity: 0.85;
font-weight: 600;
}
&__balance-value {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 工具:打印(含下载)/ 消息 */
&__utils {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 2px;
padding: 2px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.5);
}
&__tool {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 32px;
flex-shrink: 0;
width: 30px;
height: 30px;
border: none;
border-radius: 8px;
border-radius: 6px;
background: transparent;
color: var(--color-text-3);
cursor: pointer;
text-decoration: none;
font-size: 16px;
font-size: 14px;
transition: color 0.14s ease, background-color 0.14s ease;
&:hover {
color: var(--color-text-1);
background: rgba(255, 255, 255, 0.95);
background: rgba(255, 255, 255, 0.85);
}
&.is-enabled {
@ -867,12 +866,16 @@ onUnmounted(() => {
opacity: 0.6;
pointer-events: none;
}
&--print {
opacity: 0.9;
}
}
&__tool-dot {
position: absolute;
top: 5px;
right: 7px;
right: 5px;
width: 5px;
height: 5px;
border-radius: 50%;
@ -883,60 +886,24 @@ onUnmounted(() => {
background: rgb(var(--green-6));
}
&__tool-slot {
display: flex;
align-items: center;
justify-content: center;
height: 32px;
border-radius: 8px;
color: var(--color-text-3);
:deep(.dropDown),
:deep(.lang-icon),
:deep(.arco-badge) {
color: inherit;
}
:deep(.lang-icon) {
font-size: 16px;
}
:deep(.message-dropdown-trigger) {
height: 32px;
min-width: 28px;
padding: 0 4px;
}
}
&__user {
flex: 1;
flex: 0 0 auto;
display: flex;
align-items: center;
min-width: 0;
:deep(.user-dropdown-trigger) {
width: 100%;
max-width: none;
height: 36px;
padding: 0 8px;
:deep(.user-dropdown-trigger),
:deep(.user-dropdown-trigger.is-compact) {
width: 30px;
height: 30px;
padding: 0;
margin: 0;
justify-content: center;
color: var(--color-text-1);
}
:deep(.user-dropdown-trigger:hover) {
background: rgba(255, 255, 255, 0.85);
}
:deep(.user-dropdown-trigger.is-compact) {
width: 36px;
height: 36px;
padding: 0;
margin: 0;
}
:deep(.user-name) {
max-width: none;
font-size: 13px;
}
}
&__toggle {
@ -944,23 +911,24 @@ onUnmounted(() => {
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 32px;
height: 32px;
width: 28px;
height: 28px;
border: none;
border-radius: 8px;
border-radius: 6px;
background: transparent;
color: var(--color-text-3);
cursor: pointer;
font-size: 15px;
font-size: 13px;
&:hover {
color: var(--app-primary-color);
background: rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.65);
}
}
&__toggle-icon {
display: inline-flex;
font-size: 13px;
}
/*
@ -1038,15 +1006,15 @@ onUnmounted(() => {
.customer-side-rail__dock {
align-items: center;
gap: 6px;
padding: 8px 0 10px;
gap: 4px;
padding: 6px 0 8px;
}
.customer-side-rail__user-row {
.customer-side-rail__toolbar {
width: 40px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 2px;
background: transparent;
box-shadow: none;
@ -1058,57 +1026,54 @@ onUnmounted(() => {
}
.customer-side-rail__user {
width: 36px;
width: 30px;
flex: 0 0 auto;
}
.customer-side-rail__message-slot {
width: 30px;
flex: 0 0 auto;
:deep(.user-dropdown-trigger),
:deep(.user-dropdown-trigger.is-compact) {
width: 36px;
height: 36px;
margin: 0;
:deep(.message-dropdown-trigger) {
width: 30px;
max-width: none;
height: 28px;
padding: 0;
justify-content: center;
}
}
.customer-side-rail__user-balance {
display: none;
}
.customer-side-rail__utils {
width: 40px;
grid-template-columns: 1fr;
gap: 2px;
justify-items: center;
padding: 2px 0;
background: transparent;
}
.customer-side-rail__tool,
.customer-side-rail__tool-slot {
width: 36px;
height: 32px;
:deep(.message-count) {
display: none;
}
}
.customer-side-rail__tool-slot :deep(.message-count) {
display: none;
.customer-side-rail__tool {
width: 30px;
height: 28px;
}
.customer-side-rail__footer-row {
width: 40px;
flex-direction: column;
align-items: center;
gap: 4px;
gap: 2px;
}
.customer-side-rail__warehouse {
width: 32px;
.customer-side-rail__warehouse-btn {
width: 36px;
min-width: 36px;
height: 28px;
flex: 0 0 auto;
padding: 0;
justify-content: center;
text-align: center;
background: transparent;
}
.customer-side-rail__toggle {
width: 32px;
height: 32px;
width: 28px;
height: 28px;
padding: 0;
}
}
}

View File

@ -352,9 +352,8 @@
if (nextRoute.query?._refresh || nextRoute.query?._r) {
return;
}
if (!nextRoute.meta.noAffix) {
// 登录/官网等独立壳不进页签;具体过滤在 updateTabList
tabBarStore.updateTabList(nextRoute);
}
nextTick(() => scrollActiveTagIntoView());
}, true);

View File

@ -1,12 +1,19 @@
<template>
<!-- Top-nav inbox / announcement entry (unread counts stay here, not on Nana FAB). -->
<a-dropdown
trigger="hover"
:trigger="trigger"
:position="dropdownPosition"
:content-class="contentClass"
:popup-translate="popupTranslate"
@visible-change="onMessageDropdownVisibleChange"
>
<div class="message-dropdown-trigger" :title="t('navbar.message')">
<div
class="message-dropdown-trigger"
:class="{ 'is-emphasized': emphasize && totalCount > 0 }"
:title="t('navbar.message')"
>
<IconNotification class="message-icon" :class="totalCount > 0 ? 'theMessage' : ''" />
<span v-if="showLabel" class="message-label">{{ t('navbar.message') }}</span>
<span v-if="totalCount > 0" class="message-count">({{ displayCount }})</span>
</div>
<template #content>
@ -21,7 +28,7 @@
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { computed, toRefs } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { IconNotification } from '@arco-design/web-vue/es/icon';
@ -29,12 +36,32 @@ import { useInboxUnread } from '@/composables/useInboxUnread';
const props = withDefaults(
defineProps<{
dropdownPosition?: 'br' | 'bl' | 'tr' | 'tl' | 'rt' | 'rb';
dropdownPosition?: 'br' | 'bl' | 'tr' | 'tl' | 'rt' | 'rb'
trigger?: 'hover' | 'click'
/** 侧栏:展示「消息」文案,强化未读入口 */
showLabel?: boolean
/** 有未读时加强视觉 */
emphasize?: boolean
contentClass?: string
}>(),
{ dropdownPosition: 'br' },
{
dropdownPosition: 'br',
trigger: 'hover',
showLabel: false,
emphasize: false,
contentClass: '',
},
);
const { showLabel, emphasize } = toRefs(props);
const dropdownPosition = computed(() => props.dropdownPosition);
const trigger = computed(() => props.trigger);
const contentClass = computed(() => props.contentClass || undefined);
const popupTranslate = computed<[number, number] | undefined>(() =>
props.dropdownPosition === 'tl' || props.dropdownPosition === 'tr'
? [0, -6]
: undefined,
);
const router = useRouter();
const { t } = useI18n();
@ -90,18 +117,31 @@ const onMessageMenuClick = (event: { key?: string }) => {
border-radius: 6px;
cursor: pointer;
color: inherit;
transition: background-color 0.15s ease, opacity 0.15s ease;
transition: background-color 0.15s ease, opacity 0.15s ease, color 0.15s ease;
}
.message-dropdown-trigger:hover {
background-color: rgba(255, 255, 255, 0.12);
}
.message-dropdown-trigger.is-emphasized {
color: var(--app-primary-color, rgb(var(--primary-6)));
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 10%, #fff);
}
.message-icon {
font-size: 16px;
flex-shrink: 0;
}
.message-label {
flex-shrink: 0;
font-size: 12px;
font-weight: 500;
line-height: 1;
color: inherit;
}
.message-count {
flex-shrink: 0;
font-size: 14px;

View File

@ -1,7 +1,10 @@
<template>
<a-dropdown
trigger="hover"
v-model:popup-visible="menuVisible"
:trigger="dropdownTrigger"
:position="dropdownPosition"
:content-class="contentClass"
:popup-translate="popupTranslate"
@select="onDropdownSelect"
>
<div class="user-dropdown-trigger" :class="{ 'is-compact': compact }">
@ -14,8 +17,103 @@
<span v-else class="user-avatar user-avatar--fallback">{{ displayNameInitial }}</span>
<span v-if="!compact" class="user-name">{{ displayName }}</span>
</div>
<template #content>
<!-- 侧栏传入的资产与账户入口(替代侧栏「资产与账户」分组) -->
<!-- Cursor 风格:侧栏账户面板(头信息 / 菜单 / 登出固定底) -->
<template v-if="isSiderVariant" #content>
<div class="csr-user-panel" @click.stop>
<div class="csr-user-panel__header">
<div class="csr-user-panel__top">
<div class="csr-user-panel__identity">
<img
v-if="showCustomerAvatar"
:src="customerAvatarUrl"
alt=""
class="csr-user-panel__avatar"
/>
<span v-else class="csr-user-panel__avatar csr-user-panel__avatar--fallback">
{{ displayNameInitial }}
</span>
<div class="csr-user-panel__meta">
<div class="csr-user-panel__name">{{ displayName }}</div>
<div v-if="accountSubtitle" class="csr-user-panel__sub">{{ accountSubtitle }}</div>
</div>
</div>
<button
v-if="showLanguageMenu"
type="button"
class="csr-user-panel__lang-toggle"
:title="t('nav.userMenu.switchLanguage', { lang: nextLanguageOption.label })"
:aria-label="t('nav.userMenu.switchLanguage', { lang: nextLanguageOption.label })"
@click="onPanelAction(`lang:${nextLanguageOption.value}`)"
>
{{ nextLanguageOption.short }}
</button>
</div>
<button
v-if="showRechargeCta"
type="button"
class="csr-user-panel__cta"
@click="onPanelAction('recharge')"
>
{{ t('nav.balance.recharge') }}
<span v-if="balanceText" class="csr-user-panel__cta-balance">¥{{ balanceText }}</span>
</button>
</div>
<div class="csr-user-panel__body">
<button
v-for="item in accountMenus"
:key="accountMenuKey(item)"
type="button"
class="csr-user-panel__item"
@click="onPanelAction(accountMenuKey(item))"
>
<span class="csr-user-panel__item-icon">
<component :is="getSubMenuIcon(item, undefined, MENU_NAME_TO_LOCALE_KEY)" />
</span>
<span class="csr-user-panel__item-label">{{ formatAccountTitle(item) }}</span>
</button>
<button
v-if="isCustomerPortal && !hasAccountProfileItem"
type="button"
class="csr-user-panel__item"
@click="onPanelAction('userinfo')"
>
<span class="csr-user-panel__item-icon"><i class="fa fa-user" /></span>
<span class="csr-user-panel__item-label">{{ t('menu.user.profile') }}</span>
</button>
<button
type="button"
class="csr-user-panel__item"
@click="onPanelAction('changePassword')"
>
<span class="csr-user-panel__item-icon"><i class="fa fa-lock" /></span>
<span class="csr-user-panel__item-label">{{ t('profile.changePassword') }}</span>
</button>
<button
type="button"
class="csr-user-panel__item"
@click="onPanelAction('settings')"
>
<span class="csr-user-panel__item-icon"><i class="fa fa-cog" /></span>
<span class="csr-user-panel__item-label">{{ t('nav.userMenu.pageSettings') }}</span>
</button>
</div>
<div class="csr-user-panel__footer">
<button
type="button"
class="csr-user-panel__item is-danger"
@click="onPanelAction('logout')"
>
<span class="csr-user-panel__item-icon"><i class="fa fa-sign-out" /></span>
<span class="csr-user-panel__item-label">{{ t('nav.userMenu.logout') }}</span>
</button>
</div>
</div>
</template>
<template v-else #content>
<a-doption
v-for="item in accountMenus"
:key="accountMenuKey(item)"
@ -37,7 +135,7 @@
<a-doption v-if="!isCustomerPortal" value="settings">
<span class="user-menu-item">
<span class="user-menu-item__icon"><i class="fa fa-cog" /></span>
<span class="user-menu-item__label">页面配置</span>
<span class="user-menu-item__label">{{ t('nav.userMenu.pageSettings') }}</span>
</span>
</a-doption>
<a-doption value="changePassword">
@ -49,7 +147,7 @@
<a-doption v-if="isCustomerPortal" value="settings">
<span class="user-menu-item">
<span class="user-menu-item__icon"><i class="fa fa-cog" /></span>
<span class="user-menu-item__label">页面配置</span>
<span class="user-menu-item__label">{{ t('nav.userMenu.pageSettings') }}</span>
</span>
</a-doption>
<template v-if="showLanguageMenu">
@ -69,8 +167,8 @@
</template>
<a-doption value="logout">
<span class="user-menu-item is-danger">
<span class="user-menu-item__icon"><i class="fa fa-sign-in" /></span>
<span class="user-menu-item__label">退出登录</span>
<span class="user-menu-item__icon"><i class="fa fa-sign-out" /></span>
<span class="user-menu-item__label">{{ t('nav.userMenu.logout') }}</span>
</span>
</a-doption>
</template>
@ -160,18 +258,28 @@ const props = withDefaults(
defineProps<{
/** 侧栏收起等场景只显示头像 */
compact?: boolean
/** 下拉相对触发器位置,侧栏用 rt */
/** 下拉相对触发器位置;侧栏 Cursor 风格默认 tl(向上) */
dropdownPosition?: 'br' | 'bl' | 'tr' | 'tl' | 'rt' | 'rb'
/** 客户侧栏:资产与账户菜单,收进用户下拉 */
accountMenus?: CustomerNavItem[]
/** 客户侧栏:语言收进用户菜单,不再单独占底栏 */
showLanguageMenu?: boolean
/** navbar 保持原下拉;sider 用 Cursor 式面板 */
variant?: 'navbar' | 'sider'
trigger?: 'hover' | 'click'
/** 侧栏面板展示余额并提供去充值 CTA */
balanceText?: string
showRechargeCta?: boolean
}>(),
{
compact: false,
dropdownPosition: 'br',
dropdownPosition: undefined,
accountMenus: () => [],
showLanguageMenu: false,
variant: 'navbar',
trigger: undefined,
balanceText: '',
showRechargeCta: false,
},
);
@ -182,12 +290,42 @@ const userStore = useUserStore();
const appStore = useAppStore();
const rbacUserStore = useRbacUserStore();
const menuVisible = ref(false);
const isSiderVariant = computed(() => props.variant === 'sider');
const showLanguageMenu = computed(() => props.showLanguageMenu);
const showRechargeCta = computed(() => props.showRechargeCta && isSiderVariant.value);
const balanceText = computed(() => String(props.balanceText || '').trim());
const dropdownTrigger = computed(() => {
if (props.trigger) return props.trigger;
return isSiderVariant.value ? 'click' : 'hover';
});
const dropdownPosition = computed(
() => props.dropdownPosition ?? (isSiderVariant.value ? 'tl' : 'br'),
);
const contentClass = computed(() =>
isSiderVariant.value ? 'csr-dock-dropdown csr-account-dropdown' : 'user-account-dropdown',
);
/** 向上弹出时略抬离触发器,贴近 Cursor */
const popupTranslate = computed<[number, number] | undefined>(() =>
isSiderVariant.value ? [0, -6] : undefined,
);
const languageOptions = [
{ value: 'zh-CN', label: '中文', icon: '🇨🇳' },
{ value: 'en-US', label: 'English', icon: '🇺🇸' },
{ value: 'zh-CN', label: '中文', short: '中', icon: '🇨🇳' },
{ value: 'en-US', label: 'English', short: 'EN', icon: '🇺🇸' },
] as const;
/** 点击切换到另一种语言(单按钮,非分段滑块) */
const nextLanguageOption = computed(() => {
const current = String(locale.value || 'zh-CN');
return languageOptions.find((item) => item.value !== current) || languageOptions[1];
});
const isCustomerPortal = computed(() => rbacUserStore.isCustomerPortal());
const displayName = computed(() => {
@ -199,7 +337,22 @@ const displayNameInitial = computed(() => {
return name ? name.slice(0, 1).toUpperCase() : '?';
});
const dropdownPosition = computed(() => props.dropdownPosition);
/** 后端占位邮箱,不当真实邮箱展示 */
function isPlaceholderEmail(value: string) {
const v = value.trim().toLowerCase();
if (!v) return true;
return v.endsWith('@noemail.local') || v.includes('@noemail.');
}
const accountSubtitle = computed(() => {
const info = rbacUserStore.userInfo as Record<string, unknown> | null | undefined;
const account = String(info?.account ?? '').trim();
const email = String(info?.email ?? '').trim();
if (email && !isPlaceholderEmail(email)) return email;
if (account && !isPlaceholderEmail(account)) return account;
return '';
});
const compact = computed(() => props.compact);
const accountMenus = computed(() => props.accountMenus || []);
@ -215,6 +368,7 @@ function accountMenuKey(item: CustomerNavItem) {
}
function formatAccountTitle(item: CustomerNavItem) {
void locale.value;
return translateMenuTitle(t, {
name: item.name,
route: item.route || item.path,
@ -363,12 +517,24 @@ async function handleChangePasswordSubmit() {
}
} catch (err: any) {
if (err?.errors || err?.errorFields) return;
Message.error(err?.message || '修改密码失败');
Message.error(err?.message || t('profile.changePasswordFailed'));
} finally {
changePasswordLoading.value = false;
}
}
function closeMenu() {
menuVisible.value = false;
}
function onPanelAction(key: string) {
// 切语言留在面板内,其它操作关闭
if (!key.startsWith('lang:')) {
closeMenu();
}
void onMenuClick({ key });
}
const onDropdownSelect = (value: string | number | Record<string, unknown> | undefined) => {
if (value === undefined || value === null) return;
onMenuClick({ key: String(value) });
@ -377,6 +543,11 @@ const onDropdownSelect = (value: string | number | Record<string, unknown> | und
const onMenuClick = async (event: { key: string }) => {
const { key } = event;
if (key === 'recharge') {
router.push({ name: 'VipRechargeDeposit' }).catch(() => undefined);
return;
}
if (key.startsWith('account:')) {
const path = key.slice('account:'.length);
const item = accountMenus.value.find(
@ -515,4 +686,232 @@ const onMenuClick = async (event: { key: string }) => {
padding: 8px 12px;
}
}
/* Cursor 风格:侧栏 dock 账户面板 */
.csr-account-dropdown {
width: 200px !important;
min-width: 200px !important;
max-width: 200px !important;
padding: 0 !important;
border: 1px solid var(--color-border-2, rgba(0, 0, 0, 0.08)) !important;
border-radius: 10px !important;
box-shadow:
0 8px 28px rgba(15, 23, 42, 0.12),
0 2px 8px rgba(15, 23, 42, 0.06) !important;
overflow: hidden;
background: #fff !important;
.arco-dropdown {
width: 100%;
min-width: 0 !important;
padding: 0;
background: transparent;
box-shadow: none;
}
.arco-dropdown-list-wrapper,
.arco-scrollbar,
.arco-dropdown-list {
max-height: none !important;
padding: 0 !important;
margin: 0 !important;
}
}
.csr-user-panel {
/* 与客户侧栏展开宽对齐 */
width: 200px;
box-sizing: border-box;
display: flex;
flex-direction: column;
background: #fff;
color: var(--color-text-1);
&__header {
padding: 10px 10px 8px;
border-bottom: 1px solid var(--color-border-2, rgba(0, 0, 0, 0.06));
}
&__top {
display: flex;
align-items: flex-start;
gap: 6px;
min-width: 0;
}
&__identity {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
&__lang-toggle {
flex-shrink: 0;
min-width: 28px;
height: 26px;
padding: 0 4px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--color-text-2);
font-size: 12px;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition: color 0.12s ease, background-color 0.12s ease;
&:hover {
color: var(--app-primary-color, #1d7281);
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 6%, #fff);
}
}
&__avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
background: var(--color-fill-3, #e5e6eb);
&--fallback {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
color: var(--color-text-1);
}
}
&__meta {
min-width: 0;
flex: 1;
}
&__name {
font-size: 14px;
font-weight: 650;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__sub {
margin-top: 2px;
font-size: 12px;
color: var(--color-text-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__cta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
width: 100%;
margin-top: 8px;
height: 30px;
padding: 0 10px;
border: 1px solid color-mix(in srgb, var(--app-primary-color, #1d7281) 28%, transparent);
border-radius: 6px;
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 8%, #fff);
color: var(--app-primary-color, #1d7281);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.14s ease;
&:hover {
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 14%, #fff);
}
}
&__cta-balance {
font-variant-numeric: tabular-nums;
font-weight: 700;
}
&__body {
padding: 6px;
max-height: min(52vh, 360px);
overflow: auto;
}
&__footer {
padding: 6px;
border-top: 1px solid var(--color-border-2, rgba(0, 0, 0, 0.06));
background: color-mix(in srgb, var(--color-fill-2, #f2f3f5) 55%, #fff);
}
&__item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 36px;
padding: 0 10px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--color-text-1);
font-size: 13px;
font-weight: 500;
text-align: left;
cursor: pointer;
transition: background-color 0.12s ease;
&:hover {
background: color-mix(in srgb, var(--app-primary-color, #1d7281) 8%, #fff);
}
&.is-danger {
color: rgb(var(--red-6));
.csr-user-panel__item-icon {
color: rgb(var(--red-6));
}
}
}
&__item-icon {
width: 18px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: var(--color-text-3);
}
&__item-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__hint {
margin-left: 8px;
color: var(--app-primary-color, rgb(var(--primary-6)));
font-weight: 600;
}
}
.csr-dock-dropdown {
.arco-dropdown-option {
padding: 0 4px;
}
.arco-dropdown-option-content {
padding: 8px 10px;
border-radius: 6px;
}
}
</style>

View File

@ -153,6 +153,27 @@ export const ORDER_SHIPPED_STATUSES: readonly OrderStatusCode[] = [
'deliveryFailed',
]
/**
* 出库后可人工校正的状态集合。
* 规则:四态互改;派送成功只进不出(见 getPostShipStatusChangeTargets)。
*/
export const ORDER_POST_SHIP_CHANGEABLE_STATUSES: readonly OrderStatusCode[] = [
'pendingOnline',
'shipping',
'deliverySuccess',
'deliveryFailed',
]
/** 当前状态允许改到的目标(派送成功返回空) */
export function getPostShipStatusChangeTargets(fromStatus: unknown): OrderStatusCode[] {
const code = normalizeOrderStatusCode(
typeof fromStatus === 'string' ? fromStatus.trim() : resolveOrderStatusCode(fromStatus as any),
) as OrderStatusCode
if (!ORDER_POST_SHIP_CHANGEABLE_STATUSES.includes(code)) return []
if (code === 'deliverySuccess') return []
return ORDER_POST_SHIP_CHANGEABLE_STATUSES.filter((s) => s !== code)
}
const ORDER_SHIPPED_STATUS_SET = new Set<string>(ORDER_SHIPPED_STATUSES)
/** 未发货:待上网之前的状态均可尝试拦截/取消拦截 */

View File

@ -189,6 +189,7 @@ export const PERMISSION_ACTION_LABELS: Record<string, string> = {
RecalculatePayable: '重算应付',
BindSnapshotVersions: '切换版本',
ImportDownstreamWeight: '导入对账件重尺',
ChangeStatus: '修改订单状态',
AddDeclarationRow: '添加申报行',
DownstreamForecastResult: '下游预报结果',
More: '更多',
@ -577,6 +578,7 @@ const CORE_GROUPS: PageButtonGroup[] = [
{ code: 'Delete', name: '删除' },
{ code: 'Export', name: '导出' },
{ code: 'ImportDownstreamWeight', name: '导入对账件重尺' },
{ code: 'ChangeStatus', name: '修改订单状态' },
{ code: 'View', name: '查看详情' },
{ code: 'ViewTrack', name: '轨迹' },
{ code: 'More', name: '更多' },

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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
@ -1688,123 +1689,29 @@ export default {
// Top nav - contact & balance
'nav.contactService.title': 'Q&A & Support',
'nav.contactService.selectWarehouse': 'Select warehouse',
'nav.contactService.shipWarehouse': 'Ship-from warehouse',
'nav.contactService.whseName': 'Warehouse name',
'nav.sideRail.collapse': 'Collapse menu',
'nav.sideRail.expand': 'Expand menu',
'nav.userMenu.logout': 'Log out',
'nav.userMenu.pageSettings': 'Page settings',
'nav.userMenu.language': 'Language',
'nav.userMenu.switchLanguage': 'Switch to {lang}',
'nav.warehousePeek.title': 'Warehouse address',
'nav.warehousePeek.tip': 'Put your customer code on the parcel for warehouse intake',
'nav.warehousePeek.selectHint': 'Click a card to switch the shipping warehouse',
'nav.warehousePeek.useThis': 'Use',
'nav.warehousePeek.switched': 'Switched to {name}',
'nav.warehousePeek.current': 'Current',
'nav.warehousePeek.copyAll': 'Copy',
'nav.warehousePeek.empty': 'No warehouse address yet',
'nav.warehousePeek.emptyField': 'Nothing to copy',
'nav.contactService.serviceWechat': 'Customer WeChat',
'nav.contactService.wechatQrcode': 'WeChat QR code',
'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',
@ -1948,9 +1855,24 @@ export default {
'order.myOrders.createPickup.predeclaredOnly': 'Only pre-declared orders can be added to a pickup',
'order.myOrders.actions.export': 'Export',
'order.myOrders.actions.modifyOrder': 'Edit order',
'order.myOrders.actions.changeStatus': 'Change order status',
'order.myOrders.actions.createProblematic': 'Create problematic',
'order.myOrders.actions.createClaimed': 'Create claim',
'order.myOrders.actions.reallocate': 'Reallocate',
'order.waybillManage.changeStatus.modalTitle': 'Change order status',
'order.waybillManage.changeStatus.modalTip':
'Only among Pending online, Shipping, Delivered, and Delivery failed. Delivered is enter-only; entering it runs delivery marketing side effects.',
'order.waybillManage.changeStatus.current': 'Current status',
'order.waybillManage.changeStatus.target': 'Target status',
'order.waybillManage.changeStatus.remark': 'Remark',
'order.waybillManage.changeStatus.remarkPlaceholder': 'Explain why (required)',
'order.waybillManage.changeStatus.needOrder': 'Select an order first',
'order.waybillManage.changeStatus.singleOnly': 'Change status supports a single selection only',
'order.waybillManage.changeStatus.notAllowed':
'Current status cannot be corrected (only Pending online / Shipping / Delivery failed)',
'order.waybillManage.changeStatus.noTargets': 'No targets available (Delivered cannot be rolled back)',
'order.waybillManage.changeStatus.success': 'Order status updated',
'order.waybillManage.changeStatus.failed': 'Failed to update order status',
'order.waybillManage.importDownstream.action': 'Import reconcile weight',
'order.waybillManage.importDownstream.modalTitle': 'Import reconcile weight',
'order.waybillManage.importDownstream.tip':

114
src/locale/nana/en-US.ts Normal file
View 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
View 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': '发送失败,请稍后重试',
}

View File

@ -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 {
// 通用
@ -124,7 +125,7 @@ export default {
'menu.order.myOrders': '我的订单',
'menu.order.fulfillOrders': '代发订单',
'menu.order.waybillManage': '运单管理',
'menu.order.pickupOrder': '揽收单',
'menu.order.pickupOrder': '揽收订单',
'menu.order.customs9610Order': '9610订单',
'menu.order.batchJobs': '批量任务',
'menu.order.platformStore': '店铺授权',
@ -1444,122 +1445,29 @@ export default {
// 顶部导航 - 客服 & 余额
'nav.contactService.title': '疑问解答 & 问题咨询',
'nav.contactService.selectWarehouse': '选择仓库',
'nav.contactService.shipWarehouse': '发货仓库',
'nav.contactService.whseName': '仓库名称',
'nav.sideRail.collapse': '收起菜单',
'nav.sideRail.expand': '展开菜单',
'nav.userMenu.logout': '退出登录',
'nav.userMenu.pageSettings': '页面配置',
'nav.userMenu.language': '语言',
'nav.userMenu.switchLanguage': '切换到{lang}',
'nav.warehousePeek.title': '仓库地址',
'nav.warehousePeek.tip': '寄件请备注客户编号,便于仓库认领',
'nav.warehousePeek.selectHint': '点击卡片可切换当前发货仓库',
'nav.warehousePeek.useThis': '选用',
'nav.warehousePeek.switched': '已切换为 {name}',
'nav.warehousePeek.current': '当前',
'nav.warehousePeek.copyAll': '复制',
'nav.warehousePeek.empty': '暂无仓库地址',
'nav.warehousePeek.emptyField': '无可复制内容',
'nav.contactService.serviceWechat': '客服微信',
'nav.contactService.wechatQrcode': '微信二维码',
'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': '余额已更新',
@ -1772,11 +1680,25 @@ export default {
'order.myOrders.createPickup.predeclaredOnly': '仅已预报订单可创建揽收',
'order.myOrders.actions.export': '导出',
'order.myOrders.actions.modifyOrder': '修改订单',
'order.myOrders.actions.changeStatus': '修改订单状态',
'order.myOrders.actions.createProblematic': '创建问题件',
'order.myOrders.actions.createClaimed': '创建理赔件',
'order.myOrders.actions.reallocate': '重新排货',
'order.myOrders.actions.markProcessed': '标记已处理',
'order.myOrders.actions.more': '更多',
'order.waybillManage.changeStatus.modalTitle': '修改订单状态',
'order.waybillManage.changeStatus.modalTip':
'仅可在待上网、运输中、派送成功、派送失败之间校正;派送成功只进不出,进入成功会触发签收相关营销逻辑。',
'order.waybillManage.changeStatus.current': '当前状态',
'order.waybillManage.changeStatus.target': '目标状态',
'order.waybillManage.changeStatus.remark': '变更备注',
'order.waybillManage.changeStatus.remarkPlaceholder': '请说明校正原因(必填)',
'order.waybillManage.changeStatus.needOrder': '请先选择订单',
'order.waybillManage.changeStatus.singleOnly': '修改订单状态仅支持单选',
'order.waybillManage.changeStatus.notAllowed': '当前订单状态不支持校正(仅待上网/运输中/派送失败)',
'order.waybillManage.changeStatus.noTargets': '当前状态无可选目标(派送成功不可回退)',
'order.waybillManage.changeStatus.success': '订单状态已更新',
'order.waybillManage.changeStatus.failed': '订单状态更新失败',
'order.waybillManage.importDownstream.action': '导入对账件重尺',
'order.waybillManage.importDownstream.modalTitle': '导入对账件重尺',
'order.waybillManage.importDownstream.tip':

View File

@ -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',

View File

@ -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';
@ -32,6 +33,8 @@ const router = createRouter({
meta: {
requiresAuth: false,
loginPortal: 'customer',
noAffix: true,
hideInMenu: true,
},
},
{
@ -41,6 +44,8 @@ const router = createRouter({
meta: {
requiresAuth: false,
loginPortal: 'admin',
noAffix: true,
hideInMenu: true,
},
},
{
@ -60,6 +65,7 @@ const router = createRouter({
meta: {
requiresAuth: false,
hideInMenu: true,
noAffix: true,
customerPortalOnly: true,
locale: 'h5.login.title',
},
@ -72,6 +78,7 @@ const router = createRouter({
meta: {
requiresAuth: false,
hideInMenu: true,
noAffix: true,
adminPortalOnly: true,
locale: 'h5Admin.login.title',
},
@ -188,12 +195,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 +203,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 +214,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 +225,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 +247,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 +337,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';
},
},
],

View File

@ -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

View File

@ -9,6 +9,7 @@ import { isString } from '@/utils/is';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import { buildMenuTitleMaps, resolveMenuTitleByPath, resolveMenuTitleFromTree } from '@/utils/menu-display';
import { translateMenuTitleByPath } from '@/utils/menu-i18n';
import { isLoginRouteName } from '@/utils/login-portal';
import i18n from '@/locale';
import type { MenuTreeDto } from '@/api/rbac';
import { TabBarState, TagProps } from './types';
@ -56,7 +57,19 @@ const formatTag = (route: RouteLocationNormalized): TagProps => {
};
};
const BAN_LIST = [REDIRECT_ROUTE_NAME];
const BAN_LIST = [REDIRECT_ROUTE_NAME, 'H5Login', 'H5AdminLogin', 'siteRoot'];
function shouldSkipTabRoute(route: RouteLocationNormalized): boolean {
const name = route.name != null ? String(route.name) : '';
if (!name) return true;
if (BAN_LIST.includes(name)) return true;
if (isLoginRouteName(name)) return true;
if (route.meta?.noAffix) return true;
if (route.meta?.requiresAuth === false && (route.path === '/login' || route.path === '/admin')) {
return true;
}
return false;
}
const useTabBarStore = defineStore('tabBar', {
state: (): TabBarState => ({
@ -104,7 +117,11 @@ const useTabBarStore = defineStore('tabBar', {
},
updateTabList(route: RouteLocationNormalized) {
if (BAN_LIST.includes(route.name as string)) return;
if (shouldSkipTabRoute(route)) return;
// 登录页曾被误写入时清掉,避免登录成功后仍挂着「login」页签
this.tagList = this.tagList.filter(
(tag) => !isLoginRouteName(tag.name) && tag.name !== 'H5Login' && tag.name !== 'H5AdminLogin',
);
const next = formatTag(route);
const idx = this.tagList.findIndex(
(tag) =>
@ -131,6 +148,13 @@ const useTabBarStore = defineStore('tabBar', {
/** 菜单/标签点击立刻高亮,不等路由 afterEach */
previewNavigate(tag: TagProps) {
if (!tag?.fullPath && !tag?.name) return;
if (
isLoginRouteName(tag.name) ||
tag.name === 'H5Login' ||
tag.name === 'H5AdminLogin'
) {
return;
}
this.previewingPath = tag.fullPath || '';
this.previewingName = tag.name || '';
const exists = this.tagList.some(

View File

@ -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) {

View 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
}

View 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
}

View File

@ -66,6 +66,12 @@ export function getCustomerNavPath(item: { path?: string; route?: string } | nul
/** 收进用户下拉的辅助页(非高频办事入口) */
const USER_MENU_EXTRA_PATH_PREFIXES = [
'/api/openApi',
] as const
/**
* 客户侧栏/用户菜单不展示的页面(由消息铃铛、仓名旁眼睛等入口打开)。
*/
const CUSTOMER_NAV_DOCK_ONLY_PATH_PREFIXES = [
'/system/announcement',
'/system/warehouseAddress',
] as const
@ -277,6 +283,9 @@ export function buildCustomerFlatNavMenus(
const path = getCustomerNavPath(item)
if (!path || seen.has(path)) return
if (!isVisibleNavNode(item, loginType)) return
if (CUSTOMER_NAV_DOCK_ONLY_PATH_PREFIXES.some((prefix) => matchesPathPrefix(path, prefix))) {
return
}
seen.add(path)
result.push(item)
}

View File

@ -1,7 +1,7 @@
import type { RouteLocationRaw } from 'vue-router';
import { isNavigationFailure } from 'vue-router';
import router from '@/router';
import { useUserStore } from '@/store';
import { useUserStore, useTabBarStore } from '@/store';
import { ensureDynamicRouterReady } from '@/router/ensure-dynamic-router';
import { ensureHeavyPlugins } from '@/plugins/heavy-plugins';
import { ensureFullLocaleMessages } from '@/locale';
@ -63,6 +63,8 @@ export async function enterSystemAfterLogin(options: {
]);
skipNextPageTransition();
// 登录壳不进业务页签;进系统前清空,避免残留「login」标签
useTabBarStore().resetTabList();
try {
await router.replace({ ...options.target } as RouteLocationRaw);

24
src/utils/h5-redirect.ts Normal file
View 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 }
}

View File

@ -194,15 +194,22 @@ export function translateMenuTitle(
menu: MenuTitleSource,
): string {
const rbacName = (menu.name || menu.meta?.title || '').trim()
// 菜单管理配置的名称优先于静态 locale 文案
if (rbacName && !rbacName.startsWith('menu.')) {
const key = resolveMenuI18nKey(menu)
const locale = String(i18n.global.locale.value || 'zh-CN')
const isZh = locale.toLowerCase().startsWith('zh')
// 中文:菜单管理配置名优先(与 RBAC 一致,避免被语言包简称覆盖,如「揽收订单」→「揽收单」)
if (isZh && rbacName && !rbacName.startsWith('menu.')) {
return rbacName
}
const key = resolveMenuI18nKey(menu)
if (!key) return rbacName
// 非中文:跟语言包走,保证切英文能切换;无文案再回退 RBAC 名
if (key) {
const translated = String(t(key))
return translated && translated !== key ? translated : rbacName
if (translated && translated !== key) return translated
}
if (rbacName && !rbacName.startsWith('menu.')) return rbacName
return rbacName || key || ''
}
/** 在非 setup 场景(如 pinia)用全局 i18n 翻译路径标题;fallback 为菜单管理名称时直接使用 */

View File

@ -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;

View File

@ -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/');
case 401: {
// 401:已登录才走会话过期;访客可逛页的公开接口失败不踢登录
if (isLogin()) {
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 });
}
handleSessionExpired(errorMessage);
}
break;
}
case 403:
Message.error(`禁止访问: ${data?.message || statusText}`);
break;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View 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>

View 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,
}
}

View File

@ -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')))
})
}

View File

@ -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);
// 访客也可逛顾问等页:空闲补全量语言包(bootstrap 已含 H5 + nanaAssistant)
const run = () => {
void ensureFullLocaleMessages(locale.value);
preloadCreateFlow();
};
if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
idlePreloadHandle = window.requestIdleCallback(
() => {
run();
preloadCreateFlow();
},
{ timeout: 2500 },
) as unknown as number;
idlePreloadHandle = window.requestIdleCallback(run, {
timeout: 2500,
}) as unknown as number;
} else {
idlePreloadHandle = window.setTimeout(() => {
run();
preloadCreateFlow();
}, 800) as unknown as number;
}
idlePreloadHandle = window.setTimeout(run, 800) as unknown as number;
}
if (showTabBar.value) {
scheduleBadgeRefresh();

View File

@ -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',

View File

@ -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} 条未命中',

View File

@ -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;

View File

@ -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;

View File

@ -52,6 +52,7 @@
</div>
</section>
<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" />
@ -100,6 +101,7 @@
<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
!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;

View File

@ -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();
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,
};
})
.filter((c) => c.value);
});
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;

View File

@ -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>

View File

@ -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;
}

View File

@ -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%;
}

View File

@ -0,0 +1,217 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue'
import ModalPro from '@/components/ModalPro/index.vue'
import useModal from '@/components/ModalPro/useModal'
import FormPro from '@/components/FormPro/index.vue'
import { postOrderChangePostShipStatus } from '@/api/order'
import {
getOrderStatusMeta,
getPostShipStatusChangeTargets,
type OrderStatusCode,
} from '@/constants/orderStatus'
const props = defineProps<{
visible: boolean
orderId: number | null
currentStatus: string
waybillLabel?: string
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: []
}>()
const { t } = useI18n()
const [registerModal, modalMethods] = useModal()
const formRef = ref<InstanceType<typeof FormPro>>()
const submitting = ref(false)
const formState = reactive({
targetStatus: undefined as OrderStatusCode | undefined,
remark: '',
})
const targetOptions = computed(() =>
getPostShipStatusChangeTargets(props.currentStatus).map((code) => {
const meta = getOrderStatusMeta(code)
return {
value: code,
label: meta ? t(meta.labelKey) : code,
}
}),
)
const currentStatusLabel = computed(() => {
const meta = getOrderStatusMeta(props.currentStatus)
return meta ? t(meta.labelKey) : props.currentStatus || '--'
})
const itemList = computed(() => {
const requiredMsg = t('system.serviceProviders.modal.rules.requiredInput')
return [
{
label: t('order.waybillManage.changeStatus.current'),
field: 'currentStatusDisplay',
comp: 'slot',
span: 24,
},
{
label: t('order.waybillManage.changeStatus.target'),
field: 'targetStatus',
comp: 'ASelect',
span: 24,
rules: [{ required: true, message: requiredMsg }],
compProps: {
options: targetOptions.value,
placeholder: t('common.selectPlaceholder'),
allowClear: false,
},
},
{
label: t('order.waybillManage.changeStatus.remark'),
field: 'remark',
comp: 'ATextarea',
span: 24,
rules: [{ required: true, message: requiredMsg }],
compProps: {
autoSize: { minRows: 3, maxRows: 5 },
maxlength: 512,
showWordLimit: true,
placeholder: t('order.waybillManage.changeStatus.remarkPlaceholder'),
},
},
]
})
function resetForm() {
formState.targetStatus = targetOptions.value[0]?.value
formState.remark = ''
}
watch(
() => props.visible,
async (open) => {
if (!open) return
resetForm()
await nextTick()
formRef.value?.setValues?.({ ...formState })
formRef.value?.formRef?.clearValidate?.()
},
{ immediate: true },
)
function onUpdateVisible(val: boolean) {
if (val === props.visible) return
emit('update:visible', val)
}
function handleCancel() {
emit('update:visible', false)
}
async function handleSubmit() {
if (!props.orderId) {
Message.warning(t('order.waybillManage.changeStatus.needOrder'))
return
}
if (!targetOptions.value.length) {
Message.warning(t('order.waybillManage.changeStatus.noTargets'))
return
}
const valid = await formRef.value?.formRef?.validate?.()
if (valid) return
const values = formRef.value?.getValues?.() ?? {}
const targetStatus = String(values.targetStatus ?? formState.targetStatus ?? '').trim()
const remark = String(values.remark ?? formState.remark ?? '').trim()
if (!targetStatus || !remark) {
Message.warning(t('system.serviceProviders.modal.rules.requiredInput'))
return
}
submitting.value = true
try {
await postOrderChangePostShipStatus({
id: props.orderId,
targetStatus,
remark,
})
Message.success(t('order.waybillManage.changeStatus.success'))
emit('success')
emit('update:visible', false)
modalMethods.closeModal()
} catch (e: any) {
Message.error(e?.message || t('order.waybillManage.changeStatus.failed'))
} finally {
submitting.value = false
}
}
</script>
<template>
<ModalPro
:visible="visible"
:title="t('order.waybillManage.changeStatus.modalTitle')"
:width="520"
:mask-closable="false"
:confirm-loading="submitting"
@register="registerModal"
@update:visible="onUpdateVisible"
@ok="handleSubmit"
@cancel="handleCancel"
>
<a-alert type="info" :show-icon="false" class="modal-form-tip">
{{ t('order.waybillManage.changeStatus.modalTip') }}
</a-alert>
<!-- 勿传 :model:FormPro 用内部 formValue;attrs 会覆盖导致输入与校验脱节 -->
<FormPro
ref="formRef"
:item-list="itemList"
:row-gutter="16"
:default-span="24"
form-layout="vertical"
>
<template #currentStatusDisplay>
<div class="current-status-line">
<span class="current-status-line__label">{{ currentStatusLabel }}</span>
<span v-if="waybillLabel" class="current-status-line__waybill">{{ waybillLabel }}</span>
</div>
</template>
</FormPro>
</ModalPro>
</template>
<style scoped lang="less">
.modal-form-tip {
margin-bottom: 16px;
border-radius: 6px;
background: color-mix(in srgb, var(--app-primary-color) 8%, #fff);
border: 1px solid color-mix(in srgb, var(--app-primary-color) 18%, transparent);
color: var(--color-text-2);
font-size: 13px;
line-height: 1.5;
}
.current-status-line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-height: 32px;
color: var(--color-text-1);
&__label {
font-weight: 600;
}
&__waybill {
color: var(--color-text-3);
font-size: 12px;
}
}
</style>

View File

@ -87,6 +87,7 @@
isOrderDeletable,
isOrderNotShipped,
getOrderStatusMeta,
getPostShipStatusChangeTargets,
buildOrderStatusTagHtml,
buildOrderStatusSubTagHtml,
buildBooleanTagHtml,
@ -133,6 +134,9 @@
const StockBindModal = defineAsyncComponent(
() => import('../myOrders/components/StockBindModal.vue'),
);
const ChangePostShipStatusModal = defineAsyncComponent(
() => import('./components/ChangePostShipStatusModal.vue'),
);
defineOptions({
name: 'WaybillManage',
@ -579,11 +583,20 @@
const showStockBindAction = computed(
() => hasPermission('Order.WaybillManage.StockBind'),
);
const showChangeStatusAction = computed(
() =>
hasPermission('Order.WaybillManage.ChangeStatus') &&
isActiveTab('all', 'pendingOnline', 'shipping', 'deliverySuccess', 'deliveryFailed'),
);
const stockBindModalVisible = ref(false);
const stockBindOrderIds = ref<number[]>([]);
/** 本次打开绑定前是否含直发单(用于成功后提示已转代发) */
const stockBindDidConvert = ref(false);
const changeStatusModalVisible = ref(false);
const changeStatusOrderId = ref<number | null>(null);
const changeStatusCurrent = ref('');
const changeStatusWaybillLabel = ref('');
const problematicModalVisible = ref(false);
const claimedModalVisible = ref(false);
const reallocateModalVisible = ref(false);
@ -1636,6 +1649,29 @@
await openDetailWithOptionalFetch(rows[0], { editBasic: true });
};
const openChangeStatusModal = () => {
const rows = requireSelectedRows();
if (!rows.length) return;
if (rows.length > 1) {
Message.warning(t('order.waybillManage.changeStatus.singleOnly'));
return;
}
const row = rows[0];
const id = getOrderRowId(row);
if (id == null) return;
const status = resolveOrderStatusCode(row);
if (!getPostShipStatusChangeTargets(status).length) {
Message.warning(t('order.waybillManage.changeStatus.notAllowed'));
return;
}
const waybillNo = String(row?.waybillNo ?? row?.WaybillNo ?? '').trim();
const serviceNo = String(row?.serviceNo ?? row?.ServiceNo ?? '').trim();
changeStatusOrderId.value = Number(id);
changeStatusCurrent.value = status;
changeStatusWaybillLabel.value = waybillNo || serviceNo || `#${id}`;
changeStatusModalVisible.value = true;
};
const openProblematicModal = () => {
const rows = requireSelectedRows();
if (!rows.length) return;
@ -1799,6 +1835,7 @@
if (key === 'export') return handleExport();
if (key === 'importDownstreamWeight') return openDownstreamWeightImport();
if (key === 'modifyOrder') return handleModifyOrder();
if (key === 'changeStatus') return openChangeStatusModal();
if (key === 'stockBind') return openStockBindModal();
};
@ -2319,6 +2356,11 @@
:disabled="selectedRowCount === 0"
@click="onMoreActionClick('modifyOrder')"
>{{ t('order.myOrders.actions.modifyOrder') }}</a-doption>
<a-doption
v-if="showChangeStatusAction"
:disabled="selectedRowCount === 0"
@click="onMoreActionClick('changeStatus')"
>{{ t('order.myOrders.actions.changeStatus') }}</a-doption>
</template>
</a-dropdown>
</div>
@ -2511,6 +2553,14 @@
:order-ids="stockBindOrderIds"
@success="handleStockBindSuccess"
/>
<ChangePostShipStatusModal
v-if="changeStatusModalVisible"
v-model:visible="changeStatusModalVisible"
:order-id="changeStatusOrderId"
:current-status="changeStatusCurrent"
:waybill-label="changeStatusWaybillLabel"
@success="handleExceptionActionSuccess"
/>
</div>
</template>

View File

@ -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(() => {