14 changed files with 3137 additions and 102 deletions
@ -0,0 +1,697 @@ |
|||
<template> |
|||
<el-dialog |
|||
v-model="showDialog" |
|||
title="完整合同查看" |
|||
width="1200px" |
|||
:destroy-on-close="true" |
|||
:close-on-click-modal="false" |
|||
class="contract-detail-dialog" |
|||
> |
|||
<div class="contract-detail-content" v-loading="loading"> |
|||
<!-- 合同基础信息 --> |
|||
<div class="contract-basic-info" v-if="contractData?.contract"> |
|||
<h3>{{ contractData.contract.contract_name }}</h3> |
|||
<div class="info-grid"> |
|||
<div class="info-item"> |
|||
<label>合同类型:</label> |
|||
<span>{{ contractData.contract.contract_type }}</span> |
|||
</div> |
|||
<div class="info-item"> |
|||
<label>签署状态:</label> |
|||
<el-tag :type="getStatusType(contractData.status)"> |
|||
{{ getStatusLabel(contractData.status) }} |
|||
</el-tag> |
|||
</div> |
|||
<div class="info-item"> |
|||
<label>创建时间:</label> |
|||
<span>{{ formatDate(contractData.created_at) }}</span> |
|||
</div> |
|||
<div class="info-item" v-if="contractData.sign_time"> |
|||
<label>签署时间:</label> |
|||
<span>{{ formatDate(contractData.sign_time) }}</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 合同字段表单 --> |
|||
<div class="contract-fields" v-if="contractData?.field_configs"> |
|||
<h4>合同字段信息</h4> |
|||
|
|||
<!-- 字段分组:按类型分组显示 --> |
|||
<div class="fields-container"> |
|||
<!-- 基本信息字段组 --> |
|||
<div class="field-group" v-if="basicFields.length > 0"> |
|||
<div class="group-header"> |
|||
<span class="group-title">基本信息</span> |
|||
<el-tag size="small" type="info">{{ basicFields.length }} 项</el-tag> |
|||
</div> |
|||
<div class="fields-grid"> |
|||
<div |
|||
v-for="field in basicFields" |
|||
:key="field.placeholder" |
|||
class="field-card" |
|||
> |
|||
<div class="field-header"> |
|||
<span class="field-name">{{ field.placeholder }}</span> |
|||
<div class="field-tags"> |
|||
<el-tag size="small" :type="getFieldTypeColor(field.data_type)"> |
|||
{{ getFieldTypeLabel(field.data_type) }} |
|||
</el-tag> |
|||
<el-tag size="small" type="info" v-if="field.sign_party"> |
|||
{{ field.sign_party === 'party_a' ? '甲方' : '乙方' }} |
|||
</el-tag> |
|||
</div> |
|||
</div> |
|||
<div class="field-content"> |
|||
<!-- 用户输入字段 --> |
|||
<el-input |
|||
v-if="field.data_type === 'user_input' && field.is_editable" |
|||
v-model="formData[field.placeholder]" |
|||
:placeholder="`请输入${field.placeholder}`" |
|||
size="small" |
|||
/> |
|||
<div v-else class="field-display"> |
|||
<!-- 数据库字段和系统字段 --> |
|||
<span v-if="field.data_type === 'database' || field.data_type === 'system'" class="value-text"> |
|||
{{ field.display_value || '未获取' }} |
|||
</span> |
|||
<!-- 用户输入只读字段 --> |
|||
<span v-else-if="field.data_type === 'user_input'" class="value-text"> |
|||
{{ field.display_value || '未填写' }} |
|||
</span> |
|||
<!-- 签名图片字段 --> |
|||
<div v-else-if="field.data_type === 'signature' || field.data_type === 'sign_img'" class="signature-display"> |
|||
<el-image |
|||
v-if="field.display_value" |
|||
:src="field.display_value" |
|||
style="width: 100px; height: 50px;" |
|||
:preview-src-list="[field.display_value]" |
|||
fit="contain" |
|||
class="signature-image" |
|||
/> |
|||
<span v-else class="no-signature">未签名</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 日期时间字段组 --> |
|||
<div class="field-group" v-if="dateFields.length > 0"> |
|||
<div class="group-header"> |
|||
<span class="group-title">日期信息</span> |
|||
<el-tag size="small" type="info">{{ dateFields.length }} 项</el-tag> |
|||
</div> |
|||
<div class="fields-grid"> |
|||
<div |
|||
v-for="field in dateFields" |
|||
:key="field.placeholder" |
|||
class="field-card" |
|||
> |
|||
<div class="field-header"> |
|||
<span class="field-name">{{ field.placeholder }}</span> |
|||
<div class="field-tags"> |
|||
<el-tag size="small" :type="getFieldTypeColor(field.data_type)"> |
|||
{{ getFieldTypeLabel(field.data_type) }} |
|||
</el-tag> |
|||
</div> |
|||
</div> |
|||
<div class="field-content"> |
|||
<span class="value-text">{{ field.display_value || '未获取' }}</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 签名相关字段组 --> |
|||
<div class="field-group" v-if="signatureFields.length > 0"> |
|||
<div class="group-header"> |
|||
<span class="group-title">签名信息</span> |
|||
<el-tag size="small" type="info">{{ signatureFields.length }} 项</el-tag> |
|||
</div> |
|||
<div class="fields-grid signature-grid"> |
|||
<div |
|||
v-for="field in signatureFields" |
|||
:key="field.placeholder" |
|||
class="field-card signature-card" |
|||
> |
|||
<div class="field-header"> |
|||
<span class="field-name">{{ field.placeholder }}</span> |
|||
<div class="field-tags"> |
|||
<el-tag size="small" :type="getFieldTypeColor(field.data_type)"> |
|||
{{ getFieldTypeLabel(field.data_type) }} |
|||
</el-tag> |
|||
<el-tag size="small" type="info" v-if="field.sign_party"> |
|||
{{ field.sign_party === 'party_a' ? '甲方' : '乙方' }} |
|||
</el-tag> |
|||
</div> |
|||
</div> |
|||
<div class="field-content"> |
|||
<div class="signature-display"> |
|||
<el-image |
|||
v-if="field.display_value" |
|||
:src="field.display_value" |
|||
style="width: 120px; height: 60px;" |
|||
:preview-src-list="[field.display_value]" |
|||
fit="contain" |
|||
class="signature-image" |
|||
/> |
|||
<span v-else class="no-signature">未签名</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 其他字段组 --> |
|||
<div class="field-group" v-if="otherFields.length > 0"> |
|||
<div class="group-header"> |
|||
<span class="group-title">其他信息</span> |
|||
<el-tag size="small" type="info">{{ otherFields.length }} 项</el-tag> |
|||
</div> |
|||
<div class="fields-grid"> |
|||
<div |
|||
v-for="field in otherFields" |
|||
:key="field.placeholder" |
|||
class="field-card" |
|||
> |
|||
<div class="field-header"> |
|||
<span class="field-name">{{ field.placeholder }}</span> |
|||
<div class="field-tags"> |
|||
<el-tag size="small" :type="getFieldTypeColor(field.data_type)"> |
|||
{{ getFieldTypeLabel(field.data_type) }} |
|||
</el-tag> |
|||
</div> |
|||
</div> |
|||
<div class="field-content"> |
|||
<el-input |
|||
v-if="field.data_type === 'user_input' && field.is_editable" |
|||
v-model="formData[field.placeholder]" |
|||
:placeholder="`请输入${field.placeholder}`" |
|||
size="small" |
|||
/> |
|||
<span v-else class="value-text">{{ field.display_value || '未填写' }}</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 合同内容预览 --> |
|||
<div class="contract-content-preview" v-if="contractData?.contract?.contract_content"> |
|||
<h4>合同内容预览</h4> |
|||
<div class="content-display"> |
|||
{{ contractData.contract.contract_content }} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<template #footer> |
|||
<span class="dialog-footer"> |
|||
<el-button @click="showDialog = false">关闭</el-button> |
|||
<el-button |
|||
v-if="hasEditableFields" |
|||
type="primary" |
|||
:loading="saving" |
|||
@click="handleSaveFields" |
|||
> |
|||
保存字段 |
|||
</el-button> |
|||
<el-button |
|||
v-if="contractData?.status === 1" |
|||
type="success" |
|||
:loading="generating" |
|||
@click="handleGenerateDocument" |
|||
> |
|||
生成Word文档 |
|||
</el-button> |
|||
</span> |
|||
</template> |
|||
</el-dialog> |
|||
</template> |
|||
|
|||
<script lang="ts" setup> |
|||
import { ref, computed, watch } from 'vue' |
|||
import { ElMessage, ElForm } from 'element-plus' |
|||
import { updateContractSignStatus } from '@/app/api/contract_sign' |
|||
|
|||
interface Props { |
|||
show: boolean |
|||
contractData: any |
|||
personnelId: number | string |
|||
} |
|||
|
|||
const props = defineProps<Props>() |
|||
const emit = defineEmits(['update:show', 'success']) |
|||
|
|||
// 响应式数据 |
|||
const showDialog = computed({ |
|||
get: () => props.show, |
|||
set: (value) => emit('update:show', value) |
|||
}) |
|||
|
|||
const loading = ref(false) |
|||
const saving = ref(false) |
|||
const generating = ref(false) |
|||
const formRef = ref<InstanceType<typeof ElForm>>() |
|||
const formData = ref<Record<string, any>>({}) |
|||
|
|||
// 计算属性 |
|||
const hasEditableFields = computed(() => { |
|||
if (!props.contractData?.field_configs) return false |
|||
return props.contractData.field_configs.some((field: any) => field.is_editable) |
|||
}) |
|||
|
|||
// 字段分组计算 |
|||
const fieldGroups = computed(() => { |
|||
if (!props.contractData?.field_configs) return { |
|||
basicFields: [], |
|||
dateFields: [], |
|||
signatureFields: [], |
|||
otherFields: [] |
|||
} |
|||
|
|||
const fields = props.contractData.field_configs |
|||
const basicFields: any[] = [] |
|||
const dateFields: any[] = [] |
|||
const signatureFields: any[] = [] |
|||
const otherFields: any[] = [] |
|||
|
|||
fields.forEach((field: any) => { |
|||
// 签名相关字段 |
|||
if (field.data_type === 'signature' || field.data_type === 'sign_img') { |
|||
signatureFields.push(field) |
|||
} |
|||
// 日期相关字段 |
|||
else if (field.placeholder.includes('年') || field.placeholder.includes('月') || field.placeholder.includes('日') || |
|||
field.placeholder.includes('year') || field.placeholder.includes('month') || field.placeholder.includes('day') || |
|||
field.placeholder.includes('时间') || field.placeholder.includes('期')) { |
|||
dateFields.push(field) |
|||
} |
|||
// 基本信息字段(姓名、角色、联系方式等) |
|||
else if (field.placeholder.includes('姓名') || field.placeholder.includes('乙方') || field.placeholder.includes('甲方') || |
|||
field.placeholder.includes('岗位') || field.placeholder.includes('角色') || field.placeholder.includes('电话') || |
|||
field.placeholder.includes('地址') || field.placeholder.includes('证件') || field.placeholder.includes('代码')) { |
|||
basicFields.push(field) |
|||
} |
|||
// 其他字段 |
|||
else { |
|||
otherFields.push(field) |
|||
} |
|||
}) |
|||
|
|||
return { |
|||
basicFields, |
|||
dateFields, |
|||
signatureFields, |
|||
otherFields |
|||
} |
|||
}) |
|||
|
|||
const basicFields = computed(() => fieldGroups.value.basicFields) |
|||
const dateFields = computed(() => fieldGroups.value.dateFields) |
|||
const signatureFields = computed(() => fieldGroups.value.signatureFields) |
|||
const otherFields = computed(() => fieldGroups.value.otherFields) |
|||
|
|||
// 状态配置 |
|||
const statusConfig = { |
|||
1: { label: '未签署', type: 'warning' }, |
|||
2: { label: '已签署', type: 'success' }, |
|||
3: { label: '已生效', type: 'primary' }, |
|||
4: { label: '已失效', type: 'info' } |
|||
} |
|||
|
|||
// 方法 |
|||
const getStatusLabel = (status?: number) => { |
|||
if (!status) return '未签署' |
|||
return statusConfig[status as keyof typeof statusConfig]?.label || '未知' |
|||
} |
|||
|
|||
const getStatusType = (status?: number) => { |
|||
if (!status) return 'warning' |
|||
return statusConfig[status as keyof typeof statusConfig]?.type || 'info' |
|||
} |
|||
|
|||
const getFieldTypeLabel = (dataType: string) => { |
|||
const labels = { |
|||
'user_input': '用户输入', |
|||
'database': '数据库', |
|||
'system': '系统函数', |
|||
'signature': '手写签名', |
|||
'sign_img': '签名图片' |
|||
} |
|||
return labels[dataType as keyof typeof labels] || dataType |
|||
} |
|||
|
|||
const getFieldTypeColor = (dataType: string) => { |
|||
const colors = { |
|||
'user_input': 'primary', |
|||
'database': 'success', |
|||
'system': 'info', |
|||
'signature': 'warning', |
|||
'sign_img': 'warning' |
|||
} |
|||
return colors[dataType as keyof typeof colors] || 'info' |
|||
} |
|||
|
|||
const formatDate = (dateStr: string) => { |
|||
if (!dateStr) return '未设置' |
|||
try { |
|||
const date = new Date(dateStr) |
|||
return date.toLocaleDateString('zh-CN', { |
|||
year: 'numeric', |
|||
month: '2-digit', |
|||
'day': '2-digit', |
|||
hour: '2-digit', |
|||
minute: '2-digit' |
|||
}) |
|||
} catch (error) { |
|||
return dateStr |
|||
} |
|||
} |
|||
|
|||
// 初始化表单数据 |
|||
const initFormData = () => { |
|||
if (!props.contractData?.field_configs) return |
|||
|
|||
formData.value = {} |
|||
props.contractData.field_configs.forEach((field: any) => { |
|||
if (field.is_editable && field.data_type === 'user_input') { |
|||
formData.value[field.placeholder] = field.value || field.default_value || '' |
|||
} |
|||
}) |
|||
} |
|||
|
|||
// 保存字段数据 |
|||
const handleSaveFields = async () => { |
|||
if (!formRef.value) return |
|||
|
|||
try { |
|||
saving.value = true |
|||
|
|||
// 这里需要调用API保存字段数据到 fill_data |
|||
const fillData = { ...props.contractData.fill_data, ...formData.value } |
|||
|
|||
// TODO: 调用更新fill_data的API |
|||
// await updateContractFillData(props.contractData.id, { fill_data: JSON.stringify(fillData) }) |
|||
|
|||
ElMessage.success('字段保存成功') |
|||
emit('success') |
|||
|
|||
} catch (error) { |
|||
console.error('保存字段失败:', error) |
|||
ElMessage.error('保存字段失败') |
|||
} finally { |
|||
saving.value = false |
|||
} |
|||
} |
|||
|
|||
// 生成Word文档 |
|||
const handleGenerateDocument = async () => { |
|||
try { |
|||
generating.value = true |
|||
|
|||
// TODO: 调用生成Word文档的API |
|||
// await generateContractDocument(props.contractData.id) |
|||
|
|||
ElMessage.success('Word文档生成成功') |
|||
|
|||
} catch (error) { |
|||
console.error('生成文档失败:', error) |
|||
ElMessage.error('生成文档失败') |
|||
} finally { |
|||
generating.value = false |
|||
} |
|||
} |
|||
|
|||
// 监听弹窗显示状态 |
|||
watch(() => props.show, (visible) => { |
|||
if (visible && props.contractData) { |
|||
initFormData() |
|||
} |
|||
}) |
|||
</script> |
|||
|
|||
<style lang="scss" scoped> |
|||
.contract-detail-dialog { |
|||
:deep(.el-dialog) { |
|||
border-radius: 8px; |
|||
} |
|||
|
|||
:deep(.el-dialog__header) { |
|||
padding: 20px 24px 16px; |
|||
border-bottom: 1px solid #f0f0f0; |
|||
background: #fafafa; |
|||
border-radius: 8px 8px 0 0; |
|||
|
|||
.el-dialog__title { |
|||
font-size: 18px; |
|||
font-weight: 600; |
|||
color: #1f2937; |
|||
} |
|||
} |
|||
|
|||
:deep(.el-dialog__body) { |
|||
padding: 24px; |
|||
max-height: 80vh; |
|||
overflow-y: auto; |
|||
} |
|||
} |
|||
|
|||
.contract-detail-content { |
|||
.contract-basic-info { |
|||
margin-bottom: 24px; |
|||
padding: 20px; |
|||
background: #f8f9fa; |
|||
border-radius: 8px; |
|||
border: 1px solid #e9ecef; |
|||
|
|||
h3 { |
|||
margin: 0 0 16px; |
|||
font-size: 18px; |
|||
color: #333; |
|||
} |
|||
|
|||
.info-grid { |
|||
display: grid; |
|||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); |
|||
gap: 20px; |
|||
|
|||
.info-item { |
|||
display: flex; |
|||
align-items: center; |
|||
padding: 12px 16px; |
|||
background: #fff; |
|||
border-radius: 8px; |
|||
border: 1px solid #e4e7ed; |
|||
transition: all 0.3s ease; |
|||
|
|||
&:hover { |
|||
border-color: #409eff; |
|||
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.1); |
|||
} |
|||
|
|||
label { |
|||
font-weight: 600; |
|||
color: #666; |
|||
margin-right: 12px; |
|||
min-width: 90px; |
|||
font-size: 14px; |
|||
} |
|||
|
|||
span { |
|||
color: #333; |
|||
font-size: 14px; |
|||
flex: 1; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
.contract-fields { |
|||
margin-bottom: 24px; |
|||
|
|||
h4 { |
|||
margin: 0 0 24px; |
|||
font-size: 18px; |
|||
color: #333; |
|||
padding-bottom: 12px; |
|||
border-bottom: 2px solid #e9ecef; |
|||
font-weight: 600; |
|||
} |
|||
|
|||
.fields-container { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 24px; |
|||
} |
|||
|
|||
.field-group { |
|||
background: #fafbfc; |
|||
border-radius: 12px; |
|||
padding: 20px; |
|||
border: 1px solid #e9ecef; |
|||
|
|||
.group-header { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
margin-bottom: 16px; |
|||
padding-bottom: 12px; |
|||
border-bottom: 1px solid #e9ecef; |
|||
|
|||
.group-title { |
|||
font-size: 16px; |
|||
font-weight: 600; |
|||
color: #333; |
|||
} |
|||
} |
|||
|
|||
.fields-grid { |
|||
display: grid; |
|||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); |
|||
gap: 16px; |
|||
|
|||
// 签名网格特殊布局 |
|||
&.signature-grid { |
|||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); |
|||
} |
|||
} |
|||
|
|||
.field-card { |
|||
background: #fff; |
|||
border-radius: 8px; |
|||
padding: 16px; |
|||
border: 1px solid #e4e7ed; |
|||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); |
|||
transition: all 0.3s ease; |
|||
|
|||
&:hover { |
|||
border-color: #409eff; |
|||
box-shadow: 0 4px 8px rgba(64, 158, 255, 0.1); |
|||
transform: translateY(-2px); |
|||
} |
|||
|
|||
&.signature-card { |
|||
min-height: 120px; |
|||
display: flex; |
|||
flex-direction: column; |
|||
justify-content: space-between; |
|||
} |
|||
|
|||
.field-header { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
margin-bottom: 12px; |
|||
|
|||
.field-name { |
|||
font-weight: 600; |
|||
color: #333; |
|||
font-size: 14px; |
|||
flex: 1; |
|||
margin-right: 8px; |
|||
} |
|||
|
|||
.field-tags { |
|||
display: flex; |
|||
gap: 4px; |
|||
flex-shrink: 0; |
|||
} |
|||
} |
|||
|
|||
.field-content { |
|||
.field-display { |
|||
.value-text { |
|||
display: block; |
|||
padding: 8px 12px; |
|||
background: #f8f9fa; |
|||
border: 1px solid #e9ecef; |
|||
border-radius: 6px; |
|||
color: #333; |
|||
font-size: 14px; |
|||
min-height: 20px; |
|||
line-height: 20px; |
|||
} |
|||
|
|||
.signature-display { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
|
|||
.signature-image { |
|||
border: 1px solid #e9ecef; |
|||
border-radius: 6px; |
|||
background: #fff; |
|||
} |
|||
|
|||
.no-signature { |
|||
color: #999; |
|||
font-style: italic; |
|||
padding: 16px 24px; |
|||
background: #f8f9fa; |
|||
border: 1px dashed #d9d9d9; |
|||
border-radius: 6px; |
|||
font-size: 14px; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.value-text { |
|||
display: block; |
|||
padding: 8px 12px; |
|||
background: #f8f9fa; |
|||
border: 1px solid #e9ecef; |
|||
border-radius: 6px; |
|||
color: #333; |
|||
font-size: 14px; |
|||
min-height: 20px; |
|||
line-height: 20px; |
|||
} |
|||
|
|||
:deep(.el-input) { |
|||
.el-input__wrapper { |
|||
border-radius: 6px; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
.contract-content-preview { |
|||
h4 { |
|||
margin: 0 0 16px; |
|||
font-size: 16px; |
|||
color: #333; |
|||
padding-bottom: 8px; |
|||
border-bottom: 2px solid #e9ecef; |
|||
} |
|||
|
|||
.content-display { |
|||
padding: 20px; |
|||
background: #fff; |
|||
border: 1px solid #e9ecef; |
|||
border-radius: 8px; |
|||
max-height: 300px; |
|||
overflow-y: auto; |
|||
font-size: 14px; |
|||
line-height: 1.6; |
|||
color: #666; |
|||
white-space: pre-wrap; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.dialog-footer { |
|||
.el-button { |
|||
padding: 10px 24px; |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
border-radius: 6px; |
|||
margin-left: 8px; |
|||
} |
|||
} |
|||
</style> |
|||
File diff suppressed because it is too large
@ -0,0 +1,765 @@ |
|||
<template> |
|||
<div class="contract-sign-tab"> |
|||
<div class="contracts-wrapper"> |
|||
<!-- 左侧:筛选区域和合同列表 --> |
|||
<div class="contracts-sidebar"> |
|||
<!-- 头部筛选区域 --> |
|||
<div class="filter-section"> |
|||
<div class="filter-header"> |
|||
<h4>合同状态筛选</h4> |
|||
<el-button |
|||
type="primary" |
|||
size="small" |
|||
:loading="refreshing" |
|||
@click="refreshData" |
|||
> |
|||
刷新 |
|||
</el-button> |
|||
</div> |
|||
<el-radio-group v-model="activeStatus" size="small" @change="handleStatusChange"> |
|||
<el-radio-button :label="null">全部</el-radio-button> |
|||
<el-radio-button :label="1">未签署</el-radio-button> |
|||
<el-radio-button :label="2">已签署</el-radio-button> |
|||
<el-radio-button :label="3">已生效</el-radio-button> |
|||
<el-radio-button :label="4">已失效</el-radio-button> |
|||
</el-radio-group> |
|||
</div> |
|||
|
|||
<!-- 合同列表 --> |
|||
<div class="contract-list" v-loading="loading"> |
|||
<div |
|||
v-for="contract in filteredContracts" |
|||
:key="contract.id" |
|||
class="contract-item" |
|||
:class="{ 'active': selectedContract?.id === contract.id }" |
|||
@click="selectContract(contract)" |
|||
> |
|||
<div class="contract-header"> |
|||
<div class="contract-title"> |
|||
{{ contract.contract_name }} |
|||
<el-tag v-if="contract.personnel_name" type="info" size="small" class="personnel-tag"> |
|||
{{ contract.personnel_name }} |
|||
</el-tag> |
|||
</div> |
|||
<div class="contract-status"> |
|||
<el-tag |
|||
:type="getStatusType(contract.sign_status)" |
|||
size="small" |
|||
> |
|||
{{ getStatusLabel(contract.sign_status) }} |
|||
</el-tag> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="contract-meta"> |
|||
<span class="contract-type">{{ contract.contract_type }}</span> |
|||
<span class="contract-date">{{ formatDate(contract.created_at) }}</span> |
|||
<span v-if="contract.file_size" class="file-size"> |
|||
{{ formatFileSize(contract.file_size) }} |
|||
</span> |
|||
</div> |
|||
|
|||
<div v-if="contract.remarks" class="contract-remarks"> |
|||
{{ contract.remarks }} |
|||
</div> |
|||
|
|||
<div class="contract-actions" v-if="contract.sign_status === 1"> |
|||
<el-button |
|||
type="primary" |
|||
size="small" |
|||
@click.stop="handleSignContract(contract)" |
|||
> |
|||
签署合同 |
|||
</el-button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 空状态 --> |
|||
<div v-if="filteredContracts.length === 0" class="empty-state"> |
|||
<el-empty description="暂无合同数据" /> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 右侧:合同详情区域 --> |
|||
<div class="contract-detail" v-if="selectedContract"> |
|||
<div class="detail-header"> |
|||
<h3>{{ selectedContract.contract_name }}</h3> |
|||
<div class="detail-actions"> |
|||
<el-button @click="viewFullContract">查看完整合同</el-button> |
|||
<el-button |
|||
v-if="selectedContract.sign_status === 1" |
|||
type="primary" |
|||
@click="handleSignContract(selectedContract)" |
|||
> |
|||
签署合同 |
|||
</el-button> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="detail-content"> |
|||
<div class="contract-info"> |
|||
<el-descriptions :column="2" border> |
|||
<el-descriptions-item label="合同名称"> |
|||
{{ selectedContract.contract_name }} |
|||
</el-descriptions-item> |
|||
<el-descriptions-item label="合同类型"> |
|||
<el-tag :type="selectedContract.contract_type === '内部' ? 'success' : 'primary'" size="small"> |
|||
{{ selectedContract.contract_type }} |
|||
</el-tag> |
|||
</el-descriptions-item> |
|||
<el-descriptions-item label="签署状态"> |
|||
<el-tag :type="getStatusType(selectedContract.sign_status)"> |
|||
{{ getStatusLabel(selectedContract.sign_status) }} |
|||
</el-tag> |
|||
</el-descriptions-item> |
|||
<el-descriptions-item label="员工姓名" v-if="selectedContract.personnel_name"> |
|||
<el-tag type="info" size="small"> |
|||
{{ selectedContract.personnel_name }} |
|||
</el-tag> |
|||
</el-descriptions-item> |
|||
<el-descriptions-item label="创建时间"> |
|||
{{ formatDate(selectedContract.created_at) }} |
|||
</el-descriptions-item> |
|||
<el-descriptions-item label="签署时间" v-if="selectedContract.sign_time"> |
|||
{{ formatDate(selectedContract.sign_time) }} |
|||
</el-descriptions-item> |
|||
<el-descriptions-item label="文件大小" v-if="selectedContract.file_size"> |
|||
{{ formatFileSize(selectedContract.file_size) }} |
|||
</el-descriptions-item> |
|||
<el-descriptions-item label="原始文件名" v-if="selectedContract.original_filename"> |
|||
{{ selectedContract.original_filename }} |
|||
</el-descriptions-item> |
|||
<el-descriptions-item label="签名图片" v-if="selectedContract.signature_image"> |
|||
<el-image |
|||
:src="selectedContract.signature_image" |
|||
style="width: 80px; height: 40px;" |
|||
:preview-src-list="[selectedContract.signature_image]" |
|||
fit="contain" |
|||
/> |
|||
</el-descriptions-item> |
|||
</el-descriptions> |
|||
</div> |
|||
|
|||
<div v-if="selectedContract.remarks" class="contract-remarks-detail"> |
|||
<h4>合同备注</h4> |
|||
<div class="remarks-content">{{ selectedContract.remarks }}</div> |
|||
</div> |
|||
|
|||
<div class="contract-preview" v-if="selectedContract.contract_content"> |
|||
<div class="preview-header"> |
|||
<h4>合同内容预览</h4> |
|||
</div> |
|||
<div class="content-preview"> |
|||
{{ getContractPreview(selectedContract.contract_content) }} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 合同签署弹窗 --> |
|||
<ContractSignDialog |
|||
v-model:show="showSignDialog" |
|||
:contract-data="currentSignContract" |
|||
:personnel-id="personnelId" |
|||
@success="handleSignSuccess" |
|||
/> |
|||
|
|||
<!-- 合同详情弹窗 --> |
|||
<ContractDetailDialog |
|||
v-model:show="showContractDetailDialogFlag" |
|||
:contract-data="contractDetailData" |
|||
:personnel-id="personnelId" |
|||
@success="handleSignSuccess" |
|||
/> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts" setup> |
|||
import { ref, computed, onMounted, watch } from 'vue' |
|||
import { ElMessage } from 'element-plus' |
|||
import { getContractSignList, getContractSignInfo } from '@/app/api/contract_sign' |
|||
import ContractSignDialog from './ContractSignDialog.vue' |
|||
import ContractDetailDialog from './ContractDetailDialog.vue' |
|||
|
|||
interface Props { |
|||
personnelId: number | string |
|||
} |
|||
|
|||
interface ContractItem { |
|||
id: number |
|||
contract_name: string |
|||
contract_type: string |
|||
contract_status: string |
|||
created_at: string |
|||
sign_status?: number |
|||
sign_time?: string |
|||
signature_image?: string |
|||
source_type?: string |
|||
contract_content?: string |
|||
} |
|||
|
|||
const props = defineProps<Props>() |
|||
const emit = defineEmits(['refresh']) |
|||
|
|||
// 响应式数据 |
|||
const loading = ref(false) |
|||
const refreshing = ref(false) |
|||
const contractList = ref<ContractItem[]>([]) |
|||
const selectedContract = ref<ContractItem | null>(null) |
|||
const activeStatus = ref<number | null>(null) |
|||
const showSignDialog = ref(false) |
|||
const currentSignContract = ref<ContractItem | null>(null) |
|||
const showContractDetailDialogFlag = ref(false) |
|||
const contractDetailData = ref<any>(null) |
|||
|
|||
// 状态配置 |
|||
const statusConfig = { |
|||
1: { label: '未签署', type: 'warning' }, |
|||
2: { label: '已签署', type: 'success' }, |
|||
3: { label: '已生效', type: 'primary' }, |
|||
4: { label: '已失效', type: 'info' } |
|||
} |
|||
|
|||
const sourceTypeConfig = { |
|||
manual: '手动分发', |
|||
auto_course: '自动课程分发' |
|||
} |
|||
|
|||
// 计算属性 |
|||
const filteredContracts = computed(() => { |
|||
if (activeStatus.value === null) { |
|||
return contractList.value |
|||
} |
|||
return contractList.value.filter(contract => { |
|||
// 没有签署记录的算作未签署 |
|||
const status = contract.sign_status || 1 |
|||
return status === activeStatus.value |
|||
}) |
|||
}) |
|||
|
|||
// 格式化日期 |
|||
const formatDate = (dateStr: string) => { |
|||
if (!dateStr) return '未设置' |
|||
try { |
|||
const date = new Date(dateStr) |
|||
return date.toLocaleDateString('zh-CN', { |
|||
year: 'numeric', |
|||
month: '2-digit', |
|||
'day': '2-digit' |
|||
}) |
|||
} catch (error) { |
|||
return dateStr |
|||
} |
|||
} |
|||
|
|||
// 格式化文件大小 |
|||
const formatFileSize = (size: number) => { |
|||
if (!size || size === 0) return '' |
|||
const units = ['B', 'KB', 'MB', 'GB'] |
|||
let fileSize = size |
|||
let unitIndex = 0 |
|||
|
|||
while (fileSize >= 1024 && unitIndex < units.length - 1) { |
|||
fileSize /= 1024 |
|||
unitIndex++ |
|||
} |
|||
|
|||
return `${fileSize.toFixed(1)} ${units[unitIndex]}` |
|||
} |
|||
|
|||
// 方法 |
|||
const getStatusLabel = (status?: number) => { |
|||
if (!status) return '未签署' |
|||
return statusConfig[status as keyof typeof statusConfig]?.label || '未知' |
|||
} |
|||
|
|||
const getStatusType = (status?: number) => { |
|||
if (!status) return 'warning' |
|||
return statusConfig[status as keyof typeof statusConfig]?.type || 'info' |
|||
} |
|||
|
|||
const getSourceTypeLabel = (sourceType: string) => { |
|||
return sourceTypeConfig[sourceType as keyof typeof sourceTypeConfig] || sourceType |
|||
} |
|||
|
|||
const getContractPreview = (content: string) => { |
|||
if (!content) return '' |
|||
// 完整显示合同内容 |
|||
return content |
|||
} |
|||
|
|||
const loadContractList = async () => { |
|||
if (!props.personnelId) return |
|||
|
|||
loading.value = true |
|||
try { |
|||
const params = { |
|||
personnel_id: props.personnelId, |
|||
page: 1, |
|||
limit: 100 |
|||
} |
|||
|
|||
const response = await getContractSignList(params) |
|||
console.log('API响应数据:', response) // 调试日志 |
|||
|
|||
// 处理分页格式数据结构 |
|||
let dataList = [] |
|||
if (response.data && response.data.data && Array.isArray(response.data.data)) { |
|||
// 分页格式:{data: {data: [], total: 100}} |
|||
dataList = response.data.data |
|||
} else { |
|||
console.warn('未找到有效的数组数据,原始响应:', response) |
|||
dataList = [] |
|||
} |
|||
|
|||
if (dataList.length > 0) { |
|||
contractList.value = dataList.map((item: any) => ({ |
|||
id: item.id, // 这是合同签署记录的ID |
|||
contract_id: item.contract_id, |
|||
personnel_id: props.personnelId, |
|||
// 直接使用模型关联绑定返回的字段 |
|||
contract_name: item.contract_id_name || '未知合同', |
|||
contract_type: item.contract_type || '外部', |
|||
contract_status: item.contract_status || 'active', |
|||
created_at: item.created_at, |
|||
sign_status: item.status, |
|||
sign_time: item.sign_time, |
|||
signature_image: item.signature_image, |
|||
source_type: item.source_type, |
|||
contract_content: item.contract_content || '', |
|||
remarks: item.remarks || '', |
|||
original_filename: item.original_filename || '', |
|||
file_size: item.file_size || 0, |
|||
// 关联的员工信息 |
|||
personnel_name: item.personnel_id_name || '' |
|||
})) |
|||
} else { |
|||
contractList.value = [] |
|||
} |
|||
|
|||
console.log('处理后的合同列表:', contractList.value) // 调试日志 |
|||
} catch (error) { |
|||
console.error('获取合同列表失败:', error) |
|||
ElMessage.error('获取合同列表失败') |
|||
} finally { |
|||
loading.value = false |
|||
} |
|||
} |
|||
|
|||
const refreshData = async () => { |
|||
refreshing.value = true |
|||
await loadContractList() |
|||
refreshing.value = false |
|||
ElMessage.success('数据已刷新') |
|||
} |
|||
|
|||
const handleStatusChange = () => { |
|||
// 状态改变时,清空选中状态 |
|||
selectedContract.value = null |
|||
} |
|||
|
|||
const selectContract = (contract: ContractItem) => { |
|||
selectedContract.value = contract |
|||
} |
|||
|
|||
const viewFullContract = async () => { |
|||
console.log('=== viewFullContract 开始 ===') |
|||
console.log('选中的合同:', selectedContract.value) |
|||
|
|||
if (!selectedContract.value) return |
|||
|
|||
try { |
|||
// 找到对应的合同签署记录ID |
|||
const contractSignRecord = contractList.value.find( |
|||
item => item.contract_id === selectedContract.value?.contract_id |
|||
) |
|||
|
|||
if (!contractSignRecord?.id) { |
|||
// 如果没有签署记录,使用合同ID创建一个临时的查看 |
|||
console.log('没有签署记录,显示临时合同详情') |
|||
showContractDetailDialog(selectedContract.value) |
|||
return |
|||
} |
|||
|
|||
// 调用API获取完整的合同信息 |
|||
const response = await getContractSignInfo(contractSignRecord.id) |
|||
|
|||
if (response.data) { |
|||
console.log('获取到完整合同信息,显示详情弹窗') |
|||
showContractDetailDialog(response.data) |
|||
} else { |
|||
ElMessage.error('获取合同详情失败') |
|||
} |
|||
} catch (error) { |
|||
console.error('查看完整合同失败:', error) |
|||
ElMessage.error('查看完整合同失败') |
|||
} |
|||
console.log('=== viewFullContract 结束 ===') |
|||
} |
|||
|
|||
const handleSignContract = (contract: ContractItem) => { |
|||
// 防止意外调用 |
|||
if (!contract) { |
|||
ElMessage.warning('请选择合同') |
|||
return |
|||
} |
|||
|
|||
console.log('=== handleSignContract 开始 ===') |
|||
console.log('传入的合同数据:', contract) |
|||
|
|||
currentSignContract.value = contract |
|||
console.log('currentSignContract 已设置:', currentSignContract.value) |
|||
|
|||
// 确保另一个弹窗是关闭的 |
|||
showContractDetailDialogFlag.value = false |
|||
console.log('已关闭详情弹窗') |
|||
|
|||
showSignDialog.value = true |
|||
console.log('showSignDialog 已设置为:', showSignDialog.value) |
|||
console.log('=== handleSignContract 结束 ===') |
|||
} |
|||
|
|||
const handleSignSuccess = () => { |
|||
// 签署成功后刷新数据 |
|||
loadContractList() |
|||
showSignDialog.value = false |
|||
currentSignContract.value = null |
|||
emit('refresh') |
|||
} |
|||
|
|||
const showContractDetailDialog = (data: any) => { |
|||
contractDetailData.value = data |
|||
showContractDetailDialogFlag.value = true |
|||
} |
|||
|
|||
const copyContractContent = async () => { |
|||
if (!selectedContract.value?.contract_content) return |
|||
|
|||
try { |
|||
// 使用 Clipboard API 复制内容 |
|||
await navigator.clipboard.writeText(selectedContract.value.contract_content) |
|||
ElMessage.success('合同内容已复制到剪贴板') |
|||
} catch (error) { |
|||
// 降级方案:使用传统的复制方法 |
|||
const textArea = document.createElement('textarea') |
|||
textArea.value = selectedContract.value.contract_content |
|||
textArea.style.position = 'fixed' |
|||
textArea.style.opacity = '0' |
|||
document.body.appendChild(textArea) |
|||
textArea.focus() |
|||
textArea.select() |
|||
|
|||
try { |
|||
document.execCommand('copy') |
|||
ElMessage.success('合同内容已复制到剪贴板') |
|||
} catch (err) { |
|||
ElMessage.error('复制失败,请手动选择复制') |
|||
} |
|||
|
|||
document.body.removeChild(textArea) |
|||
} |
|||
} |
|||
|
|||
// 监听人员ID变化 |
|||
watch(() => props.personnelId, (newId) => { |
|||
if (newId) { |
|||
loadContractList() |
|||
} |
|||
}, { immediate: true }) |
|||
|
|||
// 生命周期 - 移除重复调用 |
|||
onMounted(() => { |
|||
// 不再在这里调用,因为watch已经有immediate: true |
|||
}) |
|||
</script> |
|||
|
|||
<style lang="scss" scoped> |
|||
.contract-sign-tab { |
|||
display: flex; |
|||
height: 100%; |
|||
gap: 20px; |
|||
} |
|||
|
|||
.contracts-wrapper { |
|||
display: flex; |
|||
flex: 1; |
|||
height: 100%; |
|||
} |
|||
|
|||
.contracts-sidebar { |
|||
flex: 0 0 400px; |
|||
display: flex; |
|||
flex-direction: column; |
|||
height: calc(100vh - 300px); |
|||
border: 1px solid #e9ecef; |
|||
border-radius: 8px; |
|||
background: #fff; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.filter-section { |
|||
padding: 16px; |
|||
background: #fafbfc; |
|||
border-bottom: 1px solid #e9ecef; |
|||
} |
|||
|
|||
.filter-header { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
margin-bottom: 12px; |
|||
|
|||
h4 { |
|||
margin: 0; |
|||
font-size: 14px; |
|||
font-weight: 600; |
|||
color: #333; |
|||
} |
|||
} |
|||
|
|||
.filter-section .el-radio-group { |
|||
display: flex; |
|||
flex-wrap: wrap; |
|||
gap: 8px; |
|||
} |
|||
|
|||
.contract-list { |
|||
flex: 1; |
|||
overflow-y: auto; |
|||
background: #fff; |
|||
|
|||
.contract-item { |
|||
padding: 16px; |
|||
border-bottom: 1px solid #f0f0f0; |
|||
cursor: pointer; |
|||
transition: all 0.2s ease; |
|||
|
|||
&:hover { |
|||
background: #f8f9fa; |
|||
} |
|||
|
|||
&.active { |
|||
background: #e3f2fd; |
|||
border-color: #2196f3; |
|||
} |
|||
|
|||
&:last-child { |
|||
border-bottom: none; |
|||
} |
|||
} |
|||
|
|||
.contract-header { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: flex-start; |
|||
margin-bottom: 8px; |
|||
|
|||
.contract-title { |
|||
flex: 1; |
|||
font-weight: 500; |
|||
color: #333; |
|||
font-size: 14px; |
|||
line-height: 1.4; |
|||
margin-right: 8px; |
|||
} |
|||
} |
|||
|
|||
.contract-meta { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
margin-bottom: 8px; |
|||
|
|||
.contract-type { |
|||
font-size: 12px; |
|||
color: #666; |
|||
background: #f5f5f5; |
|||
padding: 2px 6px; |
|||
border-radius: 3px; |
|||
} |
|||
|
|||
.contract-date { |
|||
font-size: 12px; |
|||
color: #999; |
|||
} |
|||
} |
|||
|
|||
.contract-actions { |
|||
text-align: right; |
|||
} |
|||
|
|||
.empty-state { |
|||
padding: 40px 20px; |
|||
text-align: center; |
|||
} |
|||
|
|||
.personnel-tag { |
|||
margin-left: 8px; |
|||
} |
|||
|
|||
.file-size { |
|||
font-size: 12px; |
|||
color: #999; |
|||
background: #f0f0f0; |
|||
padding: 2px 6px; |
|||
border-radius: 3px; |
|||
} |
|||
|
|||
.contract-remarks { |
|||
margin-top: 8px; |
|||
padding: 8px; |
|||
background: #f8f9fa; |
|||
border-radius: 4px; |
|||
font-size: 12px; |
|||
color: #666; |
|||
line-height: 1.4; |
|||
border-left: 3px solid #e9ecef; |
|||
} |
|||
} |
|||
|
|||
.contract-detail { |
|||
flex: 1; |
|||
min-width: 0; |
|||
height: calc(100vh - 300px); |
|||
overflow-y: auto; |
|||
border: 1px solid #e9ecef; |
|||
border-radius: 8px; |
|||
background: #fff; |
|||
|
|||
.detail-header { |
|||
padding: 20px; |
|||
border-bottom: 1px solid #f0f0f0; |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
|
|||
h3 { |
|||
margin: 0; |
|||
font-size: 18px; |
|||
color: #333; |
|||
} |
|||
|
|||
.detail-actions { |
|||
display: flex; |
|||
gap: 12px; |
|||
} |
|||
} |
|||
|
|||
.detail-content { |
|||
padding: 20px; |
|||
|
|||
.contract-info { |
|||
margin-bottom: 24px; |
|||
} |
|||
|
|||
.contract-preview { |
|||
.preview-header { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
margin-bottom: 12px; |
|||
|
|||
h4 { |
|||
margin: 0; |
|||
font-size: 16px; |
|||
color: #333; |
|||
} |
|||
|
|||
.preview-actions { |
|||
display: flex; |
|||
gap: 8px; |
|||
} |
|||
} |
|||
|
|||
.content-preview { |
|||
padding: 20px; |
|||
background: #f8f9fa; |
|||
border: 1px solid #e9ecef; |
|||
border-radius: 6px; |
|||
font-size: 14px; |
|||
line-height: 1.8; |
|||
color: #333; |
|||
white-space: pre-wrap; |
|||
word-wrap: break-word; |
|||
min-height: 400px; |
|||
max-height: 70vh; |
|||
overflow-y: auto; |
|||
|
|||
// 美化滚动条样式 |
|||
&::-webkit-scrollbar { |
|||
width: 8px; |
|||
} |
|||
|
|||
&::-webkit-scrollbar-track { |
|||
background: #f1f1f1; |
|||
border-radius: 4px; |
|||
} |
|||
|
|||
&::-webkit-scrollbar-thumb { |
|||
background: #c1c1c1; |
|||
border-radius: 4px; |
|||
|
|||
&:hover { |
|||
background: #a8a8a8; |
|||
} |
|||
} |
|||
|
|||
// 美化下划线样式,使其更明显 |
|||
:deep(_) { |
|||
color: #1890ff; |
|||
font-weight: 500; |
|||
background: #e6f7ff; |
|||
padding: 0 2px; |
|||
border-radius: 2px; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.contract-remarks-detail { |
|||
margin-bottom: 24px; |
|||
|
|||
h4 { |
|||
margin: 0 0 12px; |
|||
font-size: 16px; |
|||
color: #333; |
|||
} |
|||
|
|||
.remarks-content { |
|||
padding: 12px 16px; |
|||
background: #f8f9fa; |
|||
border: 1px solid #e9ecef; |
|||
border-radius: 6px; |
|||
font-size: 14px; |
|||
line-height: 1.6; |
|||
color: #666; |
|||
white-space: pre-wrap; |
|||
border-left: 4px solid #007bff; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 美化滚动条 |
|||
.contract-list, |
|||
.contract-detail { |
|||
&::-webkit-scrollbar { |
|||
width: 6px; |
|||
} |
|||
|
|||
&::-webkit-scrollbar-track { |
|||
background: #f1f1f1; |
|||
border-radius: 3px; |
|||
} |
|||
|
|||
&::-webkit-scrollbar-thumb { |
|||
background: #c1c1c1; |
|||
border-radius: 3px; |
|||
|
|||
&:hover { |
|||
background: #a8a8a8; |
|||
} |
|||
} |
|||
} |
|||
</style> |
|||
Loading…
Reference in new issue