Browse Source

修改 bug

develop
王泽彦 5 months ago
parent
commit
98c65d5606
  1. 18
      admin/src/app/api/contract_sign.ts
  2. 6
      admin/src/app/views/contract/contract.vue
  3. 697
      admin/src/app/views/personnel/components/ContractDetailDialog.vue
  4. 1073
      admin/src/app/views/personnel/components/ContractSignDialog.vue
  5. 765
      admin/src/app/views/personnel/components/ContractSignTab.vue
  6. 29
      admin/src/app/views/personnel/components/personnel-edit.vue
  7. 47
      niucloud/app/adminapi/controller/contract_sign/ContractSign.php
  8. 6
      niucloud/app/adminapi/route/contract_sign.php
  9. 36
      niucloud/app/model/contract_sign/ContractSign.php
  10. 83
      niucloud/app/service/admin/contract/ContractDistributionService.php
  11. 343
      niucloud/app/service/admin/contract_sign/ContractSignService.php
  12. 65
      niucloud/app/service/admin/document/DocumentTemplateService.php
  13. 67
      uniapp/pages-common/contract/contract_sign.vue
  14. 4
      uniapp/pages-common/contract/staff-contract-sign.vue

18
admin/src/app/api/contract_sign.ts

@ -55,4 +55,22 @@ export function getWithContractList(params: Record<string,any>){
return request.get('contract_sign/personnel_all', {params})
}
/**
*
* @param params personnel_id参数
* @returns
*/
export function getPersonnelContractList(params: Record<string, any>) {
return request.get('contract_sign/personnel_contracts', {params})
}
/**
*
* @param params
* @returns
*/
export function updateContractSignStatus(params: Record<string, any>) {
return request.put('contract_sign/update_status', params, { showErrorMessage: true, showSuccessMessage: true })
}
// USER_CODE_END -- contract_sign

6
admin/src/app/views/contract/contract.vue

@ -174,7 +174,7 @@
<div class="config-section">
<h4>配置说明</h4>
<ul>
<li>占位符格式双大括号包围例如{{学员姓名}}</li>
<li>占位符格式双大括号包围例如{\t{学员姓名}\t}</li>
<li>请为每个占位符配置对应的数据源表和字段</li>
<li>必填项在生成合同时必须有值否则会报错</li>
</ul>
@ -516,7 +516,6 @@
<th>手机号</th>
<th>部门</th>
<th>角色</th>
<th>状态</th>
</tr>
</thead>
<tbody>
@ -533,9 +532,6 @@
<td>{{ staff.phone }}</td>
<td>{{ staff.department }}</td>
<td>{{ staff.role }}</td>
<td>
<span class="status-tag status-active">{{ staff.status }}</span>
</td>
</tr>
</tbody>
</table>

697
admin/src/app/views/personnel/components/ContractDetailDialog.vue

@ -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_dataAPI
// 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: WordAPI
// 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>

1073
admin/src/app/views/personnel/components/ContractSignDialog.vue

File diff suppressed because it is too large

765
admin/src/app/views/personnel/components/ContractSignTab.vue

