Przeglądaj źródła

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>
wuzilong 1 miesiąc temu
rodzic
commit
8d1d5c19b5
1 zmienionych plików z 323 dodań i 57 usunięć
  1. 323 57
      web-admin/src/views/m/pl/plan/index.vue

+ 323 - 57
web-admin/src/views/m/pl/plan/index.vue

@@ -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">
-        <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 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-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)"
+      <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)"
         >
-          {{ tab.label }}
-        </button>
-      </div>
+          <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 class="mp-wv-toolbar">
-        <button class="mp-wv-btn" type="button" @click="goPaste">三方人车</button>
+        <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 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 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>
-        <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 class="mp-wv-speed-main" type="button" aria-label="快捷操作" @click="toggleSpeed">
+          +
         </button>
       </div>
+
+      <MpBottomSheet v-model="moreOpen" title="卡片操作" half>
+        <button
+          v-for="item in moreActions"
+          :key="item.key"
+          class="mp-wv-filter-opt"
+          :class="{ 'is-danger': item.danger }"
+          type="button"
+          :disabled="acting"
+          @click="runMoreAction(item.key)"
+        >
+          {{ item.label }}
+        </button>
+      </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 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 applyTab(tab: PlPlanListTab) {
+  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>