feat(web-admin): add mobile card list for transport plans in webview.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-09-14 16:25:09 +08:00
1 parent 5220637639
commit 17edd7016f
3 files changed
+401 -9

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
.mp-wv-header {
padding: 8px 0 12px;
color: #fff;
background: linear-gradient(145deg, #087f57 0%, #0a9f6d 72%, #d6a343 165%);
margin: -12px -16px 12px;
padding: 12px 16px 16px;
border-radius: 0 0 20px 20px;
}
.mp-wv-header__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.mp-wv-header__back {
font-size: 13px;
opacity: 0.95;
background: none;
border: none;
color: inherit;
cursor: pointer;
}
.mp-wv-header h1 {
margin: 8px 0 0;
font-size: 20px;
font-weight: 700;
}
.mp-wv-search {
margin-top: 12px;
padding: 10px 14px;
font-size: 13px;
color: #1f2a26;
background: #fff;
border: none;
border-radius: 999px;
width: 100%;
}
.mp-wv-chips,
.mp-wv-tabs {
display: flex;
gap: 8px;
overflow-x: auto;
padding: 0 0 10px;
-webkit-overflow-scrolling: touch;
}
.mp-wv-chip,
.mp-wv-tab {
flex: 0 0 auto;
height: 32px;
padding: 0 12px;
font-size: 12px;
color: #6b7b74;
background: #fff;
border: 1px solid #dcebe4;
border-radius: 999px;
cursor: pointer;
}
.mp-wv-chip.is-active,
.mp-wv-tab.is-active {
color: #fff;
background: #0a9f6d;
border-color: #0a9f6d;
font-weight: 600;
}
.mp-wv-card {
padding: 12px;
margin-bottom: 10px;
background: #fff;
border: 1px solid #dcebe4;
border-radius: 14px;
box-shadow: 0 6px 16px rgba(10, 159, 109, 0.08);
cursor: pointer;
}
.mp-wv-card__head {
display: flex;
justify-content: space-between;
gap: 8px;
}
.mp-wv-card__no {
font-size: 15px;
font-weight: 700;
color: #087f57;
}
.mp-wv-card__sub,
.mp-wv-card__meta {
margin-top: 4px;
font-size: 12px;
color: #6b7b74;
}
.mp-wv-card__product {
margin-top: 8px;
padding: 8px 10px;
font-size: 12px;
color: #087f57;
background: #e9f9f2;
border-radius: 10px;
}
.mp-wv-empty {
padding: 32px 8px;
font-size: 14px;
color: #6b7b74;
text-align: center;
}
.mp-wv-toolbar {
display: flex;
gap: 8px;
margin: 0 0 12px;
}
.mp-wv-btn {
height: 36px;
padding: 0 12px;
font-size: 13px;
color: #1f2a26;
background: #fff;
border: 1px solid #dcebe4;
border-radius: 8px;
cursor: pointer;
}
.mp-wv-btn--primary {
color: #fff;
background: #0a9f6d;
border-color: #0a9f6d;
font-weight: 600;
}
+34
View File
@@ -0,0 +1,34 @@
import type { Plan } from '@/api/pl'
import {
formatPlVehiclePlateDisplay,
getPlPlanFollowStatusLabel,
getPlPlanFollowStatusTagType,
getPlPlanTypeLabel
} from '@/utils/pl-display'
function productSummary(plan: Plan): string {
const extra = (plan as Plan & { productSummary?: string }).productSummary
if (extra) return extra
const names = (plan.products || [])
.map((item) => item.productName)
.filter(Boolean)
if (names.length) return names.join(' · ')
const parties = [plan.supplierName, plan.customerName || plan.destinationName]
.filter(Boolean)
.join(' → ')
return parties || '—'
}
export function buildPlanCard(plan: Plan) {
return {
id: plan.id,
planNo: plan.planNo || '—',
typeLabel: getPlPlanTypeLabel(plan.planType || ''),
planDate: plan.planDate || '—',
statusLabel: getPlPlanFollowStatusLabel(plan.followStatus),
statusTagType: getPlPlanFollowStatusTagType(plan.followStatus),
plate: formatPlVehiclePlateDisplay(plan.licensePlate, plan.trailerNo) || '—',
people: plan.personnelSummary || '—',
product: productSummary(plan)
}
}
+227 -9
View File
@@ -1,18 +1,236 @@
<template>
<MpWebviewShell>
<p class="mp-wv-placeholder">运输计划加载中…</p>
<div v-if="forbidden" class="mp-wv-empty">无权限</div>
<template v-else>
<header class="mp-wv-header">
<div class="mp-wv-header__row">
<button class="mp-wv-header__back" type="button" @click="goBack">← 工作台</button>
<button class="mp-wv-btn mp-wv-btn--primary" type="button" @click="goNew">新增</button>
</div>
<h1>运输计划</h1>
<input
v-model="keyword"
class="mp-wv-search"
type="search"
placeholder="计划编号 / 供方 / 需方 / 产品 / 车牌号"
@keyup.enter="reload"
/>
</header>
<div class="mp-wv-chips">
<button
v-for="item in dateChips"
:key="item.value"
type="button"
class="mp-wv-chip"
:class="{ 'is-active': dateShortcut === item.value }"
@click="applyDate(item.value)"
>
{{ item.label }}
</button>
</div>
<div class="mp-wv-tabs">
<button
v-for="tab in PL_PLAN_LIST_TABS"
:key="tab.name"
type="button"
class="mp-wv-tab"
:class="{ 'is-active': activeTab === tab.name }"
@click="applyTab(tab.name)"
>
{{ tab.label }}
</button>
</div>
<div class="mp-wv-toolbar">
<button class="mp-wv-btn" type="button" @click="goPaste">三方人车</button>
</div>
<div v-if="loading && !cards.length" class="mp-wv-empty">加载中…</div>
<div v-else-if="!cards.length" class="mp-wv-empty">暂无计划</div>
<article
v-for="card in cards"
:key="card.id"
class="mp-wv-card"
@click="goDetail(card.id)"
>
<div class="mp-wv-card__head">
<div>
<div class="mp-wv-card__no">{{ card.planNo }}</div>
<div class="mp-wv-card__sub">{{ card.typeLabel }} · {{ card.planDate }}</div>
</div>
<el-tag :type="card.statusTagType" size="small">{{ card.statusLabel }}</el-tag>
</div>
<div class="mp-wv-card__meta">车牌:{{ card.plate }}</div>
<div class="mp-wv-card__meta">人员:{{ card.people }}</div>
<div class="mp-wv-card__product">{{ card.product }}</div>
</article>
<div v-if="hasMore" class="mp-wv-toolbar">
<button class="mp-wv-btn" type="button" :disabled="loading" @click="loadMore">
{{ loading ? '加载中…' : '加载更多' }}
</button>
</div>
</template>
</MpWebviewShell>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
</script>
import { planApi, type Plan } from '@/api/pl'
import { buildPlanCard } from '@/utils/mp-pl-plan-card'
import { getWxMiniProgram } from '@/utils/mp-webview'
import {
PL_PLAN_LIST_TABS,
plPlanListTabToFilters,
type PlPlanListTab
} from '@/utils/pl-display'
import { HttpError } from '@/utils/http/error'
import { ApiStatus } from '@/utils/http/status'
import '@/styles/mp-webview.scss'
<style scoped>
.mp-wv-placeholder {
margin: 24px 0 0;
font-size: 14px;
color: #6b7b74;
text-align: center;
type DateChip = 'today' | 'tomorrow' | 'week' | 'month'
const router = useRouter()
const keyword = ref('')
const activeTab = ref<PlPlanListTab>('pending_arrange')
const dateShortcut = ref<DateChip>('today')
const dateRange = ref<string[]>([])
const loading = ref(false)
const forbidden = ref(false)
const page = ref(1)
const pageSize = 20
const total = ref(0)
const plans = ref<Plan[]>([])
const dateChips: { label: string; value: DateChip }[] = [
{ label: '今天', value: 'today' },
{ label: '明天', value: 'tomorrow' },
{ label: '本周', value: 'week' },
{ label: '本月', value: 'month' }
]
const cards = computed(() => plans.value.map((row) => buildPlanCard(row)))
const hasMore = computed(() => plans.value.length < total.value)
function formatYmd(date: Date) {
const m = `${date.getMonth() + 1}`.padStart(2, '0')
const d = `${date.getDate()}`.padStart(2, '0')
return `${date.getFullYear()}-${m}-${d}`
}
</style>
function rangeFor(type: DateChip): string[] {
const today = new Date()
if (type === 'today') {
const d = formatYmd(today)
return [d, d]
}
if (type === 'tomorrow') {
const t = new Date(today)
t.setDate(t.getDate() + 1)
const d = formatYmd(t)
return [d, d]
}
if (type === 'week') {
const start = new Date(today)
const day = start.getDay() || 7
start.setDate(start.getDate() - day + 1)
const end = new Date(start)
end.setDate(start.getDate() + 6)
return [formatYmd(start), formatYmd(end)]
}
const start = new Date(today.getFullYear(), today.getMonth(), 1)
const end = new Date(today.getFullYear(), today.getMonth() + 1, 0)
return [formatYmd(start), formatYmd(end)]
}
function buildParams() {
const tabFilters = plPlanListTabToFilters(activeTab.value)
const params: Record<string, unknown> = {
page: page.value,
pageSize
}
const q = keyword.value.trim()
if (q) params.q = q
if (tabFilters.followStatus) params.followStatus = tabFilters.followStatus
if (tabFilters.planType) params.planType = tabFilters.planType
if (dateRange.value[0]) params.planDateFrom = dateRange.value[0]
if (dateRange.value[1]) params.planDateTo = dateRange.value[1]
return params
}
async function fetchPage(reset: boolean) {
if (reset) {
page.value = 1
plans.value = []
}
loading.value = true
try {
const res = await planApi.list(buildParams())
const data = (res as { data?: { list?: Plan[]; total?: number }; list?: Plan[]; total?: number })
.data ?? res
const list = (data as { list?: Plan[] }).list || []
total.value = (data as { total?: number }).total || 0
plans.value = reset ? list : plans.value.concat(list)
forbidden.value = false
} catch (error) {
if (error instanceof HttpError && error.code === ApiStatus.forbidden) {
forbidden.value = true
plans.value = []
return
}
throw error
} finally {
loading.value = false
}
}
function reload() {
void fetchPage(true)
}
function applyDate(type: DateChip) {
dateShortcut.value = type
dateRange.value = rangeFor(type)
reload()
}
function applyTab(tab: PlPlanListTab) {
activeTab.value = tab
reload()
}
function loadMore() {
page.value += 1
void fetchPage(false)
}
function goBack() {
const mini = getWxMiniProgram()
if (mini?.navigateBack) {
mini.navigateBack({ delta: 1 })
return
}
router.back()
}
function goNew() {
void router.push('/m/pl/plan/new')
}
function goPaste() {
void router.push('/m/pl/plan/paste')
}
function goDetail(id: string) {
void router.push(`/m/pl/plan/${id}`)
}
onMounted(() => {
dateRange.value = rangeFor('today')
reload()
})
</script>