@ -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(() => {
// watchimmediate: 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>

29
admin/src/app/views/personnel/components/personnel-edit.vue

@ -28,6 +28,17 @@
/>
</div>
</el-tab-pane>
<!-- Tab 3: 合同签署 -->
<el-tab-pane label="合同签署" name="contract" :disabled="!formData.id">
<div class="tab-content contract-tab-content">
<ContractSignTab
v-if="formData.id"
:personnel-id="formData.id"
@refresh="handleContractRefresh"
/>
</div>
</el-tab-pane>
</el-tabs>
<template #footer>
<span class="dialog-footer">
@ -49,6 +60,7 @@ import {
} from '@/app/api/personnel'
import BaseInfoForm from './BaseInfoForm.vue'
import DetailInfoForm from './DetailInfoForm.vue'
import ContractSignTab from './ContractSignTab.vue'
let showDialog = ref(false)
const loading = ref(false)
@ -110,6 +122,14 @@ const confirm = async () => {
}
}
/**
* 处理合同刷新
*/
const handleContractRefresh = () => {
//
console.log('合同签署状态已更新')
}
/**
* 设置表单数据
*/
@ -220,6 +240,15 @@ defineExpose({
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
min-height: 400px;
}
.contract-tab-content {
padding: 0;
border: none;
box-shadow: none;
background: transparent;
min-height: 500px;
}
}

47
niucloud/app/adminapi/controller/contract_sign/ContractSign.php

@ -29,11 +29,23 @@ class ContractSign extends BaseAdminController
public function lists(){
$data = $this->request->params([
["contract_id",""],
["personnel_id",0],
["status",""],
["created_at",["",""]],
["sign_time",["",""]]
]);
return success((new ContractSignService())->getPage($data));
// 过滤掉空字符串参数,避免不必要的搜索条件
$filtered_data = array_filter($data, function($value) {
return $value !== "" && $value !== [];
});
// 确保personnel_id总是有数值类型
if (!isset($filtered_data['personnel_id'])) {
$filtered_data['personnel_id'] = 0;
}
return success((new ContractSignService())->getPage($filtered_data));
}
/**
@ -95,4 +107,37 @@ class ContractSign extends BaseAdminController
return success(( new ContractSignService())->getPersonnelAll());
}
/**
* 获取员工所有合同(包括未签署的)
* @return \think\Response
*/
public function getPersonnelContracts(){
$data = $this->request->params([
["personnel_id", 0]
]);
return success((new ContractSignService())->getPersonnelContracts($data));
}
/**
* 更新合同签署状态
* @return \think\Response
*/
public function updateStatus(){
$data = $this->request->params([
["id", 0],
["contract_id", 0],
["personnel_id", 0],
["status", 1],
["signature_image", ""],
["sign_time", ""],
["fill_data", ""]
]);
$id = $data['id'];
unset($data['id']);
(new ContractSignService())->edit($id, $data);
return success('UPDATE_SUCCESS');
}
}

6
niucloud/app/adminapi/route/contract_sign.php

