Explorar o código

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

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong hai 2 meses
pai
achega
17edd7016f

+ 140 - 0
web-admin/src/styles/mp-webview.scss

@@ -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 - 0
web-admin/src/utils/mp-pl-plan-card.ts

@@ -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
web-admin/src/views/m/pl/plan/index.vue

@@ -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'
+
+  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}`
+  }
+
+  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)
+  }
 
-<style scoped>
-  .mp-wv-placeholder {
-    margin: 24px 0 0;
-    font-size: 14px;
-    color: #6b7b74;
-    text-align: center;
+  function goBack() {
+    const mini = getWxMiniProgram()
+    if (mini?.navigateBack) {
+      mini.navigateBack({ delta: 1 })
+      return
+    }
+    router.back()
   }
-</style>
+
+  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>