Files
shanxia2.0/web-admin/src/layouts/MpWebviewShell.vue
T
wuzilongandCursor ea46761f40 feat(pl): 合同用证改为客户用证并修复列表筛选。
证件额度按客户展示;Web 客户列表改用 toQueryParams,修复 buildQuery is not a function。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-15 14:46:46 +08:00

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>