@ -34,6 +34,12 @@ Route::group('contract_sign', function () {
Route::get('personnel_all','contract_sign.ContractSign/getPersonnelAll');
// 获取员工所有合同(包括未签署的)
Route::get('personnel_contracts','contract_sign.ContractSign/getPersonnelContracts');
// 更新合同签署状态
Route::put('update_status','contract_sign.ContractSign/updateStatus');
})->middleware([
AdminCheckToken::class,
AdminCheckRole::class,

36
niucloud/app/model/contract_sign/ContractSign.php

@ -54,6 +54,30 @@ class ContractSign extends BaseModel
*/
protected $defaultSoftDelete = 0;
/**
* 搜索器:合同ID
* @param $value
* @param $data
*/
public function searchContractIdAttr($query, $value, $data)
{
if ($value && $value != 0) {
$query->where("contract_id", $value);
}
}
/**
* 搜索器:合同关系人员ID
* @param $value
* @param $data
*/
public function searchPersonnelIdAttr($query, $value, $data)
{
if ($value && $value != 0) {
$query->where("personnel_id", $value);
}
}
/**
* 搜索器:合同关系签署状态
* @param $value
@ -108,7 +132,17 @@ class ContractSign extends BaseModel
public function contract(){
return $this->hasOne(Contract::class, 'id', 'contract_id')->joinType('left')->withField('contract_name,id')->bind(['contract_id_name'=>'contract_name']);
return $this->hasOne(Contract::class, 'id', 'contract_id')->joinType('left')
->withField('contract_name,contract_type,remarks,original_filename,file_size,contract_content,placeholder_config,id')
->bind([
'contract_id_name'=>'contract_name',
'contract_type'=>'contract_type',
'remarks'=>'remarks',
'original_filename'=>'original_filename',
'file_size'=>'file_size',
'contract_content'=>'contract_content',
'placeholder_config'=>'placeholder_config'
]);
}
public function personnel(){

83
niucloud/app/service/admin/contract/ContractDistributionService.php

@ -17,6 +17,7 @@ use app\model\personnel\Personnel;
use app\model\member\Member;
use core\base\BaseAdminService;
use think\facade\Db;
use think\facade\Log;
/**
* 合同分发服务类
@ -77,7 +78,12 @@ class ContractDistributionService extends BaseAdminService
'source_id' => null
];
$this->model->create($data);
$contractSignRecord = $this->model->create($data);
// 复制数据源配置到新的合同签署记录
if ($contractSignRecord && $contractSignRecord->id) {
$this->copyDataSourceConfig($contractId, $contractSignRecord->id);
}
}
Db::commit();
@ -223,7 +229,7 @@ class ContractDistributionService extends BaseAdminService
{
if ($type === 1) {
// 内部员工
$count = (new Personnel())->whereIn('id', $personnelIds)->count();
$count = Db::table('school_personnel')->whereIn('id', $personnelIds)->where('deleted_at', 0)->count();
} else {
// 外部会员
$count = (new Member())->whereIn('id', $personnelIds)->count();
@ -285,4 +291,77 @@ class ContractDistributionService extends BaseAdminService
return $personnelData;
}
/**
* 复制数据源配置到新的合同签署记录
* @param int $contractId 合同ID
* @param int $contractSignId 合同签署记录ID
* @return bool
*/
private function copyDataSourceConfig(int $contractId, int $contractSignId): bool
{
try {
// 获取原合同的模板配置(contract_sign_id 为 null 的记录)
$sourceConfigs = Db::table('school_document_data_source_config')
->where('contract_id', $contractId)
->where('contract_sign_id', null)
->select();
if (empty($sourceConfigs)) {
// 如果没有模板配置,记录日志但不阻止分发流程
Log::info('合同无数据源配置模板', [
'contract_id' => $contractId,
'contract_sign_id' => $contractSignId
]);
return true;
}
$now = date('Y-m-d H:i:s');
$insertData = [];
foreach ($sourceConfigs as $config) {
$insertData[] = [
'contract_id' => $config['contract_id'],
'contract_sign_id' => $contractSignId,
'placeholder' => $config['placeholder'],
'data_type' => $config['data_type'],
'table_name' => $config['table_name'],
'field_name' => $config['field_name'],
'field_type' => $config['field_type'],
'is_required' => $config['is_required'],
'default_value' => $config['default_value'],
'system_function' => $config['system_function'],
'sign_party' => $config['sign_party'],
'validation_rule' => $config['validation_rule'],
'created_at' => $now,
'updated_at' => $now
];
}
// 批量插入配置数据
if (!empty($insertData)) {
$result = Db::table('school_document_data_source_config')->insertAll($insertData);
Log::info('复制数据源配置成功', [
'contract_id' => $contractId,
'contract_sign_id' => $contractSignId,
'copied_count' => count($insertData),
'result' => $result
]);
return $result > 0;
}
return true;
} catch (\Exception $e) {
Log::error('复制数据源配置失败', [
'contract_id' => $contractId,
'contract_sign_id' => $contractSignId,
'error' => $e->getMessage()
]);
// 不阻止分发流程,只记录错误
return true;
}
}
}

343
niucloud/app/service/admin/contract_sign/ContractSignService.php

@ -41,21 +41,87 @@ class ContractSignService extends BaseAdminService
$field = 'id,contract_id,personnel_id,sign_file,status,created_at,sign_time,updated_at,deleted_at';
$order = 'id desc';
$search_model = $this->model->withSearch(["status","created_at","sign_time","contract_id"], $where)->with(['contract','personnel'])->field($field)->order($order);
$list = $this->pageQuery($search_model);
return $list;
$search_model = $this->model->withSearch(["status","created_at","sign_time","contract_id","personnel_id"], $where)
->with(['contract','personnel'])
->field($field)
->order($order);
$result = $this->pageQuery($search_model);
// 处理合同内容占位符
if (isset($result['data']) && is_array($result['data'])) {
foreach ($result['data'] as &$item) {
// 检查直接的 contract_content 字段
if (isset($item['contract_content']) && !empty($item['contract_content'])) {
$item['contract_content'] = replace_placeholders_with_underlines($item['contract_content']);
}
// 检查关联对象中的 contract_content 字段
elseif (isset($item['contract']['contract_content']) && !empty($item['contract']['contract_content'])) {
$item['contract']['contract_content'] = replace_placeholders_with_underlines($item['contract']['contract_content']);
}
}
}
return $result;
}
/**
* 获取合同关系信息
* 获取合同关系信息(包含完整字段配置和填充数据)
* @param int $id
* @return array
*/
public function getInfo(int $id)
{
$field = 'id,contract_id,personnel_id,sign_file,status,created_at,sign_time,updated_at,deleted_at';
$field = 'id,contract_id,personnel_id,sign_file,status,created_at,sign_time,signature_image,source_type,fill_data,type,student_id,updated_at,deleted_at';
// 获取基础合同签署信息
$info = $this->model->field($field)->where([['id', "=", $id]])->with(['contract','personnel'])->findOrEmpty()->toArray();
if (empty($info)) {
return $info;
}
// 解析填充数据
$fillData = [];
if (!empty($info['fill_data'])) {
$fillData = json_decode($info['fill_data'], true) ?: [];
}
// 获取合同占位符配置
$placeholderConfig = [];
if (!empty($info['contract']['placeholder_config'])) {
$placeholderConfig = json_decode($info['contract']['placeholder_config'], true) ?: [];
}
// 获取文档数据源配置
$fieldConfigs = \think\facade\Db::table('school_document_data_source_config')
->where('contract_id', $info['contract_id'])
->where('contract_sign_id', $info['id'])
->select()
->toArray();
// 如果没有找到特定配置,则使用通用配置(contract_sign_id IS NULL)
if (empty($fieldConfigs)) {
$fieldConfigs = \think\facade\Db::table('school_document_data_source_config')
->where('contract_id', $info['contract_id'])
->where('contract_sign_id', null)
->select()
->toArray();
}
// 如果数据源配置为空,从占位符配置生成
if (empty($fieldConfigs) && !empty($placeholderConfig)) {
$fieldConfigs = $this->generateFieldConfigsFromPlaceholder($placeholderConfig, $info['contract_id']);
}
// 处理字段配置和数据获取
$processedFields = $this->processFieldConfigs($fieldConfigs, $info['personnel_id'], $fillData, $info['contract']['contract_type'] ?? '');
// 组装完整数据
$info['field_configs'] = $processedFields;
$info['fill_data'] = $fillData;
$info['placeholder_config'] = $placeholderConfig;
return $info;
}
@ -107,5 +173,272 @@ class ContractSignService extends BaseAdminService
return $personnelModel->select()->toArray();
}
/**
* 获取员工所有合同(包括未签署的)
* @param array $where
* @return array
*/
public function getPersonnelContracts(array $where)
{
$personnel_id = $where['personnel_id'] ?? 0;
if (!$personnel_id) {
return [];
}
// 获取所有可用的合同
$contractModel = new Contract();
$contracts = $contractModel->where([['deleted_at', '=', 0]])->select();
$contracts = $contracts ? $contracts->toArray() : [];
// 获取该员工已有的签署记录
$signRecords = $this->model->where([
['personnel_id', '=', $personnel_id],
['deleted_at', '=', 0]
])->select();
$signRecords = $signRecords ? $signRecords->toArray() : [];
$signMap = [];
foreach ($signRecords as $record) {
$signMap[$record['contract_id']] = $record;
}
// 合并数据
$result = [];
foreach ($contracts as $contract) {
$contractId = $contract['id'];
$signRecord = $signMap[$contractId] ?? null;
$result[] = [
'contract_id' => $contractId,
'personnel_id' => $personnel_id,
'contract_name' => $contract['contract_name'],
'contract_type' => $contract['contract_type'],
'contract_status' => $contract['contract_status'],
'created_at' => $signRecord['created_at'] ?? $contract['created_at'],
'contract_content' => $contract['contract_content'] ?? '',
'sign_status' => $signRecord ? $signRecord['status'] : 1, // 默认未签署
'sign_time' => $signRecord['sign_time'] ?? null,
'signature_image' => $signRecord['signature_image'] ?? null,
'source_type' => $signRecord['source_type'] ?? null
];
}
return $result;
}
/**
* 从占位符配置生成字段配置
* @param array $placeholderConfig
* @param int $contractId
* @return array
*/
private function generateFieldConfigsFromPlaceholder(array $placeholderConfig, int $contractId): array
{
$fieldConfigs = [];
foreach ($placeholderConfig as $placeholder => $config) {
$fieldConfigs[] = [
'placeholder' => $placeholder,
'data_type' => $config['data_type'] ?? 'user_input',
'field_name' => $config['field_name'] ?? '',
'field_type' => $config['field_type'] ?? 'text',
'sign_party' => $config['sign_party'] ?? '',
'table_name' => $config['table_name'] ?? '',
'is_required' => $config['is_required'] ?? 0,
'default_value' => $config['default_value'] ?? '',
'system_function' => $config['system_function'] ?? '',
'contract_id' => $contractId
];
}
return $fieldConfigs;
}
/**
* 处理字段配置和数据获取
* @param array $fieldConfigs
* @param int $personnelId
* @param array $fillData
* @param string $contractType
* @return array
*/
private function processFieldConfigs(array $fieldConfigs, int $personnelId, array $fillData, string $contractType = ''): array
{
$processedFields = [];
foreach ($fieldConfigs as $config) {
$placeholder = $config['placeholder'];
$dataType = $config['data_type'];
$signParty = $config['sign_party'] ?? '';
$value = $fillData[$placeholder] ?? '';
// 根据数据类型获取值
switch ($dataType) {
case 'database':
$value = $this->getDatabaseValue($config, $personnelId, $contractType);
break;
case 'system':
$value = $this->getSystemValue($config['system_function'] ?? '', $personnelId);
break;
case 'signature':
if (!empty($value)) {
// 处理签名图片显示
$value = $this->getSignatureImagePath($value);
}
break;
case 'sign_img':
if (!empty($value)) {
// 处理上传的签名图片
$value = $this->getSignatureImagePath($value);
}
break;
case 'user_input':
// 用户输入的值直接使用
break;
}
// 判断是否可编辑(只有甲方可以编辑)
$isEditable = ($signParty === 'party_a');
$processedFields[] = [
'placeholder' => $placeholder,
'data_type' => $dataType,
'field_name' => $config['field_name'] ?? '',
'field_type' => $config['field_type'] ?? 'text',
'sign_party' => $signParty,
'table_name' => $config['table_name'] ?? '',
'is_required' => $config['is_required'] ?? 0,
'default_value' => $config['default_value'] ?? '',
'system_function' => $config['system_function'] ?? '',
'value' => $value,
'is_editable' => $isEditable,
'display_value' => $this->formatDisplayValue($value, $dataType)
];
}
return $processedFields;
}
/**
* 获取数据库字段值
* @param array $config
* @param int $personnelId
* @param string $contractType
* @return mixed
*/
private function getDatabaseValue(array $config, int $personnelId, string $contractType = '')
{
$tableName = $config['table_name'] ?? '';
$fieldName = $config['field_name'] ?? '';
if (empty($tableName) || empty($fieldName)) {
return $config['default_value'] ?? '';
}
try {
// 根据表名和人员ID获取数据
$value = '';
// 如果表名是动态的(如students),根据合同类型选择实际表名
$actualTableName = $tableName;
if ($tableName === 'students') {
$actualTableName = ($contractType === '外部') ? 'school_student' : 'school_personnel';
}
switch ($actualTableName) {
case 'school_personnel':
$personnel = \app\model\personnel\Personnel::where('id', $personnelId)
->value($fieldName);
$value = $personnel ?: $config['default_value'] ?? '';
break;
case 'school_student':
// 从学员表获取数据,需要根据personnel_id找到对应的sys_user_id,再查找学生记录
$sysUserId = \app\model\personnel\Personnel::where('id', $personnelId)->value('sys_user_id');
if ($sysUserId) {
$student = \think\facade\Db::table('school_student')
->where('user_id', $sysUserId)
->value($fieldName);
$value = $student ?: $config['default_value'] ?? '';
} else {
$value = $config['default_value'] ?? '';
}
break;
default:
$value = \think\facade\Db::table($actualTableName)
->where('personnel_id', $personnelId)
->value($fieldName) ?: $config['default_value'] ?? '';
break;
}
return $value;
} catch (\Exception $e) {
return $config['default_value'] ?? '';
}
}
/**
* 获取系统函数值
* @param string $functionName
* @param int $personnelId
* @return mixed
*/
private function getSystemValue(string $functionName, int $personnelId)
{
if (empty($functionName)) {
return '';
}
try {
// 检查函数是否存在
if (function_exists($functionName)) {
return $functionName($personnelId);
}
} catch (\Exception $e) {
// 函数调用失败,返回空值
}
return '';
}
/**
* 获取签名图片路径
* @param string $signatureData
* @return string
*/
private function getSignatureImagePath(string $signatureData): string
{
// 如果是base64图片数据,直接返回
if (strpos($signatureData, 'data:image') === 0) {
return $signatureData;
}
// 如果是文件路径,转换为完整URL
if (!empty($signatureData) && strpos($signatureData, 'http') !== 0) {
return request()->domain() . '/' . ltrim($signatureData, '/');
}
return $signatureData;
}
/**
* 格式化显示值
* @param mixed $value
* @param string $dataType
* @return string
*/
private function formatDisplayValue($value, string $dataType): string
{
if (empty($value)) {
return '';
}
switch ($dataType) {
case 'signature':
case 'sign_img':
// 返回图片URL或base64数据
return $value;
default:
return (string)$value;
}
}
}

