证件额度按客户展示;Web 客户列表改用 toQueryParams,修复 buildQuery is not a function。 Co-authored-by: Cursor <cursoragent@cursor.com>
347 lines
11 KiB
Vue
347 lines
11 KiB
Vue
<template>
|
|
<div
|
|
class="mp-wv-root"
|
|
:class="{
|
|
'mp-wv-root--customer-list': boot.mode === 'list',
|
|
'mp-wv-root--crm': isCrm,
|
|
'mp-wv-root--transport-plan': isTransportPlan
|
|
}"
|
|
>
|
|
<template v-if="phase === 'boot'">
|
|
<MpListPageSkeleton
|
|
v-if="boot.mode === 'list'"
|
|
variant="full"
|
|
layout="customer"
|
|
:title="boot.title"
|
|
searchable
|
|
:search-placeholder="boot.search"
|
|
:tabs="boot.tabs"
|
|
:filters="boot.filters"
|
|
:caption="boot.caption"
|
|
:aria-label="boot.caption"
|
|
@back="goToMiniWorkbench"
|
|
/>
|
|
<template v-else>
|
|
<header class="mp-wv-header" aria-hidden="true">
|
|
<div class="mp-wv-header__row">
|
|
<span class="mp-wv-header__back">←</span>
|
|
<h1 class="mp-wv-header__title">{{ boot.title || '\u00a0' }}</h1>
|
|
</div>
|
|
</header>
|
|
<MpStructSkeleton kind="detail" :caption="boot.caption" />
|
|
</template>
|
|
</template>
|
|
<p v-else-if="phase === 'need-mini'" class="mp-wv-placeholder">请从山峡化工小程序打开</p>
|
|
<p v-else-if="phase === 'expired'" class="mp-wv-placeholder">{{ failHint || '打开失败' }}</p>
|
|
<slot v-else />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { ElMessage } from 'element-plus'
|
|
// 函数式组件不会触发模板按需样式导入,直接进入详情页也必须有弹层样式。
|
|
import 'vant/es/dialog/style'
|
|
import 'vant/es/toast/style'
|
|
import { useUserStore } from '@/store/modules/user'
|
|
import { useDictStore } from '@/store/modules/dict'
|
|
import { mpWebviewApi } from '@/api/mp-webview'
|
|
import MpListPageSkeleton from '@/components/mp/MpListPageSkeleton.vue'
|
|
import MpStructSkeleton from '@/components/mp/MpStructSkeleton.vue'
|
|
import { MP_CONTRACT_APPROVE_TABS } from '@/utils/mp-crm-contract-card'
|
|
import { MP_QUOTE_APPROVE_TABS, MP_QUOTE_MANAGE_TABS } from '@/utils/mp-crm-quote-card'
|
|
import { PL_PLAN_LIST_TABS } from '@/utils/pl-display'
|
|
import {
|
|
beginMpWebviewAuthBoot,
|
|
endMpWebviewAuthBoot,
|
|
goToMiniWorkbench,
|
|
rememberMpReturnOrigin,
|
|
stripMpTicketFromUrl
|
|
} from '@/utils/mp-webview'
|
|
import '@/styles/mp-webview.scss'
|
|
|
|
const JWX_SRC = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'
|
|
|
|
type BootProfile = {
|
|
mode: 'list' | 'detail'
|
|
title: string
|
|
search: string
|
|
tabs: string[]
|
|
filters: string[]
|
|
caption: string
|
|
}
|
|
|
|
const LIST_BOOT: Record<string, BootProfile> = {
|
|
'/m/crm/customer': {
|
|
mode: 'list',
|
|
title: '客户管理',
|
|
search: '客户名称 / 编号 / 手机号 / 拼音首字母',
|
|
tabs: ['全部', '我的', '待跟进', '重点'],
|
|
filters: ['客户类型', '客户级别', '更多'],
|
|
caption: '正在加载客户列表'
|
|
},
|
|
'/m/crm/product': {
|
|
mode: 'list',
|
|
title: '产品管理',
|
|
search: '产品名称 / 编号 / 拼音',
|
|
tabs: [],
|
|
filters: ['全部分类', '全部型号', '更多'],
|
|
caption: '正在加载产品列表'
|
|
},
|
|
'/m/crm/quote': {
|
|
mode: 'list',
|
|
title: '报价单管理',
|
|
search: '报价主题 / 客户 / 单号',
|
|
tabs: MP_QUOTE_MANAGE_TABS.map((tab) => tab.label),
|
|
filters: ['全部类型', '全部金额', '更多'],
|
|
caption: '正在加载报价单'
|
|
},
|
|
'/m/crm/quote/approval': {
|
|
mode: 'list',
|
|
title: '报价单审批',
|
|
search: '报价主题 / 客户 / 单号',
|
|
tabs: MP_QUOTE_APPROVE_TABS.map((tab) => tab.label),
|
|
filters: ['全部类型', '全部金额', '更多'],
|
|
caption: '正在加载报价单'
|
|
},
|
|
'/m/crm/contract': {
|
|
mode: 'list',
|
|
title: '合同管理',
|
|
search: '合同主题 / 客户 / 单号',
|
|
tabs: [],
|
|
filters: ['全部类型', '全部金额', '更多'],
|
|
caption: '正在加载合同'
|
|
},
|
|
'/m/crm/contract/approval': {
|
|
mode: 'list',
|
|
title: '合同审批',
|
|
search: '合同主题 / 客户 / 单号',
|
|
tabs: MP_CONTRACT_APPROVE_TABS.map((tab) => tab.label),
|
|
filters: ['全部类型', '全部金额', '更多'],
|
|
caption: '正在加载合同'
|
|
},
|
|
'/m/pl/plan': {
|
|
mode: 'list',
|
|
title: '运输计划',
|
|
search: '计划编号 / 供方 / 需方 / 产品 / 车牌号',
|
|
tabs: PL_PLAN_LIST_TABS.map((tab) => tab.label),
|
|
filters: ['全部', '起止日期', '更多'],
|
|
caption: '正在加载运输计划'
|
|
},
|
|
'/m/pl/plan-cert': {
|
|
mode: 'list',
|
|
title: '运输证件管理',
|
|
search: '证件编号 / 品名',
|
|
tabs: ['证件建档', '客户用证', '证件用量', '计划段次用证', '证书齐备'],
|
|
filters: [],
|
|
caption: '正在加载运输证件管理'
|
|
}
|
|
}
|
|
|
|
const emit = defineEmits<{ ready: [] }>()
|
|
|
|
const route = useRoute()
|
|
const isCrm = computed(() => /^\/m\/crm(?:\/|$)/.test(route.path))
|
|
const isTransportPlan = computed(() => /^\/m\/pl\/plan(?:\/|$)/.test(route.path))
|
|
const router = useRouter()
|
|
const userStore = useUserStore()
|
|
const dictStore = useDictStore()
|
|
const phase = ref<'boot' | 'need-mini' | 'expired' | 'ready'>('boot')
|
|
const failHint = ref('')
|
|
let bootActive = false
|
|
let backTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
const boot = computed<BootProfile>(() => {
|
|
const path = String(route.path || '').replace(/\/$/, '') || '/'
|
|
const listed = LIST_BOOT[path]
|
|
if (listed) return listed
|
|
const title = String(route.meta?.title || '').trim() || '页面'
|
|
return {
|
|
mode: 'detail',
|
|
title,
|
|
search: '',
|
|
tabs: [],
|
|
filters: [],
|
|
caption: `正在加载${title}`
|
|
}
|
|
})
|
|
|
|
function markBootStart() {
|
|
if (bootActive) return
|
|
bootActive = true
|
|
beginMpWebviewAuthBoot()
|
|
}
|
|
|
|
function markBootEnd() {
|
|
if (!bootActive) return
|
|
bootActive = false
|
|
endMpWebviewAuthBoot()
|
|
}
|
|
|
|
function loadJweixin(): Promise<void> {
|
|
if (typeof window === 'undefined') return Promise.resolve()
|
|
const wxObj = (window as unknown as { wx?: { miniProgram?: unknown } }).wx
|
|
if (wxObj?.miniProgram) return Promise.resolve()
|
|
if (document.querySelector('script[data-mp-jweixin]')) return Promise.resolve()
|
|
return new Promise((resolve) => {
|
|
const script = document.createElement('script')
|
|
script.src = JWX_SRC
|
|
script.async = true
|
|
script.setAttribute('data-mp-jweixin', '1')
|
|
script.onload = () => resolve()
|
|
script.onerror = () => resolve()
|
|
document.head.appendChild(script)
|
|
})
|
|
}
|
|
|
|
async function enterReady() {
|
|
phase.value = 'ready'
|
|
markBootEnd()
|
|
emit('ready')
|
|
}
|
|
|
|
function readQueryParam(name: string): string {
|
|
const fromRoute = String(route.query[name] || '').trim()
|
|
if (fromRoute) return fromRoute
|
|
if (typeof window === 'undefined') return ''
|
|
try {
|
|
const search = String(window.location.search || '').replace(/^\?/, '')
|
|
if (search) {
|
|
for (const part of search.split('&')) {
|
|
const [k, ...rest] = part.split('=')
|
|
if (decodeURIComponent(k || '') === name) {
|
|
return decodeURIComponent(rest.join('=') || '').trim()
|
|
}
|
|
}
|
|
}
|
|
const hash = String(window.location.hash || '')
|
|
const q = hash.indexOf('?')
|
|
if (q >= 0) {
|
|
const qs = hash.slice(q + 1)
|
|
for (const part of qs.split('&')) {
|
|
const [k, ...rest] = part.split('=')
|
|
if (decodeURIComponent(k || '') === name) {
|
|
return decodeURIComponent(rest.join('=') || '').trim()
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
return ''
|
|
}
|
|
|
|
function clearMpTicketFromBrowserUrl() {
|
|
if (typeof window === 'undefined' || !window.history?.replaceState) return
|
|
try {
|
|
const url = new URL(window.location.href)
|
|
url.searchParams.delete('mpTicket')
|
|
url.searchParams.delete('mpReturnOrigin')
|
|
const hashPath = url.hash.split('?')[0] || url.hash
|
|
url.hash = hashPath
|
|
window.history.replaceState(window.history.state, '', url.toString())
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
function failOpenAndBack(message: string) {
|
|
phase.value = 'expired'
|
|
failHint.value = message || '打开失败'
|
|
markBootEnd()
|
|
ElMessage.warning(message || '打开失败,请返回重试')
|
|
if (backTimer) clearTimeout(backTimer)
|
|
backTimer = setTimeout(() => {
|
|
goToMiniWorkbench()
|
|
}, 1500)
|
|
}
|
|
|
|
async function bootstrap() {
|
|
markBootStart()
|
|
try {
|
|
await loadJweixin()
|
|
rememberMpReturnOrigin(readQueryParam('mpReturnOrigin'))
|
|
const ticket = readQueryParam('mpTicket')
|
|
if (ticket) {
|
|
try {
|
|
userStore.setLoginStatus(false)
|
|
userStore.setToken('', '')
|
|
const data = await mpWebviewApi.exchange(ticket)
|
|
userStore.setToken(data.token, data.refreshToken)
|
|
userStore.setUserInfo(data.userInfo)
|
|
userStore.setLoginStatus(true)
|
|
clearMpTicketFromBrowserUrl()
|
|
await dictStore.loadAllDict()
|
|
const nextQuery = stripMpTicketFromUrl(route.path, { ...route.query })
|
|
await router.replace({ path: route.path, query: nextQuery as Record<string, string> })
|
|
await enterReady()
|
|
} catch (err) {
|
|
const msg = (err && (err as { message?: string }).message) || '打开失败,请返回重试'
|
|
if (userStore.isLogin && userStore.accessToken) {
|
|
clearMpTicketFromBrowserUrl()
|
|
await dictStore.loadAllDict()
|
|
await enterReady()
|
|
return
|
|
}
|
|
failOpenAndBack(msg)
|
|
}
|
|
return
|
|
}
|
|
if (userStore.isLogin) {
|
|
await dictStore.loadAllDict()
|
|
await enterReady()
|
|
return
|
|
}
|
|
phase.value = 'need-mini'
|
|
markBootEnd()
|
|
} catch (err) {
|
|
const msg = (err && (err as { message?: string }).message) || '打开失败,请返回重试'
|
|
failOpenAndBack(msg)
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
if (typeof document !== 'undefined') {
|
|
// 壳页已 custom,无系统导航栏;标题仍给 H5 / 分享兜底
|
|
document.title = String(route.meta?.title || '').trim() || '山峡化工'
|
|
}
|
|
void bootstrap()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (backTimer) clearTimeout(backTimer)
|
|
markBootEnd()
|
|
})
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.mp-wv-root {
|
|
--brand: #0a9f6d;
|
|
--el-color-primary: #0a9f6d;
|
|
/* 与原生顶栏同色,接缝处即使露底也是同绿,不出现灰线 */
|
|
--mp-nav-green: #087f57;
|
|
box-sizing: border-box;
|
|
width: 100%;
|
|
min-height: 100vh;
|
|
max-width: min(480px, 100%);
|
|
margin: 0 auto;
|
|
padding: 0 10px calc(16px + env(safe-area-inset-bottom));
|
|
overflow-x: clip;
|
|
color: #1f2a26;
|
|
background-color: #f5f8f7;
|
|
/* 仅铺状态栏接缝,勿用固定 96px:短顶栏(新增/详情)圆角下会露出方形绿块 */
|
|
background-image: linear-gradient(var(--mp-nav-green), var(--mp-nav-green));
|
|
background-repeat: no-repeat;
|
|
background-size: 100% calc(12px + env(safe-area-inset-top, 0px));
|
|
}
|
|
|
|
.mp-wv-placeholder {
|
|
margin: 24px 0 0;
|
|
font-size: 14px;
|
|
color: #b45309;
|
|
text-align: center;
|
|
font-weight: 600;
|
|
}
|
|
</style>
|