feat: 查价价卡展示贸易条款与产品分组,并优化 H5/登录与顾问体验

销售产品可配置 DDP/DDU;查价返回分组与贸易条款并展示在价卡上。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-30 11:48:36 +08:00
parent 30a69b69c7
commit c6a4484448
63 changed files with 4488 additions and 1994 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -103,14 +103,19 @@ export async function omsPost<T = any>(path: string, body?: Record<string, any>)
return unwrapData<T>(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
}

View File

@ -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<typeof useRbacUserStore>) {
router.replace({
name: userStore.getLoginRouteName(),
query: {
redirect: router.currentRoute.value.fullPath,
},
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)
}
// 请求拦截器
@ -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('没有权限访问')

View File

@ -0,0 +1,20 @@
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="28" cy="46.5" rx="14" ry="3.2" fill="#007979" opacity="0.22"/>
<!-- clipboard / order pad -->
<path d="M18 16c0-1.7 1.3-3 3-3h14c1.7 0 3 1.3 3 3v24c0 1.7-1.3 3-3 3H21c-1.7 0-3-1.3-3-3V16z" fill="url(#oc-pad)"/>
<path d="M18 16c0-1.7 1.3-3 3-3h14c1.7 0 3 1.3 3 3v2.5H18V16z" fill="#00A8A8"/>
<rect x="23" y="11.5" width="10" height="5" rx="2" fill="#142637"/>
<rect x="24.5" y="12.5" width="7" height="3" rx="1.2" fill="#3A5366"/>
<rect x="23" y="23" width="14" height="2.2" rx="1.1" fill="#fff" opacity="0.9"/>
<rect x="23" y="28" width="11" height="2.2" rx="1.1" fill="#fff" opacity="0.7"/>
<rect x="23" y="33" width="8" height="2.2" rx="1.1" fill="#fff" opacity="0.55"/>
<!-- plus badge -->
<circle cx="40" cy="36" r="9" fill="#FF4D00"/>
<path d="M40 31.6v8.8M35.6 36h8.8" stroke="#fff" stroke-width="2.2" stroke-linecap="round"/>
<defs>
<linearGradient id="oc-pad" x1="18" y1="13" x2="38" y2="43" gradientUnits="userSpaceOnUse">
<stop stop-color="#00A3A3"/>
<stop offset="1" stop-color="#007979"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,24 @@
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="28" cy="46.5" rx="15" ry="3.2" fill="#007979" opacity="0.22"/>
<!-- parcel left -->
<path d="M10 24.5L20 19l10 5.5v12L20 42 10 36.5v-12z" fill="#005F5F"/>
<path d="M10 24.5L20 30l10-5.5L20 19 10 24.5z" fill="#00A3A3"/>
<path d="M20 30v12l10-5.5v-12L20 30z" fill="#007979"/>
<rect x="18.6" y="23" width="2.8" height="12" rx="1" fill="#fff" opacity="0.5"/>
<!-- truck body -->
<path d="M24 26h16.5c1.1 0 2 .9 2 2v10.5c0 .8-.7 1.5-1.5 1.5H24V26z" fill="url(#ol-truck)"/>
<path d="M40.5 28.5H46c.8 0 1.5.5 1.8 1.2l2 5.2c.1.3.2.6.2.9V38c0 .8-.7 1.5-1.5 1.5h-8V28.5z" fill="#142637"/>
<path d="M41.2 29.8h4.2l1.6 4.2H41.2v-4.2z" fill="#3A5366"/>
<circle cx="29" cy="39.5" r="3.8" fill="#142637"/>
<circle cx="29" cy="39.5" r="1.7" fill="#C9CDD4"/>
<circle cx="43.5" cy="39.5" r="3.8" fill="#142637"/>
<circle cx="43.5" cy="39.5" r="1.7" fill="#C9CDD4"/>
<!-- accent light -->
<circle cx="48.2" cy="34.2" r="1.4" fill="#FF4D00"/>
<defs>
<linearGradient id="ol-truck" x1="24" y1="26" x2="42" y2="40" gradientUnits="userSpaceOnUse">
<stop stop-color="#00B3B3"/>
<stop offset="1" stop-color="#007979"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,24 @@
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="28" cy="46.5" rx="14" ry="3.2" fill="#142637" opacity="0.16"/>
<!-- calendar base -->
<path d="M14 20c0-1.7 1.3-3 3-3h22c1.7 0 3 1.3 3 3v20c0 1.7-1.3 3-3 3H17c-1.7 0-3-1.3-3-3V20z" fill="url(#op-cal)"/>
<path d="M14 20c0-1.7 1.3-3 3-3h22c1.7 0 3 1.3 3 3v5H14v-5z" fill="#00A8A8"/>
<rect x="20" y="14.5" width="2.8" height="7" rx="1.4" fill="#142637"/>
<rect x="33.2" y="14.5" width="2.8" height="7" rx="1.4" fill="#142637"/>
<!-- date cells -->
<rect x="19" y="28" width="5" height="5" rx="1.2" fill="#fff" opacity="0.85"/>
<rect x="26.5" y="28" width="5" height="5" rx="1.2" fill="#fff" opacity="0.55"/>
<rect x="34" y="28" width="5" height="5" rx="1.2" fill="#FF4D00"/>
<rect x="19" y="35.5" width="5" height="5" rx="1.2" fill="#fff" opacity="0.45"/>
<rect x="26.5" y="35.5" width="5" height="5" rx="1.2" fill="#fff" opacity="0.7"/>
<!-- pickup pin badge -->
<circle cx="41.5" cy="36.5" r="8.5" fill="#142637"/>
<path d="M41.5 31.2c-2.4 0-4.3 1.9-4.3 4.2 0 2.8 4.3 7.2 4.3 7.2s4.3-4.4 4.3-7.2c0-2.3-1.9-4.2-4.3-4.2z" fill="#007979"/>
<circle cx="41.5" cy="35.3" r="1.7" fill="#fff"/>
<defs>
<linearGradient id="op-cal" x1="14" y1="17" x2="42" y2="43" gradientUnits="userSpaceOnUse">
<stop stop-color="#00A3A3"/>
<stop offset="1" stop-color="#007979"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,10 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="200" height="200">
<path
d="M972.868 76.203a33.587 33.587 0 0 0-25.07-25.071c-183.348-42.12-383.352 8.004-525.859 150.51a560.947 560.947 0 0 0-55.415 64.632c-4.762 6.485-11.571 10.974-19.37 12.903-74.446 18.432-146.16 57.326-205.705 116.872a450.338 450.338 0 0 0-69.07 88.815c-16.28 27.494 11.623 59.921 41.592 48.845 47.292-17.476 98.39-26.061 151.057-26.095-0.734 4.488-1.792 8.926-2.423 13.414a2271.232 2271.232 0 0 0 115.592 124.775 2265.07 2265.07 0 0 0 124.775 115.592c4.488-0.631 8.926-1.69 13.397-2.423-0.034 52.667-8.619 103.765-26.095 151.057-11.076 29.969 21.35 57.873 48.845 41.591a450.44 450.44 0 0 0 88.832-69.069c59.528-59.528 98.423-131.26 116.855-205.687 1.929-7.8 6.435-14.61 12.903-19.37a560.947 560.947 0 0 0 64.631-55.416C964.864 459.538 1014.99 259.55 972.868 76.203z m-229.683 365.84A114.005 114.005 0 1 1 581.94 280.799a114.005 114.005 0 0 1 161.246 161.246z"
fill="#007979"
/>
<path
d="M183.484 689.493C148.924 724.053 2.406 938.19 44.117 979.9c41.711 41.71 255.847-104.824 290.407-139.367 34.56-34.56 28.757-96.375-12.954-138.069-41.71-41.728-103.543-47.53-138.086-12.97z"
fill="#007979"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,21 @@
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="28" cy="46.5" rx="14" ry="3.2" fill="#007979" opacity="0.22"/>
<!-- back coin -->
<ellipse cx="33" cy="28" rx="12" ry="12" fill="#005F5F"/>
<ellipse cx="33" cy="26.5" rx="12" ry="12" fill="#3A5366"/>
<ellipse cx="33" cy="26.5" rx="8" ry="8" fill="#142637" opacity="0.25"/>
<!-- front coin -->
<ellipse cx="23" cy="31" rx="13" ry="13" fill="#005F5F"/>
<ellipse cx="23" cy="29" rx="13" ry="13" fill="url(#scm-coin)"/>
<ellipse cx="23" cy="29" rx="8.5" ry="8.5" stroke="#fff" stroke-width="1.6" opacity="0.55"/>
<path d="M20.4 32.8V25.6h2.2c1.7 0 2.8.9 2.8 2.4 0 1.1-.6 1.9-1.5 2.2l1.9 2.6h-2.1l-1.6-2.4H22v2.4h-1.6zm2.1-3.8h.5c.7 0 1.15-.4 1.15-1s-.45-1-1.15-1h-.5v2z" fill="#fff"/>
<!-- spark accent -->
<circle cx="40" cy="18" r="5.5" fill="#FF4D00"/>
<path d="M40 15.2v5.6M37.2 18h5.6" stroke="#fff" stroke-width="1.6" stroke-linecap="round"/>
<defs>
<linearGradient id="scm-coin" x1="10" y1="16" x2="36" y2="42" gradientUnits="userSpaceOnUse">
<stop stop-color="#00B3B3"/>
<stop offset="1" stop-color="#007979"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,20 @@
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="28" cy="46.5" rx="14" ry="3.2" fill="#FF4D00" opacity="0.22"/>
<!-- ticket back -->
<path d="M12 20c0-1.7 1.3-3 3-3h26c1.7 0 3 1.3 3 3v4.2c-1.5.3-2.6 1.6-2.6 3.2S42.5 30.3 44 30.6V36c0 1.7-1.3 3-3 3H15c-1.7 0-3-1.3-3-3v-5.4c1.5-.3 2.6-1.6 2.6-3.2S13.5 24.3 12 24V20z" fill="url(#sc-ticket)"/>
<!-- left notch shade -->
<path d="M12 24c1.5.3 2.6 1.6 2.6 3.2S13.5 30.3 12 30.6" stroke="#fff" stroke-width="1.4" stroke-linecap="round" opacity="0.35"/>
<!-- dash line -->
<path d="M22 19.5v17" stroke="#fff" stroke-width="1.6" stroke-dasharray="2.4 2.8" stroke-linecap="round" opacity="0.55"/>
<!-- amount chip -->
<rect x="26" y="24" width="14" height="8" rx="2.5" fill="#fff" opacity="0.92"/>
<path d="M28.2 29.4V26.6h1.1c.9 0 1.5.5 1.5 1.4s-.6 1.4-1.5 1.4h-1.1zm1.1-3.4H27v4.6h1.2v-1.3h.1c1.6 0 2.6-.8 2.6-2.15 0-1.3-.9-2.15-2.6-2.15zm5.6 4.6l-2.2-4.6h1.35l1.45 3.2 1.45-3.2h1.3l-2.2 4.6h-1.15z" fill="#FF4D00"/>
<!-- fold highlight -->
<path d="M15 17h26c1.7 0 3 1.3 3 3v1.2H12V20c0-1.7 1.3-3 3-3z" fill="#FF7A3D"/>
<defs>
<linearGradient id="sc-ticket" x1="12" y1="17" x2="44" y2="39" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF6A2B"/>
<stop offset="1" stop-color="#FF4D00"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,20 @@
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="28" cy="46.5" rx="14" ry="3.2" fill="#142637" opacity="0.18"/>
<!-- back person -->
<circle cx="34.5" cy="18.5" r="6.2" fill="#3A5366"/>
<path d="M24.5 34.5c0-5.5 4.5-8.5 10-8.5s10 3 10 8.5V37H24.5v-2.5z" fill="#3A5366"/>
<!-- front person -->
<circle cx="22" cy="20" r="7" fill="#007979"/>
<path d="M10.5 36c0-6.2 5.2-9.5 11.5-9.5S33.5 29.8 33.5 36V38.5H10.5V36z" fill="url(#si-body)"/>
<!-- plus badge -->
<circle cx="40.5" cy="34.5" r="8.5" fill="#FF4D00"/>
<path d="M40.5 30.2v8.6M36.2 34.5h8.6" stroke="#fff" stroke-width="2.2" stroke-linecap="round"/>
<!-- highlight on front head -->
<ellipse cx="20.2" cy="17.8" rx="2.2" ry="1.6" fill="#fff" opacity="0.28"/>
<defs>
<linearGradient id="si-body" x1="10.5" y1="26.5" x2="33.5" y2="38.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#00A0A0"/>
<stop offset="1" stop-color="#007979"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,23 @@
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="28" cy="46" rx="14" ry="3.2" fill="#007979" opacity="0.22"/>
<!-- box body -->
<path d="M14 22.5L28 15l14 7.5v15L28 45 14 37.5v-15z" fill="url(#so-body)"/>
<!-- top face -->
<path d="M14 22.5L28 30l14-7.5L28 15 14 22.5z" fill="#00A3A3"/>
<!-- right face shade -->
<path d="M28 30v15l14-7.5v-15L28 30z" fill="#005F5F"/>
<!-- left face -->
<path d="M14 22.5L28 30v15L14 37.5v-15z" fill="#007979"/>
<!-- tape -->
<path d="M25.2 16.6l2.8 1.5 2.8-1.5v3.2L28 21.4l-2.8-1.6v-3.2z" fill="#142637" opacity="0.35"/>
<rect x="26.6" y="21" width="2.8" height="16" rx="1" fill="#fff" opacity="0.55"/>
<!-- send badge -->
<circle cx="40" cy="36" r="9" fill="#FF4D00"/>
<path d="M36.2 36.8l3.2-5.2 3.4 5.2-3.4-1.4-3.2 1.4z" fill="#fff"/>
<defs>
<linearGradient id="so-body" x1="14" y1="15" x2="42" y2="45" gradientUnits="userSpaceOnUse">
<stop stop-color="#009999"/>
<stop offset="1" stop-color="#007979"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

View File

@ -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"
/>
<div
v-else-if="failed"
@ -70,6 +91,17 @@ watch(
.invite-qr-image {
display: block;
object-fit: contain;
visibility: hidden;
border-radius: 0;
image-rendering: -webkit-optimize-contrast;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
-webkit-user-drag: none;
&.is-ready {
visibility: visible;
}
}
.invite-qr-fallback {

View File

@ -140,35 +140,6 @@
>
<span class="ai-mcp-card__title">{{ t(cap.titleKey) }}</span>
<span class="ai-mcp-card__desc">{{ t(cap.descKey) }}</span>
<ul class="ai-mcp-card__fields">
<li
v-for="f in cap.fields"
:key="f.key"
:class="f.required ? 'is-required' : 'is-optional'"
>
{{ f.required ? t('nanaAssistant.mcp.required') : t('nanaAssistant.mcp.optional') }}
{{ t(f.labelKey) }}
</li>
</ul>
<span class="ai-mcp-card__example">{{ t(cap.exampleKey) }}</span>
</button>
</div>
<p class="ai-quick-section-label">{{ t('nanaAssistant.quickLabel') }}</p>
<div class="ai-quick-grid">
<button
v-for="tag in quickTags"
:key="tag.id"
type="button"
class="ai-quick-card"
:disabled="chatLoading"
@click="sendQuickTag(tag)"
>
<span class="ai-quick-card__icon" :class="{ 'ai-quick-card__icon--logo': tag.iconSrc }">
<img v-if="tag.iconSrc" :src="tag.iconSrc" alt="" class="ai-quick-card__logo" />
<component :is="tag.icon" v-else />
</span>
<span class="ai-quick-card__text">{{ tag.label }}</span>
</button>
</div>
</section>
@ -194,14 +165,14 @@
<div v-show="isChatting" class="ai-followup is-visible" role="toolbar">
<button
v-for="tag in followupTags"
:key="tag.id"
v-for="cap in mcpCapabilities"
:key="cap.id"
type="button"
class="ai-followup-chip"
:disabled="chatLoading"
@click="sendQuickTag(tag)"
@click="startMcpCapability(cap.id)"
>
{{ tag.label }}
{{ t(cap.titleKey) }}
</button>
</div>
@ -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();

View File

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

View File

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

View File

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

View File

@ -77,7 +77,7 @@
},
]);
const DEFAULT_THEME_COLOR = '#132c44';
const DEFAULT_THEME_COLOR = '#007979';
const cancel = () => {
appStore.updateSettings({ globalSettings: false });

View File

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

View File

@ -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<void> {
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,

View File

@ -7,7 +7,7 @@
"hideMenu": false,
"menuCollapse": false,
"footer": false,
"themeColor": "#132c44",
"themeColor": "#007979",
"menuWidth": 220,
"globalSettings": false,
"device": "desktop",

View File

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

View File

@ -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': '偏远分区',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,5 @@
import { OmsLoginType } from '@/api/rbac'
/** 解析 JWT payload(不做签名校验,仅读取客户端声明) */
export function parseJwtPayload(token: string): Record<string, unknown> | 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
}

View File

@ -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<string, string> } {
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<string, unknown>) => 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,
})
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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 @@
/>
</div>
</div>
<div class="form-item">
<label class="form-label">{{ t('channel.salesProducts.form.tradeTerms') }}</label>
<a-select
v-model="formData.tradeTerms"
allow-clear
:placeholder="t('channel.salesProducts.form.placeholder.select')"
class="form-select"
>
<a-option value="DDP">DDP</a-option>
<a-option value="DDU">DDU</a-option>
</a-select>
</div>
<div class="form-item">
<label class="form-label">{{ t('channel.salesProducts.form.customerWaybill') }}</label>
<a-select
@ -4112,6 +4137,18 @@
/>
</div>
</div>
<div class="form-item">
<label class="form-label">{{ t('channel.salesProducts.form.tradeTerms') }}</label>
<a-select
v-model="addProductFormData.tradeTerms"
allow-clear
:placeholder="t('channel.salesProducts.form.placeholder.select')"
class="form-select"
>
<a-option value="DDP">DDP</a-option>
<a-option value="DDU">DDU</a-option>
</a-select>
</div>
<div class="form-item">
<label class="form-label">{{ t('channel.salesProducts.form.customerWaybill') }}</label>
<a-select

