feat(web-admin): add mp webview CRM contract list pages.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
e54a168829
commit
a9ee5d440e
4 files changed
+824
-28
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user