diff --git a/.cursor/rules/frontend-design-spec.mdc b/.cursor/rules/frontend-design-spec.mdc index 519a957..e116577 100644 --- a/.cursor/rules/frontend-design-spec.mdc +++ b/.cursor/rules/frontend-design-spec.mdc @@ -16,7 +16,7 @@ alwaysApply: true - 工具栏:`table-toolbar` + `toolbar-left` + `toolbar-right` ### 色彩 -- 使用 `var(--app-primary-color)`,不硬编码 `#2D8C8C` +- 使用 `var(--app-primary-color)`,不硬编码 `#007979`(默认主色) ### 组件 - 按钮 32px 高,border-radius 4px,间距 8px diff --git a/docs/前端页面设计规范.md b/docs/前端页面设计规范.md index 32cd552..5a75b62 100644 --- a/docs/前端页面设计规范.md +++ b/docs/前端页面设计规范.md @@ -143,18 +143,18 @@ a-layout (垂直) ```css :root { - --app-primary-color: #2D8C8C; - --app-primary-color-hover: #2A7F7F; - --app-primary-color-light: #e6f7f7; - --app-navbar-bg: #2D8C8C; - --app-sidebar-bg: #2D8C8C; + --app-primary-color: #007979; + --app-primary-color-hover: #006868; + --app-primary-color-light: #e6f4f4; + --app-navbar-bg: #007979; + --app-sidebar-bg: #007979; --app-button-gap: 8px; /* 相邻按钮间距 */ } ``` **约定:** -- 主色:`var(--app-primary-color)` 或 `#2D8C8C` +- 主色:`var(--app-primary-color)` 或 `#007979` - Hover 色:`var(--app-primary-color-hover)` - 浅底(选中背景):`var(--app-primary-color-light)` - 页面背景:`var(--color-bg-1)`(Arco) @@ -529,7 +529,7 @@ const { t } = useI18n(); - [ ] **`.list-wrap`**:`padding: 16px`、`flex: 1; min-height: 0`;需测高时 `ref` 挂在本层 - [ ] 工具栏使用 `table-toolbar`、`toolbar-left`、`toolbar-right`,工具栏 **`padding: 16px 16px 0`** - [ ] 按钮、输入框符合全局样式(高度、圆角) -- [ ] 使用主题色变量,不硬编码 `#2D8C8C` +- [ ] 使用主题色变量,不硬编码 `#007979` - [ ] 弹窗 / 抽屉使用 ModalPro / DrawerPro + useModal - [ ] 表单使用 FormPro 或统一 label/span - [ ] 表格优先 TablePro / CustomList / HandsontablePro diff --git a/nginx-default.conf b/nginx-default.conf index 66c2bf1..3f97a36 100644 --- a/nginx-default.conf +++ b/nginx-default.conf @@ -305,6 +305,19 @@ server { proxy_read_timeout 300s; } + location ^~ /uploads/ { + rewrite ^/(.*)$ /admin/$1 break; + proxy_pass http://api.nanafa.com; + proxy_http_version 1.1; + proxy_set_header Host api.nanafa.com; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 30s; + proxy_send_timeout 30s; + proxy_read_timeout 120s; + } + location ^~ /mcp/ { rewrite ^/(.*)$ /admin/$1 break; proxy_pass http://api.nanafa.com; diff --git a/nginx-docker.conf b/nginx-docker.conf index 03666a9..5544f1e 100644 --- a/nginx-docker.conf +++ b/nginx-docker.conf @@ -168,6 +168,17 @@ server { proxy_read_timeout 300s; } + location ^~ /uploads/ { + rewrite ^/(.*)$ /admin/$1 break; + proxy_pass http://api.nanafa.com; + proxy_http_version 1.1; + proxy_set_header Host api.nanafa.com; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + } + location ^~ /mcp/ { rewrite ^/(.*)$ /admin/$1 break; proxy_pass http://api.nanafa.com; diff --git a/src/api/oms/common.ts b/src/api/oms/common.ts index 79bf3eb..6c81f9c 100644 --- a/src/api/oms/common.ts +++ b/src/api/oms/common.ts @@ -103,14 +103,19 @@ export async function omsPost(path: string, body?: Record) return unwrapData(res.data) } -/** 将 /uploads/... 相对路径转为可访问的完整 URL */ +/** 将 /uploads/... 相对路径转为可访问 URL(同源相对,或 VITE_API_ASSET_ORIGIN 绝对前缀) */ export function resolveOmsAssetUrl(path?: string | null): string { if (!path) return '' const value = path.trim() if (!value) return '' if (/^https?:\/\//i.test(value) || value.startsWith('data:') || value.startsWith('blob:')) { - return value + // 纠正常见错链:api.nanafa.com/uploads → api.nanafa.com/admin/uploads + return value.replace( + /^(https?:\/\/api\.nanafa\.com)\/uploads\//i, + '$1/admin/uploads/', + ) } const origin = resolveAdminAssetOrigin() - return `${origin}${value.startsWith('/') ? value : `/${value}`}` + const rel = value.startsWith('/') ? value : `/${value}` + return origin ? `${origin}${rel}` : rel } diff --git a/src/api/rbac/axios-config.ts b/src/api/rbac/axios-config.ts index 49bfcde..b500cf8 100644 --- a/src/api/rbac/axios-config.ts +++ b/src/api/rbac/axios-config.ts @@ -1,8 +1,9 @@ import axios, { AxiosError, AxiosRequestConfig } from 'axios' import { Message } from '@arco-design/web-vue' import router from '@/router' -import { OpenAPI, clearAuthToken } from './index' +import { OpenAPI, clearAuthToken, OmsLoginType } from './index' import { useRbacUserStore } from '@/store/modules/rbac-user' +import { resolvePostLogoutLogin } from '@/utils/login-portal' // 创建自定义 axios 实例 // baseURL 为空,URL 由 OpenAPI.BASE + path 构建;生产默认同源反代,OpenAPI.BASE 为空 @@ -30,13 +31,33 @@ function onTokenRefreshed(token: string) { refreshSubscribers = [] } -function redirectToLoginPage(userStore: ReturnType) { - router.replace({ - name: userStore.getLoginRouteName(), - query: { - redirect: router.currentRoute.value.fullPath, - }, +function redirectToLoginPage( + userStore: ReturnType, + loginType?: OmsLoginType | null +) { + const current = router.currentRoute.value + const target = resolvePostLogoutLogin({ + loginType: loginType !== undefined ? loginType : userStore.resolveLoginTypeForRedirect(), + path: current.fullPath, }) + if (target.path) { + router.replace({ + path: target.path, + query: target.query, + }) + return + } + router.replace({ + name: target.name, + query: target.query, + }) +} + +function logoutAndRedirectToLogin(userStore: ReturnType, message: string) { + const loginType = userStore.resolveLoginTypeForRedirect() + userStore.logout() + Message.error(message) + redirectToLoginPage(userStore, loginType) } // 请求拦截器 @@ -105,17 +126,13 @@ axiosInstance.interceptors.response.use( // 401:保活窗内(含 F5 从 lastActivity 恢复的 idle 窗)才允许续期 if (status === 401 && !originalRequest._retry) { if (!userStore.isKeepAliveActive()) { - userStore.logout() - Message.error('登录已过期,请重新登录') - redirectToLoginPage(userStore) + logoutAndRedirectToLogin(userStore, '登录已过期,请重新登录') return Promise.reject(error) } // 如果没有 refreshToken,直接登出 if (!userStore.refreshToken) { - userStore.logout() - Message.error('未授权,请重新登录') - redirectToLoginPage(userStore) + logoutAndRedirectToLogin(userStore, '未授权,请重新登录') return Promise.reject(error) } @@ -148,16 +165,11 @@ axiosInstance.interceptors.response.use( return axiosInstance(originalRequest) } else { // 刷新失败,登出 - userStore.logout() - Message.error('登录已过期,请重新登录') - // 跳转到登录页 - redirectToLoginPage(userStore) + logoutAndRedirectToLogin(userStore, '登录已过期,请重新登录') return Promise.reject(error) } } catch { - userStore.logout() - Message.error('登录已过期,请重新登录') - redirectToLoginPage(userStore) + logoutAndRedirectToLogin(userStore, '登录已过期,请重新登录') return Promise.reject(error) } finally { isRefreshing = false @@ -167,10 +179,7 @@ axiosInstance.interceptors.response.use( switch (status) { case 401: // 如果走到这里,说明401没有被上面的逻辑处理(可能是没有retry标记的情况) - Message.error('未授权,请重新登录') - userStore.logout() - // 跳转到登录页 - redirectToLoginPage(userStore) + logoutAndRedirectToLogin(userStore, '未授权,请重新登录') break case 403: Message.error('没有权限访问') diff --git a/src/assets/icons/h5/order-create.svg b/src/assets/icons/h5/order-create.svg new file mode 100644 index 0000000..e502986 --- /dev/null +++ b/src/assets/icons/h5/order-create.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/h5/order-logistics.svg b/src/assets/icons/h5/order-logistics.svg new file mode 100644 index 0000000..3c74624 --- /dev/null +++ b/src/assets/icons/h5/order-logistics.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/h5/order-pickup.svg b/src/assets/icons/h5/order-pickup.svg new file mode 100644 index 0000000..92cbb06 --- /dev/null +++ b/src/assets/icons/h5/order-pickup.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/h5/route-plane.svg b/src/assets/icons/h5/route-plane.svg new file mode 100644 index 0000000..7072e66 --- /dev/null +++ b/src/assets/icons/h5/route-plane.svg @@ -0,0 +1,10 @@ + + + + diff --git a/src/assets/icons/h5/service-commission.svg b/src/assets/icons/h5/service-commission.svg new file mode 100644 index 0000000..856a272 --- /dev/null +++ b/src/assets/icons/h5/service-commission.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/h5/service-coupon.svg b/src/assets/icons/h5/service-coupon.svg new file mode 100644 index 0000000..66dea55 --- /dev/null +++ b/src/assets/icons/h5/service-coupon.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/h5/service-invite.svg b/src/assets/icons/h5/service-invite.svg new file mode 100644 index 0000000..cc930b1 --- /dev/null +++ b/src/assets/icons/h5/service-invite.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/h5/service-order.svg b/src/assets/icons/h5/service-order.svg new file mode 100644 index 0000000..39363f1 --- /dev/null +++ b/src/assets/icons/h5/service-order.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/style/global.less b/src/assets/style/global.less index 10082d5..02d41ac 100644 --- a/src/assets/style/global.less +++ b/src/assets/style/global.less @@ -1,11 +1,16 @@ /* 主题色 CSS 变量(默认值,运行时由 themeColor 工具覆盖) */ :root { - --app-primary-color: #2D8C8C; - --app-primary-color-hover: #2A7F7F; - --app-primary-color-light: #e6f7f7; - --app-navbar-bg: #2D8C8C; - --app-sidebar-bg: #2D8C8C; + --app-primary-color: #007979; + --app-primary-color-hover: #006868; + --app-primary-color-light: #e6f4f4; + --app-navbar-bg: #007979; + --app-sidebar-bg: #007979; --app-button-gap: 8px; /* 相邻按钮间距,规范统一 */ + /* 同步 Arco Design 主色(rgb 分量) */ + --primary-6: 0, 121, 121; + --primary-5: 20, 140, 140; + --primary-7: 0, 100, 100; + --color-primary-light-1: #e6f4f4; } * { diff --git a/src/components/InviteQrImage.vue b/src/components/InviteQrImage.vue index 70d2826..839c7e0 100644 --- a/src/components/InviteQrImage.vue +++ b/src/components/InviteQrImage.vue @@ -18,16 +18,20 @@ const props = withDefaults( const dataUrl = ref(''); const failed = ref(false); +const ready = ref(false); async function render() { failed.value = false; + ready.value = false; dataUrl.value = ''; if (!props.value) return; try { + // 放大生成再缩小展示:像素更实,微信长按一次识别更稳 + const renderSize = Math.max(props.size * 2, 320); dataUrl.value = await QRCode.toDataURL(props.value, { - width: props.size, - margin: 2, - errorCorrectionLevel: 'M', + width: renderSize, + margin: 3, + errorCorrectionLevel: 'H', color: { dark: '#1d2129', light: '#ffffff' }, }); } catch { @@ -35,6 +39,18 @@ async function render() { } } +async function onLoad(event: Event) { + const img = event.target as HTMLImageElement; + if (typeof img.decode === 'function') { + try { + await img.decode(); + } catch { + // ignore + } + } + ready.value = true; +} + onMounted(() => { void render(); }); @@ -55,7 +71,12 @@ watch( :width="size" :height="size" class="invite-qr-image" + :class="{ 'is-ready': ready }" :style="{ width: `${size}px`, height: `${size}px` }" + decoding="sync" + draggable="false" + @contextmenu.prevent + @load="onLoad" />
{{ t(cap.titleKey) }} {{ t(cap.descKey) }} -
    -
  • - {{ f.required ? t('nanaAssistant.mcp.required') : t('nanaAssistant.mcp.optional') }} - {{ t(f.labelKey) }} -
  • -
