feat(web-admin): redesign mp transport plan list to match demo.

Use dual dropdown filters, richer cards, and a speed dial for create actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-09-15 09:32:47 +08:00
1 parent 92bb4bf0d0
commit 8d1d5c19b5
1 file changed
+327 -61
+327 -61
View File
@@ -5,7 +5,6 @@
<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
@@ -17,61 +16,135 @@
/>
</header>
<div class="mp-wv-chips">
<div class="mp-wv-filter-wrap">
<div class="mp-wv-filter-bar">
<button
class="mp-wv-filter-trig"
:class="{ open: openFilter === 'date' }"
type="button"
@click="toggleFilter('date')"
>
<span>{{ dateLabel }}</span>
<span>▼</span>
</button>
<button
class="mp-wv-filter-trig"
:class="{ open: openFilter === 'tab' }"
type="button"
@click="toggleFilter('tab')"
>
<span>{{ tabLabel }}</span>
<span>▼</span>
</button>
</div>
<div class="mp-wv-filter-panel" :class="{ open: openFilter === 'date' }">
<button
v-for="item in dateOptions"
:key="item.value"
class="mp-wv-filter-opt"
:class="{ active: dateShortcut === item.value }"
type="button"
@click="pickDate(item.value)"
>
<span>{{ item.label }}</span>
<span v-if="dateShortcut === item.value">✓</span>
</button>
<div v-if="dateShortcut === 'custom'" class="mp-wv-custom-date">
<div class="mp-wv-custom-date__row">
<input v-model="customFrom" class="mp-wv-custom-date__input" type="date" />
<input v-model="customTo" class="mp-wv-custom-date__input" type="date" />
</div>
<button class="mp-wv-btn mp-wv-btn--primary" type="button" style="width: 100%" @click="applyCustomDate">
确定
</button>
</div>
</div>
<div class="mp-wv-filter-panel" :class="{ open: openFilter === 'tab' }">
<button
v-for="tab in PL_PLAN_LIST_TABS"
:key="tab.name"
class="mp-wv-filter-opt"
:class="{ active: activeTab === tab.name }"
type="button"
@click="pickTab(tab.name)"
>
<span>{{ tab.label }}</span>
<span v-if="activeTab === tab.name">✓</span>
</button>
</div>
</div>
<div
class="mp-wv-filter-mask"
:class="{ open: !!openFilter }"
@click="closeFilter"
/>
<div class="mp-wv-pad-speed">
<div v-if="loading && !planRows.length" class="mp-wv-empty">加载中…</div>
<div v-else-if="!planRows.length" class="mp-wv-empty">暂无计划</div>
<article
v-for="row in planRows"
:key="row.plan.id"
class="mp-wv-card"
@click="goDetail(row.plan.id)"
>
<div class="mp-wv-card__head">
<div>
<div class="mp-wv-card__no">{{ row.card.planNo }}</div>
<div class="mp-wv-card__sub">{{ row.card.typeLabel }} · {{ row.card.planDate }}</div>
</div>
<el-tag :type="row.card.statusTagType" size="small">{{ row.card.statusLabel }}</el-tag>
</div>
<div class="mp-wv-card__meta">供方 → 需方:{{ row.card.partyLine }}</div>
<div class="mp-wv-card__meta">跟进人:{{ row.card.follower }}</div>
<div class="mp-wv-card__meta">车牌:{{ row.card.plate }}</div>
<div class="mp-wv-card__meta">人员:{{ row.card.people }}</div>
<div class="mp-wv-card__product">{{ row.card.product }}</div>
<div class="mp-wv-card-actions" @click.stop>
<button class="mp-wv-btn" type="button" @click="openCardMore(row.plan)">更多</button>
<button class="mp-wv-btn" type="button" @click="goCopy(row.plan.id)">复制</button>
<button
v-if="row.canArrange"
class="mp-wv-btn mp-wv-btn--primary"
type="button"
@click="goArrange(row.plan.id)"
>
{{ row.arrangeLabel }}
</button>
</div>
</article>
<div v-if="hasMore" class="mp-wv-toolbar" style="padding: 0 0 12px">
<button class="mp-wv-btn" type="button" style="width: 100%" :disabled="loading" @click="loadMore">
{{ loading ? '加载中…' : `加载更多(${plans.length}/${total})` }}
</button>
</div>
</div>
<div class="mp-wv-speed-mask" :class="{ open: speedOpen }" @click="speedOpen = false" />
<div class="mp-wv-speed-dial" :class="{ open: speedOpen }">
<div class="mp-wv-speed-menu">
<button class="mp-wv-speed-item" type="button" @click="goPaste">三方人车快速创建</button>
<button class="mp-wv-speed-item primary" type="button" @click="goNew">新增计划</button>
</div>
<button class="mp-wv-speed-main" type="button" aria-label="快捷操作" @click="toggleSpeed">
+
</button>
</div>
<MpBottomSheet v-model="moreOpen" title="卡片操作" half>
<button
v-for="item in dateChips"
:key="item.value"
v-for="item in moreActions"
:key="item.key"
class="mp-wv-filter-opt"
:class="{ 'is-danger': item.danger }"
type="button"
class="mp-wv-chip"
:class="{ 'is-active': dateShortcut === item.value }"
@click="applyDate(item.value)"
:disabled="acting"
@click="runMoreAction(item.key)"
>
{{ 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>
</MpBottomSheet>
</template>
</MpWebviewShell>
</template>
@@ -79,7 +152,9 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
import MpBottomSheet from '@/components/mp/MpBottomSheet.vue'
import { planApi, type Plan } from '@/api/pl'
import { buildPlanCard } from '@/utils/mp-pl-plan-card'
import { getWxMiniProgram } from '@/utils/mp-webview'
@@ -88,17 +163,32 @@
plPlanListTabToFilters,
type PlPlanListTab
} from '@/utils/pl-display'
import {
arrangeTransportLabel,
canArrangeTransport,
canCancelWaitNotice,
canVoidPlan,
canWaitNotice
} from '@/utils/pl-plan-action-guards'
import { HttpError } from '@/utils/http/error'
import { ApiStatus } from '@/utils/http/status'
import '@/styles/mp-webview.scss'
type DateChip = 'today' | 'tomorrow' | 'week' | 'month'
type DateShortcut = 'today' | 'tomorrow' | 'week' | 'month' | 'custom'
const router = useRouter()
const keyword = ref('')
const activeTab = ref<PlPlanListTab>('pending_arrange')
const dateShortcut = ref<DateChip>('today')
const dateShortcut = ref<DateShortcut>('today')
const dateRange = ref<string[]>([])
const customFrom = ref('')
const customTo = ref('')
const customDateLabel = ref('')
const openFilter = ref<'' | 'date' | 'tab'>('')
const speedOpen = ref(false)
const moreOpen = ref(false)
const morePlan = ref<Plan | null>(null)
const acting = ref(false)
const loading = ref(false)
const forbidden = ref(false)
const page = ref(1)
@@ -106,23 +196,52 @@
const total = ref(0)
const plans = ref<Plan[]>([])
const dateChips: { label: string; value: DateChip }[] = [
const dateOptions: { label: string; value: DateShortcut }[] = [
{ label: '今天', value: 'today' },
{ label: '明天', value: 'tomorrow' },
{ label: '本周', value: 'week' },
{ label: '本月', value: 'month' }
{ label: '本月', value: 'month' },
{ label: '自定义', value: 'custom' }
]
const cards = computed(() => plans.value.map((row) => buildPlanCard(row)))
const tabLabel = computed(
() => PL_PLAN_LIST_TABS.find((t) => t.name === activeTab.value)?.label || '待安排'
)
const dateLabel = computed(() => {
if (dateShortcut.value === 'custom') return customDateLabel.value || '自定义'
return dateOptions.find((d) => d.value === dateShortcut.value)?.label || '今天'
})
const planRows = computed(() =>
plans.value.map((plan) => ({
plan,
card: buildPlanCard(plan),
canArrange: canArrangeTransport(plan),
arrangeLabel: arrangeTransportLabel(plan)
}))
)
const hasMore = computed(() => plans.value.length < total.value)
const moreActions = computed(() => {
const p = morePlan.value
if (!p) return [] as { key: string; label: string; danger?: boolean }[]
const items: { key: string; label: string; danger?: boolean }[] = [
{ key: 'detail', label: '查看详情' },
{ key: 'copy', label: '复制' }
]
if (canWaitNotice(p)) items.push({ key: 'wait', label: '等客户通知' })
if (canCancelWaitNotice(p)) items.push({ key: 'cancel-wait', label: '取消等通知' })
if (canVoidPlan(p)) items.push({ key: 'void', label: '作废', danger: true })
return items
})
function formatYmd(date: Date) {
const m = `${date.getMonth() + 1}`.padStart(2, '0')
const d = `${date.getDate()}`.padStart(2, '0')
return `${date.getFullYear()}-${m}-${d}`
}
function rangeFor(type: DateChip): string[] {
function rangeFor(type: Exclude<DateShortcut, 'custom'>): string[] {
const today = new Date()
if (type === 'today') {
const d = formatYmd(today)
@@ -170,8 +289,9 @@
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 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)
@@ -192,14 +312,45 @@
void fetchPage(true)
}
function applyDate(type: DateChip) {
function closeFilter() {
openFilter.value = ''
}
function toggleFilter(kind: 'date' | 'tab') {
speedOpen.value = false
openFilter.value = openFilter.value === kind ? '' : kind
}
function pickDate(type: DateShortcut) {
dateShortcut.value = type
if (type === 'custom') {
if (!customFrom.value || !customTo.value) {
const [from, to] = rangeFor('month')
customFrom.value = from
customTo.value = to
}
return
}
dateRange.value = rangeFor(type)
closeFilter()
reload()
}
function applyTab(tab: PlPlanListTab) {
function applyCustomDate() {
if (!customFrom.value || !customTo.value) {
ElMessage.warning('请选择起止日期')
return
}
dateShortcut.value = 'custom'
dateRange.value = [customFrom.value, customTo.value]
customDateLabel.value = `${customFrom.value} ~ ${customTo.value}`
closeFilter()
reload()
}
function pickTab(tab: PlPlanListTab) {
activeTab.value = tab
closeFilter()
reload()
}
@@ -208,6 +359,11 @@
void fetchPage(false)
}
function toggleSpeed() {
closeFilter()
speedOpen.value = !speedOpen.value
}
function goBack() {
const mini = getWxMiniProgram()
if (mini?.navigateBack) {
@@ -218,10 +374,12 @@
}
function goNew() {
speedOpen.value = false
void router.push('/m/pl/plan/new')
}
function goPaste() {
speedOpen.value = false
void router.push('/m/pl/plan/paste')
}
@@ -229,8 +387,116 @@
void router.push(`/m/pl/plan/${id}`)
}
function goCopy(id: string) {
void router.push({ path: '/m/pl/plan/new', query: { copyFrom: id } })
}
function goArrange(id: string) {
void router.push(`/m/pl/plan/${id}/arrange`)
}
function openCardMore(plan: Plan) {
morePlan.value = plan
moreOpen.value = true
}
async function runMoreAction(key: string) {
const p = morePlan.value
if (!p) return
if (key === 'detail') {
moreOpen.value = false
goDetail(p.id)
return
}
if (key === 'copy') {
moreOpen.value = false
goCopy(p.id)
return
}
if (key === 'wait') {
await ElMessageBox.confirm('确认将该计划标记为等客户通知?', '等客户通知', {
type: 'warning'
})
acting.value = true
try {
await planApi.waitNotice(p.id)
ElMessage.success('已标记为等客户通知')
moreOpen.value = false
reload()
} finally {
acting.value = false
}
return
}
if (key === 'cancel-wait') {
await ElMessageBox.confirm('确认取消等通知,恢复为待安排?', '取消等通知', {
type: 'warning'
})
acting.value = true
try {
await planApi.cancelWaitNotice(p.id)
ElMessage.success('已取消等通知')
moreOpen.value = false
reload()
} finally {
acting.value = false
}
return
}
if (key === 'void') {
await ElMessageBox.confirm('确认作废该运输计划?此操作不可恢复。', '作废运输计划', {
type: 'warning'
})
acting.value = true
try {
await planApi.cancel(p.id)
ElMessage.success('已作废')
moreOpen.value = false
reload()
} finally {
acting.value = false
}
}
}
onMounted(() => {
dateRange.value = rangeFor('today')
reload()
})
</script>
<style scoped lang="scss">
.mp-wv-card-actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
.mp-wv-card-actions .mp-wv-btn {
flex: 1;
}
.mp-wv-custom-date {
padding: 8px 12px 12px;
}
.mp-wv-custom-date__row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 8px;
}
.mp-wv-custom-date__input {
width: 100%;
height: 36px;
padding: 0 8px;
border: 1px solid #dcebe4;
border-radius: 8px;
font-size: 13px;
}
.is-danger {
color: #d14343 !important;
}
</style>