Browse Source

feat(web-admin): add mp webview CRM quote list pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 4 tuần trước cách đây
mục cha
commit
63f64b1375

+ 321 - 0
web-admin/src/views/m/crm/quote/MpQuoteList.vue

@@ -0,0 +1,321 @@
+<template>
+  <MpWebviewShell @ready="onShellReady">
+    <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" aria-label="返回工作台" @click="goBack">
+            ←
+          </button>
+          <h1 class="mp-wv-header__title">{{ headerTitle }}</h1>
+        </div>
+        <input
+          v-model="keyword"
+          class="mp-wv-search"
+          type="search"
+          placeholder="报价主题 / 客户 / 单号"
+          @search="reload"
+          @keyup.enter="reload"
+        />
+      </header>
+
+      <div class="mp-wv-filter-wrap">
+        <div class="mp-wv-filter-bar">
+          <button
+            class="mp-wv-filter-trig"
+            :class="{ open: openFilter === 'view' }"
+            type="button"
+            @click="toggleFilter"
+          >
+            <span class="mp-wv-filter-trig__val">{{ viewLabel }}</span>
+            <span class="mp-wv-filter-trig__arrow" aria-hidden="true">▼</span>
+          </button>
+        </div>
+        <div class="mp-wv-filter-panel" :class="{ open: openFilter === 'view' }">
+          <button
+            v-for="item in viewTabs"
+            :key="item.value"
+            class="mp-wv-filter-opt"
+            :class="{ active: view === item.value }"
+            type="button"
+            @click="pickView(item.value)"
+          >
+            <span>{{ item.label }}</span>
+            <span class="mp-wv-filter-opt__check" aria-hidden="true">✓</span>
+          </button>
+        </div>
+      </div>
+      <div
+        class="mp-wv-filter-mask"
+        :class="{ open: !!openFilter }"
+        @click="closeFilter"
+      />
+
+      <div :class="mode === 'manage' ? 'mp-wv-pad-speed' : ''">
+        <div
+          v-if="loading && !cards.length"
+          class="mp-plan-form-loading mp-plan-form-loading--page"
+          role="status"
+          aria-live="polite"
+          aria-busy="true"
+        >
+          <div class="mp-plan-form-loading__orb" aria-hidden="true">
+            <span class="mp-plan-form-loading__dot" />
+          </div>
+          <p class="mp-plan-form-loading__title">正在加载报价单</p>
+          <p class="mp-plan-form-loading__desc">请稍候,正在获取报价单列表…</p>
+          <div class="mp-plan-form-loading__skel" aria-hidden="true">
+            <div class="mp-wv-skel mp-wv-skel--block" />
+            <div class="mp-wv-skel mp-wv-skel--mid" />
+            <div class="mp-wv-skel mp-wv-skel--line" />
+            <div class="mp-wv-skel mp-wv-skel--short" />
+          </div>
+        </div>
+        <div v-else-if="!cards.length" class="mp-wv-empty">
+          <div class="mp-wv-empty__icon" aria-hidden="true">📄</div>
+          <div class="mp-wv-empty__title">暂无报价单</div>
+          <div class="mp-wv-empty__desc">可调整筛选或搜索后再试</div>
+        </div>
+        <article
+          v-for="card in cards"
+          :key="card.id"
+          class="mp-wv-card"
+          @click="goDetail(card.id)"
+        >
+          <div class="mp-wv-card__title">{{ card.quoteTitle }}</div>
+          <div class="mp-wv-card__meta">{{ card.quoteNo }} · {{ card.customerName }}</div>
+          <div class="mp-wv-card__meta">{{ card.amountText }} · {{ card.quoteDate }}</div>
+          <div class="mp-wv-card__tags">
+            <el-tag :type="card.quoteTypeTagType" size="small">{{ card.quoteTypeLabel }}</el-tag>
+            <el-tag :type="statusTagType(card.status)" size="small">
+              {{ statusLabel(card.status) }}
+            </el-tag>
+          </div>
+        </article>
+
+        <div v-if="hasMore" class="mp-wv-toolbar" style="padding: 0 0 12px">
+          <button
+            class="mp-wv-btn mp-wv-btn--block"
+            type="button"
+            :disabled="loading"
+            @click="loadMore"
+          >
+            <span v-if="loading" class="mp-wv-spinner mp-wv-spinner--sm" aria-hidden="true" />
+            <span>{{ loading ? '加载中…' : `加载更多(${quotes.length}/${total})` }}</span>
+          </button>
+        </div>
+      </div>
+
+      <template v-if="mode === 'manage'">
+        <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 primary" type="button" @click="goNew">新增报价单</button>
+          </div>
+          <button class="mp-wv-speed-main" type="button" aria-label="快捷操作" @click="toggleSpeed">
+            +
+          </button>
+        </div>
+      </template>
+    </template>
+  </MpWebviewShell>
+</template>
+
+<script setup lang="ts">
+  import { computed, ref } from 'vue'
+  import { useRouter } from 'vue-router'
+  import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
+  import { listQuote } from '@/api/crm/quote'
+  import {
+    MP_QUOTE_APPROVE_TABS,
+    MP_QUOTE_MANAGE_TABS,
+    buildQuoteCard,
+    buildQuoteListQuery,
+    type MpQuoteMode,
+    type MpQuoteView
+  } from '@/utils/mp-crm-quote-card'
+  import { useUserStore } from '@/store/modules/user'
+  import { useDictStore } from '@/store/modules/dict'
+  import { goToMiniWorkbench } from '@/utils/mp-webview'
+  import { HttpError } from '@/utils/http/error'
+  import { ApiStatus } from '@/utils/http/status'
+  import '@/styles/mp-webview.scss'
+
+  type QuoteRow = {
+    id: string | number
+    quoteNo?: string
+    quoteTitle?: string
+    customerName?: string
+    quoteType?: string
+    finalAmount?: number
+    totalAmount?: number
+    status?: string
+    quoteDate?: string
+  }
+
+  const props = defineProps<{
+    mode: MpQuoteMode
+  }>()
+
+  const router = useRouter()
+  const userStore = useUserStore()
+  const dictStore = useDictStore()
+
+  const keyword = ref('')
+  const view = ref<MpQuoteView>(props.mode === 'approve' ? 'submitted' : 'all')
+  const openFilter = ref<'' | 'view'>('')
+  const speedOpen = ref(false)
+  const loading = ref(false)
+  const forbidden = ref(false)
+  const pageNum = ref(1)
+  const pageSize = 20
+  const total = ref(0)
+  const quotes = ref<QuoteRow[]>([])
+
+  /** 当前登录用户 ID(后端 current-user 返回 id,兼容历史 userId 字段) */
+  const resolveCurrentUserId = (): string => {
+    const info = userStore.info || ({} as Api.Auth.UserInfo)
+    const fromInfo = String(info.id || info.userId || '')
+    if (fromInfo) return fromInfo
+    const token = userStore.accessToken
+    if (!token || token.split('.').length < 2) return ''
+    try {
+      const payload = token.split('.')[1]
+      const b64 = payload.replace(/-/g, '+').replace(/_/g, '/')
+      const pad = '='.repeat((4 - (b64.length % 4)) % 4)
+      const obj = JSON.parse(atob(b64 + pad))
+      return String(obj?.sub || '')
+    } catch {
+      return ''
+    }
+  }
+
+  const headerTitle = computed(() =>
+    props.mode === 'approve' ? '报价单审批' : '报价单管理'
+  )
+
+  const viewTabs = computed(() =>
+    props.mode === 'approve' ? MP_QUOTE_APPROVE_TABS : MP_QUOTE_MANAGE_TABS
+  )
+
+  const viewLabel = computed(
+    () => viewTabs.value.find((item) => item.value === view.value)?.label || '全部'
+  )
+
+  const cards = computed(() => quotes.value.map((row) => buildQuoteCard(row)))
+
+  const hasMore = computed(() => quotes.value.length < total.value)
+
+  function statusLabel(status?: string) {
+    const raw = status || ''
+    return dictStore.getLabel('crm_quote_status', raw) || raw || '—'
+  }
+
+  function statusTagType(status?: string) {
+    return dictStore.getTagType('crm_quote_status', status || '') as
+      | 'success'
+      | 'warning'
+      | 'info'
+      | 'primary'
+      | 'danger'
+  }
+
+  async function fetchPage(reset: boolean) {
+    if (reset) {
+      pageNum.value = 1
+      quotes.value = []
+    }
+    loading.value = true
+    try {
+      const res = await listQuote(
+        buildQuoteListQuery({
+          mode: props.mode,
+          view: view.value,
+          pageNum: pageNum.value,
+          pageSize,
+          keyword: keyword.value,
+          currentUserId: resolveCurrentUserId()
+        }) as any
+      )
+      const data = (res as any)?.data || res || {}
+      const list = (data.list || data.rows || []) as QuoteRow[]
+      total.value = data.total ?? 0
+      quotes.value = reset ? list : quotes.value.concat(list)
+      forbidden.value = false
+    } catch (error) {
+      if (error instanceof HttpError && error.code === ApiStatus.forbidden) {
+        forbidden.value = true
+        quotes.value = []
+        return
+      }
+      throw error
+    } finally {
+      loading.value = false
+    }
+  }
+
+  function reload() {
+    void fetchPage(true)
+  }
+
+  function closeFilter() {
+    openFilter.value = ''
+  }
+
+  function toggleFilter() {
+    speedOpen.value = false
+    openFilter.value = openFilter.value === 'view' ? '' : 'view'
+  }
+
+  function pickView(value: MpQuoteView) {
+    view.value = value
+    closeFilter()
+    reload()
+  }
+
+  function loadMore() {
+    pageNum.value += 1
+    void fetchPage(false)
+  }
+
+  function toggleSpeed() {
+    closeFilter()
+    speedOpen.value = !speedOpen.value
+  }
+
+  function goBack() {
+    goToMiniWorkbench()
+  }
+
+  function goNew() {
+    speedOpen.value = false
+    void router.push('/m/crm/quote/new')
+  }
+
+  function goDetail(id: string) {
+    void router.push({ path: `/m/crm/quote/${id}`, query: { from: props.mode } })
+  }
+
+  function onShellReady() {
+    view.value = props.mode === 'approve' ? 'submitted' : 'all'
+    keyword.value = ''
+    speedOpen.value = false
+    closeFilter()
+    reload()
+  }
+</script>
+
+<style scoped lang="scss">
+  .mp-wv-card__title {
+    font-size: 15px;
+    font-weight: 700;
+    color: #087f57;
+  }
+
+  .mp-wv-card__tags {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 6px;
+    margin-top: 8px;
+  }
+</style>

