|
|
@@ -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>
|