|
|
@@ -1,17 +1,851 @@
|
|
|
<template>
|
|
|
- <MpWebviewShell>
|
|
|
+ <MpWebviewShell @ready="onShellReady">
|
|
|
<header class="mp-wv-header">
|
|
|
<div class="mp-wv-header__row">
|
|
|
- <h1 class="mp-wv-header__title">{{ title }}</h1>
|
|
|
+ <button class="mp-wv-header__back" type="button" aria-label="返回列表" @click="goList">
|
|
|
+ ←
|
|
|
+ </button>
|
|
|
+ <h1 class="mp-wv-header__title">{{ headerTitle }}</h1>
|
|
|
+ <el-tag v-if="contract && !loading" :type="statusTagType" size="small">
|
|
|
+ {{ statusLabel }}
|
|
|
+ </el-tag>
|
|
|
+ </div>
|
|
|
+ <div v-if="contract && !loading" class="mp-wv-header__sub">
|
|
|
+ {{ contract.contractNo || '—' }} · {{ contract.customerName || '—' }}
|
|
|
</div>
|
|
|
</header>
|
|
|
+
|
|
|
+ <div
|
|
|
+ v-if="loading"
|
|
|
+ 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="errorText" class="mp-wv-empty">{{ errorText }}</div>
|
|
|
+ <template v-else-if="contract">
|
|
|
+ <div class="mp-wv-detail-body">
|
|
|
+ <section class="mp-wv-section">
|
|
|
+ <h3>基础信息</h3>
|
|
|
+ <div class="mp-wv-kv"><span>合同编号</span><span>{{ contract.contractNo || '—' }}</span></div>
|
|
|
+ <div class="mp-wv-kv"><span>合同名称</span><span>{{ contract.contractName || '—' }}</span></div>
|
|
|
+ <div class="mp-wv-kv"><span>合同类型</span><span>{{ contractTypeLabel }}</span></div>
|
|
|
+ <div class="mp-wv-kv"><span>客户</span><span>{{ contract.customerName || '—' }}</span></div>
|
|
|
+ <div class="mp-wv-kv"><span>联系人</span><span>{{ contract.contactName || '—' }}</span></div>
|
|
|
+ <div class="mp-wv-kv"><span>联系电话</span><span>{{ contract.contactMobile || '—' }}</span></div>
|
|
|
+ <div class="mp-wv-kv"><span>签订日期</span><span>{{ contract.signDate || '—' }}</span></div>
|
|
|
+ <div class="mp-wv-kv"><span>起止日期</span><span>{{ dateRangeText }}</span></div>
|
|
|
+ <div class="mp-wv-kv"><span>负责人</span><span>{{ contract.ownerUserName || '—' }}</span></div>
|
|
|
+ <div v-if="contract.invalidReason" class="mp-wv-kv">
|
|
|
+ <span>作废原因</span><span class="mp-wv-pre">{{ contract.invalidReason }}</span>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="mp-wv-section">
|
|
|
+ <h3>金额与条款</h3>
|
|
|
+ <div class="mp-wv-kv"><span>合同金额</span><span>{{ formatContractMoney(contract.amount) }}</span></div>
|
|
|
+ <div class="mp-wv-kv"><span>运费</span><span>{{ formatContractMoney(contract.freight) }}</span></div>
|
|
|
+ <div class="mp-wv-kv"><span>桶押金</span><span>{{ formatContractMoney(contract.barrelDeposit) }}</span></div>
|
|
|
+ <div class="mp-wv-kv"><span>含税金额</span><span>{{ formatContractMoney(contract.amountWithTax) }}</span></div>
|
|
|
+ <div class="mp-wv-kv">
|
|
|
+ <span>付款条件</span><span class="mp-wv-pre">{{ contract.paymentTerms || '—' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="mp-wv-kv">
|
|
|
+ <span>交货条件</span><span class="mp-wv-pre">{{ contract.deliveryTerms || '—' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="mp-wv-kv">
|
|
|
+ <span>合同条款</span><span class="mp-wv-pre">{{ contract.contractTerms || '—' }}</span>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="mp-wv-section">
|
|
|
+ <h3>甲乙方</h3>
|
|
|
+ <div class="mp-wv-kv"><span>甲方</span><span>{{ contract.partyA || '—' }}</span></div>
|
|
|
+ <div class="mp-wv-kv"><span>甲方联系人</span><span>{{ contract.partyAContact || '—' }}</span></div>
|
|
|
+ <div class="mp-wv-kv"><span>甲方电话</span><span>{{ contract.partyAMobile || '—' }}</span></div>
|
|
|
+ <div class="mp-wv-kv"><span>乙方</span><span>{{ contract.partyB || '—' }}</span></div>
|
|
|
+ <div class="mp-wv-kv"><span>乙方联系人</span><span>{{ contract.partyBContact || '—' }}</span></div>
|
|
|
+ <div class="mp-wv-kv"><span>乙方电话</span><span>{{ contract.partyBMobile || '—' }}</span></div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="mp-wv-section">
|
|
|
+ <h3>产品明细</h3>
|
|
|
+ <template v-if="products.length">
|
|
|
+ <div v-for="(p, idx) in products" :key="String(p.id || idx)" class="mp-quote-product-card">
|
|
|
+ <div class="mp-wv-card__title">{{ p.standardProductName || p.productName || '—' }}</div>
|
|
|
+ <div class="mp-wv-card__meta">
|
|
|
+ 数量 {{ p.quantity ?? '—' }} {{ p.unit || '' }} · 单价
|
|
|
+ {{ formatContractMoney(p.unitPrice) }}
|
|
|
+ </div>
|
|
|
+ <div class="mp-wv-card__meta">金额 {{ formatContractMoney(p.amount) }}</div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <p v-else class="mp-wv-card__meta">暂无产品明细</p>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="mp-wv-section">
|
|
|
+ <h3>审批流</h3>
|
|
|
+ <template v-if="approvalList.length">
|
|
|
+ <div
|
|
|
+ v-for="item in approvalList"
|
|
|
+ :key="String(item.id)"
|
|
|
+ class="mp-quote-approval-step"
|
|
|
+ >
|
|
|
+ <div class="mp-wv-kv">
|
|
|
+ <span>第{{ item.step ?? '—' }}步 · {{ item.approverName || '—' }}</span>
|
|
|
+ <span>
|
|
|
+ <el-tag
|
|
|
+ v-if="item.approvalResult === 'approved'"
|
|
|
+ type="success"
|
|
|
+ size="small"
|
|
|
+ >
|
|
|
+ 已通过
|
|
|
+ </el-tag>
|
|
|
+ <el-tag
|
|
|
+ v-else-if="item.approvalResult === 'rejected'"
|
|
|
+ type="danger"
|
|
|
+ size="small"
|
|
|
+ >
|
|
|
+ 已拒绝
|
|
|
+ </el-tag>
|
|
|
+ <el-tag v-else type="info" size="small">待审批</el-tag>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <p v-if="item.approvalOpinion" class="mp-wv-card__meta">
|
|
|
+ 意见:{{ item.approvalOpinion }}
|
|
|
+ </p>
|
|
|
+ <p v-if="item.approvalTime" class="mp-wv-card__meta">时间:{{ item.approvalTime }}</p>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <p v-else class="mp-wv-card__meta">暂无审批记录</p>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="mp-wv-section">
|
|
|
+ <h3>扫描件 / 附件</h3>
|
|
|
+ <template v-if="attachmentFiles.length">
|
|
|
+ <a
|
|
|
+ v-for="(f, idx) in attachmentFiles"
|
|
|
+ :key="f.ossId + idx"
|
|
|
+ class="mp-wv-attach-link"
|
|
|
+ :href="f.url"
|
|
|
+ target="_blank"
|
|
|
+ rel="noopener"
|
|
|
+ >
|
|
|
+ {{ f.name }}
|
|
|
+ </a>
|
|
|
+ </template>
|
|
|
+ <p v-else class="mp-wv-card__meta">暂无附件</p>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="mp-wv-section">
|
|
|
+ <h3>备注</h3>
|
|
|
+ <p class="mp-wv-remark">{{ contract.remark?.trim() || '—' }}</p>
|
|
|
+ </section>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="fabActions.length" class="mp-wv-fab-bar">
|
|
|
+ <button
|
|
|
+ v-if="moreActions.length"
|
|
|
+ class="mp-wv-btn mp-wv-btn-more"
|
|
|
+ type="button"
|
|
|
+ aria-label="更多"
|
|
|
+ @click="moreOpen = true"
|
|
|
+ >
|
|
|
+ ⋯
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ v-for="(action, idx) in primaryActions"
|
|
|
+ :key="action.key"
|
|
|
+ class="mp-wv-btn"
|
|
|
+ :class="{
|
|
|
+ 'mp-wv-btn--primary': idx === 0,
|
|
|
+ 'is-danger': action.danger
|
|
|
+ }"
|
|
|
+ type="button"
|
|
|
+ :disabled="acting || action.disabled"
|
|
|
+ :title="action.title || ''"
|
|
|
+ @click="runAction(action.key)"
|
|
|
+ >
|
|
|
+ {{ action.label }}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <input
|
|
|
+ ref="fileInputRef"
|
|
|
+ class="mp-wv-file-input"
|
|
|
+ type="file"
|
|
|
+ accept="image/*,.pdf,.doc,.docx"
|
|
|
+ multiple
|
|
|
+ @change="onFilesChosen"
|
|
|
+ />
|
|
|
+
|
|
|
+ <MpBottomSheet v-model="moreOpen" title="更多操作" half>
|
|
|
+ <button
|
|
|
+ v-for="action in moreActions"
|
|
|
+ :key="action.key"
|
|
|
+ class="mp-wv-filter-opt"
|
|
|
+ :class="{ 'is-danger': action.danger }"
|
|
|
+ type="button"
|
|
|
+ :disabled="acting || action.disabled"
|
|
|
+ @click="runAction(action.key)"
|
|
|
+ >
|
|
|
+ {{ action.label }}
|
|
|
+ </button>
|
|
|
+ </MpBottomSheet>
|
|
|
+
|
|
|
+ <MpBottomSheet v-model="submitOpen" title="提交审批">
|
|
|
+ <div class="mp-quote-sheet-form">
|
|
|
+ <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="confirmSubmit"
|
|
|
+ >
|
|
|
+ 确认提交
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </MpBottomSheet>
|
|
|
+
|
|
|
+ <MpBottomSheet v-model="approveOpen" :title="approveSheetTitle">
|
|
|
+ <div class="mp-quote-sheet-form">
|
|
|
+ <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="confirmApprove"
|
|
|
+ >
|
|
|
+ 确认{{ approveResult === 'rejected' ? '驳回' : '通过' }}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </MpBottomSheet>
|
|
|
+ </template>
|
|
|
</MpWebviewShell>
|
|
|
</template>
|
|
|
+
|
|
|
<script setup lang="ts">
|
|
|
- import { computed } from 'vue'
|
|
|
- import { useRoute } from 'vue-router'
|
|
|
+ import { computed, ref } from 'vue'
|
|
|
+ import { useRoute, useRouter } from 'vue-router'
|
|
|
+ import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
|
|
|
+ import MpBottomSheet from '@/components/mp/MpBottomSheet.vue'
|
|
|
+ import {
|
|
|
+ getContract,
|
|
|
+ listContractApproval,
|
|
|
+ submitContract,
|
|
|
+ approveContract,
|
|
|
+ rejectContract,
|
|
|
+ invalidContract,
|
|
|
+ restoreContract,
|
|
|
+ delContract,
|
|
|
+ uploadAttachments,
|
|
|
+ getContractApproverSetting,
|
|
|
+ type ContractApprovalVO,
|
|
|
+ type ContractVO
|
|
|
+ } from '@/api/crm/contract'
|
|
|
+ import { fetchUploadAttachment } from '@/api/admin'
|
|
|
+ import { fetchUserOptions } from '@/api/admin'
|
|
|
+ import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
|
|
|
+ import { formatContractMoney } from '@/utils/mp-crm-contract-card'
|
|
|
+ import {
|
|
|
+ isContractEditableStatus,
|
|
|
+ getContractReeditConfirmMessage
|
|
|
+ } from '@/views/crm/contract/contractType'
|
|
|
+ import {
|
|
|
+ parseCrmAttachmentField,
|
|
|
+ resolveCrmAttachmentFiles
|
|
|
+ } from '@/utils/crm-attachment'
|
|
|
+ import { buildAttachmentUploadFormData, CRM_UPLOAD_BIZ_TYPE } from '@/utils/crm-upload'
|
|
|
+ import {
|
|
|
+ MP_CRM_CONTRACT_APPROVAL_PATH,
|
|
|
+ MP_CRM_CONTRACT_LIST_PATH
|
|
|
+ } from '@/utils/mp-webview'
|
|
|
+ import { useUserStore } from '@/store/modules/user'
|
|
|
+ import { useDictStore } from '@/store/modules/dict'
|
|
|
import '@/styles/mp-webview.scss'
|
|
|
+
|
|
|
+ type FabAction = {
|
|
|
+ key: string
|
|
|
+ label: string
|
|
|
+ danger?: boolean
|
|
|
+ disabled?: boolean
|
|
|
+ title?: string
|
|
|
+ }
|
|
|
+
|
|
|
+ type ApproverOption = {
|
|
|
+ userId: string
|
|
|
+ nickName?: string
|
|
|
+ userName?: string
|
|
|
+ }
|
|
|
+
|
|
|
const route = useRoute()
|
|
|
- const title = computed(() => String(route.meta.title || ''))
|
|
|
+ const router = useRouter()
|
|
|
+ const userStore = useUserStore()
|
|
|
+ const dictStore = useDictStore()
|
|
|
+
|
|
|
+ const loading = ref(true)
|
|
|
+ const acting = ref(false)
|
|
|
+ const errorText = ref('')
|
|
|
+ const contract = ref<ContractVO | null>(null)
|
|
|
+ const products = ref<any[]>([])
|
|
|
+ const approvalList = ref<ContractApprovalVO[]>([])
|
|
|
+
|
|
|
+ const moreOpen = ref(false)
|
|
|
+ const submitOpen = ref(false)
|
|
|
+ const approveOpen = ref(false)
|
|
|
+ const approveResult = ref<'approved' | 'rejected'>('approved')
|
|
|
+ const approveOpinion = ref('')
|
|
|
+ const userOptions = ref<ApproverOption[]>([])
|
|
|
+ const submitForm = ref<{ approverIds: string[]; remark: string }>({
|
|
|
+ approverIds: [],
|
|
|
+ remark: ''
|
|
|
+ })
|
|
|
+ const fileInputRef = ref<HTMLInputElement | null>(null)
|
|
|
+
|
|
|
+ const isApproveEntry = computed(() => String(route.query.from || '') === 'approve')
|
|
|
+
|
|
|
+ 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(() => {
|
|
|
+ if (loading.value) return '合同详情'
|
|
|
+ return contract.value?.contractName || '合同详情'
|
|
|
+ })
|
|
|
+
|
|
|
+ const statusLabel = computed(() => {
|
|
|
+ const s = contract.value?.status || ''
|
|
|
+ return dictStore.getLabel('crm_contract_status', s) || s || '—'
|
|
|
+ })
|
|
|
+
|
|
|
+ const statusTagType = computed(() => {
|
|
|
+ return dictStore.getTagType('crm_contract_status', contract.value?.status || '') as
|
|
|
+ | 'success'
|
|
|
+ | 'warning'
|
|
|
+ | 'info'
|
|
|
+ | 'primary'
|
|
|
+ | 'danger'
|
|
|
+ })
|
|
|
+
|
|
|
+ const contractTypeLabel = computed(() => {
|
|
|
+ const t = contract.value?.contractType || ''
|
|
|
+ return dictStore.getLabel('crm_contract_type', t) || t || '—'
|
|
|
+ })
|
|
|
+
|
|
|
+ const dateRangeText = computed(() => {
|
|
|
+ const a = contract.value?.startDate || ''
|
|
|
+ const b = contract.value?.endDate || ''
|
|
|
+ if (!a && !b) return '—'
|
|
|
+ return `${a || '—'} ~ ${b || '—'}`
|
|
|
+ })
|
|
|
+
|
|
|
+ const attachmentFiles = computed(() =>
|
|
|
+ resolveCrmAttachmentFiles(parseCrmAttachmentField(contract.value?.attachmentFiles))
|
|
|
+ )
|
|
|
+
|
|
|
+ const pendingApproval = computed(() => {
|
|
|
+ const uid = resolveCurrentUserId()
|
|
|
+ if (!uid) return null
|
|
|
+ return (
|
|
|
+ approvalList.value.find(
|
|
|
+ (item) =>
|
|
|
+ String(item.approverId) === String(uid) &&
|
|
|
+ (item.approvalResult === 'pending' || !item.approvalResult)
|
|
|
+ ) || null
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ const canApproveNow = computed(() => {
|
|
|
+ if (contract.value?.canApprove) return true
|
|
|
+ return !!pendingApproval.value
|
|
|
+ })
|
|
|
+
|
|
|
+ const canDelete = computed(() =>
|
|
|
+ canDeleteByCreator((contract.value || {}) as Record<string, unknown>)
|
|
|
+ )
|
|
|
+
|
|
|
+ const canInvalid = computed(() =>
|
|
|
+ ['draft', 'approving', 'approved', 'executing'].includes(contract.value?.status || '')
|
|
|
+ )
|
|
|
+
|
|
|
+ const fabActions = computed((): FabAction[] => {
|
|
|
+ const c = contract.value
|
|
|
+ if (!c) return []
|
|
|
+ if (isApproveEntry.value) {
|
|
|
+ if (canApproveNow.value) {
|
|
|
+ return [
|
|
|
+ { key: 'approve', label: '通过' },
|
|
|
+ { key: 'reject', label: '驳回', danger: true }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ return []
|
|
|
+ }
|
|
|
+
|
|
|
+ const status = c.status || ''
|
|
|
+ if (status === 'invalid') {
|
|
|
+ return [
|
|
|
+ { key: 'restore', label: '作废恢复' },
|
|
|
+ { key: 'copy', label: '复制' },
|
|
|
+ { key: 'upload', label: '上传扫描件' }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+
|
|
|
+ const actions: FabAction[] = []
|
|
|
+ if (status === 'draft') {
|
|
|
+ actions.push({ key: 'submit', label: '提交审批' })
|
|
|
+ }
|
|
|
+ if (isContractEditableStatus(status)) {
|
|
|
+ actions.push({ key: 'edit', label: '编辑' })
|
|
|
+ }
|
|
|
+ if (status === 'draft') {
|
|
|
+ actions.push({
|
|
|
+ key: 'delete',
|
|
|
+ label: '删除',
|
|
|
+ danger: true,
|
|
|
+ disabled: !canDelete.value,
|
|
|
+ title: canDelete.value ? '' : CREATOR_DELETE_TIP
|
|
|
+ })
|
|
|
+ }
|
|
|
+ if (canInvalid.value) {
|
|
|
+ actions.push({ key: 'void', label: '作废', danger: true })
|
|
|
+ }
|
|
|
+ actions.push({ key: 'copy', label: '复制' }, { key: 'upload', label: '上传扫描件' })
|
|
|
+ return actions
|
|
|
+ })
|
|
|
+
|
|
|
+ const primaryActions = computed(() => fabActions.value.slice(0, 2))
|
|
|
+ const moreActions = computed(() => fabActions.value.slice(2))
|
|
|
+
|
|
|
+ const approveSheetTitle = computed(() =>
|
|
|
+ approveResult.value === 'rejected' ? '驳回合同' : '通过合同'
|
|
|
+ )
|
|
|
+
|
|
|
+ function formatApproverOptionLabel(user: ApproverOption) {
|
|
|
+ const nick = (user.nickName || '').trim()
|
|
|
+ const account = (user.userName || '').trim()
|
|
|
+ if (nick && account) return `${nick}(${account})`
|
|
|
+ return nick || account
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 loadDetail() {
|
|
|
+ const id = String(route.params.id || '').trim()
|
|
|
+ if (!id) {
|
|
|
+ errorText.value = '缺少合同 ID'
|
|
|
+ loading.value = false
|
|
|
+ return
|
|
|
+ }
|
|
|
+ loading.value = true
|
|
|
+ try {
|
|
|
+ const res = await getContract(id)
|
|
|
+ const data = ((res as any)?.data ?? res) as ContractVO
|
|
|
+ contract.value = data
|
|
|
+ errorText.value = ''
|
|
|
+ products.value = Array.isArray(data.products) ? data.products : []
|
|
|
+
|
|
|
+ try {
|
|
|
+ const appr = await listContractApproval(id)
|
|
|
+ const list = (appr as any)?.data
|
|
|
+ approvalList.value = (Array.isArray(list) ? list : []) as ContractApprovalVO[]
|
|
|
+ } catch {
|
|
|
+ approvalList.value = []
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ contract.value = null
|
|
|
+ products.value = []
|
|
|
+ approvalList.value = []
|
|
|
+ errorText.value = error instanceof Error ? error.message : '加载失败'
|
|
|
+ } finally {
|
|
|
+ loading.value = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function goList() {
|
|
|
+ void router.push(
|
|
|
+ isApproveEntry.value ? MP_CRM_CONTRACT_APPROVAL_PATH : MP_CRM_CONTRACT_LIST_PATH
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ async function runAction(key: string) {
|
|
|
+ moreOpen.value = false
|
|
|
+ if (key === 'submit') {
|
|
|
+ await openSubmitSheet()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (key === 'edit') {
|
|
|
+ await goEdit()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (key === 'delete') {
|
|
|
+ await onDelete()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (key === 'void') {
|
|
|
+ await onVoid()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (key === 'restore') {
|
|
|
+ await onRestore()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (key === 'copy') {
|
|
|
+ const id = contract.value?.id
|
|
|
+ if (!id) return
|
|
|
+ void router.push({ path: '/m/crm/contract/new', query: { copyFrom: String(id) } })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (key === 'upload') {
|
|
|
+ fileInputRef.value?.click()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (key === 'approve') {
|
|
|
+ approveResult.value = 'approved'
|
|
|
+ approveOpinion.value = '审批通过'
|
|
|
+ approveOpen.value = true
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (key === 'reject') {
|
|
|
+ approveResult.value = 'rejected'
|
|
|
+ approveOpinion.value = ''
|
|
|
+ approveOpen.value = true
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function openSubmitSheet() {
|
|
|
+ acting.value = true
|
|
|
+ try {
|
|
|
+ await loadUserOptions()
|
|
|
+ const prefill = await resolvePrefillApproverIds()
|
|
|
+ submitForm.value = { approverIds: prefill, remark: '' }
|
|
|
+ submitOpen.value = true
|
|
|
+ } finally {
|
|
|
+ acting.value = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function confirmSubmit() {
|
|
|
+ const id = contract.value?.id
|
|
|
+ if (!id) return
|
|
|
+ if (!submitForm.value.approverIds.length) {
|
|
|
+ ElMessage.warning('请选择审批人')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ acting.value = true
|
|
|
+ try {
|
|
|
+ await submitContract(id, submitForm.value.approverIds, submitForm.value.remark || undefined)
|
|
|
+ ElMessage.success('已提交审批')
|
|
|
+ submitOpen.value = false
|
|
|
+ await loadDetail()
|
|
|
+ } catch (error) {
|
|
|
+ ElMessage.error(error instanceof Error ? error.message : '提交失败')
|
|
|
+ } finally {
|
|
|
+ acting.value = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function goEdit() {
|
|
|
+ const id = contract.value?.id
|
|
|
+ if (!id) return
|
|
|
+ const msg = getContractReeditConfirmMessage(contract.value?.status)
|
|
|
+ if (msg) {
|
|
|
+ try {
|
|
|
+ await ElMessageBox.confirm(msg, '提示', { type: 'warning' })
|
|
|
+ } catch {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ }
|
|
|
+ void router.push(`/m/crm/contract/${id}/edit`)
|
|
|
+ }
|
|
|
+
|
|
|
+ async function onDelete() {
|
|
|
+ const id = contract.value?.id
|
|
|
+ if (!id || !canDelete.value) return
|
|
|
+ try {
|
|
|
+ await ElMessageBox.confirm('确认删除该合同?', '提示', { type: 'warning' })
|
|
|
+ } catch {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ acting.value = true
|
|
|
+ try {
|
|
|
+ await delContract([id])
|
|
|
+ ElMessage.success('已删除')
|
|
|
+ goList()
|
|
|
+ } catch (error) {
|
|
|
+ ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
|
|
+ } finally {
|
|
|
+ acting.value = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function onVoid() {
|
|
|
+ const id = contract.value?.id
|
|
|
+ if (!id) 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 {
|
|
|
+ await invalidContract({ id, invalidReason: reason })
|
|
|
+ ElMessage.success('已作废')
|
|
|
+ await loadDetail()
|
|
|
+ } catch (error) {
|
|
|
+ ElMessage.error(error instanceof Error ? error.message : '作废失败')
|
|
|
+ } finally {
|
|
|
+ acting.value = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function onRestore() {
|
|
|
+ const id = contract.value?.id
|
|
|
+ if (!id) return
|
|
|
+ try {
|
|
|
+ await ElMessageBox.confirm('确定恢复该合同为草稿吗?恢复后需重新提交审批。', '作废恢复', {
|
|
|
+ type: 'warning'
|
|
|
+ })
|
|
|
+ } catch {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ acting.value = true
|
|
|
+ try {
|
|
|
+ await restoreContract({ id })
|
|
|
+ ElMessage.success('已恢复为草稿')
|
|
|
+ await loadDetail()
|
|
|
+ } catch (error) {
|
|
|
+ ElMessage.error(error instanceof Error ? error.message : '恢复失败')
|
|
|
+ } finally {
|
|
|
+ acting.value = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function onFilesChosen(ev: Event) {
|
|
|
+ const input = ev.target as HTMLInputElement
|
|
|
+ const files = Array.from(input.files || [])
|
|
|
+ input.value = ''
|
|
|
+ const id = contract.value?.id
|
|
|
+ if (!id || !files.length) return
|
|
|
+
|
|
|
+ acting.value = true
|
|
|
+ try {
|
|
|
+ const existing = parseCrmAttachmentField(contract.value?.attachmentFiles).map((a) => ({
|
|
|
+ ossId: a.ossId,
|
|
|
+ category: a.category || 'other'
|
|
|
+ }))
|
|
|
+ for (const file of files) {
|
|
|
+ const formData = buildAttachmentUploadFormData(file, {
|
|
|
+ bizType: CRM_UPLOAD_BIZ_TYPE.CONTRACT_ATTACHMENT
|
|
|
+ })
|
|
|
+ const res = await fetchUploadAttachment(formData)
|
|
|
+ const uploaded = (res as any)?.data ?? res
|
|
|
+ const url = String(uploaded?.fileUrl || uploaded?.id || '')
|
|
|
+ if (url) existing.push({ ossId: url, category: 'scan' })
|
|
|
+ }
|
|
|
+ await uploadAttachments({ id, attachments: existing })
|
|
|
+ ElMessage.success('扫描件已上传')
|
|
|
+ await loadDetail()
|
|
|
+ } catch (error) {
|
|
|
+ ElMessage.error(error instanceof Error ? error.message : '上传失败')
|
|
|
+ } finally {
|
|
|
+ acting.value = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function confirmApprove() {
|
|
|
+ const id = contract.value?.id
|
|
|
+ if (!id || !canApproveNow.value) {
|
|
|
+ ElMessage.warning('当前无可审批节点')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (approveResult.value === 'rejected' && !approveOpinion.value.trim()) {
|
|
|
+ ElMessage.warning('驳回时请填写拒绝理由')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const resultText = approveResult.value === 'rejected' ? '驳回' : '通过'
|
|
|
+ try {
|
|
|
+ await ElMessageBox.confirm(
|
|
|
+ approveResult.value === 'rejected'
|
|
|
+ ? `确认要驳回该合同吗?\n驳回理由:${approveOpinion.value}`
|
|
|
+ : '确认要通过该合同吗?',
|
|
|
+ `确认${resultText}`,
|
|
|
+ { type: 'warning' }
|
|
|
+ )
|
|
|
+ } catch {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ acting.value = true
|
|
|
+ try {
|
|
|
+ const opinion = approveOpinion.value.trim()
|
|
|
+ if (approveResult.value === 'rejected') {
|
|
|
+ await rejectContract(id, opinion)
|
|
|
+ } else {
|
|
|
+ await approveContract(id, opinion || '审批通过')
|
|
|
+ }
|
|
|
+ ElMessage.success(`已${resultText}`)
|
|
|
+ approveOpen.value = false
|
|
|
+ await loadDetail()
|
|
|
+ } catch (error) {
|
|
|
+ ElMessage.error(error instanceof Error ? error.message : '审批失败')
|
|
|
+ } finally {
|
|
|
+ acting.value = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function onShellReady() {
|
|
|
+ void loadDetail()
|
|
|
+ }
|
|
|
</script>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+ .mp-quote-product-card {
|
|
|
+ padding: 10px 0;
|
|
|
+ border-bottom: 1px solid #e8f2ed;
|
|
|
+
|
|
|
+ &:last-child {
|
|
|
+ border-bottom: none;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .mp-quote-approval-step {
|
|
|
+ padding: 8px 0;
|
|
|
+ border-bottom: 1px solid #e8f2ed;
|
|
|
+
|
|
|
+ &:last-child {
|
|
|
+ border-bottom: none;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .mp-quote-sheet-form {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 10px;
|
|
|
+ padding: 4px 0 12px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .mp-quote-sheet-label {
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #087f57;
|
|
|
+ }
|
|
|
+
|
|
|
+ .mp-wv-btn--block {
|
|
|
+ width: 100%;
|
|
|
+ margin-top: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .mp-wv-pre {
|
|
|
+ white-space: pre-wrap;
|
|
|
+ word-break: break-word;
|
|
|
+ }
|
|
|
+
|
|
|
+ .mp-wv-attach-link {
|
|
|
+ display: block;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #087f57;
|
|
|
+ word-break: break-all;
|
|
|
+ }
|
|
|
+
|
|
|
+ .mp-wv-file-input {
|
|
|
+ display: none;
|
|
|
+ }
|
|
|
+
|
|
|
+ .is-danger {
|
|
|
+ color: #dc2626 !important;
|
|
|
+ }
|
|
|
+</style>
|