+ 2 - 13
web-admin/src/views/m/crm/quote/approval.vue

@@ -1,17 +1,6 @@
 <template>
-  <MpWebviewShell>
-    <header class="mp-wv-header">
-      <div class="mp-wv-header__row">
-        <h1 class="mp-wv-header__title">{{ title }}</h1>
-      </div>
-    </header>
-  </MpWebviewShell>
+  <MpQuoteList mode="approve" />
 </template>
 <script setup lang="ts">
-  import { computed } from 'vue'
-  import { useRoute } from 'vue-router'
-  import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
-  import '@/styles/mp-webview.scss'
-  const route = useRoute()
-  const title = computed(() => String(route.meta.title || ''))
+  import MpQuoteList from './MpQuoteList.vue'
 </script>

+ 2 - 13
web-admin/src/views/m/crm/quote/index.vue

@@ -1,17 +1,6 @@
 <template>
-  <MpWebviewShell>
-    <header class="mp-wv-header">
-      <div class="mp-wv-header__row">
-        <h1 class="mp-wv-header__title">{{ title }}</h1>
-      </div>
-    </header>
-  </MpWebviewShell>
+  <MpQuoteList mode="manage" />
 </template>
 <script setup lang="ts">
-  import { computed } from 'vue'
-  import { useRoute } from 'vue-router'
-  import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
-  import '@/styles/mp-webview.scss'
-  const route = useRoute()
-  const title = computed(() => String(route.meta.title || ''))
+  import MpQuoteList from './MpQuoteList.vue'
 </script>