View File

@ -230,7 +230,7 @@ function clearPriceContext() {
color: var(--app-primary-color);
}
/* 欢迎区 + 预置问题(恢复壳内快捷入口) */
/* 欢迎区 + MCP 能力 */
:deep(.ai-welcome__hero) {
padding: 14px 0 12px;
}
@ -243,15 +243,14 @@ function clearPriceContext() {
font-size: 0.84rem;
}
:deep(.ai-quick-grid) {
grid-template-columns: 1fr 1fr;
:deep(.ai-mcp-grid) {
grid-template-columns: 1fr;
gap: 8px;
}
:deep(.ai-quick-card) {
:deep(.ai-mcp-card) {
border-radius: 14px;
padding: 12px 10px;
min-height: 64px;
padding: 12px 14px;
}
:deep(.ai-followup) {

View File

@ -21,7 +21,7 @@
@click="emit('create-order')"
>
<span class="h5-tabbar__create-btn">
<IconPlus class="h5-tabbar__create-icon" />
<img :src="iconOrderCreate" alt="" class="h5-tabbar__create-img" draggable="false" />
</span>
<span class="h5-tabbar__label">{{ t('h5.tab.createOrder') }}</span>
</button>
@ -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 {

View File

@ -23,7 +23,7 @@
<button type="button" class="h5-ot__card is-logistics" @click="emit('select', 'logistics')">
<span class="h5-ot__icon" aria-hidden="true">
<icon-send />
<img :src="iconOrderLogistics" alt="" width="40" height="40" draggable="false" />
</span>
<span class="h5-ot__text">
<strong>{{ t('h5.orderType.logistics') }}</strong>
@ -34,7 +34,7 @@
<button type="button" class="h5-ot__card is-pickup" @click="emit('select', 'pickup')">
<span class="h5-ot__icon" aria-hidden="true">
<icon-calendar-clock />
<img :src="iconOrderPickup" alt="" width="40" height="40" draggable="false" />
</span>
<span class="h5-ot__text">
<strong>{{ t('h5.orderType.pickup') }}</strong>
@ -48,7 +48,9 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n';
import { IconCalendarClock, IconClose, IconRight, IconSend } from '@arco-design/web-vue/es/icon';
import { IconClose, IconRight } from '@arco-design/web-vue/es/icon';
import iconOrderLogistics from '@/assets/icons/h5/order-logistics.svg?url';
import iconOrderPickup from '@/assets/icons/h5/order-pickup.svg?url';
defineOptions({ name: 'H5OrderTypeSheet' });
@ -146,25 +148,30 @@ function close() {
}
&.is-logistics .h5-ot__icon {
background: color-mix(in srgb, var(--app-primary-color) 14%, #fff);
color: var(--app-primary-color);
background: color-mix(in srgb, #007979 12%, #fff);
}
&.is-pickup .h5-ot__icon {
background: color-mix(in srgb, #ff7d00 14%, #fff);
color: #ff7d00;
background: color-mix(in srgb, #142637 8%, #fff);
}
}
.h5-ot__icon {
width: 44px;
height: 44px;
border-radius: 12px;
width: 48px;
height: 48px;
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 22px;
flex-shrink: 0;
img {
width: 40px;
height: 40px;
object-fit: contain;
pointer-events: none;
-webkit-user-drag: none;
}
}
.h5-ot__text {

View File

@ -6,22 +6,27 @@
'is-loading': showOverlay,
'is-error': loadError && !qrCodeUrl,
'is-scanned': scanned,
'is-expired': expired,
}"
>
<!--
必须用原生 <img>:微信对 canvas 长按常无「识别图中二维码」菜单。
公众号带参码是 showqrcode 图片,不能用 qrcode 库重编码 URL。
统一禁 touch-callout / contextmenu,避免 iOS/微信弹出共享、存图;
微信客户端仍可接管「识别图中二维码」。
-->
<img
v-if="qrCodeUrl"
class="h5-wx-qr__img"
:class="{ 'is-ready': qrImageReady, 'is-masked': scanned }"
:class="{
'is-ready': qrImageReady,
'is-masked': scanned || expired,
}"
:src="qrCodeUrl"
alt="wechat-login-qrcode"
:width="compact ? 256 : 200"
:height="compact ? 256 : 200"
:width="compact ? 280 : 220"
:height="compact ? 280 : 220"
referrerpolicy="no-referrer"
decoding="async"
decoding="sync"
draggable="false"
@contextmenu.prevent
@load="onQrImageLoad"
@ -31,6 +36,13 @@
<icon-check-circle-fill :size="32" class="h5-wx-qr__ok" />
<span>{{ t('h5.wxQr.scanned') }}</span>
</div>
<div v-else-if="expired" class="h5-wx-qr__overlay is-expired">
<span class="h5-wx-qr__expired-title">{{ t('h5.wxQr.expired') }}</span>
<span class="h5-wx-qr__expired-desc">{{ t('h5.wxQr.expiredTip') }}</span>
<button type="button" class="h5-wx-qr__retry" :disabled="loadingQr" @click="refreshQr">
{{ t('h5.wxQr.refresh') }}
</button>
</div>
<div v-else-if="showOverlay" class="h5-wx-qr__overlay">
<a-spin :size="28" />
<span>{{ t('h5.wxQr.loading') }}</span>
@ -44,21 +56,28 @@
</div>
<template v-if="!scanned">
<p class="h5-wx-qr__tip h5-wx-qr__tip--primary">{{ t('h5.wxQr.longPress') }}</p>
<p v-if="!expired" class="h5-wx-qr__tip h5-wx-qr__tip--primary">{{ t('h5.wxQr.longPress') }}</p>
<template v-if="!compact">
<p class="h5-wx-qr__tip">{{ t('h5.wxQr.valid') }}</p>
<p v-if="!expired" class="h5-wx-qr__tip">{{ t('h5.wxQr.valid') }}</p>
<a-button long class="h5-wx-qr__refresh" :loading="loadingQr" @click="refreshQr">
{{ t('h5.wxQr.refresh') }}
{{ expired ? t('h5.wxQr.refreshExpired') : t('h5.wxQr.refresh') }}
</a-button>
</template>
<button
v-else
type="button"
class="h5-wx-qr__refresh-link"
:class="{ 'is-emphasize': expired }"
:disabled="loadingQr"
@click="refreshQr"
>
{{ loadingQr ? t('h5.wxQr.loading') : t('h5.wxQr.refresh') }}
{{
loadingQr
? t('h5.wxQr.loading')
: expired
? t('h5.wxQr.refreshExpired')
: t('h5.wxQr.refresh')
}}
</button>
</template>
</div>
@ -100,6 +119,7 @@ const {
loadError,
qrImageReady,
scanned,
expired,
showOverlay,
refreshQr,
pauseQr,
@ -139,9 +159,9 @@ defineExpose({ refreshQr, pauseQr });
.h5-wx-qr__box {
position: relative;
width: 220px;
height: 220px;
padding: 10px;
width: 244px;
height: 244px;
padding: 12px;
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 12px;
@ -149,36 +169,38 @@ defineExpose({ refreshQr, pauseQr });
align-items: center;
justify-content: center;
box-sizing: border-box;
/* 禁 iOS 系统「保存图片/共享」;微信内「识别图中二维码」由客户端接管,仍可用 */
/* 禁 iOS/微信网页 callout(共享、存图);识别由微信客户端接管 */
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
.h5-wx-qr.is-compact .h5-wx-qr__box {
width: 280px;
height: 280px;
max-width: calc(100vw - 72px);
max-height: calc(100vw - 72px);
width: 304px;
height: 304px;
max-width: calc(100vw - 56px);
max-height: calc(100vw - 56px);
padding: 12px;
border-radius: 18px;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.06);
}
.h5-wx-qr.is-compact .h5-wx-qr__img {
width: 256px;
height: 256px;
width: 280px;
height: 280px;
max-width: 100%;
max-height: 100%;
}
.h5-wx-qr__img {
display: block;
width: 200px;
height: 200px;
width: 220px;
height: 220px;
object-fit: contain;
opacity: 0;
transition: opacity 0.35s cubic-bezier(0.22, 1, 0.36, 1), filter 0.2s ease;
/* 未就绪时不透明渐变:半透图片长按识别极不稳定 */
visibility: hidden;
border-radius: 0;
image-rendering: -webkit-optimize-contrast;
/* 勿加 pointer-events:none,否则无法长按识别 */
-webkit-touch-callout: none;
-webkit-user-select: none;
@ -186,7 +208,7 @@ defineExpose({ refreshQr, pauseQr });
-webkit-user-drag: none;
&.is-ready {
opacity: 1;
visibility: visible;
}
&.is-masked {
@ -198,7 +220,7 @@ defineExpose({ refreshQr, pauseQr });
.h5-wx-qr__overlay {
position: absolute;
inset: 10px;
inset: 12px;
z-index: 1;
display: flex;
flex-direction: column;
@ -206,20 +228,37 @@ defineExpose({ refreshQr, pauseQr });
justify-content: center;
gap: 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.94);
background: rgba(255, 255, 255, 0.96);
font-size: 12px;
color: #86909c;
/* 仅 loading/error 出现;就绪后必须拿掉,否则挡住长按 */
/* 遮罩挡住长按,就绪后必须移除 */
pointer-events: auto;
transition: opacity 0.2s ease;
&.is-error {
pointer-events: auto;
}
&.is-scanned {
pointer-events: none;
}
&.is-expired {
gap: 6px;
background: rgba(255, 255, 255, 0.92);
color: #1d2129;
pointer-events: auto;
}
}
.h5-wx-qr__expired-title {
font-size: 14px;
font-weight: 700;
color: #ff7d00;
line-height: 1.3;
}
.h5-wx-qr__expired-desc {
font-size: 12px;
color: #86909c;
line-height: 1.4;
text-align: center;
padding: 0 8px;
}
.h5-wx-qr__ok {
@ -256,12 +295,23 @@ defineExpose({ refreshQr, pauseQr });
font-weight: 600;
font-size: 13px;
}
&--warn {
margin-top: 12px;
color: #ff7d00;
font-weight: 600;
font-size: 13px;
}
}
.h5-wx-qr.is-compact .h5-wx-qr__tip--primary {
.h5-wx-qr.is-compact .h5-wx-qr__tip--primary,
.h5-wx-qr.is-compact .h5-wx-qr__tip--warn {
margin-top: 14px;
font-size: 15px;
font-weight: 500;
}
.h5-wx-qr.is-compact .h5-wx-qr__tip--primary {
color: #4e5969;
}
@ -269,6 +319,11 @@ defineExpose({ refreshQr, pauseQr });
margin-top: 8px;
font-size: 14px;
padding: 6px 10px;
&.is-emphasize {
color: var(--app-primary-color);
font-weight: 600;
}
}
.h5-wx-qr.is-compact :deep(.arco-spin-icon) {
@ -276,7 +331,7 @@ defineExpose({ refreshQr, pauseQr });
}
.h5-wx-qr__refresh {
width: 220px;
width: 244px;
margin-top: 12px;
}
@ -301,8 +356,8 @@ defineExpose({ refreshQr, pauseQr });
}
@media (prefers-reduced-motion: reduce) {
.h5-wx-qr__img {
transition: none;
.h5-wx-qr__img.is-masked {
filter: none;
}
}
</style>

View File

@ -41,10 +41,23 @@ export default {
'h5.price.query': 'Check rates',
'h5.price.querying': 'Checking…',
'h5.price.reset': 'Reset',
'h5.price.sort.recommend': 'Recommended',
'h5.price.sort.price': 'Lowest',
'h5.price.sort.time': 'Fastest',
'h5.price.sort.recommend': 'Best match',
'h5.price.sort.price': 'Price',
'h5.price.sort.time': 'Speed',
'h5.price.recommendTag': 'Best',
'h5.price.tag.priceOptimal': 'Best price',
'h5.price.tag.timeOptimal': 'Fastest',
'h5.price.dimensionsToggle': 'Size (optional)',
'h5.price.dimensionsHint': 'L×W×H for volumetric weight',
'h5.price.accuracy.postalCta': 'Estimate only — add a postal code for better accuracy',
'h5.price.accuracy.fillPostal': 'Add postal code',
'h5.price.accuracy.dismiss': 'Dismiss',
'h5.price.ai.title': 'AI route pick',
'h5.price.ai.placeholder': 'e.g. lipstick, apparel, power bank',
'h5.price.ai.action': 'Recommend',
'h5.price.ai.needQuotes': 'Run a quote search first, then use AI recommend',
'h5.price.ai.needName': 'Please enter a product name',
'h5.price.hint.estimated': 'Indicative only; final after inbound',
'h5.price.askAdvisor': 'Ask AI',
'h5.price.askAiCanShip': 'Can this ship?',
'h5.price.askAiCheaper': 'Anything cheaper?',
@ -53,13 +66,16 @@ export default {
'h5.price.empty': 'Enter country and weight to get quotes',
'h5.price.noResult': 'No quotes found. Adjust filters or ask the advisor.',
'h5.price.delivery': 'Transit',
'h5.price.workingDays': 'business days',
'h5.price.chargeableWeight': 'Chargeable wt',
'h5.price.required': 'Please enter destination and weight',
'h5.price.referenceTip': 'Estimates only — final charges may differ',
'h5.price.referenceBanner': 'Rates are estimates. Tap a card for route details.',
'h5.price.referencePrice': 'Est.',
'h5.price.productGroup': 'Product group',
'h5.price.cargo': 'Cargo type',
'h5.price.warehouse': 'Warehouse',
'h5.price.tradeTerms': 'Trade terms',
'h5.price.viewDetail': 'Route details',
'h5.price.detailTitle': 'Route details',
'h5.price.detailTitleNamed': '{name} · Details',
@ -70,14 +86,15 @@ export default {
'h5.price.freight': 'Freight',
'h5.price.otherFees': 'Other fees',
'h5.price.shortcut.US': 'US',
'h5.price.shortcut.GB': 'UK',
'h5.price.shortcut.DE': 'DE',
'h5.price.shortcut.CA': 'CA',
'h5.price.shortcut.FR': 'FR',
'h5.price.shortcut.ES': 'ES',
'h5.price.shortcut.MX': 'MX',
'h5.price.shortcut.BR': 'BR',
'h5.price.shortcut.NZ': 'NZ',
'h5.price.shortcut.GB': 'UK',
'h5.price.shortcut.DE': 'DE',
'h5.price.shortcut.IT': 'IT',
'h5.price.shortcut.FR': 'FR',
'h5.price.shortcut.NL': 'NL',
'h5.orders.title': 'My Orders',
@ -134,8 +151,11 @@ export default {
'h5.wxQr.loading': 'Loading QR code…',
'h5.wxQr.loadFailed': 'Failed to load QR code. Please refresh.',
'h5.wxQr.refresh': 'Refresh QR code',
'h5.wxQr.refreshExpired': 'Tap to refresh QR code',
'h5.wxQr.longPress': 'Long-press → “Recognize QR Code in Image”',
'h5.wxQr.valid': 'QR code is valid for 10 minutes',
'h5.wxQr.expired': 'QR code expired',
'h5.wxQr.expiredTip': 'Refresh before scanning to avoid a failed login',
'h5.wxQr.scanned': 'Scanned. Signing you in…',
'h5.wxQr.success': 'Signed in',
@ -486,6 +506,9 @@ export default {
'h5.pay.later': 'Later',
'h5.me.balance': 'Balance',
'h5.me.recharge': 'Recharge',
'h5.me.points': 'Points',
'h5.me.pointsToNext': '{points} to {name}',
'h5.me.pointsTopLevel': 'Top level reached',
'h5.me.lowBalance': 'Low balance — recharge soon',
'h5.me.greet.morning': 'Good morning',
'h5.me.greet.noon': 'Good noon',

View File

@ -41,10 +41,23 @@ export default {
'h5.price.query': '一键查价',
'h5.price.querying': '查价中…',
'h5.price.reset': '重置',
'h5.price.sort.recommend': '推荐',
'h5.price.sort.price': '低价',
'h5.price.sort.time': '快时效',
'h5.price.sort.recommend': '综合',
'h5.price.sort.price': '价格',
'h5.price.sort.time': '时效',
'h5.price.recommendTag': '推荐',
'h5.price.tag.priceOptimal': '价格最优',
'h5.price.tag.timeOptimal': '时效最优',
'h5.price.dimensionsToggle': '尺寸(选填)',
'h5.price.dimensionsHint': '填齐长宽高可算体积重',
'h5.price.accuracy.postalCta': '当前为估算报价,补上邮编可提高准确性',
'h5.price.accuracy.fillPostal': '去填邮编',
'h5.price.accuracy.dismiss': '关闭',
'h5.price.ai.title': 'AI 荐线',
'h5.price.ai.placeholder': '品名,如口红、衣服、充电宝',
'h5.price.ai.action': '荐线',
'h5.price.ai.needQuotes': '请先查价,再使用 AI 荐线',
'h5.price.ai.needName': '请输入品名',
'h5.price.hint.estimated': '参考价,入库实测为准',
'h5.price.askAdvisor': '问顾问',
'h5.price.askAiCanShip': '能不能发?',
'h5.price.askAiCheaper': '有没有更便宜?',
@ -53,13 +66,16 @@ export default {
'h5.price.empty': '输入国家与重量后查询报价',
'h5.price.noResult': '暂无报价,可调整条件或问问顾问',
'h5.price.delivery': '时效',
'h5.price.workingDays': '工作日',
'h5.price.chargeableWeight': '计费重',
'h5.price.required': '请填写目的国家与重量',
'h5.price.referenceTip': '以上为参考报价,实际以出账为准',
'h5.price.referenceBanner': '报价仅供参考,点击价卡可查看线路详情',
'h5.price.referencePrice': '参考价',
'h5.price.productGroup': '产品分组',
'h5.price.cargo': '货物属性',
'h5.price.warehouse': '仓库',
'h5.price.tradeTerms': '贸易条款',
'h5.price.viewDetail': '线路详情',
'h5.price.detailTitle': '线路详情',
'h5.price.detailTitleNamed': '{name} · 线路详情',
@ -70,14 +86,15 @@ export default {
'h5.price.freight': '运费',
'h5.price.otherFees': '其他费用',
'h5.price.shortcut.US': '美国',
'h5.price.shortcut.GB': '英国',
'h5.price.shortcut.DE': '德国',
'h5.price.shortcut.CA': '加拿大',
'h5.price.shortcut.FR': '法国',
'h5.price.shortcut.ES': '西班牙',
'h5.price.shortcut.MX': '墨西哥',
'h5.price.shortcut.BR': '巴西',
'h5.price.shortcut.NZ': '新西兰',
'h5.price.shortcut.GB': '英国',
'h5.price.shortcut.DE': '德国',
'h5.price.shortcut.IT': '意大利',
'h5.price.shortcut.FR': '法国',
'h5.price.shortcut.NL': '荷兰',
'h5.orders.title': '查订单',
@ -133,8 +150,11 @@ export default {
'h5.wxQr.loading': '二维码加载中…',
'h5.wxQr.loadFailed': '二维码加载失败,请刷新重试',
'h5.wxQr.refresh': '刷新二维码',
'h5.wxQr.refreshExpired': '点击刷新二维码',
'h5.wxQr.longPress': '请长按二维码,选择「识别图中二维码」',
'h5.wxQr.valid': '二维码十分钟内有效',
'h5.wxQr.expired': '二维码已过期',
'h5.wxQr.expiredTip': '请先刷新后再识别,避免扫码无效',
'h5.wxQr.scanned': '扫码成功,正在登录…',
'h5.wxQr.success': '登录成功',
@ -484,6 +504,9 @@ export default {
'h5.pay.later': '稍后再说',
'h5.me.balance': '账户余额',
'h5.me.recharge': '去充值',
'h5.me.points': '积分',
'h5.me.pointsToNext': '距{name}还差 {points}',
'h5.me.pointsTopLevel': '已达最高等级',
'h5.me.lowBalance': '余额偏低,建议尽快充值',
'h5.me.greet.morning': '早上好',
'h5.me.greet.noon': '中午好',

File diff suppressed because it is too large Load Diff

View File

@ -76,62 +76,61 @@
v-for="(row, index) in orders"
:key="row.id || row.waybillNo"
class="h5-order-card h5-enter"
:class="[`h5-order-card--${cardTone(row)}`, { 'is-pay': isPendingPay(row) }]"
:class="{ 'is-pay': isPendingPay(row) }"
:style="{ animationDelay: `${Math.min(index, 6) * 40}ms` }"
@click="goDetail(row)"
>
<span class="h5-order-card__blob" aria-hidden="true" />
<span class="h5-order-card__shine" aria-hidden="true" />
<!-- 顶栏:状态 + 单号(右上) -->
<div class="h5-order-top">
<span class="h5-order-status" :style="statusStyle(row)">{{ statusLabel(row) }}</span>
<button
type="button"
class="h5-order-no"
@click.stop="copyText(row.waybillNo || row.customerOrderNo)"
>
<span>{{ row.waybillNo || row.customerOrderNo || row.trackingNo || '-' }}</span>
<icon-copy />
</button>
</div>
<div class="h5-order-card__main">
<div class="h5-order-card__body">
<!-- 客户第一眼:单号 + 状态 -->
<div class="h5-order-row1">
<button
type="button"
class="h5-order-no"
@click.stop="copyText(row.waybillNo || row.customerOrderNo)"
>
<span>{{ row.waybillNo || row.customerOrderNo || row.trackingNo || '-' }}</span>
<icon-copy />
</button>
<span class="h5-order-status" :style="statusStyle(row)">{{ statusLabel(row) }}</span>
</div>
<!-- 第二眼:线路 · 目的地 -->
<p v-if="channelLabel(row) || destinationLabel(row)" class="h5-order-route">
<span v-if="channelLabel(row)">{{ channelLabel(row) }}</span>
<template v-if="channelLabel(row) && destinationLabel(row)">
<i>·</i>
</template>
<span v-if="destinationLabel(row)" class="h5-order-route__dest">
{{ destinationLabel(row) }}
</span>
</p>
<!-- 辅助信息:普货 / 仓库,弱化 -->
<div
v-if="row.cargoAttributeLabel || row.warehouse"
class="h5-order-meta"
>
<span v-if="row.cargoAttributeLabel" class="h5-meta-tag">
{{ row.cargoAttributeLabel }}
</span>
<span v-if="row.warehouse" class="h5-meta-tag">
{{ row.warehouse }}
</span>
</div>
<!-- 主视觉:发货仓 → 目的国(顺丰/菜鸟式线路) -->
<div class="h5-order-route">
<div class="h5-order-route__end">
<strong>{{ warehouseLabel(row) }}</strong>
<em>{{ t('h5.price.origin') }}</em>
</div>
<div class="h5-order-route__mid" aria-hidden="true">
<span class="h5-order-route__line" />
<img :src="iconRoute" alt="" class="h5-order-route__icon" draggable="false" />
<span class="h5-order-route__line" />
</div>
<div class="h5-order-route__end h5-order-route__end--dest">
<strong>{{ countryLabel(row) || countryCode(row) || '-' }}</strong>
<em>
{{
countryLabel(row) && countryCode(row)
? countryCode(row)
: t('h5.orders.detail.country')
}}
</em>
</div>
</div>
<div class="h5-order-side" @click.stop>
<span v-if="isPendingPay(row) || row.isPaid" class="h5-order-amount">
<template v-if="isPendingPay(row)">
<small>{{ t('h5.orders.payable') }}</small>
<em>¥{{ formatMoney(row.receivableAmount) }}</em>
</template>
<template v-else>
<span class="is-paid">{{ t('h5.orders.paid') }}</span>
</template>
<!-- 底栏:渠道 + 金额/主操作 -->
<div class="h5-order-foot">
<p v-if="channelLabel(row) || row.cargoAttributeLabel" class="h5-order-sub">
<span v-if="channelLabel(row)">{{ channelLabel(row) }}</span>
<template v-if="channelLabel(row) && row.cargoAttributeLabel"> · </template>
<span v-if="row.cargoAttributeLabel">{{ row.cargoAttributeLabel }}</span>
</p>
<span v-else class="h5-order-sub h5-order-sub--spacer" />
<div class="h5-order-foot__action" @click.stop>
<span v-if="isPendingPay(row)" class="h5-order-amount">
<small>{{ t('h5.orders.payable') }}</small>
<em>¥{{ formatMoney(row.receivableAmount) }}</em>
</span>
<span v-else-if="row.isPaid" class="h5-order-amount is-paid">
{{ t('h5.orders.paid') }}
</span>
<button
v-if="isPendingPay(row)"
@ -157,7 +156,7 @@
>
{{ t('h5.orders.askAdvisor') }}
</button>
<button v-else type="button" class="primary-btn" @click="goDetail(row)">
<button v-else type="button" class="text-btn" @click="goDetail(row)">
{{ t('h5.orders.detail') }}
</button>
</div>
@ -198,6 +197,7 @@ import { useI18n } from 'vue-i18n';
import { Message, Modal } from '@arco-design/web-vue';
import { IconCopy, IconList, IconSearch } from '@arco-design/web-vue/es/icon';
import { h5Navigate } from '@/utils/device';
import iconRoute from '@/assets/icons/h5/route-plane.svg?url';
import H5EmptyState from '../components/H5EmptyState.vue';
import {
getOrderStatusCounts,
@ -347,11 +347,16 @@ function channelLabel(row: any) {
return String(row.channelName || row.productName || '').trim();
}
function destinationLabel(row: any) {
const name = String(row.consigneeCountryName || '').trim();
const code = String(row.consigneeCountry || '').trim().toUpperCase();
if (name && code && name.toUpperCase() !== code) return `${name} ${code}`;
return name || code;
function warehouseLabel(row: any) {
return String(row.warehouse || '').trim() || t('h5.price.originAny');
}
function countryLabel(row: any) {
return String(row.consigneeCountryName || '').trim();
}
function countryCode(row: any) {
return String(row.consigneeCountry || '').trim().toUpperCase();
}
function isPendingPay(row: any) {
@ -367,14 +372,6 @@ function isIssue(row: any) {
return ['deliveryFailed', 'problematic', 'claimed'].includes(code);
}
function cardTone(row: any) {
if (isPendingPay(row)) return 'danger';
if (isIssue(row)) return 'warning';
if (isShipping(row)) return 'primary';
if (row.isPaid || resolveOrderStatusCode(row) === 'deliverySuccess') return 'success';
return 'neutral';
}
function formatMoney(val: unknown) {
const n = Number(val);
if (!Number.isFinite(n)) return String(val ?? '-');
@ -722,65 +719,20 @@ void reload().then(() => tryOpenOrderFromQuery());
}
.h5-order-card {
--liquid-a: rgba(45, 140, 140, 0.1);
--liquid-b: rgba(125, 162, 255, 0.08);
--liquid-edge: rgba(255, 255, 255, 0.78);
--liquid-glow: rgba(45, 140, 140, 0.05);
position: relative;
z-index: 0;
overflow: hidden;
isolation: isolate;
margin-bottom: 12px;
padding: 16px 14px;
border-radius: 16px;
border: 1px solid var(--liquid-edge);
background:
linear-gradient(
155deg,
rgba(255, 255, 255, 0.9) 0%,
rgba(255, 255, 255, 0.6) 48%,
rgba(255, 255, 255, 0.82) 100%
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
inset 0 -1px 0 rgba(255, 255, 255, 0.18),
0 6px 18px var(--liquid-glow),
0 1px 2px rgba(15, 23, 42, 0.03);
backdrop-filter: blur(12px) saturate(1.15);
-webkit-backdrop-filter: blur(12px) saturate(1.15);
padding: 14px;
border-radius: 14px;
border: none;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
cursor: pointer;
text-align: left;
transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1);
&--primary {
--liquid-a: rgba(0, 121, 121, 0.14);
--liquid-b: rgba(64, 128, 255, 0.1);
--liquid-glow: rgba(0, 121, 121, 0.08);
}
&--warning {
--liquid-a: rgba(255, 125, 0, 0.14);
--liquid-b: rgba(255, 182, 93, 0.12);
--liquid-glow: rgba(255, 125, 0, 0.08);
}
&--danger {
--liquid-a: rgba(245, 63, 63, 0.14);
--liquid-b: rgba(249, 137, 129, 0.1);
--liquid-glow: rgba(245, 63, 63, 0.08);
}
&--success {
--liquid-a: rgba(0, 180, 42, 0.12);
--liquid-b: rgba(76, 210, 99, 0.1);
--liquid-glow: rgba(0, 180, 42, 0.06);
}
&--neutral {
--liquid-a: rgba(45, 140, 140, 0.1);
--liquid-b: rgba(125, 162, 255, 0.08);
--liquid-glow: rgba(45, 140, 140, 0.05);
&.is-pay {
box-shadow:
0 0 0 1px color-mix(in srgb, #f53f3f 18%, transparent),
0 1px 2px rgba(15, 23, 42, 0.04);
}
&:active {
@ -788,100 +740,13 @@ void reload().then(() => tryOpenOrderFromQuery());
}
}
.h5-order-card__blob,
.h5-order-card__shine,
.h5-order-card__main {
position: relative;
z-index: 1;
}
.h5-order-card__blob {
position: absolute;
z-index: 0;
inset: -42% -18% auto auto;
width: 78%;
height: 125%;
border-radius: 42% 58% 48% 52% / 48% 42% 58% 52%;
background:
radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.55) 0%, transparent 42%),
radial-gradient(circle at 70% 65%, var(--liquid-a) 0%, transparent 58%),
radial-gradient(circle at 20% 80%, var(--liquid-b) 0%, transparent 55%);
filter: blur(12px);
opacity: 0.7;
pointer-events: none;
}
.h5-order-card__shine {
position: absolute;
z-index: 0;
inset: 0;
border-radius: inherit;
background: linear-gradient(
115deg,
transparent 28%,
rgba(255, 255, 255, 0.05) 42%,
rgba(255, 255, 255, 0.22) 50%,
rgba(255, 255, 255, 0.05) 58%,
transparent 72%
);
background-size: 220% 100%;
background-position: 110% 0;
opacity: 0.38;
pointer-events: none;
}
.h5-order-card__main {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.h5-order-card__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
/* 第一眼:单号(主) + 状态(情绪) */
.h5-order-row1 {
/* 顶栏:状态 + 单号(右上) */
.h5-order-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-width: 0;
}
.h5-order-no {
flex: 1;
min-width: 0;
margin: 0;
border: none;
background: transparent;
padding: 0;
font-size: 16px;
font-weight: 700;
color: #1d2129;
display: inline-flex;
align-items: center;
gap: 5px;
cursor: pointer;
text-align: left;
letter-spacing: 0.01em;
span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:deep(.arco-icon) {
flex-shrink: 0;
color: #c9cdd4;
font-size: 14px;
}
margin-bottom: 14px;
}
.h5-order-status {
@ -893,89 +758,163 @@ void reload().then(() => tryOpenOrderFromQuery());
white-space: nowrap;
}
/* 第二眼:线路 · 目的地 */
.h5-order-route {
.h5-order-no {
min-width: 0;
max-width: 62%;
margin: 0;
font-size: 13px;
line-height: 1.4;
color: #4e5969;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
i {
font-style: normal;
margin: 0 5px;
color: #c9cdd4;
}
&__dest {
color: #1d2129;
font-weight: 600;
}
}
/* 辅助:普货 / 仓库 — 无色弱标签 */
.h5-order-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.h5-meta-tag {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 8px;
border-radius: 6px;
font-size: 11px;
border: none;
background: transparent;
padding: 0;
font-size: 12px;
font-weight: 500;
color: #86909c;
background: rgba(15, 23, 42, 0.04);
white-space: nowrap;
}
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
cursor: pointer;
text-align: right;
/* 右侧:金额 + 主操作 */
.h5-order-side {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
gap: 8px;
min-width: 72px;
span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:deep(.arco-icon) {
flex-shrink: 0;
color: #c9cdd4;
font-size: 12px;
}
}
.h5-order-amount {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 1px;
display: inline-flex;
align-items: baseline;
gap: 4px;
color: #f53f3f;
line-height: 1.15;
white-space: nowrap;
small {
font-size: 10px;
font-size: 11px;
font-weight: 500;
opacity: 0.85;
}
em {
font-style: normal;
font-size: 16px;
font-size: 15px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.is-paid {
&.is-paid {
font-size: 12px;
font-weight: 600;
color: #00b42a;
}
}
/* 主视觉:仓 → 国 */
.h5-order-route {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 6px;
margin-bottom: 14px;
padding: 12px 10px;
border-radius: 12px;
background: #f7f8fa;
}
.h5-order-route__end {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
strong {
font-size: 16px;
font-weight: 700;
color: #1d2129;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
em {
font-style: normal;
font-size: 11px;
color: #86909c;
line-height: 1.2;
}
&--dest {
text-align: right;
strong {
color: var(--app-primary-color);
}
}
}
.h5-order-route__mid {
display: flex;
align-items: center;
gap: 4px;
padding: 0 2px;
flex-shrink: 0;
}
.h5-order-route__line {
width: 16px;
height: 1px;
background: color-mix(in srgb, var(--app-primary-color) 35%, #e5e6eb);
}
.h5-order-route__icon {
width: 22px;
height: 22px;
display: block;
object-fit: contain;
pointer-events: none;
-webkit-user-drag: none;
}
/* 底栏:渠道 + 金额/操作 */
.h5-order-foot {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.h5-order-sub {
flex: 1;
min-width: 0;
margin: 0;
font-size: 12px;
color: #86909c;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&--spacer {
visibility: hidden;
}
}
.h5-order-foot__action {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 10px;
}
.primary-btn {
border: none;
height: 32px;
@ -1000,6 +939,18 @@ void reload().then(() => tryOpenOrderFromQuery());
}
}
.text-btn {
border: none;
background: transparent;
padding: 0 2px;
height: 32px;
font-size: 13px;
font-weight: 600;
color: var(--app-primary-color);
cursor: pointer;
white-space: nowrap;
}
.h5-orders-more {
margin-top: 8px;
}

File diff suppressed because it is too large Load Diff

View File

@ -1,24 +1,44 @@
<template>
<div class="wechat-login">
<div class="qr-card">
<div class="qr-box" :class="{ 'qr-box--loading': showOverlay, 'qr-box--error': loadError, 'qr-box--scanned': scanned }">
<div
class="qr-box"
:class="{
'qr-box--loading': showOverlay,
'qr-box--error': loadError,
'qr-box--scanned': scanned,
'qr-box--expired': expired,
}"
>
<img
v-if="qrCodeUrl"
class="login-wx-qrcode"
:class="{ 'login-wx-qrcode--ready': qrImageReady, 'login-wx-qrcode--masked': scanned }"
:class="{
'login-wx-qrcode--ready': qrImageReady,
'login-wx-qrcode--masked': scanned || expired,
}"
:src="qrCodeUrl"
referrerpolicy="no-referrer"
width="190"
height="190"
alt=""
decoding="async"
@load="qrImageReady = true"
decoding="sync"
draggable="false"
@contextmenu.prevent
@load="onQrImageLoad"
@error="onQrImageError"
/>
<div v-if="scanned" class="qr-overlay qr-overlay--scanned">
<icon-check-circle-fill :size="36" class="qr-overlay__ok" />
<span class="qr-overlay__text">{{ t('login.wechat.scanned') }}</span>
</div>
<div v-else-if="expired" class="qr-overlay qr-overlay--expired">
<span class="qr-overlay__title">{{ t('login.wechat.expired') }}</span>
<span class="qr-overlay__text">{{ t('login.wechat.expiredTip') }}</span>
<button type="button" class="qr-retry" :disabled="loadingQr" @click="refreshQr">
{{ t('login.wechat.refreshExpired') }}
</button>
</div>
<div v-else-if="showOverlay" class="qr-overlay">
<a-spin :size="28" />
<span class="qr-overlay__text">{{ t('login.wechat.loading') }}</span>
@ -30,15 +50,18 @@
</button>
</div>
</div>
<div v-if="!scanned" class="tips">{{ t('login.wechat.tips') }}</div>
<div v-if="!scanned" class="tips" :class="{ 'tips--warn': expired }">
{{ expired ? t('login.wechat.expiredTip') : t('login.wechat.tips') }}
</div>
<a-button
v-if="!scanned"
class="refresh"
:type="expired ? 'primary' : undefined"
:loading="loadingQr"
:disabled="loadingQr"
@click="refreshQr"
>
{{ t('login.wechat.refresh') }}
{{ expired ? t('login.wechat.refreshExpired') : t('login.wechat.refresh') }}
</a-button>
<div class="account-login-entry">
<a-tooltip :content="t('login.switchToAccount')">
@ -64,6 +87,7 @@ import { Message } from '@arco-design/web-vue';
import { IconUser, IconCheckCircleFill } from '@arco-design/web-vue/es/icon';
import { OmsLoginType } from '@/api/rbac';
import { getLoginQrcode, getWxLoginStatus, isWxLoginPendingError, wxLogin } from '@/api/wechat';
import { resolveWechatShowQrDisplayUrl } from '@/utils/wechatShowQrUrl';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import { setToken } from '@/utils/auth';
import { getDefaultHomePathByType, resolveCustomerEntryPath } from '@/utils/login-portal';
@ -91,9 +115,14 @@ const qrImageReady = ref(false);
const polling = ref(false);
/** 已扫码:遮罩二维码,防止重复扫 */
const scanned = ref(false);
/** 二维码已过期:遮罩并引导刷新 */
const expired = ref(false);
const showOverlay = computed(
() => loadingQr.value || (Boolean(qrCodeUrl.value) && !qrImageReady.value),
() =>
!scanned.value &&
!expired.value &&
(loadingQr.value || (Boolean(qrCodeUrl.value) && !qrImageReady.value)),
);
let pollTimer: ReturnType<typeof setInterval> | undefined;
@ -124,9 +153,18 @@ function clearTimers() {
function preloadImage(url: string): Promise<void> {
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;
});
@ -137,6 +175,20 @@ function onQrImageError() {
loadError.value = true;
}
async function onQrImageLoad(event?: Event) {
const img = event?.target as HTMLImageElement | undefined;
if (img && typeof img.decode === 'function') {
try {
await img.decode();
} catch {
// ignore
}
}
if (disposed) return;
qrImageReady.value = true;
loadError.value = false;
}
async function navigateAfterLogin() {
const userInfoData = rbacUserStore.userInfo;
if (!userInfoData) return;
@ -173,13 +225,14 @@ async function navigateAfterLogin() {
}
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);
@ -198,14 +251,22 @@ async function pollWxLogin() {
}
}
function startPolling() {
function markExpired() {
if (disposed || scanned.value) return;
clearTimers();
expired.value = true;
}
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();
}
@ -215,13 +276,11 @@ async function loadQrCode(options?: { keepPrevious?: boolean }) {
clearTimers();
loadError.value = false;
scanned.value = false;
expired.value = false;
loadingQr.value = true;
// 刷新时保留旧图,避免白屏闪一下
qrImageReady.value = false;
if (!options?.keepPrevious) {
qrCodeUrl.value = '';
qrImageReady.value = false;
} else {
qrImageReady.value = Boolean(qrCodeUrl.value);
}
const uuid = createLoginUuid();
@ -240,17 +299,30 @@ async function loadQrCode(options?: { keepPrevious?: boolean }) {
}
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
: QR_EXPIRE_MS;
startPolling(expireMs);
} catch {
if (seq !== loadSeq || disposed) return;
loadError.value = true;
@ -336,10 +408,22 @@ onBeforeUnmount(() => {
background: rgba(255, 255, 255, 0.94);
}
&--expired {
gap: 6px;
background: rgba(255, 255, 255, 0.94);
}
&__ok {
color: var(--app-primary-color, #007979);
}
&__title {
font-size: 14px;
font-weight: 700;
color: #ff7d00;
line-height: 1.3;
}
&__text {
font-size: 12px;
color: #86909c;
@ -369,14 +453,15 @@ onBeforeUnmount(() => {
display: block;
max-width: 100%;
max-height: 100%;
opacity: 0;
transition: opacity 0.2s ease, filter 0.2s ease;
/* 微信内长按识别:勿禁用触摸呼出 */
-webkit-touch-callout: default;
user-select: auto;
visibility: hidden;
border-radius: 0;
image-rendering: -webkit-optimize-contrast;
/* 默认禁 iOS「共享/存储图像」;PC 微信登录主要靠扫码,长按识别非主路径 */
-webkit-touch-callout: none;
user-select: none;
&--ready {
opacity: 1;
visibility: visible;
}
&--masked {
@ -393,6 +478,11 @@ onBeforeUnmount(() => {
text-align: center;
line-height: 1.5;
max-width: 240px;
&--warn {
color: #ff7d00;
font-weight: 600;
}
}
.refresh {

View File

@ -25,6 +25,9 @@
'login.wechat.loading': 'Loading QR code…',
'login.wechat.loadFailed': 'Failed to load QR code. Please refresh.',
'login.wechat.refresh': 'Refresh QR Code',
'login.wechat.refreshExpired': 'Tap to refresh QR code',
'login.wechat.expired': 'QR code expired',
'login.wechat.expiredTip': 'Refresh before scanning to avoid a failed login',
'login.wechat.longPress': 'In WeChat, long-press the QR code to recognize',
'login.theme.toDay': 'Switch to day mode',
'login.theme.toNight': 'Switch to night mode',

View File

@ -26,6 +26,9 @@
'login.wechat.loading': '二维码加载中…',
'login.wechat.loadFailed': '二维码加载失败,请刷新重试',
'login.wechat.refresh': '刷新二维码',
'login.wechat.refreshExpired': '点击刷新二维码',
'login.wechat.expired': '二维码已过期',
'login.wechat.expiredTip': '请先刷新后再扫码,避免扫码无效',
'login.theme.toDay': '切换为日间模式',
'login.theme.toNight': '切换为夜间模式',
'login.lang.toEn': '切换为 English',

View File

@ -1,16 +1,65 @@
<template>
<div class="ai-rec" :class="[`ai-rec--${variant || 'bar'}`, { 'has-hint': showPostalHint }]">
<div class="ai-rec__left">
<div class="ai-rec__label">
<span class="ai-rec__title">{{ t('order.priceCheck.ai.title') }}</span>
<span class="ai-rec__desc">{{ t('order.priceCheck.ai.descShort') }}</span>
</div>
<div class="ai-rec__controls">
<a-input
v-model="productName"
class="ai-rec__input"
allow-clear
:max-length="80"
:placeholder="t('order.priceCheck.ai.productName.placeholder')"
@press-enter="handleRecommend"
/>
<a-button
type="primary"
:loading="loading"
:disabled="loading || !productName.trim()"
@click="handleRecommend"
>
{{ t('order.priceCheck.ai.action') }}
</a-button>
</div>
</div>
<div v-if="showPostalHint" class="ai-rec__hint">
<div class="ai-rec__hint-text">
<icon-info-circle />
<span>{{ t('order.priceCheck.hint.postalCta') }}</span>
</div>
<button
type="button"
class="ai-rec__hint-dismiss"
:title="t('order.priceCheck.hint.dismiss')"
@click="emit('dismissHint')"
>
×
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { IconThunderbolt } from '@arco-design/web-vue/es/icon'
import { IconInfoCircle } from '@arco-design/web-vue/es/icon'
defineOptions({ name: 'PriceCheckAiRecommendCard' })
const props = defineProps<{
defineProps<{
loading?: boolean
/** card=表单旁竖卡;bar=结果区顶栏横条 */
variant?: 'card' | 'bar'
/** 与 AI 栏融合的邮编准确性提示(同一行右侧) */
showPostalHint?: boolean
}>()
const emit = defineEmits<{
recommend: [productName: string]
dismissHint: []
}>()
const { t } = useI18n()
@ -21,162 +70,120 @@
}
</script>
<template>
<div class="ai-card">
<div class="ai-card__head">
<span class="ai-card__badge">AI</span>
<span class="ai-card__title">{{ t('order.priceCheck.ai.title') }}</span>
</div>
<p class="ai-card__desc">{{ t('order.priceCheck.ai.desc') }}</p>
<a-input
v-model="productName"
class="ai-card__input"
allow-clear
:max-length="80"
:placeholder="t('order.priceCheck.ai.productName.placeholder')"
@press-enter="handleRecommend"
/>
<button
type="button"
class="ai-card__btn"
:class="{ 'is-loading': loading }"
:disabled="loading || !productName.trim()"
:aria-label="t('order.priceCheck.ai.action')"
@click="handleRecommend"
>
<span class="ai-card__btn-ring" aria-hidden="true" />
<icon-thunderbolt v-if="!loading" class="ai-card__btn-icon" />
<span v-else class="ai-card__btn-spin" />
<span class="ai-card__btn-text">{{ t('order.priceCheck.ai.action') }}</span>
</button>
</div>
</template>
<style lang="less" scoped>
.ai-card {
width: 168px;
min-width: 156px;
padding: 12px;
border: 1px solid rgba(45, 140, 140, 0.18);
border-radius: 12px;
background:
linear-gradient(160deg, rgba(45, 140, 140, 0.08) 0%, rgba(255, 255, 255, 0.96) 48%, #fff 100%);
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.04);
.ai-rec {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 14px;
border: 1px solid #eef2f6;
border-radius: 10px;
background: #fafbfc;
flex-wrap: wrap;
&.has-hint {
background: linear-gradient(90deg, #fafbfc 0%, #fafbfc 55%, #fffbeb 100%);
}
}
.ai-rec__left {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1;
flex-wrap: wrap;
}
.ai-rec__label {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
flex-shrink: 0;
}
.ai-card__head {
.ai-rec__title {
color: #0f172a;
font-size: 13px;
font-weight: 600;
flex-shrink: 0;
}
.ai-rec__desc {
color: #94a3b8;
font-size: 12px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 140px;
}
.ai-rec__controls {
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
min-width: 0;
}
.ai-card__badge {
display: inline-flex;
align-items: center;
justify-content: center;
height: 18px;
padding: 0 6px;
border-radius: 999px;
background: var(--app-primary-color, #2d8c8c);
color: #fff;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
}
.ai-rec__input {
width: 200px;
max-width: 100%;
.ai-card__title {
color: #1d2129;
font-size: 13px;
font-weight: 650;
}
.ai-card__desc {
margin: 0;
color: var(--color-text-3);
font-size: 11px;
line-height: 1.4;
}
.ai-card__input {
:deep(.arco-input-wrapper) {
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
border-radius: 6px;
background: #fff;
}
}
.ai-card__btn {
position: relative;
align-self: center;
margin-top: 4px;
width: 72px;
height: 72px;
border: 1px solid rgba(45, 140, 140, 0.22);
border-radius: 50%;
background: linear-gradient(160deg, #f3faf9 0%, #e7f4f3 55%, #dceeee 100%);
color: var(--app-primary-color, #2d8c8c);
cursor: pointer;
.ai-rec__hint {
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
box-shadow: 0 4px 12px rgba(45, 140, 140, 0.1);
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease, border-color 0.15s ease;
gap: 8px;
flex-shrink: 0;
max-width: 100%;
padding: 6px 10px;
border-radius: 8px;
background: #fffbeb;
border: 1px solid #fde68a;
}
&:hover:not(:disabled) {
transform: translateY(-1px);
border-color: rgba(45, 140, 140, 0.35);
background: linear-gradient(160deg, #f7fcfb 0%, #edf7f6 55%, #e4f2f1 100%);
box-shadow: 0 6px 14px rgba(45, 140, 140, 0.14);
.ai-rec__hint-text {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
color: #b45309;
font-size: 12px;
line-height: 1.35;
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 320px;
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
box-shadow: none;
}
&.is-loading {
pointer-events: none;
:deep(.arco-icon) {
flex-shrink: 0;
color: #d97706;
}
}
.ai-card__btn-ring {
position: absolute;
inset: -4px;
border-radius: 50%;
border: 1px dashed rgba(45, 140, 140, 0.22);
pointer-events: none;
}
.ai-rec__hint-dismiss {
border: none;
background: transparent;
color: #92400e;
font-size: 16px;
line-height: 1;
padding: 2px 4px;
cursor: pointer;
opacity: 0.7;
.ai-card__btn-icon {
font-size: 20px;
color: var(--app-primary-color, #2d8c8c);
}
.ai-card__btn-text {
font-size: 11px;
font-weight: 650;
line-height: 1.2;
color: var(--app-primary-color, #2d8c8c);
}
.ai-card__btn-spin {
width: 16px;
height: 16px;
border: 2px solid rgba(45, 140, 140, 0.2);
border-top-color: var(--app-primary-color, #2d8c8c);
border-radius: 50%;
animation: ai-spin 0.8s linear infinite;
}
@keyframes ai-spin {
to {
transform: rotate(360deg);
&:hover {
opacity: 1;
}
}
</style>

View File

@ -1,10 +1,11 @@
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { IconClose } from '@arco-design/web-vue/es/icon';
import MarkdownViewer from '@/components/MarkdownViewer/index.vue';
import { getSalesProductDetail } from '@/api/channel/salesProduct';
import { Message } from '@arco-design/web-vue';
import type { RecommendAlternative, ShipVerdict } from '../recommendChannel';
defineOptions({ name: 'PriceCheckChannelGuidePanel' });
@ -12,10 +13,17 @@
record: Record<string, any> | null;
failureText?: string;
recommendReason?: string;
recommendVerdict?: ShipVerdict | '';
recommendEvidence?: string[];
recommendCargoName?: string;
alternatives?: RecommendAlternative[];
}>();
const emit = defineEmits<{
close: [];
'select-alternative': [rowKey: string];
'place-order': [];
'ask-advisor': [];
}>();
const { t } = useI18n();
@ -23,12 +31,55 @@
const guideContent = ref('');
const productTitle = ref('');
const metaText = ref('');
const guideExpanded = ref(false);
const verdict = computed(() => props.recommendVerdict || '');
const evidence = computed(() => (props.recommendEvidence || []).filter(Boolean).slice(0, 3));
const alternatives = computed(() => (props.alternatives || []).slice(0, 3));
const showRecommendBlock = computed(
() =>
!!props.recommendReason ||
!!verdict.value ||
evidence.value.length > 0 ||
alternatives.value.length > 0,
);
const verdictLabel = computed(() => {
switch (verdict.value) {
case 'ok':
return t('order.priceCheck.ai.verdict.ok');
case 'caution':
return t('order.priceCheck.ai.verdict.caution');
case 'forbid':
return t('order.priceCheck.ai.verdict.forbid');
case 'unknown':
return t('order.priceCheck.ai.verdict.unknown');
default:
return '';
}
});
const verdictHint = computed(() => {
switch (verdict.value) {
case 'ok':
return t('order.priceCheck.ai.verdictHint.ok');
case 'caution':
return t('order.priceCheck.ai.verdictHint.caution');
case 'forbid':
return t('order.priceCheck.ai.verdictHint.forbid');
case 'unknown':
return t('order.priceCheck.ai.verdictHint.unknown');
default:
return '';
}
});
async function loadGuide(record: Record<string, any> | null) {
if (!record) {
guideContent.value = '';
productTitle.value = '';
metaText.value = '';
guideExpanded.value = false;
return;
}
@ -42,6 +93,7 @@
.filter(Boolean)
.join(' · ');
guideContent.value = '';
guideExpanded.value = false;
if (productId == null || productId === '') {
Message.warning(t('order.priceCheck.customerGuide.missingProduct'));
@ -90,16 +142,87 @@
</header>
<div class="guide-panel__body liquid-panel__content">
<div v-if="recommendReason" class="guide-panel__recommend">
<div class="guide-panel__recommend-badge">{{ t('order.priceCheck.ai.reasonBadge') }}</div>
<p class="guide-panel__recommend-text">{{ recommendReason }}</p>
<div v-if="showRecommendBlock" class="guide-panel__recommend" :class="verdict ? `is-${verdict}` : ''">
<div class="guide-panel__recommend-top">
<div class="guide-panel__recommend-badge">{{ t('order.priceCheck.ai.reasonBadge') }}</div>
<span v-if="verdictLabel" class="guide-panel__verdict" :class="`is-${verdict}`">
{{ verdictLabel }}
</span>
</div>
<p v-if="recommendCargoName" class="guide-panel__cargo">
{{ t('order.priceCheck.ai.cargoLabel', { name: recommendCargoName }) }}
</p>
<p v-if="recommendReason" class="guide-panel__recommend-text">{{ recommendReason }}</p>
<p v-if="verdictHint" class="guide-panel__verdict-hint">{{ verdictHint }}</p>
<ul v-if="evidence.length" class="guide-panel__evidence">
<li v-for="(line, idx) in evidence" :key="idx" class="guide-panel__evidence-item">
<span class="guide-panel__evidence-mark">“</span>
<span>{{ line }}</span>
</li>
</ul>
<div v-if="alternatives.length" class="guide-panel__alts">
<div class="guide-panel__alts-title">{{ t('order.priceCheck.ai.alternatives') }}</div>
<div class="guide-panel__alts-list">
<button
v-for="alt in alternatives"
:key="alt.rowKey"
type="button"
class="guide-panel__alt-chip"
:class="`is-${alt.verdict}`"
@click="emit('select-alternative', alt.rowKey)"
>
<span class="guide-panel__alt-verdict">{{
t(`order.priceCheck.ai.verdict.${alt.verdict}`)
}}</span>
<span class="guide-panel__alt-blurb">{{ alt.blurb }}</span>
</button>
</div>
</div>
<div v-if="record && recommendReason" class="guide-panel__actions">
<a-button
v-if="verdict !== 'forbid'"
type="primary"
size="small"
@click="emit('place-order')"
>
{{ t('order.priceCheck.ai.cta.order') }}
</a-button>
<a-button size="small" @click="emit('ask-advisor')">
{{ t('order.priceCheck.ai.cta.ask') }}
</a-button>
</div>
</div>
<div v-if="record" class="guide-panel__guide">
<button
v-if="guideContent && showRecommendBlock"
type="button"
class="guide-panel__guide-toggle"
@click="guideExpanded = !guideExpanded"
>
{{
guideExpanded
? t('order.priceCheck.ai.guideCollapse')
: t('order.priceCheck.ai.guideExpand')
}}
</button>
<a-spin :loading="loading" class="guide-panel__spin">
<MarkdownViewer v-if="guideContent" :content="guideContent" />
<div v-else-if="!loading" class="guide-panel__empty">
<MarkdownViewer
v-if="guideContent && (!showRecommendBlock || guideExpanded)"
:content="guideContent"
/>
<div v-else-if="!loading && !guideContent" class="guide-panel__empty">
<a-empty :description="t('order.priceCheck.customerGuide.empty')" />
</div>
<p
v-else-if="guideContent && showRecommendBlock && !guideExpanded"
class="guide-panel__guide-teaser"
>
{{ t('order.priceCheck.ai.guideTeaser') }}
</p>
</a-spin>
</div>
<div v-else-if="failureText" class="guide-panel__failure">
@ -118,55 +241,9 @@
position: relative;
z-index: 0;
overflow: hidden;
isolation: isolate;
border: 1px solid rgba(255, 255, 255, 0.92);
border-radius: 16px;
background:
linear-gradient(160deg, rgba(255, 255, 255, 0.96) 0%, rgba(255, 255, 255, 0.88) 48%, rgba(255, 255, 255, 0.94) 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.98),
inset 0 -1px 0 rgba(255, 255, 255, 0.28),
0 8px 24px rgba(15, 23, 42, 0.05),
0 1px 2px rgba(15, 23, 42, 0.03);
backdrop-filter: blur(14px) saturate(1.08);
-webkit-backdrop-filter: blur(14px) saturate(1.08);
&::before {
content: '';
position: absolute;
z-index: 0;
inset: -28% -12% auto auto;
width: 62%;
height: 100%;
border-radius: 42% 58% 48% 52% / 48% 42% 58% 52%;
background:
radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.9) 0%, transparent 44%),
radial-gradient(circle at 68% 70%, rgba(240, 243, 244, 0.7) 0%, transparent 58%);
filter: blur(16px);
opacity: 0.75;
pointer-events: none;
animation: guide-liquid-drift 14s ease-in-out infinite;
}
&::after {
content: '';
position: absolute;
z-index: 0;
inset: 0;
border-radius: inherit;
background: linear-gradient(
115deg,
transparent 30%,
rgba(255, 255, 255, 0.06) 44%,
rgba(255, 255, 255, 0.32) 50%,
rgba(255, 255, 255, 0.06) 56%,
transparent 70%
);
background-size: 220% 100%;
background-position: 120% 0;
opacity: 0.42;
pointer-events: none;
}
border: 1px solid rgba(15, 23, 42, 0.06);
border-radius: 12px;
background: #fff;
}
.liquid-panel__content {
@ -174,24 +251,6 @@
z-index: 1;
}
@keyframes guide-liquid-drift {
0%,
100% {
border-radius: 42% 58% 48% 52% / 48% 42% 58% 52%;
transform: translate(0, 0) scale(1);
}
50% {
border-radius: 54% 46% 52% 48% / 44% 56% 44% 56%;
transform: translate(-4%, 3%) scale(1.04);
}
}
@media (prefers-reduced-motion: reduce) {
.liquid-panel::before {
animation: none;
}
}
.guide-panel {
display: flex;
flex-direction: column;
@ -265,7 +324,7 @@
.guide-panel__spin {
width: 100%;
min-height: 160px;
min-height: 80px;
}
.guide-panel__empty,
@ -306,15 +365,42 @@
.guide-panel__recommend {
margin-bottom: 12px;
padding: 10px 12px;
border-radius: 8px;
padding: 12px;
border-radius: 10px;
background: rgba(45, 140, 140, 0.08);
border: 1px solid rgba(45, 140, 140, 0.16);
&.is-ok {
background: rgba(0, 180, 42, 0.06);
border-color: rgba(0, 180, 42, 0.2);
}
&.is-caution {
background: rgba(255, 125, 0, 0.07);
border-color: rgba(255, 125, 0, 0.22);
}
&.is-forbid {
background: rgba(245, 63, 63, 0.06);
border-color: rgba(245, 63, 63, 0.2);
}
&.is-unknown {
background: rgba(78, 89, 105, 0.06);
border-color: rgba(78, 89, 105, 0.16);
}
}
.guide-panel__recommend-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.guide-panel__recommend-badge {
display: inline-flex;
margin-bottom: 6px;
padding: 0 8px;
height: 20px;
align-items: center;
@ -325,6 +411,43 @@
font-weight: 600;
}
.guide-panel__verdict {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 650;
&.is-ok {
background: rgba(0, 180, 42, 0.14);
color: #00b42a;
}
&.is-caution {
background: rgba(255, 125, 0, 0.14);
color: #ff7d00;
}
&.is-forbid {
background: rgba(245, 63, 63, 0.12);
color: #f53f3f;
}
&.is-unknown {
background: rgba(78, 89, 105, 0.1);
color: #4e5969;
}
}
.guide-panel__cargo {
margin: 0 0 6px;
color: var(--color-text-3);
font-size: 12px;
line-height: 1.4;
}
.guide-panel__recommend-text {
margin: 0;
color: #1d2129;
@ -333,4 +456,133 @@
white-space: pre-wrap;
word-break: break-word;
}
.guide-panel__verdict-hint {
margin: 8px 0 0;
color: var(--color-text-3);
font-size: 12px;
line-height: 1.5;
}
.guide-panel__evidence {
margin: 10px 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
}
.guide-panel__evidence-item {
display: flex;
gap: 4px;
padding: 8px 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.72);
color: #4e5969;
font-size: 12px;
line-height: 1.55;
word-break: break-word;
}
.guide-panel__evidence-mark {
flex-shrink: 0;
color: var(--app-primary-color, #2d8c8c);
font-size: 16px;
font-weight: 700;
line-height: 1.2;
}
.guide-panel__alts {
margin-top: 12px;
}
.guide-panel__alts-title {
margin-bottom: 6px;
color: var(--color-text-3);
font-size: 12px;
font-weight: 600;
}
.guide-panel__alts-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.guide-panel__alt-chip {
display: flex;
align-items: flex-start;
gap: 8px;
width: 100%;
padding: 8px 10px;
border: 1px solid var(--color-border-2, #e5e6eb);
border-radius: 8px;
background: #fff;
text-align: left;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
&:hover {
border-color: rgba(45, 140, 140, 0.35);
background: rgba(45, 140, 140, 0.04);
}
}
.guide-panel__alt-verdict {
flex-shrink: 0;
font-size: 11px;
font-weight: 650;
line-height: 18px;
color: var(--app-primary-color, #2d8c8c);
}
.guide-panel__alt-chip.is-caution .guide-panel__alt-verdict {
color: #ff7d00;
}
.guide-panel__alt-chip.is-forbid .guide-panel__alt-verdict {
color: #f53f3f;
}
.guide-panel__alt-chip.is-unknown .guide-panel__alt-verdict {
color: #4e5969;
}
.guide-panel__alt-blurb {
color: #1d2129;
font-size: 12px;
line-height: 1.45;
word-break: break-word;
}
.guide-panel__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.guide-panel__guide-toggle {
display: inline-flex;
margin-bottom: 8px;
padding: 0;
border: none;
background: transparent;
color: var(--app-primary-color, #2d8c8c);
font-size: 12px;
font-weight: 600;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.guide-panel__guide-teaser {
margin: 0;
color: var(--color-text-3);
font-size: 12px;
line-height: 1.5;
}
</style>

View File

@ -0,0 +1,414 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { IconDown, IconUp } from '@arco-design/web-vue/es/icon'
defineOptions({ name: 'PriceCheckQuoteResultCard' })
const props = defineProps<{
record: Record<string, any>
selected?: boolean
}>()
const emit = defineEmits<{
select: []
order: []
ask: [intent: 'canShip' | 'cheaper']
}>()
const { t } = useI18n()
const feeExpanded = ref(false)
function formatMoney(value: string | number | null | undefined) {
if (value == null || value === '') return ''
const num = typeof value === 'number' ? value : parseFloat(String(value))
if (Number.isNaN(num)) return String(value)
if (Number.isInteger(num)) return String(num)
return num.toFixed(2)
}
const isEstimated = computed(() => {
const level = String(props.record.quoteLevel ?? props.record.QuoteLevel ?? '').toLowerCase()
return level === 'estimated'
})
const metaLine = computed(() => {
const group = String(props.record.productGroupName || props.record.productGroup || '').trim()
const cargo = String(props.record.cargoTypeName || '').trim()
const warehouse = String(props.record.warehouse || '').trim()
const tradeTerms = String(props.record.tradeTerms || '').trim().toUpperCase()
const parts = [
group,
cargo,
warehouse,
tradeTerms === 'DDP' || tradeTerms === 'DDU' ? tradeTerms : '',
].filter(Boolean)
return parts.join(' · ')
})
function onCardClick(e: MouseEvent) {
const target = e.target as HTMLElement | null
if (target?.closest('.quote-card__actions, .quote-card__fee-toggle, .quote-card__fee')) return
emit('select')
}
</script>
<template>
<article
class="quote-card"
:class="{ 'is-selected': selected }"
role="button"
tabindex="0"
@click="onCardClick"
@keydown.enter.prevent="emit('select')"
>
<div class="quote-card__main">
<div class="quote-card__left">
<div class="quote-card__name-row">
<h3 class="quote-card__name">{{ record.shippingMethod || '-' }}</h3>
<div class="quote-card__tags">
<span v-if="isEstimated" class="quote-tag is-warn">{{ t('order.priceCheck.tag.estimated') }}</span>
<span v-if="record.isPriceOptimal" class="quote-tag is-price">{{
t('order.priceCheck.tag.priceOptimal')
}}</span>
<span v-if="record.isTimeOptimal" class="quote-tag is-time">{{
t('order.priceCheck.tag.timeOptimal')
}}</span>
</div>
</div>
<p v-if="metaLine" class="quote-card__meta">{{ metaLine }}</p>
<button
type="button"
class="quote-card__fee-toggle"
@click.stop="feeExpanded = !feeExpanded"
>
{{ feeExpanded ? t('order.priceCheck.card.hideFee') : t('order.priceCheck.card.showFee') }}
<icon-up v-if="feeExpanded" />
<icon-down v-else />
</button>
</div>
<div class="quote-card__time">
<div class="quote-card__metric">
<span class="quote-card__metric-value is-time">{{ record.deliveryTime || '-' }}</span>
</div>
<span class="quote-card__metric-label">{{ t('order.priceCheck.card.eta') }}</span>
</div>
<div class="quote-card__right">
<div class="quote-card__price">
<div class="quote-card__metric">
<span class="quote-card__metric-value is-price">¥{{ formatMoney(record.totalCost) }}</span>
</div>
<span class="quote-card__metric-label">{{ t('order.priceCheck.card.total') }}</span>
</div>
<div class="quote-card__actions" @click.stop>
<a-dropdown trigger="hover" position="br">
<button type="button" class="ops-btn ops-btn--ghost">
{{ t('order.priceCheck.table.askAi') }}
<icon-down style="margin-left: 2px" />
</button>
<template #content>
<a-doption @click="emit('ask', 'canShip')">
{{ t('order.priceCheck.table.askAiCanShip') }}
</a-doption>
<a-doption @click="emit('ask', 'cheaper')">
{{ t('order.priceCheck.table.askAiCheaper') }}
</a-doption>
</template>
</a-dropdown>
<button type="button" class="ops-btn ops-btn--primary" @click="emit('order')">
{{ t('order.priceCheck.table.orderNow') }}
</button>
</div>
</div>
</div>
<div v-if="feeExpanded" class="quote-card__fee" @click.stop>
<div class="quote-card__fee-row">
<span>{{ t('order.priceCheck.table.freight') }}</span>
<span>¥{{ formatMoney(record.freight) || '0' }}</span>
</div>
<div class="quote-card__fee-row">
<span>{{ t('order.priceCheck.table.otherFees') }}</span>
<span>¥{{ formatMoney(record.otherFees) || '0' }}</span>
</div>
<div
v-if="record.chargeableWeight != null && record.chargeableWeight !== ''"
class="quote-card__fee-row"
>
<span>{{ t('order.priceCheck.table.chargeableWeightShort') }}</span>
<span>{{ record.chargeableWeight }} kg</span>
</div>
<div
v-if="record.volumetricWeight != null && record.volumetricWeight !== ''"
class="quote-card__fee-row"
>
<span>{{ t('order.priceCheck.table.volumetricWeightShort') }}</span>
<span>{{ record.volumetricWeight }} kg</span>
</div>
<div v-if="record.costFormula" class="quote-card__fee-formula">
{{ record.costFormula }}
</div>
<div v-if="record.otherFeeList?.length" class="quote-card__fee-list">
<div v-for="(fee, idx) in record.otherFeeList" :key="idx" class="quote-card__fee-row is-sub">
<span>{{ fee.name || fee.costItem }}</span>
<span>¥{{ formatMoney(fee.amount) }}</span>
</div>
</div>
</div>
</article>
</template>
<style lang="less" scoped>
.quote-card {
padding: 14px 16px;
border: 1px solid #eef2f6;
border-radius: 10px;
background: #fff;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
&:hover {
border-color: #d7e3e3;
background: #fbfdfe;
}
&.is-selected {
border-color: var(--app-primary-color, #007979);
background: rgba(0, 121, 121, 0.03);
}
}
.quote-card__main {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
gap: 16px 24px;
align-items: center;
}
.quote-card__left {
min-width: 0;
justify-self: start;
}
.quote-card__name-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.quote-card__name {
margin: 0;
color: #0f172a;
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.35;
word-break: break-word;
}
.quote-card__tags {
display: inline-flex;
flex-wrap: wrap;
gap: 6px;
}
.quote-tag {
font-size: 11px;
font-weight: 500;
line-height: 1;
padding: 3px 6px;
border-radius: 3px;
background: #f1f5f9;
color: #64748b;
&.is-price {
background: rgba(245, 63, 63, 0.08);
color: #f53f3f;
}
&.is-time {
background: rgba(0, 121, 121, 0.08);
color: var(--app-primary-color, #007979);
}
&.is-warn {
background: #fff7ed;
color: #c2410c;
}
}
.quote-card__meta {
margin: 6px 0 0;
color: #94a3b8;
font-size: 12px;
line-height: 1.4;
}
.quote-card__time {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
min-width: 72px;
justify-self: center;
}
.quote-card__right {
display: flex;
align-items: center;
gap: 20px;
justify-self: end;
}
.quote-card__price {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
min-width: 96px;
}
.quote-card__metric {
display: inline-flex;
align-items: baseline;
gap: 2px;
}
.quote-card__metric-value {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.1;
font-variant-numeric: tabular-nums;
&.is-time {
color: #1d2129;
}
&.is-price {
color: #f53f3f;
}
}
.quote-card__metric-label {
color: #94a3b8;
font-size: 11px;
}
.quote-card__actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.ops-btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
padding: 0 14px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
border: 1px solid transparent;
&--ghost {
background: transparent;
border-color: transparent;
color: #64748b;
&:hover {
color: var(--app-primary-color, #007979);
background: rgba(0, 121, 121, 0.04);
}
}
&--primary {
background: var(--app-primary-color, #007979);
color: #fff;
&:hover {
filter: brightness(1.04);
}
}
}
.quote-card__fee-toggle {
display: inline-flex;
align-items: center;
gap: 2px;
margin-top: 8px;
padding: 0;
border: none;
background: transparent;
color: #94a3b8;
font-size: 12px;
cursor: pointer;
&:hover {
color: var(--app-primary-color, #007979);
}
}
.quote-card__fee {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f1f5f9;
display: flex;
flex-direction: column;
gap: 6px;
}
.quote-card__fee-row {
display: flex;
justify-content: space-between;
gap: 16px;
color: #475569;
font-size: 13px;
&.is-sub {
color: #94a3b8;
font-size: 12px;
padding-left: 8px;
}
}
.quote-card__fee-formula {
margin-top: 4px;
color: #94a3b8;
font-size: 12px;
line-height: 1.5;
word-break: break-all;
}
.quote-card__fee-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 4px;
}
@media (max-width: 1100px) {
.quote-card__main {
grid-template-columns: 1fr;
gap: 12px;
}
.quote-card__time {
justify-self: start;
align-items: flex-start;
}
.quote-card__right {
justify-self: stretch;
justify-content: space-between;
width: 100%;
padding-top: 10px;
border-top: 1px dashed #eef2f6;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,11 @@
import { getSalesProductDetail } from '@/api/channel/salesProduct'
import { callMcpTool } from '@/api/mcp'
import { chatXhsAi, listXhsAiProviders } from '@/api/xhs'
export type QuoteCandidate = {
rowKey: string
productId?: number | string
productCode?: string
shippingMethod: string
cargoTypeName?: string
warehouse?: string
@ -13,16 +15,32 @@ export type QuoteCandidate = {
isPriceOptimal?: boolean
isTimeOptimal?: boolean
guide?: string
hasGuide?: boolean
}
/** 对照对客说明后的承运判定 */
export type ShipVerdict = 'ok' | 'caution' | 'forbid' | 'unknown'
export type RecommendAlternative = {
rowKey: string
verdict: ShipVerdict
blurb: string
}
export type RecommendResult = {
rowKey: string
reason: string
source: 'ai' | 'local'
source: 'ai' | 'local' | 'mcp'
verdict: ShipVerdict
/** 对客说明中支撑判定的原文摘录 */
evidence: string[]
cargoName: string
alternatives: RecommendAlternative[]
}
const FORBIDDEN_HINTS = ['禁运', '不收', '不可走', '不能走', '拒收', '禁止', '不得寄', '无法承运', '不接受']
const ALLOWED_HINTS = ['可走', '可寄', '可发', '接受', '适用', '支持', '允许', '可承运', '建议']
const CAUTION_HINTS = ['需确认', '需申报', '敏感', '限寄', '可能', '视情况', '提前沟通', '特殊要求']
const CARGO_KEYWORDS: Array<{ keys: string[]; tags: string[] }> = [
{ keys: ['电池', '带电', '锂电', '充电宝', '移动电源'], tags: ['带电', '电池', '锂电', '敏感'] },
@ -33,49 +51,167 @@ const CARGO_KEYWORDS: Array<{ keys: string[]; tags: string[] }> = [
{ keys: ['数码', '手机', '电脑', '耳机'], tags: ['普货', '带电', '数码'] },
]
const MCP_GUIDE_TOOL = 'oms.channel_guide'
const MCP_GUIDE_CONCURRENCY = 6
const MCP_GUIDE_MAX_PRODUCTS = 10
function normalizeGuide(detail: Record<string, any> | undefined) {
return String(detail?.customerGuideMarkdown ?? detail?.CustomerGuideMarkdown ?? '').trim()
}
export async function loadGuidesForCandidates(candidates: QuoteCandidate[]) {
const idSet = new Map<string, QuoteCandidate[]>()
candidates.forEach((item) => {
function normalizeVerdict(raw: unknown): ShipVerdict {
const v = String(raw || '')
.trim()
.toLowerCase()
if (['ok', 'allow', 'allowed', 'pass', '可寄', '可走', '可发'].includes(v)) return 'ok'
if (['caution', 'warn', 'warning', '慎发', '需确认', '敏感'].includes(v)) return 'caution'
if (['forbid', 'ban', 'blocked', '禁运', '不可寄', '不收'].includes(v)) return 'forbid'
return 'unknown'
}
function extractEvidenceFromGuide(guide: string, productName: string, limit = 2): string[] {
const text = String(guide || '').trim()
if (!text) return []
const name = productName.trim()
const lines = text
.split(/\n+/)
.map((l) => l.replace(/^#+\s*/, '').replace(/^[-*•]\s*/, '').trim())
.filter((l) => l.length >= 4 && l.length <= 120)
const hits: string[] = []
const lowerName = name.toLowerCase()
for (const line of lines) {
const lower = line.toLowerCase()
const nameHit =
!!lowerName &&
(lower.includes(lowerName) ||
lowerName.split(/\s+/).some((w) => w.length > 1 && lower.includes(w)))
const hintHit = [...FORBIDDEN_HINTS, ...ALLOWED_HINTS, ...CAUTION_HINTS].some((h) =>
lower.includes(h.toLowerCase()),
)
if (nameHit || hintHit) hits.push(line)
if (hits.length >= limit) break
}
if (!hits.length && lines[0]) hits.push(lines[0])
return hits
}
function parseMcpGuideContent(content: string): { guide: string; hasGuide: boolean } {
try {
const parsed = JSON.parse(content) as Record<string, any>
const items = (parsed?.items ?? parsed?.Items ?? []) as any[]
const first = items[0]
if (!first) return { guide: '', hasGuide: false }
const guide = String(
first.customerGuideMarkdown ?? first.CustomerGuideMarkdown ?? '',
).trim()
const hasGuide = Boolean(first.hasGuide ?? first.HasGuide ?? !!guide)
return { guide, hasGuide }
} catch {
return { guide: '', hasGuide: false }
}
}
async function mapPool<T, R>(
items: T[],
concurrency: number,
worker: (item: T) => Promise<R>,
): Promise<R[]> {
const results: R[] = new Array(items.length)
let cursor = 0
async function run() {
while (cursor < items.length) {
const idx = cursor++
results[idx] = await worker(items[idx])
}
}
const runners = Array.from({ length: Math.min(concurrency, items.length) }, () => run())
await Promise.all(runners)
return results
}
/**
* 优先走 MCP「查渠道对客说明」,失败再回落销售产品详情接口。
* 同一 productCode / productId 只拉一次。
*/
export async function loadGuidesForCandidates(
candidates: QuoteCandidate[],
cargoName: string,
): Promise<QuoteCandidate[]> {
type Key = string
const groups = new Map<Key, QuoteCandidate[]>()
for (const item of candidates) {
const code = String(item.productCode || '').trim()
const id = item.productId == null || item.productId === '' ? '' : String(item.productId)
if (!id) return
const list = idSet.get(id) ?? []
const key = code ? `code:${code}` : id ? `id:${id}` : ''
if (!key) continue
const list = groups.get(key) ?? []
list.push(item)
idSet.set(id, list)
groups.set(key, list)
}
const keys = [...groups.keys()].slice(0, MCP_GUIDE_MAX_PRODUCTS)
const guideMap = new Map<Key, { guide: string; hasGuide: boolean; via: 'mcp' | 'api' }>()
await mapPool(keys, MCP_GUIDE_CONCURRENCY, async (key) => {
const sample = groups.get(key)![0]
const productCode = String(sample.productCode || '').trim()
const productName = String(sample.shippingMethod || '').trim()
if (productCode || productName) {
try {
const args: Record<string, string> = { cargoName: cargoName.trim() }
if (productCode) args.productCode = productCode
else args.productName = productName
const res = await callMcpTool(MCP_GUIDE_TOOL, JSON.stringify(args))
if (res.success && res.content) {
const parsed = parseMcpGuideContent(res.content)
if (parsed.guide || parsed.hasGuide === false) {
guideMap.set(key, { ...parsed, via: 'mcp' })
return
}
}
} catch {
// fall through to detail API
}
}
const id = sample.productId == null || sample.productId === '' ? '' : String(sample.productId)
if (!id) {
guideMap.set(key, { guide: '', hasGuide: false, via: 'api' })
return
}
try {
const detail = await getSalesProductDetail(id)
const guide = normalizeGuide(detail as Record<string, any>)
guideMap.set(key, { guide, hasGuide: !!guide, via: 'api' })
} catch {
guideMap.set(key, { guide: '', hasGuide: false, via: 'api' })
}
})
const guideMap = new Map<string, string>()
await Promise.all(
[...idSet.keys()].map(async (id) => {
try {
const detail = await getSalesProductDetail(id)
guideMap.set(id, normalizeGuide(detail as Record<string, any>))
} catch {
guideMap.set(id, '')
}
}),
)
return candidates.map((item) => ({
...item,
guide: item.productId == null ? '' : guideMap.get(String(item.productId)) || '',
}))
return candidates.map((item) => {
const code = String(item.productCode || '').trim()
const id = item.productId == null || item.productId === '' ? '' : String(item.productId)
const key = code ? `code:${code}` : id ? `id:${id}` : ''
const hit = key ? guideMap.get(key) : undefined
return {
...item,
guide: hit?.guide || '',
hasGuide: hit?.hasGuide ?? false,
}
})
}
function isForbidden(guide: string, productName: string) {
const text = guide.toLowerCase()
const name = productName.toLowerCase()
if (!guide) return false
// 品名附近带禁运语义
for (const hint of FORBIDDEN_HINTS) {
if (!text.includes(hint.toLowerCase())) continue
if (text.includes(name) || name.split(/\s+/).some((w) => w.length > 1 && text.includes(w))) {
return true
}
// 全局禁运词 + 品类标签同时命中
const hitTag = CARGO_KEYWORDS.some(
(group) =>
group.keys.some((k) => name.includes(k.toLowerCase())) &&
@ -86,14 +222,32 @@ function isForbidden(guide: string, productName: string) {
return false
}
function inferLocalVerdict(item: QuoteCandidate, productName: string): ShipVerdict {
const guide = String(item.guide || '')
if (!guide) return 'unknown'
if (isForbidden(guide, productName)) return 'forbid'
const lower = guide.toLowerCase()
const name = productName.toLowerCase()
const nameInGuide =
!!name &&
(lower.includes(name) || name.split(/\s+/).some((w) => w.length > 1 && lower.includes(w)))
const cautionHit = CAUTION_HINTS.some((h) => lower.includes(h.toLowerCase()))
const allowHit = ALLOWED_HINTS.some((h) => lower.includes(h.toLowerCase()))
if (nameInGuide && cautionHit) return 'caution'
if (nameInGuide || allowHit) return 'ok'
if (cautionHit) return 'caution'
return 'unknown'
}
function scoreCandidate(item: QuoteCandidate, productName: string) {
const guide = String(item.guide || '')
const name = productName.trim()
let score = 0
const reasons: string[] = []
const verdict = inferLocalVerdict(item, name)
if (isForbidden(guide, name)) {
return { score: -1000, reasons: ['线路说明提示该品类可能禁运/不收'] }
if (verdict === 'forbid') {
return { score: -1000, reasons: ['线路说明提示该品类可能禁运/不收'], verdict }
}
if (!guide) {
@ -105,7 +259,7 @@ function scoreCandidate(item: QuoteCandidate, productName: string) {
const lowerName = name.toLowerCase()
if (lowerName && lowerGuide.includes(lowerName)) {
score += 40
reasons.push('线路说明中直接提到该品名')
reasons.push('对客说明中直接提到该品名')
}
for (const hint of ALLOWED_HINTS) {
if (lowerGuide.includes(hint.toLowerCase())) {
@ -120,11 +274,15 @@ function scoreCandidate(item: QuoteCandidate, productName: string) {
const tagHit = group.tags.some((tag) => lowerGuide.includes(tag.toLowerCase()))
if (tagHit) {
score += 25
reasons.push(`品名与线路说明品类匹配(${group.keys[0]})`)
reasons.push(`品名与对客说明品类匹配(${group.keys[0]})`)
}
}
}
if (verdict === 'ok') score += 12
if (verdict === 'caution') score += 2
if (verdict === 'unknown') score -= 4
if (item.isPriceOptimal) {
score += 30
reasons.push('价格最优')
@ -133,7 +291,6 @@ function scoreCandidate(item: QuoteCandidate, productName: string) {
score += 16
reasons.push('时效最优')
}
// 越便宜分越高
if (Number.isFinite(item.totalCostNum)) {
score += Math.max(0, 20 - Math.min(20, item.totalCostNum / 50))
}
@ -141,23 +298,44 @@ function scoreCandidate(item: QuoteCandidate, productName: string) {
score += Math.max(0, 10 - Math.min(10, item.maxDeliveryDays))
}
return { score, reasons }
return { score, reasons, verdict }
}
export function recommendLocally(productName: string, candidates: QuoteCandidate[]): RecommendResult | null {
export function recommendLocally(
productName: string,
candidates: QuoteCandidate[],
): RecommendResult | null {
if (!candidates.length) return null
const ranked = candidates
.map((item) => {
const { score, reasons } = scoreCandidate(item, productName)
return { item, score, reasons }
const scored = scoreCandidate(item, productName)
return { item, ...scored }
})
.filter((x) => x.score > -500)
.sort((a, b) => b.score - a.score || a.item.totalCostNum - b.item.totalCostNum)
const best = ranked[0]
if (!best) return null
const reason = `推荐「${best.item.shippingMethod}」:${best.reasons.slice(0, 3).join(';') || '综合报价与线路说明择优'}`
return { rowKey: best.item.rowKey, reason, source: 'local' }
const alternatives: RecommendAlternative[] = ranked
.slice(1, 4)
.filter((x) => x.verdict !== 'forbid')
.map((x) => ({
rowKey: x.item.rowKey,
verdict: x.verdict,
blurb: `${x.item.shippingMethod} · ¥${Number(x.item.totalCostNum || 0).toFixed(2)}`,
}))
const usedMcp = candidates.some((c) => c.hasGuide || c.guide)
return {
rowKey: best.item.rowKey,
reason: `推荐「${best.item.shippingMethod}」:${best.reasons.slice(0, 3).join(';') || '综合报价与对客说明择优'}`,
source: usedMcp ? 'mcp' : 'local',
verdict: best.verdict,
evidence: extractEvidenceFromGuide(String(best.item.guide || ''), productName),
cargoName: productName.trim(),
alternatives,
}
}
function extractJsonObject(text: string): Record<string, any> | null {
@ -176,11 +354,60 @@ function extractJsonObject(text: string): Record<string, any> | null {
}
}
function buildResultFromAi(
parsed: Record<string, any>,
withGuides: QuoteCandidate[],
productName: string,
local: RecommendResult | null,
): RecommendResult | null {
const rowKey = String(parsed?.rowKey ?? parsed?.RowKey ?? '').trim()
if (!rowKey || !withGuides.some((c) => c.rowKey === rowKey)) return null
const hit = withGuides.find((c) => c.rowKey === rowKey)!
const reason = String(parsed?.reason ?? parsed?.Reason ?? '').trim()
const verdict = normalizeVerdict(parsed?.verdict ?? parsed?.Verdict)
const evidenceRaw = parsed?.evidence ?? parsed?.Evidence
const evidence = Array.isArray(evidenceRaw)
? evidenceRaw.map((x) => String(x || '').trim()).filter(Boolean).slice(0, 3)
: extractEvidenceFromGuide(String(hit.guide || ''), productName)
const altRaw = parsed?.alternatives ?? parsed?.Alternatives
let alternatives: RecommendAlternative[] = []
if (Array.isArray(altRaw)) {
alternatives = altRaw
.map((a: any) => ({
rowKey: String(a?.rowKey ?? a?.RowKey ?? '').trim(),
verdict: normalizeVerdict(a?.verdict ?? a?.Verdict),
blurb: String(a?.blurb ?? a?.Blurb ?? a?.reason ?? '').trim(),
}))
.filter((a) => a.rowKey && withGuides.some((c) => c.rowKey === a.rowKey) && a.rowKey !== rowKey)
.slice(0, 3)
}
if (!alternatives.length && local?.alternatives?.length) {
alternatives = local.alternatives.filter((a) => a.rowKey !== rowKey).slice(0, 3)
}
return {
rowKey,
reason:
reason ||
`AI 对照对客说明后推荐:${hit.shippingMethod || rowKey}`,
source: 'ai',
verdict: verdict === 'unknown' ? inferLocalVerdict(hit, productName) : verdict,
evidence: evidence.length ? evidence : extractEvidenceFromGuide(String(hit.guide || ''), productName),
cargoName: productName.trim(),
alternatives,
}
}
/**
* 查价页 AI 推荐:先 MCP 拉对客说明,再让模型给出可寄判定 + 理由 + 证据 + 备选。
*/
export async function recommendWithAi(
productName: string,
candidates: QuoteCandidate[],
): Promise<RecommendResult | null> {
const withGuides = await loadGuidesForCandidates(candidates)
const withGuides = await loadGuidesForCandidates(candidates, productName)
const local = recommendLocally(productName, withGuides)
try {
@ -195,12 +422,14 @@ export async function recommendWithAi(
const compact = withGuides.slice(0, 12).map((item) => ({
rowKey: item.rowKey,
channel: item.shippingMethod,
productCode: item.productCode || '',
cargo: item.cargoTypeName,
warehouse: item.warehouse,
deliveryTime: item.deliveryTime,
totalCost: item.totalCostNum,
priceOptimal: !!item.isPriceOptimal,
timeOptimal: !!item.isTimeOptimal,
hasGuide: !!item.hasGuide,
guide: String(item.guide || '').slice(0, 1200),
}))
@ -209,13 +438,24 @@ export async function recommendWithAi(
messages: [
{
role: 'system',
content:
'你是国际物流渠道顾问。根据用户品名与各渠道线路说明,选出最合适的一条渠道。只返回 JSON:{"rowKey":"...","reason":"..."},不要其它文字。若说明明确禁运则不要选该渠道。同等适合时优先更便宜、其次更快。',
content: [
'你是国际物流对客顾问。输入是客户品名 + 各渠道报价与对客说明(已由 oms.channel_guide 拉取)。',
'任务:选出最适合该品名的一条渠道,并给出承运判定。',
'规则:',
'1) 对客说明明确禁运/不收 → verdict=forbid,不要选该渠道;',
'2) 说明未提及该品名 → verdict=unknown 或 caution,勿擅自说「一定可寄」;',
'3) 说明明确可走/可寄 → verdict=ok;敏感但可议 → caution;',
'4) 同等适合时优先更便宜,其次更快;',
'5) evidence 必须摘自 guide 原文短句(1~3条),面向客户可读;',
'6) alternatives 给 0~3 条备选(不同 rowKey),含简短 blurb;',
'7) reason 用对客口吻,一句说清「为什么推荐这条」;',
'只返回 JSON:{"rowKey":"...","verdict":"ok|caution|forbid|unknown","reason":"...","evidence":["..."],"alternatives":[{"rowKey":"...","verdict":"ok","blurb":"..."}]}',
].join('\n'),
},
{
role: 'user',
content: JSON.stringify({
productName,
cargoName: productName,
candidates: compact,
}),
},
@ -223,14 +463,9 @@ export async function recommendWithAi(
})
const parsed = extractJsonObject(content?.content ?? '')
const rowKey = String(parsed?.rowKey ?? parsed?.RowKey ?? '').trim()
const reason = String(parsed?.reason ?? parsed?.Reason ?? '').trim()
if (rowKey && withGuides.some((c) => c.rowKey === rowKey)) {
return {
rowKey,
reason: reason || `AI 推荐渠道:${withGuides.find((c) => c.rowKey === rowKey)?.shippingMethod || rowKey}`,
source: 'ai',
}
if (parsed) {
const built = buildResultFromAi(parsed, withGuides, productName, local)
if (built) return built
}
} catch {
// fallback local

View File

@ -231,6 +231,7 @@ import { useRbacUserStore } from '@/store/modules/rbac-user'
import { AuthService } from '@/api/rbac'
import { resolveAvatarUrl } from '@/utils/avatar'
import { getMapIdFromAccessToken } from '@/utils/jwt'
import { resolvePostLogoutLogin } from '@/utils/login-portal'
defineOptions({
name: 'UserProfile',
@ -421,8 +422,15 @@ async function handleResetPasswordSubmit() {
})
Message.success(t('profile.changePasswordSuccess'))
resetPasswordModalVisible.value = false
const loginType = rbacUserStore.resolveLoginTypeForRedirect()
const currentPath = router.currentRoute.value.fullPath
rbacUserStore.logout()
router.replace({ name: rbacUserStore.getLoginRouteName() })
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 || t('profile.changePasswordFailed'))

View File

@ -24,7 +24,21 @@
</div>
<template v-else>
<div v-if="statusCode === 'oauth-error'" class="wechat-h5-state">
<div v-if="statusCode === 'need-auth'" class="wechat-h5-state wechat-h5-auth-gate">
<div class="wechat-h5-auth-gate__icon" aria-hidden="true">
<icon-user />
</div>
<h2 class="wechat-h5-auth-gate__title">{{ t('wechat.common.authBindGateTitle') }}</h2>
<p class="wechat-h5-hint">{{ t('wechat.common.authBindGateTip') }}</p>
<div class="wechat-h5-state-actions">
<a-button type="primary" long size="large" class="wechat-h5-submit" @click="startOAuth">
{{ t('wechat.common.authOk') }}
</a-button>
<a-button long @click="goLogin">{{ t('wechat.bind.goLogin') }}</a-button>
</div>
</div>
<div v-else-if="statusCode === 'oauth-error'" class="wechat-h5-state">
<a-result status="error" :title="t('wechat.bind.oauthError')" />
<div class="wechat-h5-state-actions">
<a-button type="primary" long @click="retryOAuth">{{ t('wechat.common.retry') }}</a-button>
@ -117,6 +131,20 @@
</div>
</template>
</div>
<a-modal
v-model:visible="authModalVisible"
:title="t('wechat.common.authTitle')"
:ok-text="t('wechat.common.authOk')"
:cancel-text="t('wechat.common.authLater')"
:mask-closable="false"
:esc-to-close="false"
unmount-on-close
simple
@ok="startOAuth"
>
<p class="wechat-h5-auth-modal__desc">{{ t('wechat.bind.authDesc') }}</p>
</a-modal>
</div>
</template>
@ -126,7 +154,7 @@ import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue';
import type { FormInstance, FieldRule } from '@arco-design/web-vue';
import { IconCheckCircleFill, IconLeft } from '@arco-design/web-vue/es/icon';
import { IconCheckCircleFill, IconLeft, IconUser } from '@arco-design/web-vue/es/icon';
import {
bindByWechat,
getOAuthUserInfo,
@ -139,7 +167,7 @@ import LoginLangToggle from '@/views/login/components/LoginLangToggle.vue';
defineOptions({ name: 'WechatBind' });
type PageStatus = 'loading' | 'ready' | 'oauth-error' | 'already-bound';
type PageStatus = 'loading' | 'ready' | 'oauth-error' | 'already-bound' | 'need-auth';
const { t } = useI18n();
const route = useRoute();
@ -157,14 +185,20 @@ function goRegister() {
router.replace('/wechat/register');
}
function retryOAuth() {
function startOAuth() {
authModalVisible.value = false;
redirectToWeChatOAuth('bind');
}
function retryOAuth() {
startOAuth();
}
const formRef = ref<FormInstance>();
const success = ref(false);
const submitting = ref(false);
const statusCode = ref<PageStatus>('loading');
const authModalVisible = ref(false);
const wxUserInfo = ref<WeChatOAuthUserInfo | null>(null);
const formState = reactive({
@ -238,7 +272,8 @@ async function handleSubmit() {
onMounted(() => {
const code = route.query.code;
if (typeof code !== 'string' || !code) {
redirectToWeChatOAuth('bind');
statusCode.value = 'need-auth';
authModalVisible.value = true;
return;
}
void loadOAuthUserInfo(code);
@ -247,4 +282,32 @@ onMounted(() => {
<style lang="less" scoped>
@import '../styles/wechat-h5.less';
.wechat-h5-auth-gate__icon {
width: 56px;
height: 56px;
margin: 0 auto 12px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: var(--app-primary-color);
font-size: 28px;
}
.wechat-h5-auth-gate__title {
margin: 0 0 6px;
font-size: 18px;
font-weight: 700;
color: #1d2129;
text-align: center;
}
.wechat-h5-auth-modal__desc {
margin: 0;
font-size: 14px;
line-height: 1.6;
color: #4e5969;
}
</style>

View File

@ -1,6 +1,15 @@
export default {
'wechat.common.loading': 'Getting WeChat authorization…',
'wechat.common.retry': 'Retry authorization',
'wechat.common.authTitle': 'WeChat authorization',
'wechat.common.authDesc':
'Registration needs your WeChat nickname and avatar to create an account. Tap “Authorize now” to continue.',
'wechat.common.authOk': 'Authorize now',
'wechat.common.authLater': 'Later',
'wechat.common.authGateTitle': 'Start WeChat registration',
'wechat.common.authGateTip': 'Authorize to fill in details and finish registration',
'wechat.common.authBindGateTitle': 'Bind WeChat',
'wechat.common.authBindGateTip': 'Authorize to verify your account and bind',
'wechat.register.title': 'Confirm registration',
'wechat.register.subtitle': 'WeChat name and a default password are ready — confirm to finish',
@ -63,4 +72,6 @@ export default {
'wechat.bind.goRegister': 'Register',
'wechat.bind.wxUser': 'WeChat user',
'wechat.bind.wxReady': 'WeChat authorized — verify your system account',
'wechat.bind.authDesc':
'Binding needs your WeChat identity to link the system account. Tap “Authorize now” to continue.',
};

View File

@ -1,6 +1,15 @@
export default {
'wechat.common.loading': '正在获取微信授权…',
'wechat.common.retry': '重新授权',
'wechat.common.authTitle': '微信授权',
'wechat.common.authDesc':
'注册需获取微信昵称与头像,用于创建客户账号。请点击「立即授权」完成微信授权后继续填写资料。',
'wechat.common.authOk': '立即授权',
'wechat.common.authLater': '稍后再说',
'wechat.common.authGateTitle': '开始微信注册',
'wechat.common.authGateTip': '授权后即可填写资料并完成注册',
'wechat.common.authBindGateTitle': '开始绑定微信',
'wechat.common.authBindGateTip': '授权后即可验证系统账号并绑定',
'wechat.register.title': '确认注册',
'wechat.register.subtitle': '已带入微信昵称与初始密码,确认后即可完成',
@ -61,4 +70,6 @@ export default {
'wechat.bind.goRegister': '去注册',
'wechat.bind.wxUser': '微信用户',
'wechat.bind.wxReady': '微信已授权,请验证系统账号',
'wechat.bind.authDesc':
'绑定需获取当前微信身份,用于关联系统账号。请点击「立即授权」完成微信授权后继续。',
};

View File

@ -36,6 +36,20 @@
</div>
</div>
<div v-else-if="statusCode === 'need-auth'" class="wechat-h5-state wechat-h5-auth-gate">
<div class="wechat-h5-auth-gate__icon" aria-hidden="true">
<icon-user />
</div>
<h2 class="wechat-h5-auth-gate__title">{{ t('wechat.common.authGateTitle') }}</h2>
<p class="wechat-h5-hint">{{ t('wechat.common.authGateTip') }}</p>
<div class="wechat-h5-state-actions">
<a-button type="primary" long size="large" class="wechat-h5-submit" @click="startOAuth">
{{ t('wechat.common.authOk') }}
</a-button>
<a-button long @click="goLogin">{{ t('wechat.register.goLogin') }}</a-button>
</div>
</div>
<div v-else-if="statusCode === 'oauth-error'" class="wechat-h5-state">
<a-result status="error" :title="t('wechat.register.oauthError')" />
<div class="wechat-h5-state-actions">
@ -167,6 +181,20 @@
</div>
</template>
</div>
<a-modal
v-model:visible="authModalVisible"
:title="t('wechat.common.authTitle')"
:ok-text="t('wechat.common.authOk')"
:cancel-text="t('wechat.common.authLater')"
:mask-closable="false"
:esc-to-close="false"
unmount-on-close
simple
@ok="startOAuth"
>
<p class="wechat-h5-auth-modal__desc">{{ t('wechat.common.authDesc') }}</p>
</a-modal>
</div>
</template>
@ -176,7 +204,7 @@ import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue';
import type { FormInstance, FieldRule } from '@arco-design/web-vue';
import { IconCheckCircleFill, IconLeft } from '@arco-design/web-vue/es/icon';
import { IconCheckCircleFill, IconLeft, IconUser } from '@arco-design/web-vue/es/icon';
import {
getOAuthUserInfo,
registerByWechat,
@ -201,7 +229,7 @@ import LoginLangToggle from '@/views/login/components/LoginLangToggle.vue';
defineOptions({ name: 'WechatRegister' });
type PageStatus = 'loading' | 'ready' | 'oauth-error' | 'already-bound' | 'open-in-wechat';
type PageStatus = 'loading' | 'ready' | 'oauth-error' | 'already-bound' | 'open-in-wechat' | 'need-auth';
const { t } = useI18n();
const route = useRoute();
@ -219,10 +247,15 @@ function goBind() {
router.replace('/wechat/bind');
}
function retryOAuth() {
function startOAuth() {
authModalVisible.value = false;
redirectToWeChatOAuth('register', inviteCode.value);
}
function retryOAuth() {
startOAuth();
}
async function copyInviteLink() {
const url = buildInviteRegisterUrl(inviteCode.value) || window.location.href;
try {
@ -237,6 +270,7 @@ const formRef = ref<FormInstance>();
const success = ref(false);
const submitting = ref(false);
const statusCode = ref<PageStatus>('loading');
const authModalVisible = ref(false);
const wxUserInfo = ref<WeChatOAuthUserInfo | null>(null);
const inviteCode = ref('');
const referrerValidated = ref<boolean | null>(null);
@ -371,7 +405,9 @@ onMounted(() => {
statusCode.value = 'open-in-wechat';
return;
}
redirectToWeChatOAuth('register', refCode);
// 进入注册页主动弹窗,由用户确认后再跳转 OAuth,避免微信底部「完整服务」被动提示
statusCode.value = 'need-auth';
authModalVisible.value = true;
return;
}
void loadOAuthUserInfo(code);
@ -384,4 +420,32 @@ onMounted(() => {
.wechat-h5-referrer {
margin-bottom: 16px;
}
.wechat-h5-auth-gate__icon {
width: 56px;
height: 56px;
margin: 0 auto 12px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: var(--app-primary-color);
font-size: 28px;
}
.wechat-h5-auth-gate__title {
margin: 0 0 6px;
font-size: 18px;
font-weight: 700;
color: #1d2129;
text-align: center;
}
.wechat-h5-auth-modal__desc {
margin: 0;
font-size: 14px;
line-height: 1.6;
color: #4e5969;
}
</style>

View File

@ -27,6 +27,12 @@ export default defineConfig(({ mode }) => {
// 添加中间件,在代理之前拦截前端路由
middlewareMode: false,
proxy: {
// Admin wwwroot 静态资源:/uploads/... → {admin}/uploads/...
'/uploads': {
target: omsAdminTarget,
changeOrigin: true,
secure: false,
},
// 上传静态资源:/api/upload/xxx -> http://api.nanafa.com/upload/xxx
// 必须放在通用 /api 规则前,避免被转发到 /itfx/upload 导致 404
'/api/upload': {