feat: 查价价卡展示贸易条款与产品分组,并优化 H5/登录与顾问体验
销售产品可配置 DDP/DDU;查价返回分组与贸易条款并展示在价卡上。 Co-authored-by: Cursor <cursoragent@cursor.com>
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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('没有权限访问')
|
||||
|
||||
20
src/assets/icons/h5/order-create.svg
Normal 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 |
24
src/assets/icons/h5/order-logistics.svg
Normal 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 |
24
src/assets/icons/h5/order-pickup.svg
Normal 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 |
10
src/assets/icons/h5/route-plane.svg
Normal 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 |
21
src/assets/icons/h5/service-commission.svg
Normal 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 |
20
src/assets/icons/h5/service-coupon.svg
Normal 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 |
20
src/assets/icons/h5/service-invite.svg
Normal 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 |
23
src/assets/icons/h5/service-order.svg
Normal 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 |
@ -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;
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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}`;
|
||||
});
|
||||
|
||||
|
||||
@ -77,7 +77,7 @@
|
||||
},
|
||||
]);
|
||||
|
||||
const DEFAULT_THEME_COLOR = '#132c44';
|
||||
const DEFAULT_THEME_COLOR = '#007979';
|
||||
|
||||
const cancel = () => {
|
||||
appStore.updateSettings({ globalSettings: false });
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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(proxiedUrl);
|
||||
} catch {
|
||||
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,
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
"hideMenu": false,
|
||||
"menuCollapse": false,
|
||||
"footer": false,
|
||||
"themeColor": "#132c44",
|
||||
"themeColor": "#007979",
|
||||
"menuWidth": 220,
|
||||
"globalSettings": false,
|
||||
"device": "desktop",
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '偏远分区',
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
name: loginRouteName,
|
||||
path: target.path,
|
||||
query: {
|
||||
redirect: to.name as string,
|
||||
...(target.query || {}),
|
||||
} as LocationQueryRaw,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: target.name,
|
||||
query: {
|
||||
...(target.query || {}),
|
||||
...to.query,
|
||||
} as LocationQueryRaw,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 ''
|
||||
}
|
||||
|
||||
@ -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({
|
||||
name: userStore.getLoginRouteName(),
|
||||
query: {
|
||||
redirect: router.currentRoute.value.fullPath,
|
||||
},
|
||||
path: target.path,
|
||||
query: target.query,
|
||||
})
|
||||
return
|
||||
}
|
||||
router.replace({
|
||||
name: target.name,
|
||||
query: target.query,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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' },
|
||||
}),
|
||||
]);
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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,6 +25,10 @@ export function handleApiError(error: any): void {
|
||||
break
|
||||
case 401:
|
||||
Message.error('未授权,请重新登录')
|
||||
{
|
||||
const rbacUserStore = useRbacUserStore()
|
||||
const loginType = rbacUserStore.resolveLoginTypeForRedirect()
|
||||
const currentPath = router.currentRoute.value.fullPath
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('userInfo')
|
||||
// 清除所有RBAC相关存储
|
||||
@ -32,14 +37,16 @@ export function handleApiError(error: any): void {
|
||||
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 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 || '没有权限访问')
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
25
src/utils/wechatShowQrUrl.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '中午好',
|
||||
|
||||
@ -76,17 +76,13 @@
|
||||
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-card__main">
|
||||
<div class="h5-order-card__body">
|
||||
<!-- 客户第一眼:单号 + 状态 -->
|
||||
<div class="h5-order-row1">
|
||||
<!-- 顶栏:状态 + 单号(右上) -->
|
||||
<div class="h5-order-top">
|
||||
<span class="h5-order-status" :style="statusStyle(row)">{{ statusLabel(row) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="h5-order-no"
|
||||
@ -95,43 +91,46 @@
|
||||
<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">
|
||||
<!-- 主视觉:发货仓 → 目的国(顺丰/菜鸟式线路) -->
|
||||
<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-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) && destinationLabel(row)">
|
||||
<i>·</i>
|
||||
</template>
|
||||
<span v-if="destinationLabel(row)" class="h5-order-route__dest">
|
||||
{{ destinationLabel(row) }}
|
||||
</span>
|
||||
<template v-if="channelLabel(row) && row.cargoAttributeLabel"> · </template>
|
||||
<span v-if="row.cargoAttributeLabel">{{ row.cargoAttributeLabel }}</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>
|
||||
|
||||
<div class="h5-order-side" @click.stop>
|
||||
<span v-if="isPendingPay(row) || row.isPaid" class="h5-order-amount">
|
||||
<template v-if="isPendingPay(row)">
|
||||
<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>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="is-paid">{{ t('h5.orders.paid') }}</span>
|
||||
</template>
|
||||
</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 {
|
||||
span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:deep(.arco-icon) {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-width: 72px;
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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(proxiedUrl);
|
||||
} catch {
|
||||
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 {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
|
||||
&: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);
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
max-width: 100%;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
box-shadow: none;
|
||||
.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;
|
||||
}
|
||||
|
||||
&.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>
|
||||
|
||||
@ -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 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>
|
||||
<p class="guide-panel__recommend-text">{{ recommendReason }}</p>
|
||||
<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>
|
||||
|
||||
414
src/views/order/priceCheck/components/QuoteResultCard.vue
Normal 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>
|
||||
@ -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) => {
|
||||
const id = item.productId == null || item.productId === '' ? '' : String(item.productId)
|
||||
if (!id) return
|
||||
const list = idSet.get(id) ?? []
|
||||
list.push(item)
|
||||
idSet.set(id, list)
|
||||
})
|
||||
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'
|
||||
}
|
||||
|
||||
const guideMap = new Map<string, string>()
|
||||
await Promise.all(
|
||||
[...idSet.keys()].map(async (id) => {
|
||||
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)
|
||||
const key = code ? `code:${code}` : id ? `id:${id}` : ''
|
||||
if (!key) continue
|
||||
const list = groups.get(key) ?? []
|
||||
list.push(item)
|
||||
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)
|
||||
guideMap.set(id, normalizeGuide(detail as Record<string, any>))
|
||||
const guide = normalizeGuide(detail as Record<string, any>)
|
||||
guideMap.set(key, { guide, hasGuide: !!guide, via: 'api' })
|
||||
} catch {
|
||||
guideMap.set(id, '')
|
||||
guideMap.set(key, { guide: '', hasGuide: false, via: 'api' })
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return candidates.map((item) => ({
|
||||
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: item.productId == null ? '' : guideMap.get(String(item.productId)) || '',
|
||||
}))
|
||||
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
|
||||
|
||||
@ -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'))
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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.',
|
||||
};
|
||||
|
||||
@ -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':
|
||||
'绑定需获取当前微信身份,用于关联系统账号。请点击「立即授权」完成微信授权后继续。',
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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': {
|
||||
|
||||