- {{ t(cap.exampleKey) }} - -
- - -
-
@@ -194,14 +165,14 @@ @@ -468,15 +439,7 @@ const showWelcome = computed(() => !isChatting.value && !waybillContext.value); const canSend = computed(() => Boolean(inputText.value.trim() && !chatLoading.value)); -const composerPlaceholder = computed(() => { - if (activeCapability.value) { - return t(getCapability(activeCapability.value).exampleKey); - } - if (waybillContext.value) { - return t('nanaAssistant.inputPlaceholderTrack'); - } - return t('nanaAssistant.inputPlaceholderLive'); -}); +const composerPlaceholder = computed(() => ''); const youInitial = computed(() => t('nanaAssistant.you').charAt(0).toUpperCase()); @@ -888,7 +851,7 @@ async function startMcpCapability(id: McpCapabilityId) { messages.value.push({ role: 'assistant', content: coach }); guidedSession.value = true; saveThreads(); - inputText.value = t(cap.draftKey); + inputText.value = ''; await nextTick(); resizeInput(); scrollToBottom(); @@ -1185,12 +1148,13 @@ async function applyPrefillFromRoute() { intent === 'guide' || intent === 'price' || intent === 'track' ? intent : activeCapability.value; } - if (!inputText.value.trim()) { + const shouldAuto = takeQueryString('auto') === '1'; + // 默认不预填输入框,避免占位文案杂乱;仅 auto 发送时写入 + if (shouldAuto && !inputText.value.trim()) { inputText.value = text; } prefillRouteApplied.value = true; - const shouldAuto = takeQueryString('auto') === '1'; await nextTick(); resizeInput(); if (!shouldSkipAutoFocus()) focusInput(); diff --git a/src/components/NanaAssistant/ai-advisor.css b/src/components/NanaAssistant/ai-advisor.css index 6fa7cb0..0f9b725 100644 --- a/src/components/NanaAssistant/ai-advisor.css +++ b/src/components/NanaAssistant/ai-advisor.css @@ -753,23 +753,30 @@ margin-bottom: 8px; } +@media (max-width: 720px) { + .ai-mcp-grid { + grid-template-columns: 1fr; + } +} + .ai-mcp-card { display: flex; flex-direction: column; align-items: flex-start; - gap: 6px; - padding: 14px; + gap: 4px; + padding: 14px 12px; text-align: left; border-radius: 14px; - border: 1px solid rgba(45, 140, 140, 0.18); - background: linear-gradient(160deg, #fff 0%, rgba(45, 140, 140, 0.06) 100%); + border: none; + background: color-mix(in srgb, var(--app-primary-color, #007979) 8%, #fff); + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04); cursor: pointer; transition: transform 0.15s ease, box-shadow 0.15s ease; } .ai-mcp-card:hover { - transform: translateY(-2px); - box-shadow: 0 8px 20px rgba(45, 140, 140, 0.12); + transform: translateY(-1px); + box-shadow: 0 6px 16px color-mix(in srgb, var(--app-primary-color, #007979) 16%, transparent); } .ai-mcp-card:disabled { @@ -786,48 +793,10 @@ .ai-mcp-card__desc { font-size: 12px; - line-height: 1.45; - color: #4e5969; -} - -.ai-mcp-card__fields { - margin: 4px 0 0; - padding: 0; - list-style: none; - width: 100%; -} - -.ai-mcp-card__fields li { - position: relative; - padding-left: 10px; - margin-top: 3px; - font-size: 11px; line-height: 1.4; color: #86909c; } -.ai-mcp-card__fields li::before { - content: '·'; - position: absolute; - left: 0; -} - -.ai-mcp-card__fields li.is-required { - color: #f53f3f; - font-weight: 600; -} - -.ai-mcp-card__fields li.is-optional { - color: #86909c; -} - -.ai-mcp-card__example { - margin-top: 4px; - font-size: 11px; - color: var(--app-primary-color, #2d8c8c); - line-height: 1.4; -} - @media (max-width: 900px) { .ai-mcp-grid { grid-template-columns: 1fr; diff --git a/src/components/PrintTemplate/OrderPrintModal.vue b/src/components/PrintTemplate/OrderPrintModal.vue index 553ebd7..71860ce 100644 --- a/src/components/PrintTemplate/OrderPrintModal.vue +++ b/src/components/PrintTemplate/OrderPrintModal.vue @@ -134,6 +134,7 @@ import { hiprintSendHtmlPrint, } from '@/utils/hiprint/hiprintElectronSocket'; import { useHiprintClientState } from '@/utils/hiprint/hiprintClientState'; +import { resolveOmsAssetUrl } from '@/api/oms/common'; import { buildHiprintBlobPrintExtras, HIPRINT_PAPER_PRESET_OPTIONS, @@ -199,10 +200,13 @@ function openActiveInNewTab() { } function toAbsoluteUrl(url: string) { - if (!url) return ''; - if (/^https?:\/\//i.test(url)) return url; - if (url.startsWith('/')) return `${window.location.origin}${url}`; - return `${window.location.origin}/${url}`; + const resolved = resolveOmsAssetUrl(url) || String(url || '').trim(); + if (!resolved) return ''; + if (/^https?:\/\//i.test(resolved) || resolved.startsWith('blob:') || resolved.startsWith('data:')) { + return resolved; + } + if (resolved.startsWith('/')) return `${window.location.origin}${resolved}`; + return `${window.location.origin}/${resolved}`; } async function silentPrintByUrl(url: string) { diff --git a/src/components/global-setting/form-wrapper.vue b/src/components/global-setting/form-wrapper.vue index 20be236..faa9af8 100644 --- a/src/components/global-setting/form-wrapper.vue +++ b/src/components/global-setting/form-wrapper.vue @@ -49,7 +49,7 @@ const colorValue = computed(() => { const v = props.defaultValue as string; - if (!v || typeof v !== 'string') return '#2D8C8C'; + if (!v || typeof v !== 'string') return '#007979'; return v.startsWith('#') ? v : `#${v}`; }); diff --git a/src/components/global-setting/index.vue b/src/components/global-setting/index.vue index 45ec0b8..106106c 100644 --- a/src/components/global-setting/index.vue +++ b/src/components/global-setting/index.vue @@ -77,7 +77,7 @@ }, ]); - const DEFAULT_THEME_COLOR = '#132c44'; + const DEFAULT_THEME_COLOR = '#007979'; const cancel = () => { appStore.updateSettings({ globalSettings: false }); diff --git a/src/components/top-nav/RightTopUser.vue b/src/components/top-nav/RightTopUser.vue index dd42c6c..b513962 100644 --- a/src/components/top-nav/RightTopUser.vue +++ b/src/components/top-nav/RightTopUser.vue @@ -101,6 +101,7 @@ import { useUserStore, useAppStore } from '@/store'; import { useRbacUserStore } from '@/store/modules/rbac-user'; import { AuthService } from '@/api/rbac'; import { IconSettings } from '@arco-design/web-vue/es/icon'; +import { resolvePostLogoutLogin } from '@/utils/login-portal'; const { t } = useI18n(); const router = useRouter(); @@ -167,10 +168,16 @@ async function handleChangePasswordSubmit() { Message.success(t('profile.changePasswordSuccess')); changePasswordVisible.value = false; resetPasswordForm(); - const loginRouteName = rbacUserStore.getLoginRouteName(); + const loginType = rbacUserStore.resolveLoginTypeForRedirect(); + const currentPath = router.currentRoute.value.fullPath; rbacUserStore.logout(); userStore.logout(); - router.replace({ name: loginRouteName }); + 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 }); + } } catch (err: any) { if (err?.errors || err?.errorFields) return; Message.error(err?.message || '修改密码失败'); @@ -183,10 +190,16 @@ const onMenuClick = async (event: { key: string }) => { const { key } = event; if (key === 'logout') { - const loginRouteName = rbacUserStore.getLoginRouteName(); + const loginType = rbacUserStore.resolveLoginTypeForRedirect(); + const currentPath = router.currentRoute.value.fullPath; rbacUserStore.logout(); userStore.logout(); - router.push({ name: loginRouteName }); + const target = resolvePostLogoutLogin({ loginType, path: currentPath }); + if (target.path) { + router.push({ path: target.path, query: target.query }); + } else { + router.push({ name: target.name, query: target.query }); + } } else if (key === 'userinfo') { router.push({ name: 'UserProfile' }); } else if (key === 'changePassword') { diff --git a/src/composables/useWechatQrLogin.ts b/src/composables/useWechatQrLogin.ts index a94b841..6d60098 100644 --- a/src/composables/useWechatQrLogin.ts +++ b/src/composables/useWechatQrLogin.ts @@ -2,6 +2,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'; import { Message } from '@arco-design/web-vue'; import { getLoginQrcode, getWxLoginStatus, isWxLoginPendingError, wxLogin } from '@/api/wechat'; import type { LoginResultDto } from '@/api/rbac'; +import { resolveWechatShowQrDisplayUrl } from '@/utils/wechatShowQrUrl'; const QR_EXPIRE_MS = 600_000; const POLL_INTERVAL_MS = 1800; @@ -23,9 +24,18 @@ function createLoginUuid(): string { function preloadImage(url: string): Promise { return new Promise((resolve, reject) => { const img = new Image(); - img.decoding = 'async'; + img.decoding = 'sync'; img.referrerPolicy = 'no-referrer'; - img.onload = () => resolve(); + img.onload = () => { + if (typeof img.decode === 'function') { + img + .decode() + .then(() => resolve()) + .catch(() => resolve()); + return; + } + resolve(); + }; img.onerror = () => reject(new Error('qr image load failed')); img.src = url; }); @@ -41,10 +51,13 @@ export function useWechatQrLogin(options: WechatQrLoginOptions) { const polling = ref(false); /** 已扫码:遮罩二维码,防止重复扫 */ const scanned = ref(false); + /** 二维码已过期:遮罩并引导刷新,避免白扫描 */ + const expired = ref(false); const showOverlay = computed( () => !scanned.value && + !expired.value && (loadingQr.value || (Boolean(qrCodeUrl.value) && !qrImageReady.value)), ); @@ -66,24 +79,40 @@ export function useWechatQrLogin(options: WechatQrLoginOptions) { polling.value = false; } + function markExpired() { + if (disposed || scanned.value) return; + clearTimers(); + expired.value = true; + } + function onQrImageError() { qrImageReady.value = false; loadError.value = true; } - function onQrImageLoad() { + async function onQrImageLoad(event?: Event) { + const img = event?.target as HTMLImageElement | undefined; + if (img && typeof img.decode === 'function') { + try { + await img.decode(); + } catch { + // decode 失败仍展示,避免一直转圈 + } + } + if (disposed) return; qrImageReady.value = true; loadError.value = false; } async function pollWxLogin() { - if (!loginUuid.value || pollInFlight || disposed) return; + if (!loginUuid.value || pollInFlight || disposed || expired.value) return; pollInFlight = true; try { const status = await getWxLoginStatus(loginUuid.value); if (!status?.ready || disposed) return; scanned.value = true; + expired.value = false; clearTimers(); const result = await wxLogin(loginUuid.value); @@ -111,14 +140,16 @@ export function useWechatQrLogin(options: WechatQrLoginOptions) { } } - function startPolling() { + function startPolling(expireMs = QR_EXPIRE_MS) { clearTimers(); if (disposed) return; + expired.value = false; polling.value = true; pollTimer = setInterval(() => { void pollWxLogin(); }, POLL_INTERVAL_MS); - expireTimer = setTimeout(clearTimers, QR_EXPIRE_MS); + const ttl = Number.isFinite(expireMs) && expireMs > 0 ? expireMs : QR_EXPIRE_MS; + expireTimer = setTimeout(markExpired, ttl); void pollWxLogin(); } @@ -128,12 +159,11 @@ export function useWechatQrLogin(options: WechatQrLoginOptions) { clearTimers(); loadError.value = false; scanned.value = false; + expired.value = false; loadingQr.value = true; + qrImageReady.value = false; if (!opts?.keepPrevious) { qrCodeUrl.value = ''; - qrImageReady.value = false; - } else { - qrImageReady.value = Boolean(qrCodeUrl.value); } const uuid = createLoginUuid(); @@ -149,17 +179,31 @@ export function useWechatQrLogin(options: WechatQrLoginOptions) { } loginUuid.value = result.loginKey || uuid; + const proxiedUrl = resolveWechatShowQrDisplayUrl(result.qrCodeUrl); + let displayUrl = proxiedUrl; + // 先预热同源图;代理未就绪时回退微信直连,避免发版窗口期白屏 try { - await preloadImage(result.qrCodeUrl); + await preloadImage(proxiedUrl); } catch { - // img @error 兜底 + if (proxiedUrl !== result.qrCodeUrl) { + try { + await preloadImage(result.qrCodeUrl); + displayUrl = result.qrCodeUrl; + } catch { + // img @error 兜底 + } + } } if (seq !== loadSeq || disposed) return; - qrImageReady.value = true; - qrCodeUrl.value = result.qrCodeUrl; - startPolling(); + qrCodeUrl.value = displayUrl; + const expireSec = Number(result.expiresIn); + const expireMs = + Number.isFinite(expireSec) && expireSec > 0 + ? Math.max(expireSec - 5, 30) * 1000 // 提前 5 秒提示,避免临界白扫 + : QR_EXPIRE_MS; + startPolling(expireMs); } catch { if (seq !== loadSeq || disposed) return; loadError.value = true; @@ -202,6 +246,7 @@ export function useWechatQrLogin(options: WechatQrLoginOptions) { qrImageReady, polling, scanned, + expired, showOverlay, loadQrCode, refreshQr, diff --git a/src/config/settings.json b/src/config/settings.json index da709f0..9d445e7 100644 --- a/src/config/settings.json +++ b/src/config/settings.json @@ -7,7 +7,7 @@ "hideMenu": false, "menuCollapse": false, "footer": false, - "themeColor": "#132c44", + "themeColor": "#007979", "menuWidth": 220, "globalSettings": false, "device": "desktop", diff --git a/src/locale/en-US.ts b/src/locale/en-US.ts index 79e2ceb..987b3f0 100644 --- a/src/locale/en-US.ts +++ b/src/locale/en-US.ts @@ -967,7 +967,7 @@ export default { 'nanaAssistant.tabAdvisor': 'Exclusive Advisor', 'nanaAssistant.badge': 'Logistics Advisor', 'nanaAssistant.welcomeTitle': 'What do you need help shipping?', - 'nanaAssistant.welcomeSub': 'Quotes, routes, tracking, or a tough order — ask here. One dedicated advisor from quote to delivery.', + 'nanaAssistant.welcomeSub': 'Price, cargo eligibility, tracking — pick a capability below.', 'nanaAssistant.quickLabel': 'Common questions', 'nanaAssistant.welcomeMsg': "Hi — I'm your IDE logistics advisor. Pick a question below or type your own.", 'nanaAssistant.historyTitle': 'Chat history', @@ -976,14 +976,12 @@ export default { 'nanaAssistant.searchHistory': 'Search chats or waybill no.', 'nanaAssistant.noSearchResults': 'No matching conversations', 'nanaAssistant.deleteThread': 'Delete conversation', - 'nanaAssistant.hint': 'Common questions show reference answers. Type in the box to ask more.', - 'nanaAssistant.hintLive': - 'Smart advisor is live (price, channel guide, tracking). Type a question or pick one below.', + 'nanaAssistant.hint': 'Type a question, or pick a capability above.', + 'nanaAssistant.hintLive': 'Smart advisor is live — price, channel guide, tracking.', 'nanaAssistant.inputPlaceholder': 'Ask a question…', - 'nanaAssistant.inputPlaceholderLive': - 'e.g. US 0.5kg quote / can lipstick ship on XX / track waybill…', - 'nanaAssistant.inputPlaceholderTrack': 'Ask about tracking or add details…', - 'nanaAssistant.mcp.sectionLabel': 'What I can look up', + 'nanaAssistant.inputPlaceholderLive': '', + 'nanaAssistant.inputPlaceholderTrack': '', + 'nanaAssistant.mcp.sectionLabel': 'Capabilities', 'nanaAssistant.mcp.required': 'Required', 'nanaAssistant.mcp.optional': 'Optional', 'nanaAssistant.mcp.missingAsk': 'Please also provide: {fields}. Then I can look it up.', @@ -1576,7 +1574,8 @@ export default { 'order.priceCheck.form.destinationProvince': 'State / Province', 'order.priceCheck.form.destinationProvince.placeholder': 'Please select', 'order.priceCheck.form.postalCode': 'Postal code', - 'order.priceCheck.form.postalCode.placeholder': 'All', + 'order.priceCheck.form.postalCode.tip': 'more accurate', + 'order.priceCheck.form.postalCode.placeholder': 'Optional — improves zoning', 'order.priceCheck.form.moreFilters': 'More filters', 'order.priceCheck.actions.query': 'Get quotes', 'order.priceCheck.actions.reset': 'Reset', @@ -1584,21 +1583,24 @@ export default { 'order.priceCheck.validate.partialDimensions': 'Enter length, width and height together, or leave all blank', 'order.priceCheck.message.querySuccess': 'Loaded {count} quotes', 'order.priceCheck.message.queryFailed': 'Quote search failed', - 'order.priceCheck.sort.recommend': 'Recommended', - 'order.priceCheck.sort.price': 'Cheapest', - 'order.priceCheck.sort.time': 'Fastest', + 'order.priceCheck.sort.recommend': 'Best match', + 'order.priceCheck.sort.price': 'Price', + 'order.priceCheck.sort.time': 'Transit', 'order.priceCheck.resultCount': '{count} results', 'order.priceCheck.shortcuts.countryAria': 'Common destination countries', 'order.priceCheck.shortcut.US': 'United States', 'order.priceCheck.shortcut.CA': 'Canada', + 'order.priceCheck.shortcut.GB': 'United Kingdom', + 'order.priceCheck.shortcut.DE': 'Germany', + 'order.priceCheck.shortcut.AU': 'Australia', + 'order.priceCheck.shortcut.JP': 'Japan', 'order.priceCheck.shortcut.MX': 'Mexico', 'order.priceCheck.shortcut.BR': 'Brazil', 'order.priceCheck.shortcut.NZ': 'New Zealand', - 'order.priceCheck.shortcut.GB': 'United Kingdom', - 'order.priceCheck.shortcut.DE': 'Germany', 'order.priceCheck.shortcut.IT': 'Italy', 'order.priceCheck.shortcut.FR': 'France', + 'order.priceCheck.shortcut.ES': 'Spain', 'order.priceCheck.shortcut.NL': 'Netherlands', 'order.priceCheck.country.US': 'United States', @@ -1648,13 +1650,15 @@ export default { 'order.priceCheck.table.askAi': 'Ask AI', 'order.priceCheck.table.askAiCanShip': 'Can this ship?', 'order.priceCheck.table.askAiCheaper': 'Anything cheaper?', - 'order.priceCheck.table.empty': 'No quotes yet. Run a search first.', + 'order.priceCheck.table.empty': 'No matching quotes', 'order.priceCheck.table.emptyHint': 'Try adding postal code, dimensions, or cargo/warehouse filters; contact support if needed.', 'order.priceCheck.tag.priceOptimal': 'Best price', 'order.priceCheck.tag.timeOptimal': 'Fastest', 'order.priceCheck.tag.estimated': 'Reference', 'order.priceCheck.hint.summaryInline': 'Indicative only; final after inbound', + 'order.priceCheck.hint.postalCta': 'This is an estimate — add a postal code for better accuracy', + 'order.priceCheck.hint.fillPostal': 'Add postal code', 'order.priceCheck.hint.dismiss': 'Dismiss', 'order.priceCheck.hint.bannerTitle': 'Reference quote notice', 'order.priceCheck.hint.bannerLead': 'Some details were not provided. Prices below are for reference only and may change after order or inbound weighing.', @@ -1670,19 +1674,48 @@ export default { 'order.priceCheck.customerGuide.loadFailed': 'Failed to load channel guide', 'order.priceCheck.customerGuide.missingProduct': 'Cannot identify channel; guide unavailable', 'order.priceCheck.guide.close': 'Close guide', - 'order.priceCheck.guide.idle': 'Click any quote row on the left to view its route guide', + 'order.priceCheck.guide.idle': 'Select a quote card to view customer guide and limits', + 'order.priceCheck.empty.title': 'Start a quote', + 'order.priceCheck.empty.desc': 'Pick a country and weight to compare. Postal code improves accuracy.', + 'order.priceCheck.empty.step1': 'Choose destination', + 'order.priceCheck.empty.step2': 'Enter weight (postal recommended)', + 'order.priceCheck.empty.step3': 'Search, then AI-pick by cargo', + 'order.priceCheck.card.eta': 'Business days', + 'order.priceCheck.card.total': 'Total', + 'order.priceCheck.card.showFee': 'Fee details', + 'order.priceCheck.card.hideFee': 'Hide details', 'order.priceCheck.guide.failureTitle': 'No quotes found', - 'order.priceCheck.ai.title': 'AI Recommend', - 'order.priceCheck.ai.desc': 'Enter a product name to recommend the best route from guides', - 'order.priceCheck.ai.productName.placeholder': 'Product name', - 'order.priceCheck.ai.action': 'AI', + 'order.priceCheck.ai.title': 'AI Route Pick', + 'order.priceCheck.ai.desc': 'Enter cargo name — we check customer guides for can-ship and best route', + 'order.priceCheck.ai.descShort': 'Pick a route by cargo name', + 'order.priceCheck.ai.productName.placeholder': 'e.g. lipstick, apparel, power bank', + 'order.priceCheck.ai.action': 'Recommend', 'order.priceCheck.ai.validate.productName': 'Please enter a product name', 'order.priceCheck.ai.validate.needQuotes': 'Run a quote search first, then use AI recommend', 'order.priceCheck.ai.noMatch': 'No suitable channel found for "{name}"', - 'order.priceCheck.ai.successAi': 'AI recommendation ready from route guides', + 'order.priceCheck.ai.successAi': 'AI pick ready from customer guides', + 'order.priceCheck.ai.successMcp': 'Route pick ready from customer guides', 'order.priceCheck.ai.successLocal': 'Recommendation ready from guides and quotes', 'order.priceCheck.ai.failed': 'AI recommendation failed. Please try again.', - 'order.priceCheck.ai.reasonBadge': 'Why this route', + 'order.priceCheck.ai.reasonBadge': 'Verdict', + 'order.priceCheck.ai.cargoLabel': 'Cargo: {name}', + 'order.priceCheck.ai.verdict.ok': 'OK to ship', + 'order.priceCheck.ai.verdict.caution': 'Caution', + 'order.priceCheck.ai.verdict.forbid': 'Not advised', + 'order.priceCheck.ai.verdict.unknown': 'Unclear', + 'order.priceCheck.ai.verdictHint.ok': 'Guide supports this cargo. You can place an order; still check declaration rules.', + 'order.priceCheck.ai.verdictHint.caution': 'May be restricted. Review evidence or ask the advisor first.', + 'order.priceCheck.ai.verdictHint.forbid': 'Guide advises against this cargo. Try an alternative or contact support.', + 'order.priceCheck.ai.verdictHint.unknown': 'Guide does not clearly mention this cargo. Expand the full guide or ask the advisor.', + 'order.priceCheck.ai.alternatives': 'Also consider', + 'order.priceCheck.ai.altSelected': 'Switched: {blurb}', + 'order.priceCheck.ai.altSwitched': 'Switched to alternative route', + 'order.priceCheck.ai.cta.order': 'Place order', + 'order.priceCheck.ai.cta.ask': 'Ask advisor', + 'order.priceCheck.ai.guideExpand': 'Show full customer guide', + 'order.priceCheck.ai.guideCollapse': 'Hide customer guide', + 'order.priceCheck.ai.guideTeaser': 'Full route limits are ready — expand when needed.', + 'order.priceCheck.ai.loadingHint': 'Checking customer guides…', 'order.priceCheck.pagination.total': 'Total {count} records', 'order.priceCheck.pagination.prev': 'Prev', @@ -3777,6 +3810,7 @@ export default { 'channel.salesProducts.form.productName': 'Product name', 'channel.salesProducts.form.group': 'Group', 'channel.salesProducts.form.efficiency': 'Transit time', + 'channel.salesProducts.form.tradeTerms': 'Trade terms', 'channel.salesProducts.form.customerWaybill': 'System label', 'channel.salesProducts.form.placeholder.labelTemplate': 'Select label template (global default if empty)', 'channel.salesProducts.form.blacklistArea': 'Blacklist area', diff --git a/src/locale/zh-CN.ts b/src/locale/zh-CN.ts index bdd2e0e..be65f92 100644 --- a/src/locale/zh-CN.ts +++ b/src/locale/zh-CN.ts @@ -783,7 +783,7 @@ export default { 'nanaAssistant.tabAdvisor': '专属顾问', 'nanaAssistant.badge': '物流顾问', 'nanaAssistant.welcomeTitle': '这次发货,需要什么帮助?', - 'nanaAssistant.welcomeSub': '查价格、品名能否发、查轨迹 — 点选能力卡片,按提示补全信息即可。', + 'nanaAssistant.welcomeSub': '查价格、品名能否发、查轨迹 — 点选下方能力后补充信息即可。', 'nanaAssistant.quickLabel': '常见问题', 'nanaAssistant.welcomeMsg': '你好,我是 IDE 物流顾问。可以点选下方问题,或直接输入。', 'nanaAssistant.historyTitle': '历史记录', @@ -792,12 +792,12 @@ export default { 'nanaAssistant.searchHistory': '搜索会话或运单号', 'nanaAssistant.noSearchResults': '未找到匹配的会话', 'nanaAssistant.deleteThread': '删除会话', - 'nanaAssistant.hint': '以下为常见问题的参考回复,输入框可继续提问。', - 'nanaAssistant.hintLive': '已接入智能顾问(可查价格、对客说明、轨迹),直接提问或点选下方问题。', + 'nanaAssistant.hint': '直接提问,或点选上方能力。', + 'nanaAssistant.hintLive': '已接入智能顾问,可查价格、对客说明、轨迹。', 'nanaAssistant.inputPlaceholder': '输入您的问题…', - 'nanaAssistant.inputPlaceholderLive': '例如:美国 0.5kg 多少钱 / 品名 lipstick 能否走 XX 渠道 / 查运单号…', - 'nanaAssistant.inputPlaceholderTrack': '可直接问轨迹,或补充问题…', - 'nanaAssistant.mcp.sectionLabel': '我能帮你查', + 'nanaAssistant.inputPlaceholderLive': '', + 'nanaAssistant.inputPlaceholderTrack': '', + 'nanaAssistant.mcp.sectionLabel': '可查能力', 'nanaAssistant.mcp.required': '必填', 'nanaAssistant.mcp.optional': '选填', 'nanaAssistant.mcp.missingAsk': '还需要补充:{fields}。补全后我再帮你查。', @@ -1460,7 +1460,8 @@ export default { 'order.priceCheck.form.destinationProvince': '目的州省', 'order.priceCheck.form.destinationProvince.placeholder': '请选择', 'order.priceCheck.form.postalCode': '邮编', - 'order.priceCheck.form.postalCode.placeholder': '全部', + 'order.priceCheck.form.postalCode.tip': '更准', + 'order.priceCheck.form.postalCode.placeholder': '选填,补全更准确', 'order.priceCheck.form.moreFilters': '更多条件', 'order.priceCheck.actions.query': '查询报价', 'order.priceCheck.actions.reset': '重置', @@ -1468,21 +1469,24 @@ export default { 'order.priceCheck.validate.partialDimensions': '尺寸请填写完整的长、宽、高,或全部留空', 'order.priceCheck.message.querySuccess': '已获取 {count} 条报价', 'order.priceCheck.message.queryFailed': '查价失败', - 'order.priceCheck.sort.recommend': '推荐', - 'order.priceCheck.sort.price': '最便宜', - 'order.priceCheck.sort.time': '最快', + 'order.priceCheck.sort.recommend': '综合', + 'order.priceCheck.sort.price': '价格', + 'order.priceCheck.sort.time': '时效', 'order.priceCheck.resultCount': '共 {count} 条', 'order.priceCheck.shortcuts.countryAria': '常用目的国家', 'order.priceCheck.shortcut.US': '美国', 'order.priceCheck.shortcut.CA': '加拿大', + 'order.priceCheck.shortcut.GB': '英国', + 'order.priceCheck.shortcut.DE': '德国', + 'order.priceCheck.shortcut.AU': '澳洲', + 'order.priceCheck.shortcut.JP': '日本', 'order.priceCheck.shortcut.MX': '墨西哥', 'order.priceCheck.shortcut.BR': '巴西', 'order.priceCheck.shortcut.NZ': '新西兰', - 'order.priceCheck.shortcut.GB': '英国', - 'order.priceCheck.shortcut.DE': '德国', 'order.priceCheck.shortcut.IT': '意大利', 'order.priceCheck.shortcut.FR': '法国', + 'order.priceCheck.shortcut.ES': '西班牙', 'order.priceCheck.shortcut.NL': '荷兰', 'order.priceCheck.country.US': '美国', @@ -1532,13 +1536,15 @@ export default { 'order.priceCheck.table.askAi': '问 AI', 'order.priceCheck.table.askAiCanShip': '这个能不能发?', 'order.priceCheck.table.askAiCheaper': '有没有更便宜?', - 'order.priceCheck.table.empty': '暂无报价,请先查询', + 'order.priceCheck.table.empty': '暂无匹配报价', 'order.priceCheck.table.emptyHint': '可尝试填写邮编、尺寸或选择货物属性/仓库后重试;也可联系客服协助查价。', 'order.priceCheck.tag.priceOptimal': '价格最优', 'order.priceCheck.tag.timeOptimal': '时效最优', 'order.priceCheck.tag.estimated': '参考价', 'order.priceCheck.hint.summaryInline': '参考价,入库实测为准', + 'order.priceCheck.hint.postalCta': '当前为估算报价,补上邮编可提高准确性', + 'order.priceCheck.hint.fillPostal': '去填邮编', 'order.priceCheck.hint.dismiss': '关闭提示', 'order.priceCheck.hint.bannerTitle': '查价说明', 'order.priceCheck.hint.bannerLead': '您未填写部分条件,以下报价仅供参考,下单或入库实测后可能调整。', @@ -1548,25 +1554,55 @@ export default { 'order.priceCheck.hint.missing.cargoType': '未选择货物属性:已分别试算普货/带电/敏感货等,下单时以所选属性为准。', 'order.priceCheck.hint.missing.warehouse': '未选择仓库:展示各仓库报价,下单时请确认发货仓。', + 'order.priceCheck.empty.title': '开始查价', + 'order.priceCheck.empty.desc': '选择目的国与重量即可比价,邮编可让报价更准。', + 'order.priceCheck.empty.step1': '选择目的国家', + 'order.priceCheck.empty.step2': '填写重量(建议补邮编)', + 'order.priceCheck.empty.step3': '查询后按品名荐线', + 'order.priceCheck.card.eta': '工作日', + 'order.priceCheck.card.total': '费用', + 'order.priceCheck.card.showFee': '费用明细', + 'order.priceCheck.card.hideFee': '收起明细', + 'order.priceCheck.customerGuide.drawerTitle': '线路说明', 'order.priceCheck.customerGuide.drawerTitleWithProduct': '{product} · 线路说明', 'order.priceCheck.customerGuide.empty': '该渠道暂未配置说明', 'order.priceCheck.customerGuide.loadFailed': '加载渠道说明失败', 'order.priceCheck.customerGuide.missingProduct': '无法识别渠道,暂无法查看说明', 'order.priceCheck.guide.close': '关闭说明', - 'order.priceCheck.guide.idle': '点击左侧任意报价行查看线路说明', + 'order.priceCheck.guide.idle': '点选左侧报价卡,查看对客说明与限制', 'order.priceCheck.guide.failureTitle': '查价未出结果', - 'order.priceCheck.ai.title': 'AI 推荐', - 'order.priceCheck.ai.desc': '输入品名,结合线路说明推荐最优渠道', - 'order.priceCheck.ai.productName.placeholder': '请输入品名', - 'order.priceCheck.ai.action': 'AI推荐', + 'order.priceCheck.ai.title': 'AI 荐线', + 'order.priceCheck.ai.desc': '输入品名,对照对客说明判断能否寄、哪条更合适', + 'order.priceCheck.ai.descShort': '输入品名,对照对客说明荐线', + 'order.priceCheck.ai.productName.placeholder': '如:口红、衣服、充电宝', + 'order.priceCheck.ai.action': '荐线', 'order.priceCheck.ai.validate.productName': '请先输入品名', 'order.priceCheck.ai.validate.needQuotes': '请先查询报价,再进行 AI 推荐', 'order.priceCheck.ai.noMatch': '未找到适合「{name}」的渠道,请调整品名或筛选条件', - 'order.priceCheck.ai.successAi': '已根据线路说明完成 AI 推荐', - 'order.priceCheck.ai.successLocal': '已根据线路说明与报价完成推荐', + 'order.priceCheck.ai.successAi': '已对照对客说明完成 AI 荐线', + 'order.priceCheck.ai.successMcp': '已对照对客说明完成荐线', + 'order.priceCheck.ai.successLocal': '已根据对客说明与报价完成荐线', 'order.priceCheck.ai.failed': 'AI 推荐失败,请稍后重试', - 'order.priceCheck.ai.reasonBadge': '推荐理由', + 'order.priceCheck.ai.reasonBadge': '荐线结论', + 'order.priceCheck.ai.cargoLabel': '品名:{name}', + 'order.priceCheck.ai.verdict.ok': '可寄', + 'order.priceCheck.ai.verdict.caution': '慎发', + 'order.priceCheck.ai.verdict.forbid': '不建议', + 'order.priceCheck.ai.verdict.unknown': '待确认', + 'order.priceCheck.ai.verdictHint.ok': '对客说明支持该品名,可继续下单;下单前仍建议核对申报要求。', + 'order.priceCheck.ai.verdictHint.caution': '可能涉及敏感限制,建议先看证据摘录或再问顾问确认。', + 'order.priceCheck.ai.verdictHint.forbid': '对客说明提示不宜承运,请换备选线路或联系客服。', + 'order.priceCheck.ai.verdictHint.unknown': '说明未明确提到该品名,请展开完整说明或问顾问,勿自行假设可寄。', + 'order.priceCheck.ai.alternatives': '其他也可看看', + 'order.priceCheck.ai.altSelected': '已切换:{blurb}', + 'order.priceCheck.ai.altSwitched': '已切换到备选线路', + 'order.priceCheck.ai.cta.order': '就这条,去下单', + 'order.priceCheck.ai.cta.ask': '再问顾问', + 'order.priceCheck.ai.guideExpand': '展开完整对客说明', + 'order.priceCheck.ai.guideCollapse': '收起对客说明', + 'order.priceCheck.ai.guideTeaser': '完整线路限制与要求已备好,需要时展开查看。', + 'order.priceCheck.ai.loadingHint': '正在对照对客说明…', 'order.priceCheck.pagination.total': '共 {count} 条记录', 'order.priceCheck.pagination.prev': '上一页', @@ -3792,6 +3828,7 @@ export default { 'channel.salesProducts.form.productName': '产品名称', 'channel.salesProducts.form.group': '分组', 'channel.salesProducts.form.efficiency': '时效', + 'channel.salesProducts.form.tradeTerms': '贸易条款', 'channel.salesProducts.form.customerWaybill': '系统面单', 'channel.salesProducts.form.blacklistArea': '黑名单区', 'channel.salesProducts.form.remoteAreaDivision': '偏远分区', diff --git a/src/router/dynamicRouter.ts b/src/router/dynamicRouter.ts index 1a85f64..3fc6f12 100644 --- a/src/router/dynamicRouter.ts +++ b/src/router/dynamicRouter.ts @@ -4,6 +4,7 @@ import { useUserStore } from '@/store'; import { useAuthStore } from '@/store'; import router from '@/router'; import { useRbacUserStore } from '@/store/modules/rbac-user'; +import { resolvePostLogoutLogin } from '@/utils/login-portal'; const HOME_URL = '/home/workplace'; // 默认首页路径 @@ -45,8 +46,16 @@ export const initDynamicRouter = async (options?: { duration: 3000 }); } + const loginType = rbacUserStore.resolveLoginTypeForRedirect(); + const currentPath = router.currentRoute.value.fullPath; userStore.logout(); - router.replace({ name: useRbacUserStore().getLoginRouteName() }); + rbacUserStore.logout(); + 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 }); + } return Promise.reject('No permission'); }; @@ -307,8 +316,16 @@ export const initDynamicRouter = async (options?: { } catch (error) { // 当按钮 || 菜单请求出错时,重定向到登陆页 console.error('初始化动态路由失败:', error); + const loginType = rbacUserStore.resolveLoginTypeForRedirect(); + const currentPath = router.currentRoute.value.fullPath; userStore.logout(); - router.replace({ name: useRbacUserStore().getLoginRouteName() }); + rbacUserStore.logout(); + 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 }); + } return Promise.reject(error); } }; diff --git a/src/router/guard/permission.ts b/src/router/guard/permission.ts index b5186b3..5472252 100644 --- a/src/router/guard/permission.ts +++ b/src/router/guard/permission.ts @@ -19,6 +19,7 @@ import { getDefaultHomePathByType, canAccessCustomerPortalRoute, resolveCustomerEntryPath, + resolvePostLogoutLogin, } from '@/utils/login-portal'; import { OmsLoginType } from '@/api/rbac'; @@ -132,7 +133,15 @@ export default function setupPermissionGuard(router: Router) { ) { markDynamicRouterInitialized(true); } else { - next({ name: rbacUserStore.getLoginRouteName() }); + const target = resolvePostLogoutLogin({ + loginType: rbacUserStore.resolveLoginTypeForRedirect(), + path: to.fullPath, + }); + if (target.path) { + next({ path: target.path, query: target.query }); + } else { + next({ name: target.name, query: target.query }); + } return; } } diff --git a/src/router/guard/userLoginInfo.ts b/src/router/guard/userLoginInfo.ts index 2c8f253..6d588f2 100644 --- a/src/router/guard/userLoginInfo.ts +++ b/src/router/guard/userLoginInfo.ts @@ -11,7 +11,7 @@ import { getDefaultHomePathByType, isLoginRouteName, resolveCustomerEntryPath, - resolveLoginRouteNameFromPath, + resolvePostLogoutLogin, shouldRedirectCustomerToH5, } from '@/utils/login-portal'; import { preferH5Portal } from '@/utils/device'; @@ -20,13 +20,26 @@ import { applyRbacUserToUserStore } from '@/utils/enter-system'; function buildLoginRedirect(to: { name?: string | symbol | null; path: string; + fullPath?: string; query: LocationQueryRaw; }) { - const loginRouteName = resolveLoginRouteNameFromPath(to.path); + const rbacUserStore = useRbacUserStore(); + const target = resolvePostLogoutLogin({ + loginType: rbacUserStore.resolveLoginTypeForRedirect(), + path: to.fullPath || to.path, + }); + if (target.path) { + return { + path: target.path, + query: { + ...(target.query || {}), + } as LocationQueryRaw, + }; + } return { - name: loginRouteName, + name: target.name, query: { - redirect: to.name as string, + ...(target.query || {}), ...to.query, } as LocationQueryRaw, }; diff --git a/src/store/modules/rbac-user.ts b/src/store/modules/rbac-user.ts index 9ce6247..76fe68f 100644 --- a/src/store/modules/rbac-user.ts +++ b/src/store/modules/rbac-user.ts @@ -11,6 +11,7 @@ import { } from '@/api/rbac' import { handleApiError } from '@/utils/rbac-error-handler' import { getLoginRouteNameByType } from '@/utils/login-portal' +import { getLoginTypeFromAccessToken } from '@/utils/jwt' // Token 存储 key const TOKEN_KEY = 'rbac_access_token' @@ -115,10 +116,15 @@ export const useRbacUserStore = defineStore('rbacUser', () => { persistMenusToSession(info) } - function readStoredLoginType(): OmsLoginType { + function readStoredLoginTypeRaw(): OmsLoginType | null { const raw = getAuthStorage().getItem(LOGIN_TYPE_KEY) ?? localStorage.getItem(LOGIN_TYPE_KEY) if (raw === String(OmsLoginType.Customer)) return OmsLoginType.Customer - return OmsLoginType.User + if (raw === String(OmsLoginType.User)) return OmsLoginType.User + return null + } + + function readStoredLoginType(): OmsLoginType { + return readStoredLoginTypeRaw() ?? OmsLoginType.User } function setLoginPortalType(type: OmsLoginType, options?: { sessionOnly?: boolean }) { @@ -134,6 +140,28 @@ export const useRbacUserStore = defineStore('rbacUser', () => { localStorage.setItem(LOGIN_TYPE_KEY, String(type)) } + /** 从 JWT 校正门户类型(兼容旧会话无 rbac_login_type) */ + function syncLoginPortalTypeFromToken(token?: string | null) { + const fromJwt = getLoginTypeFromAccessToken(token || accessToken.value) + if (fromJwt == null) return + if (loginPortalType.value === fromJwt && readStoredLoginTypeRaw() === fromJwt) return + setLoginPortalType(fromJwt, { sessionOnly: persistInSession.value }) + } + + /** + * 登出/过期回跳用的主体类型。 + * 优先 JWT claim,其次显式存储;无则返回 null,交给路径启发式。 + */ + function resolveLoginTypeForRedirect(token?: string | null): OmsLoginType | null { + const fromJwt = getLoginTypeFromAccessToken(token || accessToken.value) + if (fromJwt != null) return fromJwt + const stored = readStoredLoginTypeRaw() + if (stored != null) return stored + // 仅当内存明确是客户时信任(避免默认 User 把客户甩到 /admin) + if (loginPortalType.value === OmsLoginType.Customer) return OmsLoginType.Customer + return null + } + function getLoginRouteName(): string { return getLoginRouteNameByType(loginPortalType.value) } @@ -395,16 +423,21 @@ export const useRbacUserStore = defineStore('rbacUser', () => { } } - // 登出 + // 登出:保留 loginPortalType / LOGIN_TYPE,确保过期回跳仍进对应门户登录页 function logout() { accessToken.value = '' refreshToken.value = '' tokenExpiresAt.value = 0 keepAliveUntil.value = 0 userInfo.value = null - persistInSession.value = false currentUserInflight = null + // SSO 会话结束前,把客户门户类型落到 localStorage,避免仅存在 session 时丢失 + if (persistInSession.value && loginPortalType.value === OmsLoginType.Customer) { + localStorage.setItem(LOGIN_TYPE_KEY, String(OmsLoginType.Customer)) + } + persistInSession.value = false + localStorage.removeItem(TOKEN_KEY) localStorage.removeItem(REFRESH_TOKEN_KEY) localStorage.removeItem(TOKEN_EXPIRES_KEY) @@ -420,7 +453,6 @@ export const useRbacUserStore = defineStore('rbacUser', () => { sessionStorage.removeItem(REFRESH_TOKEN_KEY) sessionStorage.removeItem(TOKEN_EXPIRES_KEY) sessionStorage.removeItem(USER_INFO_KEY) - sessionStorage.removeItem(LOGIN_TYPE_KEY) sessionStorage.removeItem(LAST_ACTIVITY_KEY) clearMenusSession() @@ -431,6 +463,7 @@ export const useRbacUserStore = defineStore('rbacUser', () => { function initAuth() { if (accessToken.value) { setAuthToken(accessToken.value) + syncLoginPortalTypeFromToken(accessToken.value) } restoreKeepAliveFromStorage() restoreMenusFromSession() @@ -469,6 +502,8 @@ export const useRbacUserStore = defineStore('rbacUser', () => { loginPortalType, getLoginRouteName, isCustomerPortal, + resolveLoginTypeForRedirect, + syncLoginPortalTypeFromToken, getAccessTokenRealExpiresAt, isKeepAliveActive, enableKeepAlive, diff --git a/src/utils/adminApi.ts b/src/utils/adminApi.ts index 0574af8..33d1691 100644 --- a/src/utils/adminApi.ts +++ b/src/utils/adminApi.ts @@ -37,14 +37,15 @@ export function resolveDirectAdminApiUrl(): string { return 'https://api.nanafa.com/admin' } -/** 静态资源(uploads 等)仍托管在 api 网关 */ +/** 静态资源(uploads 等)托管在 Admin 服务 wwwroot;对外须带 PATHBASE `/admin`。 + * 生产默认同源相对路径(由 nginx 反代 `/uploads` → `api.../admin/uploads`); + * 开发走 Vite `/uploads` 代理。可用 `VITE_API_ASSET_ORIGIN` 覆盖(勿漏 `/admin`)。 + */ export function resolveAdminAssetOrigin(): string { const override = import.meta.env.VITE_API_ASSET_ORIGIN as string | undefined if (override?.trim()) { return normalizeBase(override) } - if (import.meta.env.DEV) { - return 'http://api.nanafa.com' - } - return 'https://api.nanafa.com' + // 空串 → resolveOmsAssetUrl 保留 `/uploads/...` 相对路径 + return '' } diff --git a/src/utils/auth-keepalive.ts b/src/utils/auth-keepalive.ts index f56a778..4b9a971 100644 --- a/src/utils/auth-keepalive.ts +++ b/src/utils/auth-keepalive.ts @@ -1,6 +1,7 @@ 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 { watch } from 'vue' // 用户最后一次交互起,1小时后进入“过期/跳登录” @@ -40,6 +41,8 @@ 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() @@ -47,11 +50,20 @@ async function doLogout() { } Message.error('登录已过期,请重新登录') + const target = resolvePostLogoutLogin({ + loginType, + path: currentPath, + }) + if (target.path) { + router.replace({ + path: target.path, + query: target.query, + }) + return + } router.replace({ - name: userStore.getLoginRouteName(), - query: { - redirect: router.currentRoute.value.fullPath, - }, + name: target.name, + query: target.query, }) } diff --git a/src/utils/invitePoster.ts b/src/utils/invitePoster.ts index d289c1f..5c2e16d 100644 --- a/src/utils/invitePoster.ts +++ b/src/utils/invitePoster.ts @@ -74,8 +74,8 @@ export async function buildInvitePosterDataUrl( loadImage(posterBgUrl), QRCode.toDataURL(inviteUrl, { width: QR_SIZE, - margin: 1, - errorCorrectionLevel: 'M', + margin: 3, + errorCorrectionLevel: 'H', color: { dark: '#0a3373', light: '#ffffff' }, }), ]); diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts index 0965286..430eeb3 100644 --- a/src/utils/jwt.ts +++ b/src/utils/jwt.ts @@ -1,3 +1,5 @@ +import { OmsLoginType } from '@/api/rbac' + /** 解析 JWT payload(不做签名校验,仅读取客户端声明) */ export function parseJwtPayload(token: string): Record | null { const raw = token.startsWith('Bearer ') ? token.slice(7) : token @@ -28,3 +30,16 @@ export function getMapIdFromAccessToken(token?: string | null): number | null { const n = Number(raw) return Number.isFinite(n) && n > 0 ? n : null } + +/** 从 access token 读取登录主体(claim: loginType,0=管理端 1=客户) */ +export function getLoginTypeFromAccessToken(token?: string | null): OmsLoginType | null { + if (!token) return null + const payload = parseJwtPayload(token) + if (!payload) return null + const raw = payload.loginType ?? payload.LoginType + if (raw == null || raw === '') return null + const n = Number(raw) + if (n === OmsLoginType.Customer) return OmsLoginType.Customer + if (n === OmsLoginType.User) return OmsLoginType.User + return null +} diff --git a/src/utils/login-portal.ts b/src/utils/login-portal.ts index 75cc0d5..57a64ae 100644 --- a/src/utils/login-portal.ts +++ b/src/utils/login-portal.ts @@ -142,21 +142,101 @@ export function canAccessCustomerPortalRoute( return true } +/** 仅客户会访问的路径(管理端几乎不会落在这些页) */ +function isCustomerExclusivePath(path: string): boolean { + const p = path.trim() || '/' + return ( + isCustomerPortalOnlyPath(p) || + p.startsWith('/h5') || + p.startsWith('/wechat/') || + p.startsWith('/track/') || + p.startsWith('/finance/') || + p.startsWith('/assistant') || + p.startsWith('/marketing/portal') || + p.startsWith('/order/myOrders') || + p.startsWith('/order/placeOrder') || + p.startsWith('/order/priceCheck') || + p.startsWith('/order/guide') + ) +} + +/** 客户门户常见路径(含与管理端共享的 /home,用于无类型时的启发式) */ +function isLikelyCustomerPortalPath(path: string): boolean { + const p = path.trim() || '/' + if (isAdminPortalOnlyPath(p)) return false + return isCustomerExclusivePath(p) || p === '/' || p.startsWith('/home/') +} + /** 未登录时按目标路径选择应跳转的登录页 */ export function resolveLoginRouteNameFromPath(path: string): string { - if ( - path.startsWith('/h5') || - path.startsWith('/wechat/') || - path.startsWith('/track/') || - path.startsWith('/user/profile') || - path.startsWith('/user/recharge') || - path.startsWith('/user/marketing') || - path.startsWith('/user/sub-account') || - path.startsWith('/finance/rechargeRecord') || - path.startsWith('/finance/accountFlow') || - path.startsWith('/finance/receivablePayable') - ) { + const p = (path || '').trim() || '/' + if (p === ADMIN_LOGIN_PATH || p.startsWith(`${ADMIN_LOGIN_PATH}/`)) { + return ADMIN_LOGIN_ROUTE_NAME + } + if (isAdminPortalOnlyPath(p)) { + return ADMIN_LOGIN_ROUTE_NAME + } + if (isLikelyCustomerPortalPath(p)) { return CUSTOMER_LOGIN_ROUTE_NAME } + // 系统/渠道/仓储等内部模块及其他共享管理路径 → 管理端 return ADMIN_LOGIN_ROUTE_NAME } + +/** + * 登出/过期后应回到的登录入口。 + * 优先:显式类型 → 客户专属路径纠偏 → 路径启发式,避免客户被甩到 /admin。 + */ +export function resolvePostLogoutLogin(options?: { + loginType?: OmsLoginType | null + path?: string | null +}): { name: string; path?: string; query?: Record } { + const path = String(options?.path || '').trim() || '/' + const type = options?.loginType + + let routeName: string + if (type === OmsLoginType.Customer) { + routeName = CUSTOMER_LOGIN_ROUTE_NAME + } else if (type === OmsLoginType.User) { + // 存储默认可能是 User;仅在客户专属页上纠偏(/home 管理/客户共用,不在此纠偏) + routeName = isCustomerExclusivePath(path) + ? CUSTOMER_LOGIN_ROUTE_NAME + : ADMIN_LOGIN_ROUTE_NAME + } else { + routeName = resolveLoginRouteNameFromPath(path) + } + + // 手机/微信客户:回 H5「我的」登录引导,不进 PC 登录大页 + if (routeName === CUSTOMER_LOGIN_ROUTE_NAME && preferH5Portal()) { + return { + name: CUSTOMER_LOGIN_ROUTE_NAME, + path: '/h5/me', + query: path.startsWith('/h5') + ? { redirect: path } + : { redirect: resolveCustomerEntryPath(path) }, + } + } + + return { + name: routeName, + query: path && path !== '/' ? { redirect: path } : undefined, + } +} + +/** 统一执行登出后的登录页跳转(兼容 H5 path / PC name) */ +export function navigateToPostLogoutLogin( + router: { replace: (loc: Record) => unknown }, + options?: { loginType?: OmsLoginType | null; path?: string | null } +) { + const target = resolvePostLogoutLogin(options) + if (target.path) { + return router.replace({ + path: target.path, + query: target.query, + }) + } + return router.replace({ + name: target.name, + query: target.query, + }) +} diff --git a/src/utils/rbac-error-handler.ts b/src/utils/rbac-error-handler.ts index a68d100..55530b9 100644 --- a/src/utils/rbac-error-handler.ts +++ b/src/utils/rbac-error-handler.ts @@ -2,6 +2,7 @@ import { Message } from '@arco-design/web-vue' import { ApiError } from '@/api/rbac/generated/core/ApiError' import router from '@/router' import { useRbacUserStore } from '@/store/modules/rbac-user' +import { resolvePostLogoutLogin } from '@/utils/login-portal' /** * 全局错误处理器 @@ -24,22 +25,28 @@ export function handleApiError(error: any): void { break case 401: Message.error('未授权,请重新登录') - localStorage.removeItem('token') - localStorage.removeItem('userInfo') - // 清除所有RBAC相关存储 - 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 rbacUserStore = useRbacUserStore() - router.replace({ - name: rbacUserStore.getLoginRouteName(), - query: { - redirect: router.currentRoute.value.fullPath, - }, - }) + { + const rbacUserStore = useRbacUserStore() + const loginType = rbacUserStore.resolveLoginTypeForRedirect() + const currentPath = router.currentRoute.value.fullPath + localStorage.removeItem('token') + localStorage.removeItem('userInfo') + // 清除所有RBAC相关存储 + 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 }) + } + } break case 403: Message.error(errorMessage || '没有权限访问') diff --git a/src/utils/request.ts b/src/utils/request.ts index 98973a5..4cc7a3f 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -10,6 +10,7 @@ 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'; const service: AxiosInstance = axios.create({ baseURL: '/', @@ -96,17 +97,17 @@ service.interceptors.response.use( if (codeNum === 401 || code === '401') { const errorMessage = res.message || res.msg || '登录已过期,请重新登录'; Message.error(errorMessage); - // 清除 token 和用户信息 + const rbacUserStore = useRbacUserStore(); + const loginType = rbacUserStore.resolveLoginTypeForRedirect(); + const currentPath = router.currentRoute.value.fullPath; clearToken(); userStore.resetInfo(); - // 跳转到登录页 - const currentPath = router.currentRoute.value.fullPath; - router.replace({ - name: useRbacUserStore().getLoginRouteName(), - query: { - redirect: currentPath, - }, - }); + 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 }); + } return Promise.reject(new Error(errorMessage)); } @@ -162,40 +163,39 @@ service.interceptors.response.use( content: errorMessage, duration: 3000, }); - // 清除所有token和用户信息 + const rbacUserStore = useRbacUserStore(); + const loginType = rbacUserStore.resolveLoginTypeForRedirect(); + const currentPath = router.currentRoute.value.fullPath; clearToken(); userStore.resetInfo(); - // 清除RBAC相关存储(包括新旧格式) 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 currentPath = router.currentRoute.value.fullPath; - router.replace({ - name: useRbacUserStore().getLoginRouteName(), - query: { - redirect: currentPath, - }, - }); + 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({ content: errorMessage || '登录已过期,请重新登录', duration: 3000, }); - // 清除token和用户信息 + const rbacUserStore = useRbacUserStore(); + const loginType = rbacUserStore.resolveLoginTypeForRedirect(); + const currentPath = router.currentRoute.value.fullPath; clearToken(); userStore.resetInfo(); - // 跳转到登录页 - const currentPath = router.currentRoute.value.fullPath; - router.replace({ - name: useRbacUserStore().getLoginRouteName(), - query: { - redirect: currentPath, - }, - }); + 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 }); + } } break; case 403: diff --git a/src/utils/themeColor.ts b/src/utils/themeColor.ts index bca637a..0dbadfb 100644 --- a/src/utils/themeColor.ts +++ b/src/utils/themeColor.ts @@ -4,10 +4,19 @@ */ const STORAGE_KEY = 'app-theme-color'; +/** 旧默认色:切到新主色时自动迁移,避免 localStorage 卡住旧主题 */ +const LEGACY_THEME_COLORS = new Set(['#132c44', '#132C44', '#2d8c8c', '#2D8C8C']); +export const DEFAULT_THEME_COLOR = '#007979'; export function getStoredThemeColor(): string | null { try { - return localStorage.getItem(STORAGE_KEY); + const stored = localStorage.getItem(STORAGE_KEY); + if (!stored) return null; + if (LEGACY_THEME_COLORS.has(stored)) { + localStorage.setItem(STORAGE_KEY, DEFAULT_THEME_COLOR); + return DEFAULT_THEME_COLOR; + } + return stored; } catch { return null; } @@ -56,11 +65,12 @@ function lightenHex(hex: string, mixWhite = 0.92): string { /** * 将主题色应用到页面:设置 :root 上的 CSS 变量 - * @param primaryHex 主色,如 #2D8C8C + * @param primaryHex 主色,如 #007979 */ export function applyThemeColor(primaryHex: string): void { const hex = primaryHex.startsWith('#') ? primaryHex : `#${primaryHex}`; const root = document.documentElement; + const rgb = hexToRgb(hex); const primary = hex; const primaryHover = darkenHex(hex, 0.05); @@ -71,4 +81,16 @@ export function applyThemeColor(primaryHex: string): void { root.style.setProperty('--app-primary-color-light', primaryLight); root.style.setProperty('--app-navbar-bg', primary); root.style.setProperty('--app-sidebar-bg', primary); + root.style.setProperty('--color-primary-light-1', primaryLight); + + // 同步 Arco Design primary 色阶,保证按钮/链接等组件跟主题一致 + if (rgb) { + const rgbStr = `${rgb.r}, ${rgb.g}, ${rgb.b}`; + const hover = hexToRgb(primaryHover); + const light = hexToRgb(lightenHex(hex, 0.15)); + root.style.setProperty('--primary-6', rgbStr); + root.style.setProperty('--primary-7', hover ? `${hover.r}, ${hover.g}, ${hover.b}` : rgbStr); + root.style.setProperty('--primary-5', light ? `${light.r}, ${light.g}, ${light.b}` : rgbStr); + root.style.setProperty('--arcoblue-6', rgbStr); + } } diff --git a/src/utils/wechatShowQrUrl.ts b/src/utils/wechatShowQrUrl.ts new file mode 100644 index 0000000..37fed4f --- /dev/null +++ b/src/utils/wechatShowQrUrl.ts @@ -0,0 +1,25 @@ +import { resolveAdminApiPath } from '@/utils/adminApi'; + +/** + * 将微信 mp.weixin.qq.com/showqrcode 转为同源代理地址。 + * 微信内长按跨域图常需多次才识别成功;同源 img 可明显提高一次识别率。 + */ +export function resolveWechatShowQrDisplayUrl(url: string): string { + const raw = String(url || '').trim(); + if (!raw) return ''; + + try { + const parsed = new URL(raw, typeof window !== 'undefined' ? window.location.origin : 'https://local'); + const isWechatShow = + parsed.hostname.includes('mp.weixin.qq.com') && + parsed.pathname.includes('showqrcode'); + if (!isWechatShow) return raw; + + const ticket = parsed.searchParams.get('ticket'); + if (!ticket) return raw; + + return resolveAdminApiPath(`/wechat/mp/showqrcode?ticket=${encodeURIComponent(ticket)}`); + } catch { + return raw; + } +} diff --git a/src/views/channel/salesProducts/index.vue b/src/views/channel/salesProducts/index.vue index adac2cb..95c0b73 100644 --- a/src/views/channel/salesProducts/index.vue +++ b/src/views/channel/salesProducts/index.vue @@ -113,6 +113,7 @@ group: '', minTimeliness: '', maxTimeliness: '', + tradeTerms: '' as '' | 'DDP' | 'DDU', customerWaybill: '系统面单', labelTemplateId: undefined as number | undefined, blacklistArea: '', @@ -135,6 +136,7 @@ group: string; minTimeliness: string; maxTimeliness: string; + tradeTerms: string; customerWaybill: string; labelTemplateId: number | undefined; blacklistArea: string; @@ -155,6 +157,7 @@ group: formData.group, minTimeliness: formData.minTimeliness, maxTimeliness: formData.maxTimeliness, + tradeTerms: formData.tradeTerms, customerWaybill: formData.customerWaybill, labelTemplateId: formData.labelTemplateId, blacklistArea: formData.blacklistArea, @@ -737,6 +740,7 @@ group: '', minTimeliness: '', maxTimeliness: '', + tradeTerms: '' as '' | 'DDP' | 'DDU', customerWaybill: '系统面单', labelTemplateId: undefined as number | undefined, blacklistArea: '', @@ -757,6 +761,7 @@ group: defaultProductGroupValue(), minTimeliness: '', maxTimeliness: '', + tradeTerms: '' as '' | 'DDP' | 'DDU', customerWaybill: '系统面单', labelTemplateId: undefined, blacklistArea: '', @@ -787,6 +792,7 @@ productGroup: addProductFormData.group || defaultProductGroupValue(), minTimeliness: Number(addProductFormData.minTimeliness) || 0, maxTimeliness: Number(addProductFormData.maxTimeliness) || 0, + tradeTerms: addProductFormData.tradeTerms || null, customerWaybill: addProductFormData.customerWaybill || '系统面单', labelTemplateId: addProductFormData.labelTemplateId || undefined, productDescription: addProductFormData.productDescription || '', @@ -1099,6 +1105,12 @@ group: resolveProductGroupValue(p.productGroup ?? p.ProductGroup), minTimeliness: numStr(p.minTimeliness ?? p.MinTimeliness), maxTimeliness: numStr(p.maxTimeliness ?? p.MaxTimeliness), + tradeTerms: (() => { + const raw = String(p.tradeTerms ?? p.TradeTerms ?? '') + .trim() + .toUpperCase(); + return raw === 'DDP' || raw === 'DDU' ? raw : ''; + })(), customerWaybill: p.customerWaybill ?? p.CustomerWaybill ?? '系统面单', labelTemplateId: (() => { const raw = p.labelTemplateId ?? p.LabelTemplateId; @@ -1291,6 +1303,7 @@ productGroup: formData.group, minTimeliness: Number(formData.minTimeliness) || 0, maxTimeliness: Number(formData.maxTimeliness) || 0, + tradeTerms: formData.tradeTerms || null, customerWaybill: formData.customerWaybill, labelTemplateId: formData.labelTemplateId || undefined, blacklistAreaId: blAreaId ?? null, @@ -3478,6 +3491,18 @@ /> +
+ + + DDP + DDU + +
+
+ + + DDP + DDU + +
- + {{ t('h5.tab.createOrder') }} @@ -49,13 +49,8 @@ import { computed } from 'vue'; import { useRoute } from 'vue-router'; import { useI18n } from 'vue-i18n'; -import { - IconMessage, - IconCommon, - IconList, - IconUser, - IconPlus, -} from '@arco-design/web-vue/es/icon'; +import { IconMessage, IconCommon, IconList, IconUser } from '@arco-design/web-vue/es/icon'; +import iconOrderCreate from '@/assets/icons/h5/order-create.svg?url'; defineOptions({ name: 'H5AppTabBar' }); @@ -175,26 +170,24 @@ function isActive(path: string) { display: inline-flex; align-items: center; justify-content: center; - width: 48px; - height: 48px; + width: 52px; + height: 52px; margin-top: -22px; border-radius: 50%; - color: #fff; - background: linear-gradient( - 145deg, - color-mix(in srgb, var(--app-primary-color) 88%, #fff), - var(--app-primary-color) - ); + background: #fff; box-shadow: - 0 6px 16px color-mix(in srgb, var(--app-primary-color) 36%, transparent), + 0 6px 16px rgba(20, 38, 55, 0.14), 0 0 0 4px #fff; transform: translateY(-10px); transition: transform 0.15s ease; } -.h5-tabbar__create-icon { - font-size: 26px; - font-weight: 600; +.h5-tabbar__create-img { + width: 34px; + height: 34px; + object-fit: contain; + pointer-events: none; + -webkit-user-drag: none; } .h5-tabbar__icon-wrap { diff --git a/src/views/h5/customer-center/components/H5OrderTypeSheet.vue b/src/views/h5/customer-center/components/H5OrderTypeSheet.vue index ea59cd5..8717241 100644 --- a/src/views/h5/customer-center/components/H5OrderTypeSheet.vue +++ b/src/views/h5/customer-center/components/H5OrderTypeSheet.vue @@ -23,7 +23,7 @@