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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-10-15 15:55:45 +08:00
1 parent f0680544a1
commit 63f64b1375
3 files changed
+325 -26

No files matched your search

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