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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-10-15 17:32:13 +08:00
1 parent e54a168829
commit a9ee5d440e
4 files changed
+824 -28

No files matched your search

+7 -2
View File
@@ -55,6 +55,9 @@ export function buildContractListQuery(input: {
export function buildContractCard(row: {
id: string | number
contractNo?: string
/** 桌面/API 字段 */
contractName?: string
/** 兼容旧测试/别名 */
contractTitle?: string
customerName?: string
amount?: number
@@ -62,16 +65,18 @@ export function buildContractCard(row: {
barrelDeposit?: number
status?: string
signDate?: string
canApprove?: boolean
}) {
const total =
(Number(row.amount) || 0) + (Number(row.freight) || 0) + (Number(row.barrelDeposit) || 0)
return {
id: String(row.id),
contractNo: row.contractNo || '—',
contractTitle: row.contractTitle || '—',
contractTitle: row.contractName || row.contractTitle || '—',
customerName: row.customerName || '—',
amountText: formatContractMoney(total),
status: row.status || '',
signDate: row.signDate || '—'
signDate: row.signDate || '—',
canApprove: !!row.canApprove
}
}
@@ -0,0 +1,813 @@
<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>
<button class="mp-wv-header__action" type="button" @click="toggleSelectMode">
{{ selectMode ? '取消' : '多选' }}
</button>
</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="{
'mp-wv-pad-speed': mode === 'manage' && !selectMode,
'mp-wv-pad-batch': selectMode
}"
>
<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 mp-wv-card--selectable"
@click="onCardClick(card)"
>
<label
v-if="selectMode"
class="mp-wv-card__check"
@click.stop
>
<input
type="checkbox"
:checked="selectedIds.has(card.id)"
@change="toggleSelect(card.id)"
/>
</label>
<div class="mp-wv-card__body">
<div class="mp-wv-card__title">{{ card.contractTitle }}</div>
<div class="mp-wv-card__meta">{{ card.contractNo }} · {{ card.customerName }}</div>
<div class="mp-wv-card__meta">{{ card.amountText }} · {{ card.signDate }}</div>
<div class="mp-wv-card__tags">
<el-tag :type="statusTagType(card.status)" size="small">
{{ statusLabel(card.status) }}
</el-tag>
</div>
</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 ? '加载中…' : `加载更多(${contracts.length}/${total})` }}</span>
</button>
</div>
</div>
<div v-if="selectMode" class="mp-wv-batch-bar">
<span class="mp-wv-batch-bar__count">已选 {{ selectedIds.size }} 项</span>
<div class="mp-wv-batch-bar__actions">
<template v-if="mode === 'manage'">
<button
class="mp-wv-btn mp-wv-btn--primary"
type="button"
:disabled="!selectedIds.size || acting"
@click="openBatchSubmit"
>
提交审批
</button>
<button
class="mp-wv-btn"
type="button"
:disabled="!selectedIds.size || acting"
@click="batchInvalid"
>
作废
</button>
</template>
<template v-else>
<button
class="mp-wv-btn mp-wv-btn--primary"
type="button"
:disabled="!selectedIds.size || acting"
@click="openBatchApprove('approved')"
>
通过
</button>
<button
class="mp-wv-btn is-danger"
type="button"
:disabled="!selectedIds.size || acting"
@click="openBatchApprove('rejected')"
>
驳回
</button>
</template>
</div>
</div>
<template v-if="mode === 'manage' && !selectMode">
<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>
<MpBottomSheet v-model="submitOpen" title="提交审批">
<div class="mp-quote-sheet-form">
<p v-if="batchSkipped > 0" class="mp-wv-sheet-tip">
将提交 {{ batchTargetIds.length }} 条草稿;已跳过 {{ batchSkipped }} 条非草稿
</p>
<label class="mp-quote-sheet-label">审批人(可多选,按选择顺序)</label>
<el-select
v-model="submitForm.approverIds"
multiple
filterable
placeholder="请选择审批人"
style="width: 100%"
>
<el-option
v-for="user in userOptions"
:key="user.userId"
:label="formatApproverOptionLabel(user)"
:value="user.userId"
/>
</el-select>
<label class="mp-quote-sheet-label">备注说明</label>
<el-input
v-model="submitForm.remark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="可选"
/>
<button
class="mp-wv-btn mp-wv-btn--primary mp-wv-btn--block"
type="button"
:disabled="acting"
@click="confirmBatchSubmit"
>
确认提交
</button>
</div>
</MpBottomSheet>
<MpBottomSheet v-model="approveOpen" :title="approveSheetTitle">
<div class="mp-quote-sheet-form">
<p v-if="batchSkipped > 0" class="mp-wv-sheet-tip">
将处理 {{ batchTargetIds.length }} 条;已跳过 {{ batchSkipped }} 条不可审批
</p>
<label class="mp-quote-sheet-label">
审批意见{{ approveResult === 'rejected' ? '(必填)' : '(可选)' }}
</label>
<el-input
v-model="approveOpinion"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
:placeholder="approveResult === 'rejected' ? '请填写驳回理由' : '请输入审批意见'"
/>
<button
class="mp-wv-btn mp-wv-btn--primary mp-wv-btn--block"
type="button"
:disabled="acting"
@click="confirmBatchApprove"
>
确认{{ approveResult === 'rejected' ? '驳回' : '通过' }}
</button>
</div>
</MpBottomSheet>
</template>
</MpWebviewShell>
</template>
<script setup lang="ts">
import { computed, 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 {
listContract,
batchSubmitContract,
approveContract,
rejectContract,
invalidContract,
getContractApproverSetting
} from '@/api/crm/contract'
import { fetchUserOptions } from '@/api/admin'
import {
MP_CONTRACT_APPROVE_TABS,
MP_CONTRACT_MANAGE_TABS,
buildContractCard,
buildContractListQuery,
type MpContractMode,
type MpContractView
} from '@/utils/mp-crm-contract-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 ContractRow = {
id: string | number
contractNo?: string
contractName?: string
customerName?: string
amount?: number
freight?: number
barrelDeposit?: number
status?: string
signDate?: string
canApprove?: boolean
}
type ApproverOption = {
userId: string
nickName: string
userName: string
}
const props = defineProps<{
mode: MpContractMode
}>()
const router = useRouter()
const userStore = useUserStore()
const dictStore = useDictStore()
const keyword = ref('')
const view = ref<MpContractView>(props.mode === 'approve' ? 'submitted' : 'all')
const openFilter = ref<'' | 'view'>('')
const speedOpen = ref(false)
const selectMode = ref(false)
const selectedIds = ref<Set<string>>(new Set())
const loading = ref(false)
const acting = ref(false)
const forbidden = ref(false)
const pageNum = ref(1)
const pageSize = 20
const total = ref(0)
const contracts = ref<ContractRow[]>([])
const submitOpen = ref(false)
const submitForm = ref<{ approverIds: string[]; remark: string }>({
approverIds: [],
remark: ''
})
const userOptions = ref<ApproverOption[]>([])
const approveOpen = ref(false)
const approveResult = ref<'approved' | 'rejected'>('approved')
const approveOpinion = ref('')
const batchTargetIds = ref<(string | number)[]>([])
const batchSkipped = ref(0)
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_CONTRACT_APPROVE_TABS : MP_CONTRACT_MANAGE_TABS
)
const viewLabel = computed(
() => viewTabs.value.find((item) => item.value === view.value)?.label || '全部'
)
const cards = computed(() => contracts.value.map((row) => buildContractCard(row)))
const hasMore = computed(() => contracts.value.length < total.value)
const approveSheetTitle = computed(() =>
approveResult.value === 'rejected' ? '批量驳回' : '批量通过'
)
function statusLabel(status?: string) {
const raw = status || ''
return dictStore.getLabel('crm_contract_status', raw) || raw || '—'
}
function statusTagType(status?: string) {
return dictStore.getTagType('crm_contract_status', status || '') as
| 'success'
| 'warning'
| 'info'
| 'primary'
| 'danger'
}
function formatApproverOptionLabel(user: ApproverOption) {
const nick = (user.nickName || '').trim()
const account = (user.userName || '').trim()
if (nick && account) return `${nick}(${account})`
return nick || account
}
function clearSelection() {
selectedIds.value = new Set()
}
async function fetchPage(reset: boolean) {
if (reset) {
pageNum.value = 1
contracts.value = []
clearSelection()
}
loading.value = true
try {
const res = await listContract(
buildContractListQuery({
mode: props.mode,
view: view.value,
pageNum: pageNum.value,
pageSize,
keyword: keyword.value,
currentUserId: resolveCurrentUserId()
}) as any
)
const list = ((res as any)?.rows || []) as ContractRow[]
total.value = (res as any)?.total ?? 0
contracts.value = reset ? list : contracts.value.concat(list)
forbidden.value = false
} catch (error) {
if (error instanceof HttpError && error.code === ApiStatus.forbidden) {
forbidden.value = true
contracts.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: MpContractView) {
view.value = value
closeFilter()
reload()
}
function loadMore() {
pageNum.value += 1
void fetchPage(false)
}
function toggleSpeed() {
closeFilter()
speedOpen.value = !speedOpen.value
}
function toggleSelectMode() {
selectMode.value = !selectMode.value
speedOpen.value = false
closeFilter()
if (!selectMode.value) clearSelection()
}
function toggleSelect(id: string) {
const next = new Set(selectedIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selectedIds.value = next
}
function onCardClick(card: { id: string }) {
if (selectMode.value) {
toggleSelect(card.id)
return
}
goDetail(card.id)
}
function goBack() {
goToMiniWorkbench()
}
function goNew() {
speedOpen.value = false
void router.push('/m/crm/contract/new')
}
function goDetail(id: string) {
void router.push({ path: `/m/crm/contract/${id}`, query: { from: props.mode } })
}
function selectedRows(): ContractRow[] {
return contracts.value.filter((row) => selectedIds.value.has(String(row.id)))
}
async function loadUserOptions() {
try {
const res = await fetchUserOptions()
const options = (res as any)?.data ?? res ?? []
userOptions.value = (Array.isArray(options) ? options : [])
.map((item: any) => ({
userId: String(item.value ?? item.userId ?? item.id ?? ''),
nickName: item.nickName ?? item.label ?? item.userName ?? '',
userName: item.userNo ?? item.user_no ?? item.userName ?? ''
}))
.filter((u: ApproverOption) => u.userId)
} catch {
userOptions.value = []
}
}
async function resolvePrefillApproverIds() {
try {
const { data } = await getContractApproverSetting()
if (!data || String(data.status) !== '0') return [] as string[]
const valid = new Set(userOptions.value.map((u) => String(u.userId)))
return (data.approverIds || []).map(String).filter((id) => valid.has(id))
} catch {
return [] as string[]
}
}
async function openBatchSubmit() {
const rows = selectedRows()
if (!rows.length) {
ElMessage.warning('请先选择合同')
return
}
const drafts = rows.filter((r) => r.status === 'draft')
const skipped = rows.length - drafts.length
if (!drafts.length) {
ElMessage.warning('选中的合同中没有草稿状态的合同')
return
}
if (skipped > 0) {
ElMessage.warning(`已跳过 ${skipped} 条非草稿合同,仅处理草稿`)
}
batchTargetIds.value = drafts.map((r) => r.id)
batchSkipped.value = skipped
acting.value = true
try {
await loadUserOptions()
const prefill = await resolvePrefillApproverIds()
submitForm.value = { approverIds: prefill, remark: '' }
submitOpen.value = true
} finally {
acting.value = false
}
}
async function confirmBatchSubmit() {
if (!submitForm.value.approverIds.length) {
ElMessage.warning('请选择审批人')
return
}
if (!batchTargetIds.value.length) return
acting.value = true
try {
const count = await batchSubmitContract(
batchTargetIds.value,
submitForm.value.approverIds,
submitForm.value.remark || undefined
)
const n = typeof count === 'number' ? count : batchTargetIds.value.length
ElMessage.success(
batchSkipped.value > 0
? `成功提交 ${n} 条,跳过 ${batchSkipped.value} 条`
: `成功提交 ${n} 条`
)
submitOpen.value = false
selectMode.value = false
clearSelection()
reload()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '提交失败')
} finally {
acting.value = false
}
}
function openBatchApprove(result: 'approved' | 'rejected') {
const rows = selectedRows()
if (!rows.length) {
ElMessage.warning('请先选择合同')
return
}
const valid = rows.filter((r) => r.canApprove)
const skipped = rows.length - valid.length
if (!valid.length) {
ElMessage.warning(
rows.length === 1
? '您没有该合同的审批权限,或已审批过该合同'
: '所选合同均不可审批(无权、已审或不在待审批状态)'
)
return
}
if (skipped > 0) {
ElMessage.warning(`已跳过 ${skipped} 条不可审批的合同`)
}
batchTargetIds.value = valid.map((r) => r.id)
batchSkipped.value = skipped
approveResult.value = result
approveOpinion.value = result === 'approved' ? '审批通过' : ''
approveOpen.value = true
}
async function confirmBatchApprove() {
if (approveResult.value === 'rejected' && !String(approveOpinion.value || '').trim()) {
ElMessage.warning('请填写驳回理由')
return
}
if (!batchTargetIds.value.length) return
acting.value = true
try {
const opinion = String(approveOpinion.value || '').trim()
const results = await Promise.allSettled(
batchTargetIds.value.map((id) =>
approveResult.value === 'rejected'
? rejectContract(id, opinion)
: approveContract(id, opinion || '审批通过')
)
)
const successCount = results.filter((item) => item.status === 'fulfilled').length
const failedCount = results.length - successCount
const actionLabel = approveResult.value === 'rejected' ? '驳回' : '通过'
const skipText = batchSkipped.value > 0 ? `,跳过 ${batchSkipped.value} 条` : ''
if (successCount === 0) {
ElMessage.error(`${actionLabel}失败${skipText}`)
return
}
if (failedCount > 0) {
ElMessage.warning(`成功${actionLabel} ${successCount} 条,失败 ${failedCount} 条${skipText}`)
} else {
ElMessage.success(`成功${actionLabel} ${successCount} 条${skipText}`)
}
approveOpen.value = false
selectMode.value = false
clearSelection()
reload()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '审批失败')
} finally {
acting.value = false
}
}
async function batchInvalid() {
const rows = selectedRows().filter((r) => r.status && r.status !== 'invalid')
const skipped = selectedRows().length - rows.length
if (!rows.length) {
ElMessage.warning('选中的合同均不可作废')
return
}
let reason = ''
try {
const { value } = await ElMessageBox.prompt('请输入作废原因', '批量作废', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPlaceholder: '作废原因',
inputValidator: (v) => {
if (!String(v || '').trim()) return '请填写作废原因'
return true
}
})
reason = String(value || '').trim()
} catch {
return
}
acting.value = true
try {
const results = await Promise.allSettled(
rows.map((r) => invalidContract({ id: r.id, invalidReason: reason }))
)
const successCount = results.filter((item) => item.status === 'fulfilled').length
const failedCount = results.length - successCount
const skipText = skipped > 0 ? `,跳过 ${skipped} 条` : ''
if (successCount === 0) {
ElMessage.error(`作废失败${skipText}`)
return
}
if (failedCount > 0) {
ElMessage.warning(`成功作废 ${successCount} 条,失败 ${failedCount} 条${skipText}`)
} else {
ElMessage.success(`成功作废 ${successCount} 条${skipText}`)
}
selectMode.value = false
clearSelection()
reload()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '作废失败')
} finally {
acting.value = false
}
}
function onShellReady() {
view.value = props.mode === 'approve' ? 'submitted' : 'all'
keyword.value = ''
speedOpen.value = false
selectMode.value = false
clearSelection()
closeFilter()
reload()
}
</script>
<style scoped lang="scss">
.mp-wv-header__action {
margin-left: auto;
padding: 6px 10px;
font-size: 13px;
color: #087f57;
background: transparent;
border: none;
}
.mp-wv-card--selectable {
display: flex;
gap: 10px;
align-items: flex-start;
}
.mp-wv-card__check {
display: flex;
flex-shrink: 0;
align-items: center;
padding-top: 2px;
}
.mp-wv-card__check input {
width: 18px;
height: 18px;
}
.mp-wv-card__body {
flex: 1;
min-width: 0;
}
.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;
}
.mp-wv-pad-batch {
padding-bottom: 88px;
}
.mp-wv-batch-bar {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 40;
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
padding: 10px 14px calc(10px + env(safe-area-inset-bottom));
background: #fff;
border-top: 1px solid #e2e8f0;
box-shadow: 0 -4px 16px rgb(15 23 42 / 6%);
}
.mp-wv-batch-bar__count {
font-size: 13px;
color: #475569;
white-space: nowrap;
}
.mp-wv-batch-bar__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.mp-wv-sheet-tip {
margin: 0 0 10px;
font-size: 12px;
line-height: 1.4;
color: #b45309;
}
.mp-quote-sheet-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.mp-quote-sheet-label {
font-size: 13px;
font-weight: 600;
color: #334155;
}
.is-danger {
color: #dc2626 !important;
border-color: #fecaca !important;
}
</style>
@@ -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>
<MpContractList 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 MpContractList from './MpContractList.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>
<MpContractList 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 MpContractList from './MpContractList.vue'
</script>