Ver código fonte

feat(web-admin): add mp webview CRM contract detail actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 2 semanas atrás
pai
commit
796b434a28
1 arquivos alterados com 839 adições e 5 exclusões
  1. 839 5
      web-admin/src/views/m/crm/contract/detail.vue

+ 839 - 5
web-admin/src/views/m/crm/contract/detail.vue

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