Browse Source

修改 bug

master
王泽彦 8 months ago
parent
commit
060dff936a
  1. 96
      admin/src/app/api/document.ts
  2. 435
      admin/src/app/views/contract/contract.vue
  3. 12
      admin/src/app/views/document-template.vue
  4. 300
      admin/src/app/views/document-template/components/DocumentGenerate.vue
  5. 280
      admin/src/app/views/document-template/components/PlaceholderConfig.vue
  6. 477
      admin/src/app/views/document-template/index.vue
  7. 2
      admin/src/router/modules/contract.ts
  8. 305
      admin/src/views/contract/template/components/PlaceholderConfigDialog.vue
  9. 72
      admin/src/views/contract/template/components/PlaceholderConfigDialogSimple.vue
  10. 175
      admin/src/views/contract/template/components/TemplateUploadDialog.vue
  11. 37
      admin/src/views/contract/template/components/TestDialog.vue
  12. 1168
      admin/src/views/contract/template/index.vue
  13. 29
      niucloud/app/adminapi/controller/contract/ContractDistribution.php
  14. 22
      niucloud/app/adminapi/route/contract_distribution.php
  15. 49
      niucloud/app/api/controller/apiController/Personnel.php
  16. 2
      niucloud/app/api/route/route.php
  17. 2
      niucloud/app/service/admin/contract/ContractDistributionService.php
  18. 139
      niucloud/app/service/admin/document/DocumentTemplateService.php
  19. 4
      niucloud/app/service/api/apiService/CommonService.php
  20. 151
      niucloud/app/service/api/apiService/ContractService.php
  21. 2
      niucloud/app/service/api/apiService/PersonnelService.php
  22. 55
      niucloud/app/service/api/member/SalaryService.php
  23. 237
      niucloud/app/service/api/student/ContractService.php
  24. 23
      niucloud/app/service/school_approval/SchoolApprovalConfigService.php
  25. 258
      niucloud/app/service/school_approval/SchoolApprovalProcessService.php
  26. 11
      uniapp/api/member.js
  27. 67
      uniapp/common/axios.js
  28. 3
      uniapp/components/schedule/ScheduleDetail.vue
  29. 33
      uniapp/pages-market/clue/class_arrangement_detail.vue
  30. 18
      uniapp/pages.json
  31. 0
      uniapp/pages/common/add_personnel.vue
  32. 178
      uniapp/pages/common/personnel/add_personnel.vue

96
admin/src/app/api/document.ts

