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