65
niucloud/app/service/admin/document/DocumentTemplateService.php

@ -83,14 +83,15 @@ class DocumentTemplateService extends BaseAdminService
$info['placeholders'] = $info['placeholders'] ? json_decode($info['placeholders'], true) : [];
$info['file_size_formatted'] = $this->formatFileSize($info['file_size']);
// 获取数据源配置信息,从placeholder_config字段获取
// 获取数据源配置信息,从school_document_data_source_config表获取
$dataSourceConfigs = [];
if (!empty($info['placeholder_config'])) {
// 转换placeholder_config格式为前端需要的data_source_configs格式
foreach ($info['placeholder_config'] as $placeholder => $config) {
$dbConfigs = $this->dataSourceModel->where('contract_id', $id)->select()->toArray();
if (!empty($dbConfigs)) {
foreach ($dbConfigs as $config) {
$dataSourceConfigs[] = [
'id' => 0,
'placeholder' => $placeholder,
'id' => $config['id'],
'placeholder' => $config['placeholder'],
'data_type' => $config['data_type'] ?? 'user_input',
'table_name' => $config['table_name'] ?? '',
'field_name' => $config['field_name'] ?? '',
@ -104,28 +105,39 @@ class DocumentTemplateService extends BaseAdminService
}
}
$info['data_source_configs'] = $dataSourceConfigs;
// 为所有没有配置的占位符创建默认配置
if (!empty($info['placeholders'])) {
// 获取已配置的占位符列表
$configuredPlaceholders = array_column($dataSourceConfigs, 'placeholder');
// 如果没有数据源配置,但有占位符,则创建默认配置
if (empty($dataSourceConfigs) && !empty($info['placeholders'])) {
$defaultConfigs = [];
// 为未配置的占位符创建默认配置
foreach ($info['placeholders'] as $placeholder) {
$defaultConfigs[] = [
'id' => 0,
'placeholder' => $placeholder,
'data_type' => 'user_input',
'table_name' => '',
'field_name' => '',
'system_function' => '',
'user_input_value' => '',
'sign_party' => '',
'field_type' => 'text',
'is_required' => 0,
'default_value' => ''
];
if (!in_array($placeholder, $configuredPlaceholders)) {
$dataSourceConfigs[] = [
'id' => 0,
'placeholder' => $placeholder,
'data_type' => 'user_input',
'table_name' => '',
'field_name' => '',
'system_function' => '',
'user_input_value' => '',
'sign_party' => '',
'field_type' => 'text',
'is_required' => 0,
'default_value' => ''
];
}
}
$info['data_source_configs'] = $defaultConfigs;
// 按占位符在文档中出现的顺序排序
usort($dataSourceConfigs, function($a, $b) use ($info) {
$aIndex = array_search($a['placeholder'], $info['placeholders']);
$bIndex = array_search($b['placeholder'], $info['placeholders']);
return $aIndex - $bIndex;
});
}
$info['data_source_configs'] = $dataSourceConfigs;
}
return $info;
@ -164,11 +176,14 @@ class DocumentTemplateService extends BaseAdminService
$insertData[] = [
'contract_id' => $contractId,
'placeholder' => $config['placeholder'],
'data_type' => $config['data_type'] ?? 'user_input',
'table_name' => $config['table_name'] ?? '',
'field_name' => $config['field_name'] ?? '',
'field_type' => $config['field_type'] ?? 'string',
'field_type' => $config['field_type'] ?? 'text',
'is_required' => $config['is_required'] ?? 0,
'default_value' => $config['default_value'] ?? '',
'system_function' => $config['system_function'] ?? '',
'sign_party' => $config['sign_party'] ?? '',
'created_at' => date('Y-m-d H:i:s')
];
}

67
uniapp/pages-common/contract/contract_sign.vue

@ -378,12 +378,9 @@ export default {
}
this.generateSignatureImage(() => {
if (this.isFormField) {
// -
if (this.isFormField || this.isStaff) {
// -
this.returnSignatureData()
} else if (this.isStaff) {
// -
this.uploadStaffSignature()
} else {
// -
this.uploadSignature()
@ -412,9 +409,9 @@ export default {
}
//
const response = await apiRoute.post('/member/contract_sign', {
const response = await apiRoute.signStudentContract({
contract_sign_id: this.contractId,
pic_file: uploadResult.url
signature_image: uploadResult.url
})
if (response.code === 1) {
@ -431,7 +428,7 @@ export default {
})
}, 2000)
} else {
throw new Error(response.data.msg || '提交签名失败')
throw new Error(response.msg || '提交签名失败')
}
} catch (error) {
console.error('提交签名失败:', error)
@ -444,59 +441,7 @@ export default {
}
},
//
async uploadStaffSignature() {
if (!this.signatureImageUrl) {
uni.showToast({
title: '签名生成失败,请重试',
icon: 'none'
})
return
}
this.submitting = true
try {
//
const uploadResult = await this.uploadSignatureFile()
if (!uploadResult.success) {
throw new Error(uploadResult.message || '上传签名文件失败')
}
//
const response = await apiRoute.post('/contract/sign', {
contract_id: this.contractId,
sign_file: uploadResult.url
})
if (response.code === 1) {
uni.showToast({
title: '合同签署成功',
icon: 'success',
duration: 2000
})
setTimeout(() => {
//
uni.navigateBack({
delta: 1
})
}, 2000)
} else {
throw new Error(response.msg || '合同签署失败')
}
} catch (error) {
console.error('员工端合同签署失败:', error)
uni.showToast({
title: error.message || '网络错误,请稍后重试',
icon: 'none'
})
} finally {
this.submitting = false
}
},
//
async returnSignatureData() {
if (!this.signatureImageUrl) {
@ -534,7 +479,7 @@ export default {
}
uni.showToast({
title: '签名完成',
title: this.isStaff ? '签名完成,请填写其他信息' : '签名完成',
icon: 'success',
duration: 1500
})

4
uniapp/pages-common/contract/staff-contract-sign.vue

@ -158,7 +158,7 @@
</template>
<script>
import apiRoute from '@/common/axios.js'
import apiRoute from '@/api/apiRoute.js'
export default {
data() {
@ -437,7 +437,7 @@ export default {
})
//
const response = await apiRoute.post('/contract/sign', {
const response = await apiRoute.signStaffContract({
contract_id: this.contractId,
form_data: this.formData,
signature_image: this.getSignatureImage()

Loading…
Cancel
Save