Files
jczxw-pc/app/components/console/ContractManagement.vue
2026-04-23 16:30:57 +08:00

606 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="contract-management">
<!-- 顶部操作栏 -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<a-input-search
v-model:value="queryParam.keywords"
placeholder="搜索合同名称/编号"
allow-clear
style="width: 220px"
@search="handleSearch"
@change="onKeywordChange"
/>
<a-select
v-model:value="queryParam.contractType"
placeholder="合同类型"
allow-clear
style="width: 140px"
@change="handleSearch"
>
<a-select-option v-for="opt in contractTypeOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-select-option>
</a-select>
<a-select
v-model:value="queryParam.status"
placeholder="合同状态"
allow-clear
style="width: 140px"
@change="handleSearch"
>
<a-select-option v-for="opt in contractStatusOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-select-option>
</a-select>
</div>
<a-button type="primary" @click="openAddModal">
<template #icon><PlusOutlined /></template>
新增合同
</a-button>
</div>
<!-- 统计卡片 -->
<a-row :gutter="[12, 12]" class="mb-4">
<a-col :xs="12" :sm="8" :md="4" v-for="stat in statsCards" :key="stat.label">
<div class="stat-card" :class="stat.cls">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</a-col>
</a-row>
<!-- 表格 -->
<a-card :bordered="false">
<a-table
:data-source="contracts"
:loading="loading"
:pagination="pagination"
size="middle"
:row-key="(r: Contract) => r.contractId!"
@change="handleTableChange"
>
<a-table-column title="合同编号" data-index="contractNo" width="185">
<template #default="{ record }">
<span class="font-mono text-sm text-gray-600">{{ record.contractNo || '-' }}</span>
</template>
</a-table-column>
<a-table-column title="合同名称" data-index="title" ellipsis>
<template #default="{ record }">
<span class="font-medium cursor-pointer text-blue-600 hover:text-blue-400" @click="openDetail(record)">
{{ record.title }}
</span>
</template>
</a-table-column>
<a-table-column title="合同类型" data-index="contractType" width="110">
<template #default="{ record }">
{{ contractTypeText(record.contractType) }}
</template>
</a-table-column>
<a-table-column title="签约方" width="230">
<template #default="{ record }">
<div class="text-xs leading-5">
<div>甲方:{{ record.partyA || '-' }}</div>
<div>乙方:{{ record.partyB || '-' }}</div>
</div>
</template>
</a-table-column>
<a-table-column title="合同金额" data-index="amount" width="120">
<template #default="{ record }">
<span class="text-orange-600 font-semibold">¥{{ formatAmount(record.amount) }}</span>
</template>
</a-table-column>
<a-table-column title="有效期" width="210">
<template #default="{ record }">
<span v-if="record.startDate && record.endDate" class="text-sm">
{{ record.startDate }} ~ {{ record.endDate }}
</span>
<span v-else class="text-gray-400 text-sm">-</span>
</template>
</a-table-column>
<a-table-column title="状态" data-index="status" width="100">
<template #default="{ record }">
<a-tag :color="contractStatusInfo(record.status).color">
{{ contractStatusInfo(record.status).text }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="操作" width="180" fixed="right">
<template #default="{ record }">
<a-space>
<a-button size="small" @click="openDetail(record)">查看</a-button>
<a-button size="small" @click="openEdit(record)">编辑</a-button>
<a-button danger size="small" @click="confirmRemove(record)">删除</a-button>
</a-space>
</template>
</a-table-column>
</a-table>
</a-card>
<!-- 新增/编辑弹窗 -->
<a-modal
v-model:open="formVisible"
:title="editingId ? '编辑合同' : '新增合同'"
:width="660"
:confirm-loading="formSubmitting"
@ok="handleFormSubmit"
@cancel="closeForm"
>
<a-form ref="formRef" layout="vertical" :model="formData" :rules="formRules" class="mt-2">
<a-form-item label="合同名称" name="title">
<a-input v-model:value="formData.title" placeholder="请输入合同名称" />
</a-form-item>
<a-row :gutter="12">
<a-col :span="12">
<a-form-item label="合同类型" name="contractType">
<a-select v-model:value="formData.contractType" placeholder="请选择合同类型" style="width:100%">
<a-select-option v-for="opt in contractTypeOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="合同状态" name="status">
<a-select v-model:value="formData.status" placeholder="请选择状态" style="width:100%">
<a-select-option v-for="opt in contractStatusOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="12">
<a-col :span="12">
<a-form-item label="甲方" name="partyA">
<a-input v-model:value="formData.partyA" placeholder="请输入甲方名称" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="乙方" name="partyB">
<a-input v-model:value="formData.partyB" placeholder="请输入乙方名称" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="12">
<a-col :span="8">
<a-form-item label="合同金额" name="amount">
<a-input-number
v-model:value="formData.amount"
:min="0"
:precision="2"
placeholder="金额"
style="width: 100%"
>
<template #prefix>¥</template>
</a-input-number>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="开始日期" name="startDate">
<a-date-picker
v-model:value="formData.startDate"
value-format="YYYY-MM-DD"
placeholder="开始日期"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="结束日期" name="endDate">
<a-date-picker
v-model:value="formData.endDate"
value-format="YYYY-MM-DD"
placeholder="结束日期"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<!-- 合同附件上传 -->
<a-form-item label="合同附件">
<a-upload
:file-list="fileList"
:max-count="1"
:before-upload="() => false"
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png"
@change="handleFileChange"
>
<a-button>
<UploadOutlined /> 上传合同文件
</a-button>
<span class="ml-2 text-gray-400 text-xs">支持 PDF、Word、图片最大 20MB</span>
</a-upload>
<div v-if="formData.fileUrl && !fileList.length" class="mt-1">
<a :href="formData.fileUrl" target="_blank" class="text-blue-500 text-sm">
<PaperClipOutlined /> {{ formData.fileName || '查看已上传文件' }}
</a>
<a-button type="link" danger size="small" class="ml-2" @click="clearFile">移除</a-button>
</div>
</a-form-item>
<a-form-item label="备注" name="remark">
<a-textarea v-model:value="formData.remark" :rows="3" placeholder="备注选填" />
</a-form-item>
</a-form>
</a-modal>
<!-- 详情弹窗 -->
<a-modal
v-model:open="detailVisible"
title="合同详情"
:width="700"
:footer="null"
>
<a-descriptions v-if="detail" bordered size="small" :column="2" class="mt-2">
<a-descriptions-item label="合同编号" :span="2">
<span class="font-mono">{{ detail.contractNo || '-' }}</span>
</a-descriptions-item>
<a-descriptions-item label="合同名称" :span="2">{{ detail.title }}</a-descriptions-item>
<a-descriptions-item label="合同类型">{{ contractTypeText(detail.contractType) }}</a-descriptions-item>
<a-descriptions-item label="合同状态">
<a-tag :color="contractStatusInfo(detail.status).color">{{ contractStatusInfo(detail.status).text }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="甲方">{{ detail.partyA || '-' }}</a-descriptions-item>
<a-descriptions-item label="乙方">{{ detail.partyB || '-' }}</a-descriptions-item>
<a-descriptions-item label="合同金额">
<span class="text-orange-600 font-semibold">¥{{ formatAmount(detail.amount) }}</span>
</a-descriptions-item>
<a-descriptions-item label="有效期">
<span v-if="detail.startDate && detail.endDate">{{ detail.startDate }} ~ {{ detail.endDate }}</span>
<span v-else class="text-gray-400">-</span>
</a-descriptions-item>
<a-descriptions-item label="创建人">{{ detail.userName || '-' }}</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ detail.createTime || '-' }}</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">{{ detail.remark || '-' }}</a-descriptions-item>
<a-descriptions-item v-if="detail.fileUrl" label="合同附件" :span="2">
<a :href="detail.fileUrl" target="_blank" class="text-blue-500">
<PaperClipOutlined /> {{ detail.fileName || '查看附件' }}
</a>
</a-descriptions-item>
</a-descriptions>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { PlusOutlined, UploadOutlined, PaperClipOutlined } from '@ant-design/icons-vue'
import { uploadFile } from '@/api/system/file'
import type { UploadChangeParam, UploadFile } from 'ant-design-vue'
import {
pageContract,
addContract,
updateContract,
removeContract,
statsContract,
contractTypeOptions,
contractStatusOptions,
contractTypeText,
contractStatusInfo,
type Contract,
type ContractType,
type ContractStatus,
} from '@/api/app/contract'
defineOptions({ name: 'ContractManagement' })
// ─── 列表 & 分页 ──────────────────────────────────────────────
const contracts = ref<Contract[]>([])
const loading = ref(false)
const total = ref(0)
const queryParam = reactive<{
keywords?: string
contractType?: ContractType
status?: ContractStatus
page: number
limit: number
}>({
page: 1,
limit: 15,
})
const pagination = computed(() => ({
total: total.value,
current: queryParam.page,
pageSize: queryParam.limit,
showSizeChanger: true,
showTotal: (t: number) => `${t}`,
pageSizeOptions: ['10', '15', '20', '50'],
}))
async function loadData() {
loading.value = true
try {
const res = await pageContract({
page: queryParam.page,
limit: queryParam.limit,
keywords: queryParam.keywords,
contractType: queryParam.contractType,
status: queryParam.status,
})
contracts.value = res?.list ?? []
total.value = res?.total ?? 0
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
function handleSearch() {
queryParam.page = 1
loadData()
}
let keywordTimer: ReturnType<typeof setTimeout> | null = null
function onKeywordChange() {
if (keywordTimer) clearTimeout(keywordTimer)
keywordTimer = setTimeout(handleSearch, 400)
}
function handleTableChange(pag: { current?: number; pageSize?: number }) {
queryParam.page = pag.current ?? 1
queryParam.limit = pag.pageSize ?? 15
loadData()
}
// ─── 统计 ─────────────────────────────────────────────────────
const statsData = ref<Record<string, number>>({})
const statsCards = computed(() => [
{ label: '全部', value: statsData.value.total ?? 0, cls: 'blue' },
{ label: '生效中', value: statsData.value.active ?? 0, cls: 'green' },
{ label: '待签署', value: statsData.value.pending ?? 0, cls: 'orange' },
{ label: '已过期', value: statsData.value.expired ?? 0, cls: 'red' },
{ label: '草稿', value: statsData.value.draft ?? 0, cls: 'gray' },
{ label: '已终止', value: statsData.value.terminated ?? 0, cls: 'purple' },
])
async function loadStats() {
try {
const res = await statsContract()
statsData.value = res ?? {}
} catch {}
}
// ─── 表单 ─────────────────────────────────────────────────────
const formVisible = ref(false)
const formSubmitting = ref(false)
const editingId = ref<number | null>(null)
const formRef = ref()
const fileList = ref<UploadFile[]>([])
const uploading = ref(false)
interface FormData {
title: string
contractType?: ContractType
partyA?: string
partyB?: string
amount?: number
startDate?: string
endDate?: string
status?: ContractStatus
remark?: string
fileUrl?: string
fileName?: string
}
const formData = reactive<FormData>({
title: '',
contractType: undefined,
partyA: '',
partyB: '',
amount: undefined,
startDate: undefined,
endDate: undefined,
status: 'draft',
remark: '',
fileUrl: '',
fileName: '',
})
const formRules = {
title: [{ required: true, message: '请输入合同名称' }],
contractType: [{ required: true, message: '请选择合同类型' }],
status: [{ required: true, message: '请选择合同状态' }],
}
function resetFormData() {
formData.title = ''
formData.contractType = undefined
formData.partyA = ''
formData.partyB = ''
formData.amount = undefined
formData.startDate = undefined
formData.endDate = undefined
formData.status = 'draft'
formData.remark = ''
formData.fileUrl = ''
formData.fileName = ''
fileList.value = []
editingId.value = null
}
function openAddModal() {
resetFormData()
formVisible.value = true
}
function openEdit(record: Contract) {
resetFormData()
editingId.value = record.contractId ?? null
Object.assign(formData, {
title: record.title,
contractType: record.contractType,
partyA: record.partyA ?? '',
partyB: record.partyB ?? '',
amount: record.amount,
startDate: record.startDate,
endDate: record.endDate,
status: record.status,
remark: record.remark ?? '',
fileUrl: record.fileUrl ?? '',
fileName: record.fileName ?? '',
})
formVisible.value = true
}
function closeForm() {
formVisible.value = false
formRef.value?.resetFields()
resetFormData()
}
// 文件上传
function handleFileChange({ fileList: list }: UploadChangeParam) {
fileList.value = list.slice(-1)
}
function clearFile() {
formData.fileUrl = ''
formData.fileName = ''
}
async function uploadPendingFile(): Promise<{ url: string; name: string } | null> {
const raw = fileList.value[0]?.originFileObj
if (!raw) return null
uploading.value = true
try {
const result = await uploadFile(raw as File)
return { url: (result as any).url ?? (result as any).fileUrl, name: raw.name }
} finally {
uploading.value = false
}
}
async function handleFormSubmit() {
try {
await formRef.value?.validate()
} catch {
return
}
formSubmitting.value = true
try {
// 如有待上传文件先上传
if (fileList.value.length > 0) {
const uploaded = await uploadPendingFile()
if (uploaded) {
formData.fileUrl = uploaded.url
formData.fileName = uploaded.name
}
}
const payload: Partial<Contract> = { ...formData }
if (editingId.value) {
await updateContract(editingId.value, payload)
message.success('合同已更新')
} else {
await addContract(payload)
message.success('合同已创建')
}
closeForm()
await loadData()
await loadStats()
} catch (e: any) {
message.error(e?.message || '操作失败')
} finally {
formSubmitting.value = false
}
}
// ─── 详情 ─────────────────────────────────────────────────────
const detailVisible = ref(false)
const detail = ref<Contract | null>(null)
function openDetail(record: Contract) {
detail.value = record
detailVisible.value = true
}
// ─── 删除 ─────────────────────────────────────────────────────
function confirmRemove(record: Contract) {
Modal.confirm({
title: '确认删除该合同?',
content: '删除后不可恢复。',
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
await removeContract(record.contractId!)
message.success('已删除')
if (detail.value?.contractId === record.contractId) {
detailVisible.value = false
detail.value = null
}
await loadData()
await loadStats()
},
})
}
// ─── 工具函数 ─────────────────────────────────────────────────
function formatAmount(value?: number | string | null) {
if (value === null || value === undefined) return '0.00'
const n = typeof value === 'string' ? parseFloat(value) : value
return Number.isFinite(n) ? n.toFixed(2) : '0.00'
}
// ─── 初始化 ───────────────────────────────────────────────────
onMounted(() => {
loadData()
loadStats()
})
</script>
<style scoped>
.contract-management {
width: 100%;
}
.stat-card {
padding: 14px;
border-radius: 10px;
border: 1px solid transparent;
text-align: center;
transition: box-shadow 0.2s;
}
.stat-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
.stat-card.orange{ background: #fff7ed; border-color: #fed7aa; }
.stat-card.red { background: #fef2f2; border-color: #fecaca; }
.stat-card.gray { background: #f9fafb; border-color: #e5e7eb; }
.stat-card.purple{ background: #faf5ff; border-color: #e9d5ff; }
.stat-value {
font-size: 22px;
font-weight: 700;
color: rgba(0, 0, 0, 0.85);
line-height: 1;
}
.stat-label {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-top: 4px;
}
</style>