@ -1,96 +0,0 @@
import request from '@/utils/request'
/**
*
*/
export function getDocumentTemplateList(params?: any) {
return request.get('/document_template/lists', { params })
}
/**
*
*/
export function getDocumentTemplateInfo(id: number) {
return request.get(`/document_template/info/${id}`)
}
/**
*
*/
export function deleteDocumentTemplate(id: number) {
return request.delete(`/document_template/delete/${id}`)
}
/**
*
*/
export function copyDocumentTemplate(id: number) {
return request.post(`/document_template/copy/${id}`)
}
/**
*
*/
export function uploadDocumentTemplate(formData: FormData) {
return request.post('/document_template/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
/**
*
*/
export function parseDocumentPlaceholder(data: any) {
return request.post('/document_template/parse', data)
}
/**
*
*/
export function previewDocumentTemplate(id: number) {
return request.get(`/document_template/preview/${id}`)
}
/**
*
*/
export function saveDocumentPlaceholderConfig(data: any) {
return request.post('/document_template/config/save', data)
}
/**
*
*/
export function getDocumentDataSources() {
return request.get('/document_template/datasources')
}
/**
*
*/
export function generateDocumentFile(data: any) {
return request.post('/document_template/generate', data)
}
/**
*
*/
export function downloadDocumentFile(logId: number) {
return request.get(`/document_template/download/${logId}`, { responseType: 'blob' })
}
/**
*
*/
export function getDocumentGenerateLog(params?: any) {
return request.get('/document_template/log/lists', { params })
}
/**
*
*/
export function batchDeleteDocumentLog(ids: number[]) {
return request.post('/document_template/log/batch_delete', { ids })
}

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

@ -49,11 +49,18 @@
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" />
<el-table-column label="操作" width="200">
<el-table-column label="操作" width="280">
<template #default="{ row }">
<el-button type="primary" size="small" @click="configPlaceholder(row)">
配置占位符
</el-button>
<el-button
v-if="row.contract_type === '内部'"
type="success"
size="small"
@click="distributeContract(row)">
分发给员工
</el-button>
<el-button type="danger" size="small" @click="deleteTemplate(row)">
删除
</el-button>
@ -272,6 +279,117 @@
</div>
</div>
</Teleport>
<!-- 员工分发弹窗 -->
<Teleport to="body">
<div v-if="showDistributeDialog" class="dialog-overlay" @click.self="showDistributeDialog = false">
<div class="dialog-content distribute-dialog">
<div class="dialog-header">
<h3>分发合同给员工</h3>
<button @click="showDistributeDialog = false" class="close-btn">×</button>
</div>
<div class="dialog-body">
<div class="contract-info">
<h4>合同信息</h4>
<p><strong>合同名称</strong>{{ currentContract?.contract_name }}</p>
<p><strong>合同类型</strong>{{ currentContract?.contract_type }}</p>
</div>
<!-- 员工筛选区域 -->
<div class="staff-search-section">
<h4>员工筛选</h4>
<div class="search-form">
<div class="search-row">
<div class="search-item">
<label>姓名</label>
<input v-model="staffSearchForm.name" type="text" placeholder="请输入姓名" class="form-input" @input="filterStaff" />
</div>
<div class="search-item">
<label>手机号</label>
<input v-model="staffSearchForm.phone" type="text" placeholder="请输入手机号" class="form-input" @input="filterStaff" />
</div>
<div class="search-item">
<label>部门</label>
<input v-model="staffSearchForm.department" type="text" placeholder="请输入部门" class="form-input" @input="filterStaff" />
</div>
<div class="search-item">
<label>角色</label>
<input v-model="staffSearchForm.role" type="text" placeholder="请输入角色" class="form-input" @input="filterStaff" />
</div>
</div>
<div class="search-actions">
<button @click="filterStaff" class="btn-primary btn-small">筛选</button>
<button @click="resetStaffFilter" class="btn-cancel btn-small">重置</button>
</div>
</div>
</div>
<!-- 已选员工显示 -->
<div v-if="selectedStaff.length > 0" class="selected-staff-section">
<h4>已选员工 ({{ selectedStaff.length }})</h4>
<div class="selected-staff-list">
<span v-for="staff in selectedStaff" :key="staff.id" class="staff-tag">
{{ staff.name }} ({{ staff.department }})
<button @click="toggleStaffSelection(staff)" class="tag-remove">×</button>
</span>
</div>
</div>
<!-- 员工列表 -->
<div class="staff-list-section">
<h4>员工列表</h4>
<div v-if="staffLoading" class="loading-section">
<p>正在加载员工列表...</p>
</div>
<div v-else class="staff-list">
<div v-if="filteredStaffList.length === 0" class="empty-state">
<p>暂无符合条件的员工</p>
</div>
<div v-else class="staff-table-wrapper">
<table class="staff-table">
<thead>
<tr>
<th width="60">选择</th>
<th>姓名</th>
<th>手机号</th>
<th>部门</th>
<th>角色</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="staff in filteredStaffList" :key="staff.id"
:class="{ 'selected': selectedStaff.some(s => s.id === staff.id) }">
<td>
<input
type="checkbox"
:checked="selectedStaff.some(s => s.id === staff.id)"
@change="toggleStaffSelection(staff)"
/>
</td>
<td>{{ staff.name }}</td>
<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>
</div>
</div>
</div>
</div>
<div class="dialog-footer">
<button @click="showDistributeDialog = false" class="btn-cancel">取消</button>
<button @click="confirmDistribute" class="btn-primary" :disabled="distributingContract || selectedStaff.length === 0">
{{ distributingContract ? '分发中...' : `确认分发 (${selectedStaff.length})` }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
@ -279,7 +397,7 @@
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { contractTemplateApi, type ContractTemplate } from '@/api/contract'
import { contractTemplateApi, contractDistributionApi, type ContractTemplate } from '@/api/contract'
import TemplateUploadDialog from './components/TemplateUploadDialog.vue'
import PlaceholderConfigDialog from './components/PlaceholderConfigDialog.vue'
@ -288,12 +406,17 @@ const loading = ref(false)
const tableData = ref<ContractTemplate[]>([])
const showUploadDialog = ref(false)
const showConfigDialog = ref(false)
const showDistributeDialog = ref(false)
const currentContractId = ref(0)
const currentContract = ref<ContractTemplate | null>(null)
const uploading = ref(false)
const configLoading = ref(false)
const configList = ref<any[]>([])
const fileInputKey = ref(0)
const fileInput = ref<HTMLInputElement>()
const staffList = ref<any[]>([])
const filteredStaffList = ref<any[]>([])
const staffLoading = ref(false)
const searchForm = reactive({
contract_name: '',
@ -308,6 +431,16 @@ const uploadForm = reactive({
remarks: ''
})
const staffSearchForm = reactive({
name: '',
phone: '',
department: '',
role: ''
})
const selectedStaff = ref<any[]>([])
const distributingContract = ref(false)
const pagination = reactive({
page: 1,
limit: 20,
@ -406,6 +539,127 @@ const deleteTemplate = async (row: ContractTemplate) => {
}
}
//
const distributeContract = async (row: ContractTemplate) => {
currentContract.value = row
currentContractId.value = row.id
showDistributeDialog.value = true
selectedStaff.value = []
//
await loadStaffList()
}
//
const loadStaffList = async () => {
staffLoading.value = true
try {
// APItype=1
const { data } = await contractDistributionApi.getPersonnelList({ type: 1 })
// API
const processedData = data.map((staff: any) => ({
id: staff.id,
name: staff.name,
phone: staff.phone || staff.mobile || '',
email: staff.email || '',
department: staff.department || '未分配',
role: staff.role || '员工',
status: staff.status === 1 ? '在职' : '离职'
}))
staffList.value = processedData
filteredStaffList.value = processedData
console.log('员工列表加载成功:', staffList.value)
} catch (error) {
console.error('加载员工列表失败:', error)
ElMessage.error('加载员工列表失败')
// API使
const mockStaffData = [
{ id: 1, name: '张三', phone: '13800138001', department: '教务部', role: '老师', status: '在职' },
{ id: 2, name: '李四', phone: '13800138002', department: '销售部', role: '顾问', status: '在职' },
{ id: 3, name: '王五', phone: '13800138003', department: '教务部', role: '助教', status: '在职' },
{ id: 4, name: '赵六', phone: '13800138004', department: '管理部', role: '主管', status: '在职' },
{ id: 5, name: '孙七', phone: '13800138005', department: '销售部', role: '经理', status: '在职' }
]
staffList.value = mockStaffData
filteredStaffList.value = mockStaffData
} finally {
staffLoading.value = false
}
}
//
const filterStaff = () => {
filteredStaffList.value = staffList.value.filter(staff => {
return (!staffSearchForm.name || staff.name.includes(staffSearchForm.name)) &&
(!staffSearchForm.phone || staff.phone.includes(staffSearchForm.phone)) &&
(!staffSearchForm.department || staff.department.includes(staffSearchForm.department)) &&
(!staffSearchForm.role || staff.role.includes(staffSearchForm.role))
})
}
//
const resetStaffFilter = () => {
Object.assign(staffSearchForm, {
name: '',
phone: '',
department: '',
role: ''
})
filteredStaffList.value = staffList.value
}
//
const toggleStaffSelection = (staff: any) => {
const index = selectedStaff.value.findIndex(s => s.id === staff.id)
if (index > -1) {
selectedStaff.value.splice(index, 1)
} else {
selectedStaff.value.push(staff)
}
}
//
const confirmDistribute = async () => {
if (selectedStaff.value.length === 0) {
ElMessage.warning('请选择要分发的员工')
return
}
distributingContract.value = true
try {
const staffIds = selectedStaff.value.map(staff => staff.id)
//
await contractDistributionApi.manualDistribute({
contract_id: currentContractId.value,
personnel_ids: staffIds,
type: 1 // 1
})
console.log('分发合同成功:', {
contractId: currentContractId.value,
contractName: currentContract.value?.contract_name,
staffIds: staffIds,
staffNames: selectedStaff.value.map(s => s.name)
})
ElMessage.success(`合同已成功分发给 ${selectedStaff.value.length} 名员工`)
showDistributeDialog.value = false
selectedStaff.value = []
} catch (error) {
console.error('分发合同失败:', error)
ElMessage.error(`分发合同失败: ${error.message || '未知错误'}`)
} finally {
distributingContract.value = false
}
}
const handleUploadSuccess = () => {
showUploadDialog.value = false
getList()
@ -1165,4 +1419,181 @@ onMounted(() => {
margin-right: 5px;
transform: scale(1.2);
}
/* 分发弹窗样式 */
.distribute-dialog {
width: 900px;
}
.contract-info {
margin-bottom: 20px;
padding: 15px;
background: #f5f7fa;
border-radius: 4px;
border-left: 4px solid #409eff;
}
.contract-info h4 {
margin: 0 0 10px 0;
color: #409eff;
font-size: 16px;
}
.contract-info p {
margin: 5px 0;
color: #606266;
}
.staff-search-section,
.selected-staff-section,
.staff-list-section {
margin-bottom: 20px;
}
.staff-search-section h4,
.selected-staff-section h4,
.staff-list-section h4 {
margin: 0 0 15px 0;
color: #303133;
font-size: 16px;
border-bottom: 2px solid #409eff;
padding-bottom: 5px;
}
.search-form {
background: #fafafa;
padding: 15px;
border-radius: 4px;
}
.search-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 15px;
}
.search-item label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: #303133;
font-size: 14px;
}
.search-actions {
display: flex;
gap: 10px;
}
.btn-small {
padding: 6px 12px;
font-size: 12px;
}
.selected-staff-section {
background: #ecf5ff;
padding: 15px;
border-radius: 4px;
border: 1px solid #b3d8ff;
}
.selected-staff-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.staff-tag {
display: inline-flex;
align-items: center;
padding: 6px 12px;
background: #409eff;
color: white;
border-radius: 16px;
font-size: 12px;
gap: 6px;
}
.tag-remove {
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 14px;
font-weight: bold;
padding: 0;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.tag-remove:hover {
background: rgba(255, 255, 255, 0.2);
}
.staff-table-wrapper {
max-height: 300px;
overflow-y: auto;
border: 1px solid #ebeef5;
border-radius: 4px;
}
.staff-table {
width: 100%;
border-collapse: collapse;
}
.staff-table th,
.staff-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ebeef5;
}
.staff-table th {
background: #f5f7fa;
font-weight: 500;
color: #303133;
position: sticky;
top: 0;
z-index: 1;
}
.staff-table tbody tr {
transition: background-color 0.3s;
}
.staff-table tbody tr:hover {
background: #f5f7fa;
}
.staff-table tbody tr.selected {
background: #ecf5ff;
}
.staff-table td {
color: #606266;
}
.staff-table input[type="checkbox"] {
transform: scale(1.2);
cursor: pointer;
}
.status-tag {
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.status-active {
background: #f0f9ff;
color: #409eff;
border: 1px solid #b3d8ff;
}
</style>

12
admin/src/app/views/document-template.vue

@ -1,12 +0,0 @@
<!--
Word模板管理页面 - 路由入口文件
此文件是为了匹配动态路由系统的组件加载规则而创建的
实际功能组件位于: ./document-template/index.vue
-->
<template>
<DocumentTemplateIndex />
</template>
<script setup lang="ts">
import DocumentTemplateIndex from './document-template/index.vue'
</script>

300
admin/src/app/views/document-template/components/DocumentGenerate.vue

@ -1,300 +0,0 @@
<template>
<el-dialog v-model="visible" title="生成Word文档" width="800px" :before-close="handleClose">
<div v-if="loading" class="text-center py-8">
<el-icon class="is-loading"><Loading /></el-icon>
<span class="ml-2">加载中...</span>
</div>
<div v-else>
<el-form :model="generateForm" label-width="120px" ref="generateFormRef">
<!-- 模板选择 -->
<el-form-item label="选择模板" prop="template_id" :rules="[{required: true, message: '请选择模板'}]">
<el-select v-model="generateForm.template_id"
@change="onTemplateChange"
placeholder="请选择要使用的模板"
style="width: 100%"
filterable>
<el-option v-for="template in activeTemplates"
:key="template.id"
:label="template.contract_name"
:value="template.id">
<div class="flex justify-between items-center">
<span>{{ template.contract_name }}</span>
<el-tag size="small" type="primary">{{ getTypeText(template.contract_type) }}</el-tag>
</div>
</el-option>
</el-select>
</el-form-item>
<!-- 输出文件名 -->
<el-form-item label="输出文件名" prop="output_filename">
<el-input v-model="generateForm.output_filename"
placeholder="不填写将自动生成文件名"
:suffix-icon="DocumentAdd">
<template #append>.docx</template>
</el-input>
</el-form-item>
<!-- 动态填充字段 -->
<div v-if="placeholderConfigs && Object.keys(placeholderConfigs).length > 0">
<el-divider>
<span class="text-gray-600">数据填充</span>
</el-divider>
<div class="space-y-4 max-h-96 overflow-y-auto p-4 bg-gray-50 rounded">
<div v-for="(config, placeholder) in placeholderConfigs"
:key="placeholder"
class="bg-white p-4 rounded border">
<div class="flex items-center justify-between mb-3">
<label class="font-semibold text-gray-700">
{{ config.name }}
<el-tag size="small" class="ml-2">{{ placeholder }}</el-tag>
</label>
<div class="text-sm text-gray-500">
<span v-if="config.data_source === 'database'" class="text-blue-600">
<el-icon><Database /></el-icon>
自动获取
</span>
<span v-else class="text-orange-600">
<el-icon><Edit /></el-icon>
手动填写
<el-tag v-if="config.is_required" type="danger" size="small" class="ml-1">必填</el-tag>
</span>
</div>
</div>
<!-- 手动填写的字段 -->
<div v-if="config.data_source === 'manual'">
<el-form-item :prop="`fill_data.${placeholder}`"
:rules="config.is_required ? [{required: true, message: `${config.name}不能为空`}] : []">
<el-input v-model="generateForm.fill_data[placeholder]"
:placeholder="`请输入${config.name}`"
:value="generateForm.fill_data[placeholder] || config.default_value">
<template #prepend v-if="config.process_function">
<el-tooltip :content="getProcessFunctionDesc(config.process_function)" placement="top">
<el-icon><Magic /></el-icon>
</el-tooltip>
</template>
</el-input>
</el-form-item>
</div>
<!-- 数据库字段显示 -->
<div v-else class="bg-blue-50 p-3 rounded text-sm">
<div class="text-blue-700">
<el-icon><Database /></el-icon>
数据来源{{ getTableDisplayName(config.table_name) }} > {{ getFieldDisplayName(config.table_name, config.field_name) }}
</div>
<div v-if="config.process_function" class="text-blue-600 mt-1">
<el-icon><Magic /></el-icon>
处理函数{{ getProcessFunctionDesc(config.process_function) }}
</div>
<div v-if="config.default_value" class="text-blue-600 mt-1">
<el-icon><Star /></el-icon>
默认值{{ config.default_value }}
</div>
</div>
</div>
</div>
</div>
<!-- 无占位符提示 -->
<div v-else-if="generateForm.template_id" class="text-center py-8 text-gray-500">
<el-icon size="48"><DocumentRemove /></el-icon>
<div class="mt-4">该模板暂无配置的占位符</div>
<div class="text-sm mt-2">请先配置模板的占位符</div>
</div>
</el-form>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary"
@click="generateDocument"
:loading="generateLoading"
:disabled="!generateForm.template_id">
<el-icon v-if="!generateLoading"><Document /></el-icon>
{{ generateLoading ? '生成中...' : '生成文档' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, type FormInstance } from 'element-plus'
import { getDocumentTemplateList, getDocumentTemplateInfo, generateDocumentFile, getDocumentDataSources } from '@/app/api/document'
const emit = defineEmits(['complete'])
//
const visible = ref(false)
const loading = ref(false)
const generateLoading = ref(false)
//
const activeTemplates = ref([])
const dataSources = ref({})
const placeholderConfigs = ref({})
//
const generateForm = reactive({
template_id: '',
output_filename: '',
fill_data: {}
})
//
const generateFormRef = ref<FormInstance>()
//
const open = async (preSelectedTemplate?: any) => {
visible.value = true
loading.value = true
try {
//
const [templatesResult, dataSourceResult] = await Promise.all([
getDocumentTemplateList({ contract_status: 'active', limit: 100 }),
getDocumentDataSources()
])
activeTemplates.value = templatesResult.data.data
dataSources.value = dataSourceResult.data
//
if (preSelectedTemplate) {
generateForm.template_id = preSelectedTemplate.id
await onTemplateChange()
}
} catch (error) {
ElMessage.error('加载数据失败')
console.error(error)
} finally {
loading.value = false
}
}
//
const onTemplateChange = async () => {
if (!generateForm.template_id) {
placeholderConfigs.value = {}
generateForm.fill_data = {}
return
}
try {
const { data } = await getDocumentTemplateInfo(generateForm.template_id)
placeholderConfigs.value = data.placeholder_config || {}
//
generateForm.fill_data = {}
Object.keys(placeholderConfigs.value).forEach(placeholder => {
const config = placeholderConfigs.value[placeholder]
if (config.data_source === 'manual') {
generateForm.fill_data[placeholder] = config.default_value || ''
}
})
//
const template = activeTemplates.value.find(t => t.id === generateForm.template_id)
if (template && !generateForm.output_filename) {
const timestamp = new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '')
generateForm.output_filename = `${template.contract_name}_${timestamp}`
}
} catch (error) {
ElMessage.error('获取模板信息失败')
}
}
//
const getTypeText = (type: string) => {
const typeMap = {
'general': '通用',
'course': '课程',
'membership': '会员'
}
return typeMap[type] || '未知'
}
//
const getTableDisplayName = (tableName: string) => {
return dataSources.value[tableName]?.table_alias || tableName
}
//
const getFieldDisplayName = (tableName: string, fieldName: string) => {
const fields = dataSources.value[tableName]?.fields || []
const field = fields.find(f => f.field_name === fieldName)
return field?.field_alias || fieldName
}
//
const getProcessFunctionDesc = (funcName: string) => {
const funcMap = {
'formatDate': '格式化为:2024年01月01日',
'formatDateTime': '格式化为:2024年01月01日 10:30',
'formatNumber': '格式化为数字:1,234.56',
'toUpper': '转换为大写',
'toLower': '转换为小写'
}
return funcMap[funcName] || funcName
}
//
const generateDocument = async () => {
if (!generateFormRef.value) return
const valid = await generateFormRef.value.validate()
if (!valid) return
generateLoading.value = true
try {
const { data } = await generateDocumentFile({
template_id: generateForm.template_id,
output_filename: generateForm.output_filename,
fill_data: generateForm.fill_data
})
ElMessage.success('文档生成成功!')
//
if (data.download_url) {
window.open(data.download_url, '_blank')
}
handleClose()
emit('complete')
} catch (error) {
ElMessage.error(error.message || '生成失败')
} finally {
generateLoading.value = false
}
}
//
const handleClose = () => {
visible.value = false
generateForm.template_id = ''
generateForm.output_filename = ''
generateForm.fill_data = {}
placeholderConfigs.value = {}
}
//
defineExpose({
open
})
</script>
<style scoped>
.dialog-footer {
text-align: right;
}
.space-y-4 > * + * {
margin-top: 1rem;
}
</style>

280
admin/src/app/views/document-template/components/PlaceholderConfig.vue

@ -1,280 +0,0 @@
<template>
<el-dialog v-model="visible" :title="`配置占位符 - ${templateInfo.contract_name}`" width="900px" :before-close="handleClose">
<div v-if="loading" class="text-center py-8">
<el-icon class="is-loading"><Loading /></el-icon>
<span class="ml-2">加载中...</span>
</div>
<div v-else-if="placeholders.length === 0" class="text-center py-8 text-gray-500">
<el-icon size="48"><DocumentRemove /></el-icon>
<div class="mt-4">该模板中未发现占位符</div>
<div class="text-sm mt-2">请确保模板中包含 {{变量名}} 格式的占位符</div>
</div>
<div v-else>
<div class="mb-4 p-4 bg-blue-50 rounded">
<h4 class="text-blue-800 mb-2">
<el-icon><InfoFilled /></el-icon>
配置说明
</h4>
<ul class="text-sm text-blue-700 space-y-1">
<li> 为每个占位符配置数据源和显示名称</li>
<li> 数据库数据源从系统数据表中自动获取数据</li>
<li> 手动填写数据源生成文档时需要手动输入</li>
<li> 可设置默认值和处理函数</li>
</ul>
</div>
<el-form :model="configForm" label-width="120px" ref="configFormRef">
<div class="space-y-6">
<div v-for="(placeholder, index) in placeholders" :key="placeholder"
class="border rounded-lg p-4 bg-gray-50">
<div class="flex items-center justify-between mb-4">
<h4 class="text-lg font-semibold text-gray-800">
<el-tag type="primary" size="large">{{ placeholder }}</el-tag>
</h4>
<el-tag v-if="configForm.configs[placeholder]?.is_required" type="danger" size="small">必填</el-tag>
</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="显示名称" :prop="`configs.${placeholder}.name`"
:rules="[{required: true, message: '请输入显示名称'}]">
<el-input v-model="configForm.configs[placeholder].name"
placeholder="请输入显示名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="数据源类型" :prop="`configs.${placeholder}.data_source`"
:rules="[{required: true, message: '请选择数据源类型'}]">
<el-select v-model="configForm.configs[placeholder].data_source"
@change="onDataSourceChange(placeholder)"
style="width: 100%">
<el-option label="数据库" value="database"></el-option>
<el-option label="手动填写" value="manual"></el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<!-- 数据库数据源配置 -->
<div v-if="configForm.configs[placeholder]?.data_source === 'database'">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="数据表" :prop="`configs.${placeholder}.table_name`"
:rules="[{required: true, message: '请选择数据表'}]">
<el-select v-model="configForm.configs[placeholder].table_name"
@change="onTableChange(placeholder)"
placeholder="请选择数据表" style="width: 100%">
<el-option v-for="(tableInfo, tableName) in dataSources"
:key="tableName"
:label="tableInfo.table_alias || tableName"
:value="tableName">
</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="字段" :prop="`configs.${placeholder}.field_name`"
:rules="[{required: true, message: '请选择字段'}]">
<el-select v-model="configForm.configs[placeholder].field_name"
placeholder="请选择字段" style="width: 100%"
:disabled="!configForm.configs[placeholder]?.table_name">
<el-option v-for="field in getTableFields(configForm.configs[placeholder]?.table_name)"
:key="field.field_name"
:label="field.field_alias || field.field_name"
:value="field.field_name">
<span>{{ field.field_alias || field.field_name }}</span>
<span class="text-gray-400 text-xs ml-2">({{ field.field_type }})</span>
</el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
</div>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="处理函数">
<el-select v-model="configForm.configs[placeholder].process_function"
clearable placeholder="请选择处理函数" style="width: 100%">
<el-option label="无" value=""></el-option>
<el-option label="格式化日期" value="formatDate"></el-option>
<el-option label="格式化日期时间" value="formatDateTime"></el-option>
<el-option label="格式化数字" value="formatNumber"></el-option>
<el-option label="转大写" value="toUpper"></el-option>
<el-option label="转小写" value="toLower"></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="默认值">
<el-input v-model="configForm.configs[placeholder].default_value"
placeholder="请输入默认值" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="是否必填">
<el-switch v-model="configForm.configs[placeholder].is_required" />
</el-form-item>
</el-col>
</el-row>
</div>
</div>
</el-form>
</div>
<template #footer v-if="placeholders.length > 0">
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="saveConfig" :loading="saveLoading">
{{ saveLoading ? '保存中...' : '保存配置' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, ref, nextTick } from 'vue'
import { ElMessage, type FormInstance } from 'element-plus'
import { getDocumentTemplateInfo, saveDocumentPlaceholderConfig, getDocumentDataSources } from '@/app/api/document'
const emit = defineEmits(['complete'])
//
const visible = ref(false)
const loading = ref(false)
const saveLoading = ref(false)
//
const templateInfo = ref({})
const placeholders = ref([])
const dataSources = ref({})
//
const configForm = reactive({
template_id: 0,
configs: {}
})
//
const configFormRef = ref<FormInstance>()
//
const open = async (template: any) => {
templateInfo.value = template
configForm.template_id = template.id
visible.value = true
loading.value = true
try {
//
const [templateResult, dataSourceResult] = await Promise.all([
getDocumentTemplateInfo(template.id),
getDocumentDataSources()
])
placeholders.value = templateResult.data.placeholders || []
dataSources.value = dataSourceResult.data
//
initConfigForm(templateResult.data.placeholder_config)
} catch (error) {
ElMessage.error('加载数据失败')
console.error(error)
} finally {
loading.value = false
}
}
//
const initConfigForm = (existingConfig: any) => {
configForm.configs = {}
placeholders.value.forEach(placeholder => {
// 使使
const existing = existingConfig && existingConfig[placeholder]
configForm.configs[placeholder] = {
name: existing?.name || placeholder,
data_source: existing?.data_source || 'manual',
table_name: existing?.table_name || '',
field_name: existing?.field_name || '',
process_function: existing?.process_function || '',
default_value: existing?.default_value || '',
is_required: existing?.is_required ?? true
}
})
}
//
const onDataSourceChange = (placeholder: string) => {
if (configForm.configs[placeholder].data_source === 'manual') {
configForm.configs[placeholder].table_name = ''
configForm.configs[placeholder].field_name = ''
}
}
//
const onTableChange = (placeholder: string) => {
configForm.configs[placeholder].field_name = ''
}
//
const getTableFields = (tableName: string) => {
return dataSources.value[tableName]?.fields || []
}
//
const saveConfig = async () => {
if (!configFormRef.value) return
const valid = await configFormRef.value.validate()
if (!valid) return
saveLoading.value = true
try {
await saveDocumentPlaceholderConfig({
template_id: configForm.template_id,
placeholder_config: configForm.configs
})
ElMessage.success('配置保存成功')
handleClose()
emit('complete')
} catch (error) {
ElMessage.error(error.message || '保存失败')
} finally {
saveLoading.value = false
}
}
//
const handleClose = () => {
visible.value = false
templateInfo.value = {}
placeholders.value = []
configForm.configs = {}
configForm.template_id = 0
}
//
defineExpose({
open
})
</script>
<style scoped>
.dialog-footer {
text-align: right;
}
.space-y-6 > * + * {
margin-top: 1.5rem;
}
.space-y-1 > * + * {
margin-top: 0.25rem;
}
</style>

477
admin/src/app/views/document-template/index.vue

@ -1,477 +0,0 @@
<template>
<div class="main-container">
<el-card class="box-card !border-none" shadow="never">
<div class="flex justify-between items-center">
<span class="text-lg">Word模板管理</span>
<div class="space-x-2">
<el-button type="primary" @click="uploadTemplate">
<el-icon><Upload /></el-icon>
上传模板
</el-button>
<el-button type="success" @click="generateDocument">
<el-icon><Document /></el-icon>
生成文档
</el-button>
</div>
</div>
<el-card class="box-card !border-none my-[10px] table-search-wrap" shadow="never">
<el-form :inline="true" :model="templateTable.searchParam" ref="searchFormRef">
<el-form-item label="模板名称" prop="contract_name">
<el-input class="w-[280px]" v-model="templateTable.searchParam.contract_name" clearable placeholder="请输入模板名称" />
</el-form-item>
<el-form-item label="状态" prop="contract_status">
<el-select class="w-[280px]" v-model="templateTable.searchParam.contract_status" clearable placeholder="请选择状态">
<el-option label="全部" value=""></el-option>
<el-option label="草稿" value="draft"></el-option>
<el-option label="启用" value="active"></el-option>
<el-option label="禁用" value="disabled"></el-option>
</el-select>
</el-form-item>
<el-form-item label="类型" prop="contract_type">
<el-select class="w-[280px]" v-model="templateTable.searchParam.contract_type" clearable placeholder="请选择类型">
<el-option label="全部" value=""></el-option>
<el-option label="通用" value="general"></el-option>
<el-option label="课程" value="course"></el-option>
<el-option label="会员" value="membership"></el-option>
</el-select>
</el-form-item>
<el-form-item label="创建时间" prop="created_at">
<el-date-picker v-model="templateTable.searchParam.created_at" type="datetimerange"
format="YYYY-MM-DD HH:mm:ss" start-placeholder="开始时间" end-placeholder="结束时间" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadTemplateList()">搜索</el-button>
<el-button @click="resetForm(searchFormRef)">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<div class="mt-[10px]">
<el-table :data="templateTable.data" size="large" v-loading="templateTable.loading">
<template #empty>
<span>{{ !templateTable.loading ? '暂无数据' : '' }}</span>
</template>
<el-table-column prop="contract_name" label="模板名称" min-width="200" :show-overflow-tooltip="true"/>
<el-table-column label="原始文件名" min-width="180" :show-overflow-tooltip="true">
<template #default="{ row }">
<span>{{ row.original_filename || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="文件大小" min-width="100" align="center">
<template #default="{ row }">
<span>{{ row.file_size_formatted || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="占位符数量" min-width="120" align="center">
<template #default="{ row }">
<el-tag type="info" size="small">{{ row.placeholders ? row.placeholders.length : 0 }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" min-width="100" align="center">
<template #default="{ row }">
<el-tag :type="getStatusType(row.contract_status)" size="small">
{{ getStatusText(row.contract_status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="类型" min-width="100" align="center">
<template #default="{ row }">
<el-tag type="primary" size="small" plain>
{{ getTypeText(row.contract_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" min-width="160" :show-overflow-tooltip="true"/>
<el-table-column label="操作" fixed="right" min-width="280">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="previewTemplate(row)">
<el-icon><View /></el-icon>
预览
</el-button>
<el-button type="warning" link size="small" @click="configPlaceholder(row)" v-if="row.contract_status === 'draft'">
<el-icon><Setting /></el-icon>
配置
</el-button>
<el-button type="success" link size="small" @click="generateFromTemplate(row)" v-if="row.contract_status === 'active'">
<el-icon><Document /></el-icon>
生成
</el-button>
<el-button type="info" link size="small" @click="copyTemplate(row)">
<el-icon><CopyDocument /></el-icon>
复制
</el-button>
<el-button type="danger" link size="small" @click="deleteTemplate(row)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="mt-[16px] flex justify-end">
<el-pagination v-model:current-page="templateTable.page" v-model:page-size="templateTable.limit"
layout="total, sizes, prev, pager, next, jumper" :total="templateTable.total"
@size-change="loadTemplateList()" @current-change="loadTemplateList" />
</div>
</div>
<!-- 上传模板对话框 -->
<el-dialog v-model="uploadDialog.visible" title="上传Word模板" width="500px" :before-close="closeUploadDialog">
<el-form :model="uploadDialog.form" label-width="100px" ref="uploadFormRef">
<el-form-item label="模板名称" prop="template_name" :rules="[{required: true, message: '请输入模板名称'}]">
<el-input v-model="uploadDialog.form.template_name" placeholder="请输入模板名称" />
</el-form-item>
<el-form-item label="模板类型" prop="template_type" :rules="[{required: true, message: '请选择模板类型'}]">
<el-select v-model="uploadDialog.form.template_type" placeholder="请选择模板类型" style="width: 100%">
<el-option label="通用" value="general"></el-option>
<el-option label="课程" value="course"></el-option>
<el-option label="会员" value="membership"></el-option>
</el-select>
</el-form-item>
<el-form-item label="选择文件" prop="file" :rules="[{required: true, message: '请选择Word文件'}]">
<el-upload
class="upload-demo"
:before-upload="beforeUpload"
:on-change="handleFileChange"
:auto-upload="false"
accept=".doc,.docx"
:show-file-list="true"
:limit="1">
<el-button type="primary">
<el-icon><Upload /></el-icon>
选择文件
</el-button>
<template #tip>
<div class="el-upload__tip">
只能上传 .doc/.docx 文件且不超过 10MB
</div>
</template>
</el-upload>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="uploadDialog.form.remarks" type="textarea" :rows="3" placeholder="请输入备注信息" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="closeUploadDialog">取消</el-button>
<el-button type="primary" @click="submitUpload" :loading="uploadDialog.loading">
{{ uploadDialog.loading ? '上传中...' : '上传' }}
</el-button>
</div>
</template>
</el-dialog>
<!-- 模板预览对话框 -->
<el-dialog v-model="previewDialog.visible" title="模板预览" width="800px">
<div v-if="previewDialog.loading" class="text-center py-8">
<el-icon class="is-loading"><Loading /></el-icon>
<span class="ml-2">加载中...</span>
</div>
<div v-else>
<div class="mb-4">
<h4>占位符列表</h4>
<div class="flex flex-wrap gap-2 mt-2">
<el-tag v-for="placeholder in previewDialog.placeholders" :key="placeholder" size="small" type="info">
{{ placeholder }}
</el-tag>
</div>
</div>
<div class="border rounded p-4 max-h-96 overflow-y-auto">
<pre class="whitespace-pre-wrap text-sm">{{ previewDialog.content }}</pre>
</div>
</div>
</el-dialog>
<!-- 占位符配置组件 -->
<PlaceholderConfig ref="placeholderConfigRef" @complete="loadTemplateList" />
<!-- 文档生成组件 -->
<DocumentGenerate ref="documentGenerateRef" @complete="loadTemplateList" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
import { getDocumentTemplateList, deleteDocumentTemplate, uploadDocumentTemplate, previewDocumentTemplate, copyDocumentTemplate } from '@/app/api/document'
import PlaceholderConfig from './components/PlaceholderConfig.vue'
import DocumentGenerate from './components/DocumentGenerate.vue'
//
const templateTable = reactive({
page: 1,
limit: 10,
total: 0,
loading: false,
data: [],
searchParam: {
contract_name: '',
contract_status: '',
contract_type: '',
created_at: []
}
})
//
const uploadDialog = reactive({
visible: false,
loading: false,
form: {
template_name: '',
template_type: 'general',
remarks: '',
file: null
}
})
//
const previewDialog = reactive({
visible: false,
loading: false,
content: '',
placeholders: []
})
//
const searchFormRef = ref<FormInstance>()
const uploadFormRef = ref<FormInstance>()
const placeholderConfigRef = ref()
const documentGenerateRef = ref()
//
const loadTemplateList = async () => {
templateTable.loading = true
try {
const { data } = await getDocumentTemplateList({
page: templateTable.page,
limit: templateTable.limit,
...templateTable.searchParam
})
templateTable.data = data.data
templateTable.total = data.total
} catch (error) {
ElMessage.error('获取模板列表失败')
} finally {
templateTable.loading = false
}
}
//
const resetForm = (formEl: FormInstance | undefined) => {
if (!formEl) return
formEl.resetFields()
loadTemplateList()
}
//
const getStatusType = (status: string) => {
const statusMap = {
'draft': 'info',
'active': 'success',
'disabled': 'danger'
}
return statusMap[status] || 'info'
}
//
const getStatusText = (status: string) => {
const statusMap = {
'draft': '草稿',
'active': '启用',
'disabled': '禁用'
}
return statusMap[status] || '未知'
}
//
const getTypeText = (type: string) => {
const typeMap = {
'general': '通用',
'course': '课程',
'membership': '会员'
}
return typeMap[type] || '未知'
}
//
const uploadTemplate = () => {
uploadDialog.visible = true
uploadDialog.form = {
template_name: '',
template_type: 'general',
remarks: '',
file: null
}
}
//
const closeUploadDialog = () => {
uploadDialog.visible = false
uploadDialog.form = {
template_name: '',
template_type: 'general',
remarks: '',
file: null
}
}
//
const beforeUpload = (file: File) => {
const isValidType = file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
file.type === 'application/msword'
const isLt10M = file.size / 1024 / 1024 < 10
if (!isValidType) {
ElMessage.error('只能上传 Word 文档!')
return false
}
if (!isLt10M) {
ElMessage.error('文件大小不能超过 10MB!')
return false
}
return false //
}
//
const handleFileChange = (file: any) => {
uploadDialog.form.file = file.raw
//
if (!uploadDialog.form.template_name) {
const fileName = file.name.replace(/\.[^/.]+$/, '')
uploadDialog.form.template_name = fileName
}
}
//
const submitUpload = async () => {
if (!uploadFormRef.value) return
const valid = await uploadFormRef.value.validate()
if (!valid) return
if (!uploadDialog.form.file) {
ElMessage.error('请选择要上传的文件')
return
}
uploadDialog.loading = true
try {
const formData = new FormData()
formData.append('file', uploadDialog.form.file)
formData.append('template_name', uploadDialog.form.template_name)
formData.append('template_type', uploadDialog.form.template_type)
formData.append('remarks', uploadDialog.form.remarks)
await uploadDocumentTemplate(formData)
ElMessage.success('模板上传成功')
closeUploadDialog()
loadTemplateList()
} catch (error) {
ElMessage.error(error.message || '上传失败')
} finally {
uploadDialog.loading = false
}
}
//
const previewTemplate = async (row: any) => {
previewDialog.visible = true
previewDialog.loading = true
try {
const { data } = await previewDocumentTemplate(row.id)
previewDialog.content = data.content
previewDialog.placeholders = data.placeholders
} catch (error) {
ElMessage.error('预览失败')
} finally {
previewDialog.loading = false
}
}
//
const configPlaceholder = (row: any) => {
placeholderConfigRef.value?.open(row)
}
//
const generateDocument = () => {
documentGenerateRef.value?.open()
}
//
const generateFromTemplate = (row: any) => {
documentGenerateRef.value?.open(row)
}
//
const copyTemplate = async (row: any) => {
try {
await copyDocumentTemplate(row.id)
ElMessage.success('模板复制成功')
loadTemplateList()
} catch (error) {
ElMessage.error('复制失败')
}
}
//
const deleteTemplate = (row: any) => {
ElMessageBox.confirm(
`确定要删除模板 "${row.contract_name}" 吗?此操作不可恢复!`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
try {
await deleteDocumentTemplate(row.id)
ElMessage.success('删除成功')
loadTemplateList()
} catch (error) {
ElMessage.error('删除失败')
}
}).catch(() => {
//
})
}
//
onMounted(() => {
loadTemplateList()
})
</script>
<style scoped>
.main-container {
padding: 20px;
}
.table-search-wrap {
background-color: #f8f9fa;
}
.upload-demo {
width: 100%;
}
.dialog-footer {
text-align: right;
}
</style>

2
admin/src/router/modules/contract.ts

@ -26,7 +26,7 @@ const routes: Array<RouteRecordRaw> = [
{
path: 'template',
name: 'ContractTemplate',
component: () => import('@/views/contract/template/index.vue'),
component: () => import('@/app/views/contract/contract.vue'),
meta: {
title: '模板管理',
icon: 'DocumentAdd'

305
admin/src/views/contract/template/components/PlaceholderConfigDialog.vue

@ -1,305 +0,0 @@
<template>
<el-dialog v-model="visible" title="占位符配置" width="1000px" @open="loadConfig">
<div class="config-container">
<!-- 说明文档 -->
<el-alert
title="配置说明"
type="info"
:closable="false"
show-icon
>
<template #default>
<p>1. 占位符格式双大括号包围例如学员姓名</p>
<p>2. 请为每个占位符配置对应的数据源表和字段</p>
<p>3. 必填项在生成合同时必须有值否则会报错</p>
</template>
</el-alert>
<!-- 配置表格 -->
<el-table :data="configList" v-loading="loading" class="config-table">
<el-table-column prop="placeholder" label="占位符" width="200">
<template #default="{ row }">
<code>{{ `${row.placeholder}` }}</code>
</template>
</el-table-column>
<el-table-column label="数据源表" width="150">
<template #default="{ row, $index }">
<el-select v-model="row.table_name" placeholder="选择表" @change="onTableChange(row, $index)">
<el-option
v-for="table in tableOptions"
:key="table.value"
:label="table.label"
:value="table.value"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="字段名" width="150">
<template #default="{ row, $index }">
<el-select v-model="row.field_name" placeholder="选择字段">
<el-option
v-for="field in getFieldOptions(row.table_name)"
:key="field.value"
:label="field.label"
:value="field.value"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="字段类型" width="120">
<template #default="{ row }">
<el-select v-model="row.field_type" placeholder="选择类型">
<el-option label="文本" value="text" />
<el-option label="数字" value="number" />
<el-option label="日期" value="date" />
<el-option label="金额" value="money" />
</el-select>
</template>
</el-table-column>
<el-table-column label="是否必填" width="100">
<template #default="{ row }">
<el-switch v-model="row.is_required" :active-value="1" :inactive-value="0" />
</template>
</el-table-column>
<el-table-column label="默认值" width="150">
<template #default="{ row }">
<el-input v-model="row.default_value" placeholder="默认值" />
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ $index }">
<el-button type="danger" size="small" @click="removeConfig($index)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 添加配置 -->
<div class="add-config">
<el-button type="primary" @click="addConfig">
<el-icon><Plus /></el-icon>
添加占位符
</el-button>
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveConfig">保存配置</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { contractTemplateApi, type PlaceholderConfig } from '@/api/contract'
interface Props {
modelValue: boolean
contractId: number
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const loading = ref(false)
const saving = ref(false)
const configList = ref<PlaceholderConfig[]>([])
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
//
watch(() => props.modelValue, (newVal) => {
if (newVal) {
//
loadConfig()
}
}, { immediate: false })
//
const resetForm = () => {
configList.value = []
loading.value = false
saving.value = false
}
//
const tableOptions = [
{ label: '学员信息', value: 'school_student' },
{ label: '人员信息', value: 'school_personnel' },
{ label: '课程信息', value: 'school_course' },
{ label: '班级信息', value: 'school_class' }
]
//
const fieldOptionsMap: Record<string, Array<{label: string, value: string}>> = {
school_student: [
{ label: '学员姓名', value: 'name' },
{ label: '手机号', value: 'phone' },
{ label: '身份证号', value: 'id_card' },
{ label: '年龄', value: 'age' }
],
school_personnel: [
{ label: '员工姓名', value: 'name' },
{ label: '工号', value: 'employee_number' },
{ label: '手机号', value: 'phone' },
{ label: '邮箱', value: 'email' }
],
school_course: [
{ label: '课程名称', value: 'course_name' },
{ label: '课程价格', value: 'price' },
{ label: '课时数', value: 'class_hours' }
],
school_class: [
{ label: '班级名称', value: 'class_name' },
{ label: '开课时间', value: 'start_time' },
{ label: '结课时间', value: 'end_time' }
]
}
//
const getFieldOptions = (tableName: string) => {
return fieldOptionsMap[tableName] || []
}
//
const loadConfig = async () => {
if (!props.contractId) return
loading.value = true
try {
const { data } = await contractTemplateApi.getPlaceholderConfig(props.contractId)
console.log('API返回数据:', data)
// API
if (data && typeof data === 'object') {
// placeholder_config
if (data.placeholder_config) {
configList.value = Array.isArray(data.placeholder_config) ? data.placeholder_config : []
}
// placeholders
else if (data.placeholders && Array.isArray(data.placeholders)) {
configList.value = data.placeholders.map(placeholder => ({
placeholder: placeholder,
table_name: '',
field_name: '',
field_type: 'text',
is_required: 0,
default_value: ''
}))
}
//
else if (Array.isArray(data)) {
configList.value = data
}
//
else {
configList.value = []
}
} else {
configList.value = []
}
console.log('处理后的配置列表:', configList.value)
} catch (error) {
console.error('加载配置失败:', error)
ElMessage.error('加载配置失败')
configList.value = []
} finally {
loading.value = false
}
}
//
const onTableChange = (row: PlaceholderConfig, index: number) => {
row.field_name = ''
}
//
const addConfig = () => {
configList.value.push({
id: 0,
contract_id: props.contractId,
placeholder: '',
table_name: '',
field_name: '',
field_type: 'text',
is_required: 0,
default_value: ''
})
}
//
const removeConfig = (index: number) => {
configList.value.splice(index, 1)
}
//
const saveConfig = async () => {
//
for (const config of configList.value) {
if (!config.placeholder) {
ElMessage.error('请填写占位符名称')
return
}
if (!config.table_name) {
ElMessage.error('请选择数据源表')
return
}
if (!config.field_name) {
ElMessage.error('请选择字段名')
return
}
}
saving.value = true
try {
await contractTemplateApi.savePlaceholderConfig(props.contractId, configList.value)
emit('success')
} catch (error) {
ElMessage.error('保存失败')
} finally {
saving.value = false
}
}
</script>
<style scoped>
.config-container {
max-height: 600px;
overflow-y: auto;
}
.config-table {
margin: 20px 0;
}
.add-config {
text-align: center;
margin-top: 20px;
}
code {
background-color: #f5f7fa;
padding: 2px 4px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
</style>

72
admin/src/views/contract/template/components/PlaceholderConfigDialogSimple.vue

@ -1,72 +0,0 @@
<template>
<el-dialog
v-model="visible"
title="占位符配置"
width="800px"
:close-on-click-modal="false"
>
<div>
<p>合同ID: {{ contractId }}</p>
<p>弹窗状态: {{ visible ? '打开' : '关闭' }}</p>
<div v-if="loading">加载中...</div>
<div v-else>
<p>配置数据: {{ JSON.stringify(configData) }}</p>
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="save">保存</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
interface Props {
modelValue: boolean
contractId: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'success': []
}>()
const loading = ref(false)
const configData = ref<any>({})
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
//
watch(() => props.modelValue, (newVal) => {
if (newVal) {
loadData()
}
})
const loadData = async () => {
loading.value = true
try {
// API
await new Promise(resolve => setTimeout(resolve, 1000))
configData.value = { test: '测试数据', contractId: props.contractId }
} catch (error) {
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
const save = () => {
ElMessage.success('保存成功')
emit('success')
visible.value = false
}
</script>

175
admin/src/views/contract/template/components/TemplateUploadDialog.vue

@ -1,175 +0,0 @@
<template>
<el-dialog v-model="visible" title="上传合同模板" width="600px" @close="resetForm">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="模板名称" prop="contract_name">
<el-input v-model="form.contract_name" placeholder="请输入模板名称" />
</el-form-item>
<el-form-item label="合同类型" prop="contract_type">
<el-select v-model="form.contract_type" placeholder="请选择合同类型">
<el-option label="课程合同" value="course" />
<el-option label="服务合同" value="service" />
</el-select>
</el-form-item>
<el-form-item label="模板文件" prop="file">
<FileUpload
:upload-url="uploadUrl"
@success="handleFileSuccess"
@error="handleFileError"
/>
<div v-if="form.file_path" class="file-info">
<el-icon><Document /></el-icon>
<span>{{ form.file_name }}</span>
</div>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remarks" type="textarea" :rows="3" placeholder="请输入备注信息" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="submit">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { Document } from '@element-plus/icons-vue'
import { contractTemplateApi } from '@/api/contract'
import FileUpload from '@/components/FileUpload/index.vue'
interface Props {
modelValue: boolean
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
const loading = ref(false)
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const form = reactive({
contract_name: '',
contract_type: '',
file_path: '',
file_name: '',
remarks: ''
})
const rules: FormRules = {
contract_name: [
{ required: true, message: '请输入模板名称', trigger: 'blur' }
],
contract_type: [
{ required: true, message: '请选择合同类型', trigger: 'change' }
],
file: [
{ required: true, message: '请上传模板文件', trigger: 'change' }
]
}
const uploadUrl = `${import.meta.env.VITE_APP_BASE_URL}document_template/upload`
//
const handleFileSuccess = (data: any) => {
//
form.file_path = data.file_path || data.url
form.file_name = data.file_name || data.original_name
//
if (data.id) {
ElMessage.success('模板上传成功')
emit('success')
visible.value = false
}
}
//
const handleFileError = (error: any) => {
console.error('文件上传失败:', error)
ElMessage.error('文件上传失败')
}
//
const submit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (!form.file_path) {
ElMessage.error('请先上传模板文件')
return
}
loading.value = true
//
const data = {
contract_name: form.contract_name,
contract_type: form.contract_type,
remarks: form.remarks
}
//
// await contractTemplateApi.updateTemplate(templateId, data)
ElMessage.success('模板信息保存成功')
emit('success')
} catch (error) {
console.error('提交失败:', error)
ElMessage.error('提交失败')
} finally {
loading.value = false
}
}
//
const resetForm = () => {
Object.assign(form, {
contract_name: '',
contract_type: '',
file_path: '',
file_name: '',
remarks: ''
})
formRef.value?.resetFields()
}
//
watch(visible, (newVal) => {
if (!newVal) {
resetForm()
}
})
</script>
<style scoped>
.file-info {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
padding: 8px;
background-color: #f5f7fa;
border-radius: 4px;
font-size: 14px;
color: #606266;
}
</style>

37
admin/src/views/contract/template/components/TestDialog.vue

@ -1,37 +0,0 @@
<template>
<el-dialog
v-model="dialogVisible"
title="测试弹窗"
width="500px"
>
<div>
<p>这是一个测试弹窗</p>
<p>合同ID: {{ contractId }}</p>
</div>
<template #footer>
<el-button @click="dialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
modelValue: Boolean,
contractId: Number
})
const emit = defineEmits(['update:modelValue'])
const dialogVisible = ref(false)
watch(() => props.modelValue, (val) => {
dialogVisible.value = val
})
watch(dialogVisible, (val) => {
emit('update:modelValue', val)
})
</script>

1168
admin/src/views/contract/template/index.vue

File diff suppressed because it is too large

29
niucloud/app/adminapi/controller/contract/ContractDistribution.php

@ -100,18 +100,25 @@ class ContractDistribution extends BaseAdminController
{
$type = $this->request->param('type', 1);
$service = new ContractDistributionService();
if ($type == 1) {
// 内部员工
$personnel = \app\model\personnel\Personnel::where('status', 1)
->field('id, name, phone, email')
// 内部员工 - 从school_personnel表查询
$personnel = \think\facade\Db::table('school_personnel')
->where('status', 1)
->where('deleted_at', 0)
->field('id, name, phone, email, account_type as role')
->select()
->toArray();
// 处理数据格式,添加部门信息
foreach ($personnel as &$person) {
$person['department'] = $person['role'] === 'teacher' ? '教务部' : '销售部';
$person['role'] = $person['role'] === 'teacher' ? '教师' : '销售';
}
} else {
// 外部会员
$personnel = \app\model\member\Member::where('status', 1)
->field('id, nickname as name, mobile as phone, email')
$personnel = \think\facade\Db::table('member')
->where('status', 1)
->field('member_id as id, nickname as name, mobile as phone, email')
->select()
->toArray();
}
@ -133,10 +140,10 @@ class ContractDistribution extends BaseAdminController
}
$stats = [
'total' => \app\model\contract\ContractSign::where($where)->count(),
'pending' => \app\model\contract\ContractSign::where($where)->where('status', 'pending')->count(),
'signed' => \app\model\contract\ContractSign::where($where)->where('status', 'signed')->count(),
'rejected' => \app\model\contract\ContractSign::where($where)->where('status', 'rejected')->count(),
'total' => \app\model\contract_sign\ContractSign::where($where)->count(),
'pending' => \app\model\contract_sign\ContractSign::where($where)->where('status', 'pending')->count(),
'signed' => \app\model\contract_sign\ContractSign::where($where)->where('status', 'signed')->count(),
'rejected' => \app\model\contract_sign\ContractSign::where($where)->where('status', 'rejected')->count(),
];
return success($stats);

22
niucloud/app/adminapi/route/contract_distribution.php

@ -11,31 +11,35 @@
use think\facade\Route;
use app\adminapi\middleware\AdminCheckRole;
use app\adminapi\middleware\AdminCheckToken;
use app\adminapi\middleware\AdminLog;
/**
* 合同分发路由
*/
Route::group('contract_distribution', function () {
// 分发记录列表
Route::get('lists', 'contract.ContractDistribution@lists');
Route::get('lists', 'contract.ContractDistribution/lists');
// 手动分发合同
Route::post('manual_distribute', 'contract.ContractDistribution@manualDistribute');
Route::post('manual_distribute', 'contract.ContractDistribution/manualDistribute');
// 批量分发合同
Route::post('batch_distribute', 'contract.ContractDistribution@batchDistribute');
Route::post('batch_distribute', 'contract.ContractDistribution/batchDistribute');
// 取消分发
Route::delete('cancel/:id', 'contract.ContractDistribution@cancelDistribution');
Route::delete('cancel/:id', 'contract.ContractDistribution/cancelDistribution');
// 获取可分发人员列表
Route::get('available_personnel', 'contract.ContractDistribution@getAvailablePersonnel');
Route::get('available_personnel', 'contract.ContractDistribution/getAvailablePersonnel');
// 获取分发统计信息
Route::get('stats', 'contract.ContractDistribution@getDistributionStats');
Route::get('stats', 'contract.ContractDistribution/getDistributionStats');
})->middleware([
app\adminapi\middleware\AdminCheckToken::class,
app\adminapi\middleware\AdminCheckRole::class,
app\adminapi\middleware\AdminLog::class
AdminCheckToken::class,
AdminCheckRole::class,
AdminLog::class
]);

49
niucloud/app/api/controller/apiController/Personnel.php

@ -187,16 +187,57 @@ class Personnel extends BaseApiService
}
try {
$res = (new PersonnelService())->addPersonnel($params);
if (!$res['code']) {
return fail($res['msg']);
// 检查是否使用审批流程
if (isset($params['use_approval']) && $params['use_approval'] && isset($params['approval_config_id']) && $params['approval_config_id'] > 0) {
// 使用审批流程
$approvalService = new \app\service\school_approval\SchoolApprovalProcessService();
$processId = $approvalService->createPersonnelApproval(
$params,
$this->member_id, // 当前登录用户作为申请人
$params['approval_config_id']
);
return success([
'type' => 'approval',
'process_id' => $processId,
'message' => '审批申请已提交,等待审批'
]);
} else {
// 直接添加人员
$res = (new PersonnelService())->addPersonnel($params);
if (!$res['code']) {
return fail($res['msg']);
}
return success([
'type' => 'direct',
'data' => $res['data'],
'message' => '员工信息添加成功'
]);
}
return success($res['data']);
} catch (\Exception $e) {
return fail('添加员工信息失败:' . $e->getMessage());
}
}
/**
* 获取审批配置列表
* @param Request $request
* @return mixed
*/
public function getApprovalConfigs(Request $request)
{
try {
$params = $request->all();
$businessType = $params['business_type'] ?? 'personnel_add';
$approvalService = new \app\service\school_approval\SchoolApprovalConfigService();
$configs = $approvalService->getActiveConfigs($businessType);
return success($configs);
} catch (\Exception $e) {
return fail('获取审批配置失败:' . $e->getMessage());
}
}
/**
* 获取我的服务记录列表
* @param Request $request

2
niucloud/app/api/route/route.php

@ -220,6 +220,8 @@ Route::group(function () {
Route::get('personnel/getCoachList', 'apiController.Personnel/getCoachList');
//员工端-添加新员工信息
Route::post('personnel/add', 'apiController.Personnel/add');
//员工端-获取审批配置列表
Route::get('personnel/approval-configs', 'apiController.Personnel/getApprovalConfigs');
//员工端统计(销售)-获取销售首页数据统计
Route::get('statistics/marketHome', 'apiController.Statistics/marketHome');

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

@ -12,7 +12,7 @@
namespace app\service\admin\contract;
use app\model\contract\Contract;
use app\model\contract\ContractSign;
use app\model\contract_sign\ContractSign;
use app\model\personnel\Personnel;
use app\model\member\Member;
use core\base\BaseAdminService;

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

@ -468,25 +468,128 @@ class DocumentTemplateService extends BaseAdminService
*/
public function getDataSources()
{
$dataSources = $this->dataSourceModel
->where('site_id', $this->site_id)
->where('is_active', 1)
->order('table_name,sort_order')
->select()
->toArray();
// 按表名分组
$grouped = [];
foreach ($dataSources as $item) {
$grouped[$item['table_name']]['table_alias'] = $item['table_alias'];
$grouped[$item['table_name']]['fields'][] = [
'field_name' => $item['field_name'],
'field_alias' => $item['field_alias'],
'field_type' => $item['field_type']
];
}
return [
'tables' => $this->getAvailableTables(),
'system_functions' => $this->getSystemFunctions()
];
}
return $grouped;
/**
* 获取可用数据表配置
* @return array
*/
public function getAvailableTables()
{
return [
'school_student' => [
'label' => '学员表',
'fields' => [
'id' => '学员ID',
'name' => '学员姓名',
'gender' => '性别',
'age' => '年龄',
'birthday' => '生日',
'emergency_contact' => '紧急联系人',
'contact_phone' => '联系人电话',
'status' => '学员状态',
'trial_class_count' => '体验课次数',
'created_at' => '创建时间',
'updated_at' => '修改时间'
]
],
'school_customer_resources' => [
'label' => '客户资源表',
'fields' => [
'id' => '编号',
'name' => '姓名',
'phone_number' => '联系电话',
'gender' => '性别',
'age' => '年龄',
'source_channel' => '来源渠道',
'source' => '来源',
'consultant' => '顾问',
'demand' => '需求',
'purchasing_power' => '购买力',
'initial_intent' => '客户初步意向度',
'trial_class_count' => '体验课次数',
'created_at' => '创建时间'
]
],
'school_order_table' => [
'label' => '订单表',
'fields' => [
'id' => '订单编号',
'payment_id' => '支付编号',
'order_type' => '订单类型',
'order_status' => '订单状态',
'payment_type' => '付款类型',
'order_amount' => '订单金额',
'discount_amount' => '优惠金额',
'payment_time' => '支付时间',
'created_at' => '创建时间',
'remark' => '订单备注'
]
],
'school_course' => [
'label' => '课程表',
'fields' => [
'id' => '课程编号',
'course_name' => '课程名称',
'course_type' => '课程类型',
'duration' => '课程时长',
'session_count' => '课时数量',
'single_session_count' => '单次消课数量',
'gift_session_count' => '赠送课时数量',
'price' => '课程价格',
'internal_reminder' => '内部提醒课时',
'customer_reminder' => '客户提醒课时',
'status' => '课程状态',
'created_at' => '创建时间'
]
],
'school_personnel' => [
'label' => '人员表',
'fields' => [
'id' => 'ID',
'name' => '姓名',
'gender' => '性别',
'phone' => '电话',
'email' => '邮箱',
'wx' => '微信号',
'address' => '家庭住址',
'education' => '学历',
'employee_number' => '员工编号',
'account_type' => '账号类型',
'status' => '状态',
'join_time' => '入职时间',
'create_time' => '创建时间'
]
]
];
}
/**
* 获取系统函数配置
* @return array
*/
public function getSystemFunctions()
{
return [
'current_date' => '当前日期',
'current_time' => '当前时间',
'current_datetime' => '当前日期时间',
'current_year' => '当前年份',
'current_month' => '当前月份',
'current_day' => '当前日',
'random_number' => '随机编号',
'contract_generate_time' => '合同生成时间',
'system_name' => '系统名称',
'current_user' => '当前用户',
'current_campus' => '当前校区',
// 签名占位符
'employee_signature' => '员工签名位置',
'student_signature' => '学员签名位置'
];
}
/**

4
niucloud/app/service/api/apiService/CommonService.php

@ -40,7 +40,9 @@ class CommonService extends BaseApiService
$res = $model->field($field)->find();//员工信息
if($res){
$res = $res->toArray()['dictionary'];
$data = $res->toArray();
// 模型已经自动处理JSON转换,直接返回dictionary字段
$res = $data['dictionary'] ?? [];
}else{
$res = [];
}

151
niucloud/app/service/api/apiService/ContractService.php

@ -15,6 +15,8 @@ use app\model\contract\Contract;
use app\model\contract_sign\ContractSign;
use core\base\BaseApiService;
use think\facade\Db;
use PhpOffice\PhpWord\TemplateProcessor;
use app\service\core\contract_sign\ContractSign as ContractSignService;
/**
* 合同服务层
@ -203,13 +205,23 @@ class ContractService extends BaseApiService
return $res;
}
// 生成签署后的合同文档
$generatedFile = null;
if ($sign_file) {
$generatedFile = $this->generateStaffSignedContract($contract_id, $personnel_id, $sign_file);
}
// 更新签订信息
$updateData = [
'sign_file' => $sign_file,
'sign_file' => $generatedFile ?: $sign_file,
'sign_time' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
];
if ($generatedFile) {
$updateData['signature_image'] = $sign_file;
}
$updateResult = ContractSign::where('id', $contractSign['id'])->update($updateData);
if (!$updateResult) {
@ -226,7 +238,8 @@ class ContractService extends BaseApiService
'msg' => '签订成功',
'data' => [
'contract_id' => $contract_id,
'sign_time' => $updateData['sign_time']
'sign_time' => $updateData['sign_time'],
'generated_file' => $generatedFile
]
];
@ -353,4 +366,138 @@ class ContractService extends BaseApiService
return $res;
}
/**
* 生成员工签署后的合同文档
* @param int $contractId
* @param int $personnelId
* @param string $signatureImage
* @return string|null
*/
private function generateStaffSignedContract($contractId, $personnelId, $signatureImage)
{
try {
// 获取合同模板信息
$contract = Contract::find($contractId);
if (!$contract || !$contract['contract_template']) {
return null;
}
// 构建模板路径
$templatePath = public_path() . '/upload/' . $contract['contract_template'];
if (!file_exists($templatePath)) {
return null;
}
// 生成输出文件名和路径
$outputFileName = 'staff_signed_contract_' . $personnelId . '_' . $contractId . '_' . date('YmdHis') . '.docx';
$outputRelPath = 'contracts/staff_signed/' . date('Y/m/') . $outputFileName;
$outputFullPath = public_path() . '/upload/' . $outputRelPath;
// 确保目录存在
$outputDir = dirname($outputFullPath);
if (!is_dir($outputDir)) {
mkdir($outputDir, 0755, true);
}
// 获取员工信息并准备填充数据
$fillData = $this->prepareStaffFillData($contractId, $personnelId);
// 使用PhpWord处理模板
$templateProcessor = new TemplateProcessor($templatePath);
// 填充文本数据
foreach ($fillData as $placeholder => $value) {
$templateProcessor->setValue($placeholder, $value);
}
// 处理签名图片
if ($signatureImage) {
// 处理签名图片
$signImagePath = $this->processStaffSignatureImage($signatureImage);
// 使用ContractSign服务插入签名
$contractSignService = new ContractSignService();
$contractSignService->setSign($templatePath, $outputFullPath, $signImagePath, '员工签名');
// 清理临时文件
if (file_exists($signImagePath)) {
unlink($signImagePath);
}
} else {
// 没有签名时直接保存
$templateProcessor->saveAs($outputFullPath);
}
return $outputRelPath;
} catch (\Exception $e) {
return null;
}
}
/**
* 准备员工填充数据
* @param int $contractId
* @param int $personnelId
* @return array
*/
private function prepareStaffFillData($contractId, $personnelId)
{
$fillData = [];
try {
// 获取员工信息
$personnel = Db::table('school_personnel')->where('id', $personnelId)->find();
if ($personnel) {
$fillData['员工姓名'] = $personnel['name'] ?? '';
$fillData['员工编号'] = $personnel['employee_number'] ?? '';
$fillData['员工电话'] = $personnel['phone'] ?? '';
$fillData['员工邮箱'] = $personnel['email'] ?? '';
$fillData['入职时间'] = $personnel['join_time'] ?? '';
}
// 添加系统信息
$fillData['签署日期'] = date('Y-m-d');
$fillData['签署时间'] = date('Y-m-d H:i:s');
$fillData['合同编号'] = $contractId . date('Ymd') . $personnelId;
} catch (\Exception $e) {
// 记录错误日志但不中断流程
}
return $fillData;
}
/**
* 处理员工签名图片
* @param string $signatureImage
* @return string
*/
private function processStaffSignatureImage($signatureImage)
{
$tempImagePath = public_path() . '/upload/temp_staff_sign_' . date('YmdHis') . '_' . mt_rand(1000, 9999) . '.png';
if (strpos($signatureImage, 'data:image') === 0) {
// Base64图片
$imageData = base64_decode(preg_replace('#^data:image/\w+;base64,#i', '', $signatureImage));
if ($imageData !== false) {
file_put_contents($tempImagePath, $imageData);
}
} elseif (filter_var($signatureImage, FILTER_VALIDATE_URL)) {
// URL图片
$imageContent = file_get_contents($signatureImage);
if ($imageContent !== false) {
file_put_contents($tempImagePath, $imageContent);
}
} else {
// 本地路径
$localPath = public_path() . '/upload/' . ltrim($signatureImage, '/');
if (file_exists($localPath)) {
copy($localPath, $tempImagePath);
}
}
return $tempImagePath;
}
}

2
niucloud/app/service/api/apiService/PersonnelService.php

@ -503,7 +503,7 @@ class PersonnelService extends BaseApiService
'create_time' => date('Y-m-d H:i:s'),
'update_time' => date('Y-m-d H:i:s'),
'join_time' => $data['join_time'] ?? date('Y-m-d H:i:s'),
'delete_time' => 0
'deleted_at' => 0
];
// 插入员工基本信息

55
niucloud/app/service/api/member/SalaryService.php

@ -59,7 +59,16 @@ class SalaryService extends BaseApiService
$search_model->where('s.salary_month', 'like', $where['salary_month'] . '%');
}
return $this->pageQuery($search_model);
$result = $this->pageQuery($search_model);
// 处理返回数据,转换状态
if (!empty($result['data'])) {
foreach ($result['data'] as $key => $item) {
$result['data'][$key] = $this->formatSalaryData($item);
}
}
return $result;
}
/**
@ -90,6 +99,48 @@ class SalaryService extends BaseApiService
throw new ApiException('工资条不存在或无权限查看');
}
return $info;
return $this->formatSalaryData($info);
}
/**
* 格式化工资数据
* @param array $data
* @return array
*/
private function formatSalaryData(array $data)
{
// 转换发放状态为前端期望的数字格式
if (isset($data['payment_status'])) {
switch ($data['payment_status']) {
case 'pending':
$data['status'] = 1; // 未发放
break;
case 'paid':
$data['status'] = 2; // 已发放
break;
default:
$data['status'] = 1;
}
}
// 确保所有数字字段都有默认值
$numericFields = [
'base_salary', 'performance_bonus', 'deductions', 'other_subsidies',
'work_salary', 'mgr_performance', 'social_security', 'individual_income_tax',
'gross_salary', 'net_salary', 'attendance', 'full_attendance_days'
];
foreach ($numericFields as $field) {
if (!isset($data[$field]) || $data[$field] === null) {
$data[$field] = '0.00';
}
}
// 确保整数字段
if (!isset($data['full_attendance_days']) || $data['full_attendance_days'] === null) {
$data['full_attendance_days'] = 0;
}
return $data;
}
}

237
niucloud/app/service/api/student/ContractService.php

@ -8,6 +8,8 @@ namespace app\service\api\student;
use think\facade\Db;
use core\base\BaseService;
use core\exception\CommonException;
use PhpOffice\PhpWord\TemplateProcessor;
use app\service\core\contract_sign\ContractSign;
/**
* 学员合同管理服务类
@ -261,6 +263,12 @@ class ContractService extends BaseService
// 开始事务
Db::startTrans();
try {
// 生成签署后的合同文档
$generatedFile = null;
if ($signatureImage) {
$generatedFile = $this->generateSignedContract($contractId, $studentId, $formData, $signatureImage);
}
// 更新合同签署状态
$updateData = [
'status' => 2, // 已签署
@ -273,6 +281,10 @@ class ContractService extends BaseService
$updateData['signature_image'] = $signatureImage;
}
if ($generatedFile) {
$updateData['sign_file'] = $generatedFile;
}
$result = Db::table('school_contract_sign')
->where('id', $contractSign['id'])
->update($updateData);
@ -282,7 +294,12 @@ class ContractService extends BaseService
}
Db::commit();
return true;
return [
'sign_id' => $contractSign['id'],
'generated_file' => $generatedFile,
'sign_time' => $updateData['sign_time']
];
} catch (\Exception $e) {
Db::rollback();
@ -465,6 +482,219 @@ class ContractService extends BaseService
];
}
/**
* 生成签署后的合同文档
* @param int $contractId
* @param int $studentId
* @param array $formData
* @param string $signatureImage
* @return string
* @throws CommonException
*/
private function generateSignedContract($contractId, $studentId, $formData, $signatureImage)
{
try {
// 获取合同模板信息
$contract = Db::table('school_contract')
->where('id', $contractId)
->find();
if (!$contract || !$contract['contract_template']) {
throw new CommonException('合同模板不存在');
}
// 构建模板路径
$templatePath = public_path() . '/upload/' . $contract['contract_template'];
if (!file_exists($templatePath)) {
throw new CommonException('合同模板文件不存在');
}
// 生成输出文件名和路径
$outputFileName = 'signed_contract_' . $studentId . '_' . $contractId . '_' . date('YmdHis') . '.docx';
$outputRelPath = 'contracts/signed/' . date('Y/m/') . $outputFileName;
$outputFullPath = public_path() . '/upload/' . $outputRelPath;
// 确保目录存在
$outputDir = dirname($outputFullPath);
if (!is_dir($outputDir)) {
mkdir($outputDir, 0755, true);
}
// 获取数据源配置并准备填充数据
$fillData = $this->prepareFillData($contractId, $studentId, $formData);
// 使用PhpWord处理模板
$templateProcessor = new TemplateProcessor($templatePath);
// 填充文本数据
foreach ($fillData as $placeholder => $value) {
$templateProcessor->setValue($placeholder, $value);
}
// 处理签名图片
if ($signatureImage && $this->hasSignaturePlaceholder($templateProcessor)) {
// 处理签名图片 - 支持base64和URL
$signImagePath = $this->processSignatureImage($signatureImage);
// 使用ContractSign服务插入签名
$contractSignService = new ContractSign();
$contractSignService->setSign($templatePath, $outputFullPath, $signImagePath, '学员签名');
// 清理临时文件
if (file_exists($signImagePath)) {
unlink($signImagePath);
}
} else {
// 没有签名时直接保存
$templateProcessor->saveAs($outputFullPath);
}
return $outputRelPath;
} catch (\Exception $e) {
throw new CommonException('生成签署合同失败:' . $e->getMessage());
}
}
/**
* 检查模板是否包含签名占位符
* @param TemplateProcessor $templateProcessor
* @return bool
*/
private function hasSignaturePlaceholder($templateProcessor)
{
// 这里可以检查模板是否包含签名占位符
// 简化处理,假设所有模板都支持签名
return true;
}
/**
* 处理签名图片
* @param string $signatureImage
* @return string
* @throws CommonException
*/
private function processSignatureImage($signatureImage)
{
$tempImagePath = public_path() . '/upload/temp_sign_' . date('YmdHis') . '_' . mt_rand(1000, 9999) . '.png';
if (strpos($signatureImage, 'data:image') === 0) {
// Base64图片
$imageData = base64_decode(preg_replace('#^data:image/\w+;base64,#i', '', $signatureImage));
if ($imageData === false) {
throw new CommonException('签名图片格式错误');
}
file_put_contents($tempImagePath, $imageData);
} elseif (filter_var($signatureImage, FILTER_VALIDATE_URL)) {
// URL图片
$imageContent = file_get_contents($signatureImage);
if ($imageContent === false) {
throw new CommonException('无法下载签名图片');
}
file_put_contents($tempImagePath, $imageContent);
} else {
// 本地路径
$localPath = public_path() . '/upload/' . ltrim($signatureImage, '/');
if (!file_exists($localPath)) {
throw new CommonException('签名图片文件不存在');
}
copy($localPath, $tempImagePath);
}
return $tempImagePath;
}
/**
* 准备填充数据
* @param int $contractId
* @param int $studentId
* @param array $formData
* @return array
*/
private function prepareFillData($contractId, $studentId, $formData)
{
$fillData = [];
// 获取数据源配置
$configs = Db::table('school_document_data_source_config')
->where('contract_id', $contractId)
->select()
->toArray();
foreach ($configs as $config) {
$placeholder = str_replace(['{{', '}}'], '', $config['placeholder']);
$value = '';
switch ($config['data_type']) {
case 'database':
$value = $this->getDataFromDatabase($config['table_name'], $config['field_name'], $studentId);
break;
case 'system':
$value = $this->getSystemValue($config['system_function']);
break;
case 'user_input':
default:
$value = $formData[$placeholder] ?? $config['default_value'] ?? '';
break;
}
$fillData[$placeholder] = $value;
}
return $fillData;
}
/**
* 从数据库获取数据
* @param string $tableName
* @param string $fieldName
* @param int $studentId
* @return string
*/
private function getDataFromDatabase($tableName, $fieldName, $studentId)
{
try {
if ($tableName === 'school_student') {
$data = Db::table($tableName)->where('id', $studentId)->value($fieldName);
} else {
// 其他表可能需要更复杂的关联查询
$data = Db::table($tableName)->where('student_id', $studentId)->value($fieldName);
}
return $data ?: '';
} catch (\Exception $e) {
return '';
}
}
/**
* 获取系统值
* @param string $systemFunction
* @return string
*/
private function getSystemValue($systemFunction)
{
switch ($systemFunction) {
case 'current_date':
return date('Y-m-d');
case 'current_time':
return date('H:i:s');
case 'current_datetime':
return date('Y-m-d H:i:s');
case 'current_year':
return date('Y');
case 'current_month':
return date('m');
case 'current_day':
return date('d');
case 'random_number':
return mt_rand(100000, 999999);
case 'contract_generate_time':
return date('Y-m-d H:i:s');
default:
return '';
}
}
/**
* 验证表单数据
* @param int $contractId
@ -484,8 +714,9 @@ class ContractService extends BaseService
// 检查必填字段
foreach ($requiredFields as $field) {
if (!isset($formData[$field]) || trim($formData[$field]) === '') {
throw new CommonException($field . ' 为必填项');
$fieldName = str_replace(['{{', '}}'], '', $field);
if (!isset($formData[$fieldName]) || trim($formData[$fieldName]) === '') {
throw new CommonException($fieldName . ' 为必填项');
}
}
}

23
niucloud/app/service/school_approval/SchoolApprovalConfigService.php

@ -183,4 +183,27 @@ class SchoolApprovalConfigService
{
return (new SchoolApprovalConfig())->where(['id' => $id])->update(['status' => $status]) !== false;
}
/**
* 获取启用状态的审批配置列表
* @param string $businessType 业务类型
* @return array
*/
public function getActiveConfigs(string $businessType = ''): array
{
$where = ['status' => 1];
if (!empty($businessType)) {
$where['business_type'] = $businessType;
}
$field = 'id, config_name, description, business_type, created_at';
return (new SchoolApprovalConfig())
->where($where)
->field($field)
->order('id desc')
->select()
->toArray();
}
}

258
niucloud/app/service/school_approval/SchoolApprovalProcessService.php

@ -8,6 +8,7 @@ use app\model\school_approval\SchoolApprovalConfig;
use app\model\school_approval\SchoolApprovalConfigNode;
use app\model\school_approval\SchoolApprovalParticipants;
use app\model\school_approval\SchoolApprovalProcess;
use app\model\school_approval\SchoolApprovalHistory;
use think\Exception;
use think\facade\Db;
@ -112,7 +113,11 @@ class SchoolApprovalProcessService
$participants = [];
foreach ($config_info['nodes'] as $sequence => $node) {
$approver_ids = explode(',', $node['approver_ids']);
foreach ($approver_ids as $approver_id) {
// 动态获取审批人
$actual_approvers = $this->getDynamicApprovers($node['approver_type'], $approver_ids);
foreach ($actual_approvers as $approver_id) {
$participants[] = [
'process_id' => $process_id,
'participant_id' => $approver_id,
@ -138,6 +143,11 @@ class SchoolApprovalProcessService
}
}
// 发送待审批通知给第一个审批人
if (!empty($first_participant)) {
$this->sendApprovalNotification($process_id, $first_participant['participant_id'], 'pending');
}
Db::commit();
return $process_id;
} catch (\Exception $e) {
@ -218,6 +228,9 @@ class SchoolApprovalProcessService
'remarks' => $remarks
]);
// 记录审批历史
$this->recordApprovalHistory($process_id, $approver_id, $current_participant['sequence'], $status, $remarks);
// 如果拒绝,直接更新整个流程状态为拒绝
if ($status == SchoolApprovalParticipants::STATUS_REJECTED) {
(new SchoolApprovalProcess())->where(['id' => $process_id])
@ -230,22 +243,16 @@ class SchoolApprovalProcessService
// 处理拒绝后的业务逻辑
$this->handleApprovalRejected($process_id);
// 发送审批拒绝通知给申请人
$this->sendApprovalNotification($process_id, $process_info['applicant_id'], 'rejected');
Db::commit();
return true;
}
// 检查当前节点是否需要会签
$same_sequence_participants = (new SchoolApprovalParticipants())
->where([
'process_id' => $process_id,
'sequence' => $current_participant['sequence'],
'status' => SchoolApprovalParticipants::STATUS_PENDING
])
->select();
// 如果是会签且还有其他人未审批,则等待
if ($current_participant['sign_type'] == SchoolApprovalParticipants::SIGN_TYPE_AND && !$same_sequence_participants->isEmpty()) {
// 不做任何处理,等待其他人审批
// 检查当前节点是否完成
if (!$this->isCurrentNodeCompleted($process_id, $current_participant['sequence'])) {
// 当前节点未完成(会签情况下还有其他人未审批),等待其他人审批
Db::commit();
return true;
}
@ -269,10 +276,16 @@ class SchoolApprovalProcessService
// 处理业务逻辑
$this->handleApprovalCompleted($process_id);
// 发送审批完成通知给申请人
$this->sendApprovalNotification($process_id, $process_info['applicant_id'], 'approved');
} else {
// 更新当前审批人为下一个审批人
(new SchoolApprovalProcess())->where(['id' => $process_id])
->update(['current_approver_id' => $next_participant['participant_id']]);
// 发送待审批通知给下一个审批人
$this->sendApprovalNotification($process_id, $next_participant['participant_id'], 'pending');
}
Db::commit();
@ -418,4 +431,223 @@ class SchoolApprovalProcessService
break;
}
}
/**
* 检查当前节点是否已完成
* @param int $process_id 流程ID
* @param int $sequence 节点序号
* @return bool
*/
private function isCurrentNodeCompleted(int $process_id, int $sequence): bool
{
// 获取当前节点的所有参与人
$participants = (new SchoolApprovalParticipants())
->where([
'process_id' => $process_id,
'sequence' => $sequence
])
->select()
->toArray();
if (empty($participants)) {
return true;
}
// 获取第一个参与人的审批类型(同一节点的审批类型应该一致)
$sign_type = $participants[0]['sign_type'];
if ($sign_type == SchoolApprovalParticipants::SIGN_TYPE_OR) {
// 或签:只要有一个人通过即可
foreach ($participants as $participant) {
if ($participant['status'] == SchoolApprovalParticipants::STATUS_APPROVED) {
return true;
}
}
return false;
} else {
// 会签:需要所有人都通过
foreach ($participants as $participant) {
if ($participant['status'] == SchoolApprovalParticipants::STATUS_PENDING) {
return false; // 还有人未审批
}
if ($participant['status'] == SchoolApprovalParticipants::STATUS_REJECTED) {
return true; // 有人拒绝,节点结束
}
}
return true; // 所有人都已审批通过
}
}
/**
* 获取下一个审批人
* @param int $process_id 流程ID
* @param int $current_sequence 当前节点序号
* @return int|null
*/
private function getNextApprover(int $process_id, int $current_sequence): ?int
{
// 获取下一个序号的第一个审批人
$next_participant = (new SchoolApprovalParticipants())
->where([
'process_id' => $process_id,
'sequence' => ['>', $current_sequence],
'status' => SchoolApprovalParticipants::STATUS_PENDING
])
->order('sequence', 'asc')
->find();
return $next_participant ? $next_participant['participant_id'] : null;
}
/**
* 动态获取审批人列表
* @param string $approver_type 审批人类型
* @param array $approver_ids 审批人ID数组
* @return array
*/
private function getDynamicApprovers(string $approver_type, array $approver_ids): array
{
$approvers = [];
switch ($approver_type) {
case 'user':
// 直接返回用户ID
$approvers = $approver_ids;
break;
case 'role':
// 根据角色获取用户
$roleUserModel = new \app\model\admin\AdminRole();
foreach ($approver_ids as $role_id) {
$users = $roleUserModel->where(['role_id' => $role_id])->column('admin_id');
$approvers = array_merge($approvers, $users);
}
break;
case 'department':
// 根据部门获取用户
$personnelModel = new Personnel();
foreach ($approver_ids as $dept_id) {
$users = $personnelModel->where(['department_id' => $dept_id])->column('id');
$approvers = array_merge($approvers, $users);
}
break;
}
return array_unique($approvers);
}
/**
* 记录审批历史
* @param int $process_id 流程ID
* @param int $participant_id 审批人ID
* @param int $sequence 审批序号
* @param string $status 审批状态
* @param string $remarks 审批意见
* @return void
*/
private function recordApprovalHistory(int $process_id, int $participant_id, int $sequence, string $status, string $remarks): void
{
$action = ($status == SchoolApprovalParticipants::STATUS_APPROVED) ?
SchoolApprovalHistory::ACTION_APPROVE : SchoolApprovalHistory::ACTION_REJECT;
$history_status = ($status == SchoolApprovalParticipants::STATUS_APPROVED) ?
SchoolApprovalHistory::STATUS_APPROVED : SchoolApprovalHistory::STATUS_REJECTED;
(new SchoolApprovalHistory())->insert([
'process_id' => $process_id,
'participant_id' => $participant_id,
'sequence' => $sequence,
'action' => $action,
'status' => $history_status,
'remarks' => $remarks
]);
}
/**
* 获取审批历史
* @param int $process_id 流程ID
* @return array
*/
public function getApprovalHistory(int $process_id): array
{
$history = (new SchoolApprovalHistory())
->alias('h')
->join(['school_personnel' => 'p'], 'h.participant_id = p.id', 'left')
->where(['h.process_id' => $process_id])
->field('h.*, p.name as participant_name')
->order('h.sequence asc, h.created_at asc')
->select()
->toArray();
return $history;
}
/**
* 发送审批通知
* @param int $process_id 流程ID
* @param int $to_user_id 接收人ID
* @param string $type 通知类型 pending|approved|rejected
* @return void
*/
private function sendApprovalNotification(int $process_id, int $to_user_id, string $type): void
{
try {
// 获取流程信息
$process = (new SchoolApprovalProcess())
->alias('p')
->join(['school_personnel' => 'applicant'], 'p.applicant_id = applicant.id', 'left')
->where(['p.id' => $process_id])
->field('p.*, applicant.name as applicant_name')
->find();
if (empty($process)) {
return;
}
// 根据通知类型设置消息内容
$title = '';
$content = '';
switch ($type) {
case 'pending':
$title = '待审批通知';
$content = "您有一个审批流程需要处理:{$process['process_name']},申请人:{$process['applicant_name']}";
break;
case 'approved':
$title = '审批通过通知';
$content = "您的审批申请已通过:{$process['process_name']}";
break;
case 'rejected':
$title = '审批拒绝通知';
$content = "您的审批申请已被拒绝:{$process['process_name']}";
break;
default:
return;
}
// 发送系统消息 (暂时注释,等待聊天模块完善)
// $messageModel = new \app\model\school_chat\SchoolChatMessages();
// $messageModel->insert([
// 'from_type' => 'system',
// 'from_id' => 0,
// 'to_id' => $to_user_id,
// 'friend_id' => 0,
// 'message_type' => 'notification',
// 'content' => $content,
// 'title' => $title,
// 'business_id' => $process_id,
// 'business_type' => 'approval_process',
// 'is_read' => 0,
// 'created_at' => date('Y-m-d H:i:s')
// ]);
} catch (\Exception $e) {
// 发送通知失败不影响主流程,仅记录日志
\think\facade\Log::error('发送审批通知失败:' . $e->getMessage());
}
}
}

11
uniapp/api/member.js

@ -119,4 +119,15 @@ export default {
})
})
},
//↓↓↓↓↓↓↓↓↓↓↓↓-----员工工资管理接口-----↓↓↓↓↓↓↓↓↓↓↓↓
// 获取员工工资列表
async getSalaryList(data = {}) {
return await http.get('/member/salary/list', data);
},
// 获取员工工资详情
async getSalaryInfo(data = {}) {
return await http.get(`/member/salary/info/${data.id}`);
},
}

67
uniapp/common/axios.js

@ -151,8 +151,15 @@ export default {
return new Promise((resolve, reject) => {
// 创建请求配置
// 确保URL正确拼接,避免缺少斜杠的问题
let fullUrl = Api_url;
if (!fullUrl.endsWith('/') && !options.url.startsWith('/')) {
fullUrl += '/';
}
fullUrl += options.url;
const config = {
url: Api_url + options.url,
url: fullUrl,
data: options.data,
method: options.method || 'GET',
header: {
@ -227,5 +234,63 @@ export default {
data,
method: 'PUT'
});
},
// 文件上传方法
uploadFile({ url, filePath, name = 'file', formData = {} }) {
return new Promise((resolve, reject) => {
// 获取token
const token = uni.getStorageSync("token");
// 显示加载状态
uni.showLoading({
title: '上传中...',
mask: true
});
// 构建完整URL
let fullUrl = Api_url;
if (!fullUrl.endsWith('/') && !url.startsWith('/')) {
fullUrl += '/';
}
fullUrl += url;
console.log('文件上传请求:', {
url: fullUrl,
filePath,
name,
formData
});
uni.uploadFile({
url: fullUrl,
filePath: filePath,
name: name,
formData: formData,
header: {
'token': token
},
success: (res) => {
console.log('文件上传成功:', res);
try {
const data = JSON.parse(res.data);
resolve({
data: data,
statusCode: res.statusCode
});
} catch (error) {
console.error('解析上传响应失败:', error);
reject(new Error('响应数据格式错误'));
}
},
fail: (error) => {
console.error('文件上传失败:', error);
reject(error);
},
complete: () => {
uni.hideLoading();
}
});
});
}
}

3
uniapp/components/schedule/ScheduleDetail.vue

@ -189,15 +189,12 @@
<view class="attendance-options">
<view class="option-btn sign-in" @click="handleAttendanceAction('sign_in')">
<fui-icon name="check" :size="20" color="#fff"></fui-icon>
<text>签到</text>
</view>
<view class="option-btn leave" @click="handleAttendanceAction('leave')">
<fui-icon name="clock" :size="20" color="#fff"></fui-icon>
<text>请假</text>
</view>
<view class="option-btn cancel" @click="closeAttendanceModal">
<fui-icon name="close" :size="20" color="#fff"></fui-icon>
<text>取消</text>
</view>
</view>

33
uniapp/pages-market/clue/class_arrangement_detail.vue

@ -321,9 +321,17 @@
this.currentSlot = { type, index };
this.resetForm();
//
//
if (this.presetStudent) {
this.selectedStudent = this.presetStudent;
//
if (this.presetStudent.name && this.presetStudent.phone) {
this.selectedStudent = this.presetStudent;
} else {
console.warn('预设学员信息不完整:', this.presetStudent);
//
this.presetStudent = null;
this.selectedStudent = null;
}
}
},
@ -401,6 +409,7 @@
//
async confirmSelection() {
// 1.
if (!this.selectedStudent) {
uni.showToast({
title: '请选择学员',
@ -409,7 +418,25 @@
return;
}
//
// 2.
if (!this.selectedStudent.name || !this.selectedStudent.phone) {
uni.showToast({
title: '学员信息不完整,请重新选择',
icon: 'none'
});
return;
}
// 3.
if (!this.selectedStudent.resource_id && !this.resource_id) {
uni.showToast({
title: '缺少学员资源ID,无法添加课程',
icon: 'none'
});
return;
}
// 4.
if (this.courseArrangement === '2') {
if (!this.selectedStudent.is_formal_student) {
uni.showToast({

18
uniapp/pages.json

@ -50,6 +50,15 @@
"navigationBarBackgroundColor": "#29d3b4",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/common/personnel/add_personnel",
"style": {
"navigationBarTitleText": "新员工信息填写",
"navigationStyle": "custom",
"navigationBarBackgroundColor": "#fff",
"navigationBarTextStyle": "black"
}
}
],
"subPackages": [
@ -566,15 +575,6 @@
"navigationBarTextStyle": "white"
}
},
{
"path": "personnel/add_personnel",
"style": {
"navigationBarTitleText": "新员工信息填写",
"navigationStyle": "custom",
"navigationBarBackgroundColor": "#fff",
"navigationBarTextStyle": "black"
}
},
{
"path": "contract/my_contract",
"style": {

0
uniapp/pages-common/personnel/add_personnel.vue → uniapp/pages/common/add_personnel.vue

178
uniapp/pages/common/personnel/add_personnel.vue

@ -258,6 +258,21 @@
<!-- 第三步确认信息 -->
<view v-if="currentStep === 3" class="step-content">
<view class="section-title">确认信息</view>
<!-- 审批流程提示 -->
<view class="approval-section">
<view class="section-title">审批流程</view>
<view class="approval-info">
<view class="info-item">
<text class="info-label">审批流程</text>
<text class="info-value">{{approvalData.selectedConfig ? approvalData.selectedConfig.config_name : '新入职申请'}}</text>
</view>
<view class="info-note">
<text>提交后将进入审批流程请等待审批完成</text>
</view>
</view>
</view>
<view class="confirm-info">
<view class="info-section">
<view class="info-title">基本信息</view>
@ -351,6 +366,13 @@ export default {
bank_name: '',
remark: ''
},
//
approvalData: {
useApproval: true, //
selectedConfig: null,
selectedIndex: 0
},
approvalConfigs: [],
//
politicsOptions: ['群众', '共青团员', '中共党员', '民主党派', '无党派人士'],
educationOptions: ['高中', '中专', '大专', '本科', '硕士', '博士'],
@ -365,6 +387,9 @@ export default {
const month = String(today.getMonth() + 1).padStart(2, '0')
const day = String(today.getDate()).padStart(2, '0')
this.formData.join_time = `${year}-${month}-${day}`
//
this.loadApprovalConfigs()
},
methods: {
//
@ -386,30 +411,38 @@ export default {
},
//
uploadAvatar(filePath) {
async uploadAvatar(filePath) {
uni.showLoading({ title: '上传中...' })
uni.uploadFile({
url: this.$baseUrl + '/file/avatar',
filePath: filePath,
name: 'file',
success: (res) => {
const data = JSON.parse(res.data)
if (data.code === 1) {
this.formData.head_img = data.data.url
uni.showToast({ title: '头像上传成功', icon: 'success' })
} else {
uni.showToast({ title: data.msg || '上传失败', icon: 'none' })
}
},
fail: (error) => {
console.error('头像上传失败:', error)
uni.showToast({ title: '上传失败', icon: 'none' })
},
complete: () => {
uni.hideLoading()
try {
// 使
const response = await apiRoute.uploadFile({
url: 'uploadImage', // 使
filePath: filePath,
name: 'file'
})
if (response.data.code === 1) {
this.formData.head_img = response.data.data.url
uni.showToast({
title: '头像上传成功',
icon: 'success'
})
} else {
uni.showToast({
title: response.data.msg || '上传失败',
icon: 'none'
})
}
})
} catch (error) {
console.error('头像上传失败:', error)
uni.showToast({
title: '网络错误,请稍后重试',
icon: 'none'
})
} finally {
uni.hideLoading()
}
},
//
@ -498,25 +531,73 @@ export default {
return true
},
//
async loadApprovalConfigs() {
try {
const response = await apiRoute.get('personnel/approval-configs', {
business_type: 'personnel_add'
})
if (response.data.code === 1) {
this.approvalConfigs = response.data.data || []
//
if (this.approvalConfigs.length > 0) {
this.approvalData.selectedConfig = this.approvalConfigs[0]
this.approvalData.selectedIndex = 0
}
}
} catch (error) {
console.error('加载审批配置失败:', error)
}
},
//
onApprovalConfigChange(e) {
const index = e.detail.value
this.approvalData.selectedIndex = index
this.approvalData.selectedConfig = this.approvalConfigs[index]
},
//
getAccountTypeText(type) {
const typeMap = {
'teacher': '教师',
'market': '市场',
'admin': '管理员',
'other': '其他'
}
return typeMap[type] || type
},
//
async submitForm() {
if (!this.validateCurrentStep()) {
return
}
//
if (this.approvalData.useApproval && !this.approvalData.selectedConfig) {
uni.showToast({
title: '审批流程配置加载失败,请重试',
icon: 'none'
})
return
}
this.submitting = true
try {
const submitData = {
...this.formData,
...this.detailData
...this.detailData,
use_approval: this.approvalData.useApproval,
approval_config_id: this.approvalData.selectedConfig ? this.approvalData.selectedConfig.id : 0
}
const response = await apiRoute.post('personnel/add', submitData)
if (response.data.code === 1) {
uni.showToast({
title: '员工信息提交成功',
title: '入职申请已提交,等待审批',
icon: 'success',
duration: 2000
})
@ -803,6 +884,57 @@ export default {
}
}
/* 审批流程部分 */
.approval-section {
background-color: #fff;
margin: 24rpx 32rpx;
padding: 32rpx;
border-radius: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
.section-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
margin-bottom: 24rpx;
border-left: 6rpx solid #007ACC;
padding-left: 16rpx;
}
.approval-info {
.info-item {
display: flex;
align-items: center;
margin-bottom: 16rpx;
.info-label {
font-size: 28rpx;
color: #666;
margin-right: 16rpx;
}
.info-value {
font-size: 28rpx;
color: #007ACC;
font-weight: 500;
}
}
.info-note {
padding: 16rpx;
background-color: #f8f9fa;
border-radius: 8rpx;
border-left: 4rpx solid #007ACC;
text {
font-size: 24rpx;
color: #666;
line-height: 1.5;
}
}
}
}
/* 确认信息 */
.confirm-info {
.info-section {

Loading…
Cancel
Save