This commit is contained in:
马诗敏 2025-08-15 14:49:34 +08:00
commit 6685ba8d3a
12 changed files with 845 additions and 1471 deletions

View File

@ -1,8 +1,8 @@
<template> <template>
<div class="notification-center"> <div class="notification-center">
<!-- 聊天信息图标 --> <!-- 1. 触发按钮 -->
<div class="notification-trigger"> <div class="notification-trigger">
<a-button type="text" class="notification-btn" title="聊天信息"> <a-button type="text" class="notification-btn" title="聊天信息" @click="openChat">
<template #icon> <template #icon>
<IconNotification /> <IconNotification />
</template> </template>
@ -10,664 +10,48 @@
</a-button> </a-button>
</div> </div>
<!-- 消息中心弹窗 --> <!-- 2. 聊天平台弹窗 -->
<a-modal <a-modal v-model:visible="chatVisible" title="聊天平台(注册验证码666666)" width=80% :footer="false" :mask-closable="true"
v-model:visible="modalVisible" :destroy-on-close="false" @close="chatVisible = false">
title="消息中心" <!-- 3. 嵌入 React 聊天平台 -->
width="800px" <iframe ref="chatFrame" src="http://pms.dtyx.net:11001/" frameborder="0"
:footer="false" style="width: 100%; height: 600px; border: none;" allow="camera; microphone"></iframe>
:mask-closable="true"
:closable="true"
:destroy-on-close="false"
:z-index="1000"
class="notification-modal"
>
<!-- 消息中心头部 -->
<div class="notification-header">
<div class="header-left">
<h3>消息中心</h3>
<span class="notification-count">{{ unreadCount }} 条未读</span>
</div>
<div class="header-right">
<a-button type="text" size="small" @click="markAllAsRead">
全部已读
</a-button>
<a-button type="text" size="small" @click="clearRead">
清空已读
</a-button>
</div>
</div>
<!-- 消息类型标签 -->
<div class="notification-tabs">
<a-tabs v-model:active-key="activeTab" size="small">
<a-tab-pane key="all" title="全部">
<template #title>
<span>全部 ({{ totalCount }})</span>
</template>
</a-tab-pane>
<a-tab-pane key="pending" title="待审批">
<template #title>
<span>待审批 ({{ pendingCount }})</span>
</template>
</a-tab-pane>
<a-tab-pane key="equipment" title="设备">
<template #title>
<span>设备 ({{ equipmentCount }})</span>
</template>
</a-tab-pane>
<a-tab-pane key="urgent" title="紧急">
<template #title>
<span>紧急 ({{ urgentCount }})</span>
</template>
</a-tab-pane>
</a-tabs>
</div>
<!-- 消息列表 -->
<div class="notification-list">
<div v-if="filteredNotifications.length === 0" class="empty-state">
<IconInfo style="font-size: 48px; color: #d9d9d9; margin-bottom: 16px;" />
<p>暂无消息</p>
</div>
<div
v-for="notification in filteredNotifications"
:key="notification.id"
class="notification-item"
:class="{
'unread': !notification.read,
'urgent': notification.priority === 'URGENT',
'high': notification.priority === 'HIGH'
}"
@click="handleNotificationClick(notification)"
>
<!-- 消息图标 -->
<div class="notification-icon">
<component :is="getNotificationIcon(notification.type)" />
</div>
<!-- 消息内容 -->
<div class="notification-content">
<div class="notification-title">
{{ notification.title }}
<a-tag
v-if="notification.actionRequired"
size="small"
color="red"
>
需操作
</a-tag>
<a-tag
v-if="notification.reminderType"
size="small"
color="blue"
>
{{ getReminderTypeText(notification.reminderType) }}
</a-tag>
</div>
<div class="notification-message">{{ notification.content }}</div>
<div class="notification-meta">
<span class="notification-time">{{ formatTime(notification.createTime) }}</span>
<span class="notification-category">{{ notification.category }}</span>
<span v-if="notification.source" class="notification-source">{{ notification.source }}</span>
</div>
</div>
<!-- 消息操作 -->
<div class="notification-actions">
<a-button
type="text"
size="small"
@click.stop="toggleReminder(notification)"
:title="notification.reminderTime ? '取消提醒' : '设置提醒'"
>
<IconClockCircle v-if="!notification.reminderTime" />
<IconClose v-else />
</a-button>
<a-button
type="text"
size="small"
@click.stop="removeNotification(notification.id)"
title="删除消息"
>
<IconDelete />
</a-button>
</div>
</div>
</div>
<!-- 消息底部 -->
<div class="notification-footer">
<a-button type="text" size="small" @click="viewAllNotifications">
查看全部消息
</a-button>
<a-button type="text" size="small" @click="exportNotifications">
导出消息
</a-button>
</div>
</a-modal>
<!-- 提醒设置弹窗 -->
<a-modal
v-model:visible="reminderModalVisible"
title="设置消息提醒"
width="400px"
@ok="saveReminder"
@cancel="cancelReminder"
>
<a-form :model="reminderForm" layout="vertical">
<a-form-item label="提醒类型">
<a-radio-group v-model="reminderForm.type">
<a-radio value="IMMEDIATE">立即提醒</a-radio>
<a-radio value="DELAYED">延迟提醒</a-radio>
<a-radio value="RECURRING">重复提醒</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item v-if="reminderForm.type === 'DELAYED'" label="提醒时间">
<a-date-picker
v-model="reminderForm.time"
show-time
placeholder="选择提醒时间"
style="width: 100%"
/>
</a-form-item>
<a-form-item v-if="reminderForm.type === 'RECURRING'" label="重复间隔">
<a-input-number
v-model="reminderForm.interval"
:min="1"
:max="1440"
placeholder="间隔分钟"
style="width: 100%"
/>
</a-form-item>
</a-form>
</a-modal> </a-modal>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { ref, nextTick } from 'vue'
import { useRouter } from 'vue-router' import { IconNotification } from '@arco-design/web-vue/es/icon'
import {
IconNotification,
IconInfo,
IconClockCircle,
IconClose,
IconDelete,
IconCheckCircle,
IconClockCircle as IconPending,
IconApps,
IconExclamationCircle,
IconExclamationCircle as IconWarning,
IconSettings
} from '@arco-design/web-vue/es/icon'
import message from '@arco-design/web-vue/es/message'
import notificationService from '@/services/notificationService'
import websocketService from '@/services/websocketService'
defineOptions({ name: 'NotificationCenter' }) const chatVisible = ref(false)
const chatFrame = ref<HTMLIFrameElement>()
const url = ref(import.meta.env.VITE_API_BASE_URL + ":11001")
/* 打开聊天窗口 */
function openChat() {
chatVisible.value = true
const router = useRouter() // React iframe postMessage
// nextTick(() => {
// // // iframe
const modalVisible = ref(false) // if (chatFrame.value) {
const activeTab = ref('all') // chatFrame.value.onload = () => {
const reminderModalVisible = ref(false) // // React
const currentNotification = ref<any>(null) // chatFrame.value?.contentWindow?.postMessage(
// {
// // type: 'INIT_IM',
const reminderForm = ref({ // payload: {
type: 'IMMEDIATE' as 'IMMEDIATE' | 'DELAYED' | 'RECURRING', // userID: 'your-user-id', // ID
time: null as Date | null, // token: 'your-token', // token
interval: 30 // },
}) // },
// '*' // React origin
// // )
const notifications = computed(() => notificationService.getAllNotifications()) // }
const unreadCount = computed(() => { // }
const count = notificationService.unreadCount.value // })
// NaN
if (typeof count === 'number' && !isNaN(count) && isFinite(count)) {
return count
} }
return 0
})
const totalCount = computed(() => notifications.value.length)
const pendingCount = computed(() => {
const count = notificationService.pendingCount.value
if (typeof count === 'number' && !isNaN(count) && isFinite(count)) {
return count
}
return 0
})
const equipmentCount = computed(() => {
const borrowCount = notificationService.equipmentBorrowCount.value || 0
const returnCount = notificationService.equipmentReturnCount.value || 0
const maintenanceCount = notificationService.equipmentMaintenanceCount.value || 0
const alertCount = notificationService.equipmentAlertCount.value || 0
return borrowCount + returnCount + maintenanceCount + alertCount
})
const urgentCount = computed(() => {
const count = notificationService.urgentCount.value
if (typeof count === 'number' && !isNaN(count) && isFinite(count)) {
return count
}
return 0
})
const hasUrgentNotifications = computed(() => urgentCount.value > 0)
//
const filteredNotifications = computed(() => {
let filtered = notifications.value
switch (activeTab.value) {
case 'pending':
filtered = filtered.filter(n => n.type === 'PENDING')
break
case 'equipment':
filtered = filtered.filter(n =>
['EQUIPMENT_BORROW', 'EQUIPMENT_RETURN', 'EQUIPMENT_MAINTENANCE', 'EQUIPMENT_ALERT'].includes(n.type)
)
break
case 'urgent':
filtered = filtered.filter(n => n.priority === 'URGENT' || n.priority === 'HIGH')
break
}
//
return filtered.sort((a, b) => {
const priorityOrder = { 'URGENT': 4, 'HIGH': 3, 'NORMAL': 2, 'LOW': 1 }
const aPriority = priorityOrder[a.priority || 'NORMAL'] || 2
const bPriority = priorityOrder[b.priority || 'NORMAL'] || 2
if (aPriority !== bPriority) {
return bPriority - aPriority
}
return new Date(b.createTime).getTime() - new Date(a.createTime).getTime()
}).slice(0, 20) // 20
})
//
const toggleDropdown = () => {
console.log('打开消息中心弹窗')
modalVisible.value = true
}
const markAllAsRead = () => {
notificationService.markAllAsRead()
message.success('已标记所有消息为已读')
}
const clearRead = () => {
notificationService.clearRead()
message.success('已清空已读消息')
}
const handleNotificationClick = (notification: any) => {
console.log('点击消息:', notification)
//
notificationService.markAsRead(notification.id)
//
let targetUrl = notification.targetUrl
// targetUrl
if (!targetUrl) {
targetUrl = buildTargetUrl(notification)
}
console.log('构建的目标URL:', targetUrl)
// URL
if (targetUrl) {
try {
router.push(targetUrl)
modalVisible.value = false
message.success('正在跳转到相关页面...')
} catch (error) {
console.error('路由跳转失败:', error)
message.error('页面跳转失败,请手动导航')
}
} else {
console.warn('无法构建跳转路径,消息数据:', notification)
message.warning('该消息暂无相关操作页面')
}
}
//
const buildTargetUrl = (notification: any): string | null => {
const { type, relatedId, metadata, category } = notification
console.log('构建跳转路径,消息类型:', type, '相关ID:', relatedId, '元数据:', metadata)
switch (type) {
case 'PROCUREMENT':
case 'PENDING':
// -
return '/asset-management/device-management/approval'
case 'APPROVAL':
// -
return '/asset-management/device-management/approval'
case 'EQUIPMENT_BORROW':
// -
return '/asset-management/device-management/device-center'
case 'EQUIPMENT_RETURN':
// -
return '/asset-management/device-management/device-center'
case 'EQUIPMENT_MAINTENANCE':
// -
return '/asset-management/device-management/device-center'
case 'EQUIPMENT_ALERT':
// -
return '/asset-management/device-management/device-center'
case 'WORKFLOW':
// -
if (metadata?.workflowType === 'PROJECT') {
return '/project-management/project-template/project-aproval'
}
return '/asset-management/device-management/approval'
case 'SYSTEM':
// -
return null
default:
//
return '/asset-management/device-management/approval'
}
}
const getNotificationIcon = (type: string) => {
const iconMap: Record<string, any> = {
'APPROVAL': IconCheckCircle,
'PENDING': IconPending,
'PROCUREMENT': IconApps,
'EQUIPMENT_BORROW': IconApps,
'EQUIPMENT_RETURN': IconApps,
'EQUIPMENT_MAINTENANCE': IconSettings,
'EQUIPMENT_ALERT': IconWarning,
'WORKFLOW': IconSettings,
'SYSTEM': IconExclamationCircle
}
return iconMap[type] || IconNotification
}
const getReminderTypeText = (type: string) => {
const typeMap: Record<string, string> = {
'IMMEDIATE': '立即',
'DELAYED': '延迟',
'RECURRING': '重复'
}
return typeMap[type] || type
}
const formatTime = (time: string) => {
const date = new Date(time)
const now = new Date()
const diff = now.getTime() - date.getTime()
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
if (diff < 2592000000) return `${Math.floor(diff / 86400000)}天前`
return date.toLocaleDateString()
}
const toggleReminder = (notification: any) => {
if (notification.reminderTime) {
//
notificationService.cancelNotificationReminder(notification.id)
message.success('已取消提醒')
} else {
//
currentNotification.value = notification
reminderModalVisible.value = true
}
}
const saveReminder = () => {
if (currentNotification.value) {
const reminderTime = reminderForm.value.type === 'DELAYED' && reminderForm.value.time
? reminderForm.value.time.toISOString()
: new Date().toISOString()
notificationService.setNotificationReminder(
currentNotification.value.id,
reminderTime,
reminderForm.value.type as any,
reminderForm.value.type === 'RECURRING' ? reminderForm.value.interval : undefined
)
message.success('提醒设置成功')
reminderModalVisible.value = false
resetReminderForm()
}
}
const cancelReminder = () => {
reminderModalVisible.value = false
resetReminderForm()
}
const resetReminderForm = () => {
reminderForm.value = {
type: 'IMMEDIATE',
time: null,
interval: 30
}
currentNotification.value = null
}
const removeNotification = (id: string) => {
notificationService.removeNotification(id)
message.success('消息已删除')
}
const viewAllNotifications = () => {
router.push('/notifications')
modalVisible.value = false
}
const exportNotifications = () => {
notificationService.exportNotifications()
message.success('消息导出成功')
}
// WebSocket
const setupWebSocketListeners = () => {
console.log('设置WebSocket监听器')
//
websocketService.on('message', (message) => {
console.log('收到WebSocket消息:', message)
//
if (message.data && message.data.notification) {
console.log('处理通知消息:', message.data.notification)
notificationService.addNotification({
type: message.data.notification.type || 'SYSTEM',
title: message.data.notification.title || '新通知',
content: message.data.notification.content || '',
priority: message.data.notification.priority || 'NORMAL',
category: message.data.notification.category || '系统',
targetUrl: message.data.notification.targetUrl,
metadata: message.data.notification.metadata,
source: message.data.notification.source || 'WEBSOCKET'
})
}
})
//
websocketService.on('approvalStatusChanged', (data) => {
console.log('审批状态变更:', data)
//
if (data.type === 'SUBMITTED') {
notificationService.addNotification({
type: 'PENDING',
title: '新的审批申请',
content: `收到来自 ${data.applicantName || '申请人'}${data.businessType || '设备'}申请:${data.equipmentName || '未知设备'}`,
targetUrl: '/asset-management/device-management/approval',
priority: 'HIGH',
category: '审批申请',
actionRequired: true,
source: 'APPROVAL_SYSTEM',
metadata: {
approvalId: data.approvalId,
equipmentName: data.equipmentName,
applicantName: data.applicantName,
businessType: data.businessType,
timestamp: Date.now()
}
})
}
})
//
websocketService.on('equipmentStatusChanged', (data) => {
console.log('设备状态变更:', data)
//
notificationService.addNotification({
type: 'EQUIPMENT_ALERT',
title: '设备状态更新',
content: `设备"${data.equipmentName || '未知设备'}"状态已更新为:${data.newStatus}`,
targetUrl: '/asset-management/device-management/device-center',
priority: 'NORMAL',
category: '设备状态',
source: 'EQUIPMENT_SYSTEM',
metadata: {
equipmentId: data.equipmentId,
equipmentName: data.equipmentName,
oldStatus: data.oldStatus,
newStatus: data.newStatus,
timestamp: Date.now()
}
})
})
//
websocketService.on('procurementStatusChanged', (data) => {
console.log('采购状态变更:', data)
if (data.type === 'SUBMITTED') {
notificationService.addNotification({
type: 'PROCUREMENT',
title: '新的采购申请',
content: `收到来自 ${data.applicantName || '申请人'} 的设备采购申请:${data.equipmentName || '未知设备'}`,
targetUrl: '/asset-management/device-management/approval',
priority: 'HIGH',
category: '设备采购',
actionRequired: true,
source: 'PROCUREMENT_SYSTEM',
metadata: {
procurementId: data.procurementId,
equipmentName: data.equipmentName,
applicantName: data.applicantName,
timestamp: Date.now()
}
})
}
})
//
websocketService.on('newApprovalRequest', (data) => {
console.log('新审批申请:', data)
notificationService.addNotification({
type: 'PENDING',
title: '新的审批申请',
content: `收到来自 ${data.applicantName || '申请人'}${data.businessType || '设备'}申请:${data.equipmentName || '未知设备'}`,
targetUrl: '/asset-management/device-management/approval',
priority: 'HIGH',
category: '审批申请',
actionRequired: true,
source: 'APPROVAL_SYSTEM',
metadata: {
approvalId: data.approvalId,
equipmentName: data.equipmentName,
applicantName: data.applicantName,
businessType: data.businessType,
timestamp: Date.now()
}
})
})
// WebSocket
websocketService.on('connected', () => {
console.log('WebSocket已连接')
})
websocketService.on('disconnected', (data) => {
console.log('WebSocket已断开:', data)
})
websocketService.on('error', (error) => {
console.error('WebSocket错误:', error)
})
}
// WebSocket
const cleanupWebSocketListeners = () => {
console.log('清理WebSocket监听器')
//
websocketService.off('message', () => {})
websocketService.off('approvalStatusChanged', () => {})
websocketService.off('equipmentStatusChanged', () => {})
websocketService.off('procurementStatusChanged', () => {})
websocketService.off('newApprovalRequest', () => {})
websocketService.off('connected', () => {})
websocketService.off('disconnected', () => {})
websocketService.off('error', () => {})
}
//
let reminderCheckInterval: NodeJS.Timeout | null = null
const startReminderCheck = () => {
reminderCheckInterval = setInterval(() => {
const reminderNotifications = notificationService.getReminderNotifications()
if (reminderNotifications.length > 0) {
//
reminderNotifications.forEach(notification => {
message.info(`${notification.title}: ${notification.content}`)
notificationService.markReminderProcessed(notification.id)
})
}
}, 60000) //
}
//
onMounted(() => {
setupWebSocketListeners()
startReminderCheck()
})
onUnmounted(() => {
cleanupWebSocketListeners()
if (reminderCheckInterval) {
clearInterval(reminderCheckInterval)
}
})
//
defineExpose({
toggleDropdown
})
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@ -18,6 +18,10 @@
<a-descriptions-item label="合同金额"> <a-descriptions-item label="合同金额">
<span class="font-medium text-green-600">{{ (contractDetail.amount || 0).toLocaleString() }}</span> <span class="font-medium text-green-600">{{ (contractDetail.amount || 0).toLocaleString() }}</span>
</a-descriptions-item> </a-descriptions-item>
<a-descriptions-item label="合同类别">
{{ contractDetail.category || '-' }}
</a-descriptions-item>
<a-descriptions-item label="已收款金额"> <a-descriptions-item label="已收款金额">
<span class="font-medium text-blue-600">{{ (contractDetail.receivedAmount || 0).toLocaleString() }}</span> <span class="font-medium text-blue-600">{{ (contractDetail.receivedAmount || 0).toLocaleString() }}</span>
</a-descriptions-item> </a-descriptions-item>
@ -74,6 +78,7 @@ interface ContractDetail {
code: string code: string
projectId: string projectId: string
type: string type: string
category?: string
productService: string productService: string
paymentDate: string | null paymentDate: string | null
performanceDeadline: string | null performanceDeadline: string | null

View File

@ -46,6 +46,21 @@
</a-col> </a-col>
</a-row> </a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="category" label="合同类别">
<a-select v-model="contractData.category" placeholder="请选择合同类别" allow-clear>
<a-option value="框架协议">框架协议</a-option>
<a-option value="单次合同">单次合同</a-option>
<a-option value="三年长协">三年长协</a-option>
<a-option value="两年长协">两年长协</a-option>
<a-option value="派工单">派工单</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16"> <a-row :gutter="16">
<a-col :span="12"> <a-col :span="12">
<a-form-item field="signDate" label="签订日期"> <a-form-item field="signDate" label="签订日期">

View File

@ -135,6 +135,7 @@ interface ContractItem {
code: string code: string
projectId: string projectId: string
type: string type: string
category?: string
productService: string productService: string
paymentDate: string | null paymentDate: string | null
performanceDeadline: string | null performanceDeadline: string | null
@ -165,6 +166,7 @@ const searchForm = reactive({
contractCode: '', contractCode: '',
client: '', client: '',
status: '', status: '',
category: '',
signDateRange: [] as [string, string] | [], signDateRange: [] as [string, string] | [],
page: 1, page: 1,
size: 10, size: 10,
@ -196,6 +198,21 @@ const queryFormColumns = [
], ],
}, },
}, },
{
field: 'category',
label: '合同类别',
type: 'select' as const,
props: {
placeholder: '请选择合同类别',
options: [
{ label: '框架协议', value: '框架协议' },
{ label: '单次合同', value: '单次合同' },
{ label: '三年长协', value: '三年长协' },
{ label: '两年长协', value: '两年长协' },
{ label: '派工单', value: '派工单' }
]
}
},
{ {
field: 'signDateRange', field: 'signDateRange',
label: '签署时间', label: '签署时间',
@ -218,6 +235,7 @@ const tableColumns: TableColumnData[] = [
{ title: '签署日期', dataIndex: 'signDate', slotName: 'signDate', width: 120 }, { title: '签署日期', dataIndex: 'signDate', slotName: 'signDate', width: 120 },
{ title: '履约期限', dataIndex: 'performanceDeadline', slotName: 'performanceDeadline', width: 120 }, { title: '履约期限', dataIndex: 'performanceDeadline', slotName: 'performanceDeadline', width: 120 },
{ title: '付款日期', dataIndex: 'paymentDate', slotName: 'paymentDate', width: 120 }, { title: '付款日期', dataIndex: 'paymentDate', slotName: 'paymentDate', width: 120 },
{ title: '合同类别', dataIndex: 'category', width: 120 },
{ title: '合同状态', dataIndex: 'contractStatus', slotName: 'status', width: 100 }, { title: '合同状态', dataIndex: 'contractStatus', slotName: 'status', width: 100 },
{ title: '销售人员', dataIndex: 'salespersonName', width: 100 }, { title: '销售人员', dataIndex: 'salespersonName', width: 100 },
{ title: '销售部门', dataIndex: 'salespersonDeptName', width: 100 }, { title: '销售部门', dataIndex: 'salespersonDeptName', width: 100 },
@ -239,6 +257,7 @@ const fetchContractList = async () => {
code: searchForm.contractCode, code: searchForm.contractCode,
customer: searchForm.client, customer: searchForm.client,
contractStatus: searchForm.status, contractStatus: searchForm.status,
category: (searchForm as any).category || undefined,
signDateStart: Array.isArray(searchForm.signDateRange) && searchForm.signDateRange.length === 2 ? searchForm.signDateRange[0] : undefined, signDateStart: Array.isArray(searchForm.signDateRange) && searchForm.signDateRange.length === 2 ? searchForm.signDateRange[0] : undefined,
signDateEnd: Array.isArray(searchForm.signDateRange) && searchForm.signDateRange.length === 2 ? searchForm.signDateRange[1] : undefined, signDateEnd: Array.isArray(searchForm.signDateRange) && searchForm.signDateRange.length === 2 ? searchForm.signDateRange[1] : undefined,
} }
@ -325,6 +344,8 @@ const reset = () => {
status: '', status: '',
signDateRange: [], signDateRange: [],
page: 1, page: 1,
category: '',
size: 10, size: 10,
}) })
pagination.current = 1 pagination.current = 1
@ -354,6 +375,7 @@ const newContractData = ref<ContractItem>({
code: '', code: '',
projectId: '', projectId: '',
type: '支出合同', type: '支出合同',
category: '',
productService: '', productService: '',
paymentDate: null, paymentDate: null,
performanceDeadline: null, performanceDeadline: null,
@ -387,6 +409,7 @@ const openAddModal = () => {
code: '', code: '',
projectId: '', projectId: '',
type: '支出合同', type: '支出合同',
category: '',
productService: '', productService: '',
paymentDate: null, paymentDate: null,
performanceDeadline: null, performanceDeadline: null,
@ -444,6 +467,7 @@ const handleAddSubmit = async () => {
salespersonId: (newContractData.value as any).salespersonId || '', salespersonId: (newContractData.value as any).salespersonId || '',
signDate: newContractData.value.signDate || null, signDate: newContractData.value.signDate || null,
type: newContractData.value.type || '支出合同', type: newContractData.value.type || '支出合同',
category: newContractData.value.category || '',
} }
// ID // ID
@ -535,6 +559,7 @@ const handleEditSubmit = async () => {
productService: editedContractData.value.productService || '', productService: editedContractData.value.productService || '',
projectId: editedContractData.value.projectId || '', projectId: editedContractData.value.projectId || '',
salespersonId: editedContractData.value.salespersonId || '', salespersonId: editedContractData.value.salespersonId || '',
category: editedContractData.value.category || '',
signDate: editedContractData.value.signDate || null, signDate: editedContractData.value.signDate || null,
type: editedContractData.value.type || '', type: editedContractData.value.type || '',
}; };

View File

@ -18,6 +18,10 @@
<a-descriptions-item label="合同金额"> <a-descriptions-item label="合同金额">
<span class="font-medium text-green-600">{{ (contractDetail.amount || 0).toLocaleString() }}</span> <span class="font-medium text-green-600">{{ (contractDetail.amount || 0).toLocaleString() }}</span>
</a-descriptions-item> </a-descriptions-item>
<a-descriptions-item label="合同类别">
{{ contractDetail.category || '-' }}
</a-descriptions-item>
<a-descriptions-item label="已收款金额"> <a-descriptions-item label="已收款金额">
<span class="font-medium text-blue-600">{{ (contractDetail.receivedAmount || 0).toLocaleString() }}</span> <span class="font-medium text-blue-600">{{ (contractDetail.receivedAmount || 0).toLocaleString() }}</span>
</a-descriptions-item> </a-descriptions-item>
@ -74,6 +78,7 @@ interface ContractDetail {
code: string code: string
projectId: string projectId: string
type: string type: string
category?: string
productService: string productService: string
paymentDate: string | null paymentDate: string | null
performanceDeadline: string | null performanceDeadline: string | null

View File

@ -46,6 +46,20 @@
</a-col> </a-col>
</a-row> </a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="category" label="合同类别">
<a-select v-model="contractData.category" placeholder="请选择合同类别" allow-clear>
<a-option value="框架协议">框架协议</a-option>
<a-option value="单次合同">单次合同</a-option>
<a-option value="三年长协">三年长协</a-option>
<a-option value="两年长协">两年长协</a-option>
<a-option value="派工单">派工单</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16"> <a-row :gutter="16">
<a-col :span="12"> <a-col :span="12">
<a-form-item field="signDate" label="签订日期"> <a-form-item field="signDate" label="签订日期">

View File

@ -136,6 +136,7 @@ interface ContractItem {
code: string code: string
projectId: string projectId: string
type: string type: string
category?: string
productService: string productService: string
paymentDate: string | null paymentDate: string | null
performanceDeadline: string | null performanceDeadline: string | null
@ -166,6 +167,7 @@ const searchForm = reactive({
contractCode: '', contractCode: '',
client: '', client: '',
status: '', status: '',
category: '',
signDateRange: [] as [string, string] | [], signDateRange: [] as [string, string] | [],
page: 1, page: 1,
size: 10, size: 10,
@ -186,6 +188,17 @@ const queryFormColumns = [
], ],
}, },
}, },
{ field: 'category', label: '合同类别', type: 'select' as const, props: {
placeholder: '请选择合同类别',
options: [
{ label: '框架协议', value: '框架协议' },
{ label: '单次合同', value: '单次合同' },
{ label: '三年长协', value: '三年长协' },
{ label: '两年长协', value: '两年长协' },
{ label: '派工单', value: '派工单' },
],
}
},
{ field: 'signDateRange', label: '签署时间', type: 'range-picker' as const, props: { { field: 'signDateRange', label: '签署时间', type: 'range-picker' as const, props: {
placeholder: ['开始日期', '结束日期'], format: 'YYYY-MM-DD', placeholder: ['开始日期', '结束日期'], format: 'YYYY-MM-DD',
} }
@ -198,6 +211,7 @@ const tableColumns: TableColumnData[] = [
{ title: '项目名称', dataIndex: 'projectName', width: 250, ellipsis: true, tooltip: true }, { title: '项目名称', dataIndex: 'projectName', width: 250, ellipsis: true, tooltip: true },
{ title: '客户名称', dataIndex: 'customer', width: 200, ellipsis: true, tooltip: true }, { title: '客户名称', dataIndex: 'customer', width: 200, ellipsis: true, tooltip: true },
{ title: '合同金额', dataIndex: 'amount', slotName: 'contractAmount', width: 120 }, { title: '合同金额', dataIndex: 'amount', slotName: 'contractAmount', width: 120 },
{ title: '合同类别', dataIndex: 'category', width: 120 },
{ title: '已收款金额', dataIndex: 'receivedAmount', slotName: 'receivedAmount', width: 120 }, { title: '已收款金额', dataIndex: 'receivedAmount', slotName: 'receivedAmount', width: 120 },
{ title: '未收款金额', dataIndex: 'pendingAmount', width: 120 }, { title: '未收款金额', dataIndex: 'pendingAmount', width: 120 },
{ title: '签署日期', dataIndex: 'signDate', slotName: 'signDate', width: 120 }, { title: '签署日期', dataIndex: 'signDate', slotName: 'signDate', width: 120 },
@ -312,6 +326,9 @@ const fetchContractList = async () => {
pageSize: searchForm.size, pageSize: searchForm.size,
code: searchForm.contractCode, code: searchForm.contractCode,
customer: searchForm.client, customer: searchForm.client,
category: (searchForm as any).category || undefined,
contractStatus: searchForm.status, contractStatus: searchForm.status,
signDateStart: Array.isArray(searchForm.signDateRange) && searchForm.signDateRange.length === 2 ? searchForm.signDateRange[0] : undefined, signDateStart: Array.isArray(searchForm.signDateRange) && searchForm.signDateRange.length === 2 ? searchForm.signDateRange[0] : undefined,
signDateEnd: Array.isArray(searchForm.signDateRange) && searchForm.signDateRange.length === 2 ? searchForm.signDateRange[1] : undefined, signDateEnd: Array.isArray(searchForm.signDateRange) && searchForm.signDateRange.length === 2 ? searchForm.signDateRange[1] : undefined,
@ -393,6 +410,7 @@ const reset = () => {
contractCode: '', contractCode: '',
client: '', client: '',
status: '', status: '',
category: '',
signDateRange: [], signDateRange: [],
page: 1, page: 1,
size: 10, size: 10,
@ -421,6 +439,7 @@ const showAddModal = ref(false)
const newContractData = ref<ContractItem>({ const newContractData = ref<ContractItem>({
contractId: '', customer: '', code: '', projectId: '', type: '收入合同', contractId: '', customer: '', code: '', projectId: '', type: '收入合同',
productService: '', paymentDate: null, performanceDeadline: null, productService: '', paymentDate: null, performanceDeadline: null,
category: '',
paymentAddress: '', amount: 0, accountNumber: '', notes: '', paymentAddress: '', amount: 0, accountNumber: '', notes: '',
contractStatus: '未执行', contractText: '', projectName: '', contractStatus: '未执行', contractText: '', projectName: '',
salespersonName: null, salespersonDeptName: '', settlementAmount: null, salespersonName: null, salespersonDeptName: '', settlementAmount: null,
@ -432,6 +451,7 @@ const openAddModal = () => {
Object.assign(newContractData.value, { Object.assign(newContractData.value, {
contractId: '', customer: '', code: '', projectId: '', type: '收入合同', contractId: '', customer: '', code: '', projectId: '', type: '收入合同',
productService: '', paymentDate: null, performanceDeadline: null, productService: '', paymentDate: null, performanceDeadline: null,
category: '',
paymentAddress: '', amount: 0, accountNumber: '', notes: '', paymentAddress: '', amount: 0, accountNumber: '', notes: '',
contractStatus: '未执行', contractText: '', projectName: '', contractStatus: '未执行', contractText: '', projectName: '',
salespersonName: null, salespersonDeptName: '', settlementAmount: null, salespersonName: null, salespersonDeptName: '', settlementAmount: null,
@ -464,6 +484,7 @@ const handleAddSubmit = async () => {
// projectId // projectId
projectName: newContractData.value.projectName || '', projectName: newContractData.value.projectName || '',
salespersonId: (newContractData.value as any).salespersonId || '', salespersonId: (newContractData.value as any).salespersonId || '',
category: newContractData.value.category || '',
signDate: newContractData.value.signDate || null, signDate: newContractData.value.signDate || null,
type: newContractData.value.type || '收入合同', type: newContractData.value.type || '收入合同',
} }
@ -534,6 +555,7 @@ const handleEditSubmit = async () => {
productService: editedContractData.value.productService || '', productService: editedContractData.value.productService || '',
projectId: editedContractData.value.projectId || '', projectId: editedContractData.value.projectId || '',
salespersonId: editedContractData.value.salespersonId || '', salespersonId: editedContractData.value.salespersonId || '',
category: editedContractData.value.category || '',
signDate: editedContractData.value.signDate || null, signDate: editedContractData.value.signDate || null,
type: editedContractData.value.type || '', type: editedContractData.value.type || '',
}; };

View File

@ -0,0 +1,280 @@
<template>
<!-- 基本信息 -->
<a-divider orientation="left">基本信息</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="projectName" label="项目名称" required>
<a-input v-model="model.projectName" placeholder="请输入项目名称" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="farmAddress" label="地址">
<a-input v-model="model.farmAddress" placeholder="请输入地址" />
</a-form-item>
</a-col>
<a-col>
<a-button size="mini" @click="onMapSelect">
<template #icon><icon-location /></template>
地图选点
</a-button>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="projectManagerId" label="项目经理" required>
<a-select v-model="model.projectManagerId" placeholder="请选择项目经理" :loading="userLoading">
<a-option v-for="user in userOptions" :key="user.value" :value="user.value">{{ user.label }}</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="inspectionUnit" label="业主">
<a-input v-model="model.inspectionUnit" placeholder="请输入业主单位" @input="(val:any) => (model.farmName = val)" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="inspectionContact" label="业主单位联系人">
<a-input v-model="model.inspectionContact" placeholder="请输入联系人" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="inspectionPhone" label="业主单位联系电话">
<a-input v-model="model.inspectionPhone" placeholder="请输入联系电话" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="client" label="委托单位">
<a-input v-model="model.client" placeholder="请输入委托单位" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="clientContact" label="委托单位联系人">
<a-input v-model="model.clientContact" placeholder="请输入联系人" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="clientPhone" label="委托单位联系电话">
<a-input v-model="model.clientPhone" placeholder="请输入联系电话" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="projectOrigin" label="项目来源" :rules="[{ required: true, message: '请输入项目来源' }]">
<a-input v-model="model.projectOrigin" placeholder="请输入项目来源" />
</a-form-item>
</a-col>
</a-row>
<a-divider orientation="left">任务设置</a-divider>
<div class="mb-2">
<a-button type="dashed" size="small" @click="addTask">
<template #icon><icon-plus /></template>
新增任务
</a-button>
</div>
<div v-if="!model.tasks || model.tasks.length === 0" class="text-gray-500 mb-2">暂无任务</div>
<a-space direction="vertical" fill>
<a-card v-for="(task, tIndex) in model.tasks" :key="tIndex" size="small">
<template #title>
<div class="flex items-center justify-between">
<span>任务 {{ tIndex + 1 }}</span>
<a-space>
<a-button size="mini" @click="addSubtask(tIndex)">新增子任务</a-button>
<a-button size="mini" status="danger" @click="removeTask(tIndex)">删除</a-button>
</a-space>
</div>
</template>
<a-row :gutter="12">
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.taskName`" label="任务名称" required>
<a-input v-model="task.taskName" placeholder="请输入任务名称" />
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item :field="`tasks.${tIndex}.taskCode`" label="任务编号">
<a-input v-model="task.taskCode" placeholder="编号" />
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item :field="`tasks.${tIndex}.mainUserId`" label="负责人">
<a-select v-model="task.mainUserId" placeholder="选择负责人" :loading="userLoading">
<a-option v-for="u in userOptions" :key="u.value" :value="u.value">{{ u.label }}</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="4">
<a-form-item :field="`tasks.${tIndex}.scales`" label="工量">
<a-input-number v-model="task.scales" :min="0" :max="9999" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="12">
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.planStartDate`" label="计划开始">
<a-date-picker v-model="task.planStartDate" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.planEndDate`" label="计划结束">
<a-date-picker v-model="task.planEndDate" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.taskGroupId`" label="任务组">
<a-input-number v-model="task.taskGroupId" :min="0" placeholder="可选" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<div v-if="task.children && task.children.length">
<a-divider orientation="left">子任务</a-divider>
<a-card v-for="(sub, sIndex) in task.children" :key="sIndex" size="small" class="mb-2">
<template #title>
<div class="flex items-center justify-between">
<span>子任务 {{ tIndex + 1 }}-{{ sIndex + 1 }}</span>
<a-button size="mini" status="danger" @click="removeSubtask(tIndex, sIndex)">删除</a-button>
</div>
</template>
<a-row :gutter="12">
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.children.${sIndex}.taskName`" label="任务名称" required>
<a-input v-model="sub.taskName" placeholder="请输入任务名称" />
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item :field="`tasks.${tIndex}.children.${sIndex}.taskCode`" label="任务编号">
<a-input v-model="sub.taskCode" placeholder="编号" />
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item :field="`tasks.${tIndex}.children.${sIndex}.mainUserId`" label="负责人">
<a-select v-model="sub.mainUserId" placeholder="选择负责人" :loading="userLoading">
<a-option v-for="u in userOptions" :key="u.value" :value="u.value">{{ u.label }}</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="4">
<a-form-item :field="`tasks.${tIndex}.children.${sIndex}.scales`" label="工量">
<a-input-number v-model="sub.scales" :min="0" :max="9999" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="12">
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.children.${sIndex}.planStartDate`" label="计划开始">
<a-date-picker v-model="sub.planStartDate" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.children.${sIndex}.planEndDate`" label="计划结束">
<a-date-picker v-model="sub.planEndDate" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.children.${sIndex}.taskGroupId`" label="任务组">
<a-input-number v-model="sub.taskGroupId" :min="0" placeholder="可选" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
</a-card>
</div>
</a-card>
</a-space>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="status" label="项目状态">
<a-select v-model="model.status" placeholder="请选择状态">
<a-option v-for="option in statusOptions" :key="option.value" :value="option.value">{{ option.label }}</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="scale" label="项目规模">
<a-input-number v-model="model.scale" placeholder="请输入项目规模" :min="0" :max="999" :step="1" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="startDate" label="开始时间">
<a-date-picker v-model="model.startDate" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="endDate" label="结束时间">
<a-date-picker v-model="model.endDate" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="turbineModel" label="风机型号">
<a-input v-model="model.turbineModel" placeholder="请输入风机型号" />
</a-form-item>
</a-col>
</a-row>
<template v-if="showTurbineGrid">
<a-divider orientation="left">风场信息可视化</a-divider>
<a-row :gutter="16">
<a-col :span="24">
<a-form-item label="机组网格布局">
<a-space direction="vertical" style="width: 100%">
<TurbineGrid v-model="model.turbineList" />
</a-space>
</a-form-item>
</a-col>
</a-row>
</template>
</template>
<script setup lang="ts">
import { toRefs } from 'vue'
import TurbineGrid from '../TurbineGrid.vue'
const props = defineProps<{
model: any
userOptions: { label: string; value: string }[]
userLoading: boolean
statusOptions: { label: string; value: number | string }[]
addTask: () => void
removeTask: (index: number) => void
addSubtask: (parentIndex: number) => void
removeSubtask: (parentIndex: number, index: number) => void
onMapSelect: () => void
showTurbineGrid?: boolean
}>()
// props //
const {
model,
userOptions,
userLoading,
statusOptions,
addTask,
removeTask,
addSubtask,
removeSubtask,
onMapSelect,
showTurbineGrid,
} = toRefs(props)
</script>

View File

@ -72,263 +72,27 @@
</template> </template>
</GiTable> </GiTable>
<!-- 新增/编辑项目弹窗 --> <!-- 新增项目弹窗与编辑分离 -->
<a-modal <a-modal
v-model:visible="addModalVisible" :title="modalTitle" :ok-button-props="{ loading: submitLoading }" v-model:visible="addModalVisible" :title="modalTitle" :ok-button-props="{ loading: submitLoading }"
width="800px" modal-class="project-form-modal" @cancel="resetForm" @ok="handleSubmit" width="800px" modal-class="project-form-modal" @cancel="handleCancelAdd" @ok="handleSubmit"
> >
<a-form <a-form
ref="formRef" :model="form" :rules="formRules" layout="vertical" ref="formRef" :model="form" :rules="formRules" layout="vertical"
:style="{ maxHeight: '70vh', overflow: 'auto', padding: '0 10px' }" :style="{ maxHeight: '70vh', overflow: 'auto', padding: '0 10px' }"
> >
<!-- 基本信息 --> <ProjectFormFields
<a-divider orientation="left">基本信息</a-divider> :model="form"
<a-row :gutter="16"> :user-options="userOptions"
<a-col :span="12"> :user-loading="userLoading"
<a-form-item field="projectName" label="项目名称" required> :status-options="PROJECT_STATUS_OPTIONS"
<a-input v-model="form.projectName" placeholder="请输入项目名称" /> :add-task="addTask"
</a-form-item> :remove-task="removeTask"
</a-col> :add-subtask="addSubtask"
<a-col :span="12"> :remove-subtask="removeSubtask"
<a-form-item field="farmAddress" label="地址"> :on-map-select="() => { Message.info('待开发') }"
<a-input v-model="form.farmAddress" placeholder="请输入地址" /> :show-turbine-grid="true"
</a-form-item> />
</a-col>
<a-col>
<a-button size="mini" @click="() => { Message.info(`待开发`) }">
<template #icon><icon-location /></template>
地图选点
</a-button>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="projectManagerId" label="项目经理" required>
<a-select v-model="form.projectManagerId" placeholder="请选择项目经理" :loading="userLoading">
<a-option v-for="user in userOptions" :key="user.value" :value="user.value">
{{ user.label }}
</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="inspectionUnit" label="业主">
<a-input v-model="form.inspectionUnit" placeholder="请输入业主单位" @input="(val) => (form.farmName = val)" />
<!-- 风场名称同步业主 -->
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="inspectionContact" label="业主单位联系人">
<a-input v-model="form.inspectionContact" placeholder="请输入联系人" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="inspectionPhone" label="业主单位联系电话">
<a-input v-model="form.inspectionPhone" placeholder="请输入联系电话" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="client" label="委托单位">
<a-input v-model="form.client" placeholder="请输入委托单位" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="clientContact" label="委托单位联系人">
<a-input v-model="form.clientContact" placeholder="请输入联系人" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="clientPhone" label="委托单位联系电话">
<a-input v-model="form.clientPhone" placeholder="请输入联系电话" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="projectOrigin" label="项目来源" :rules="[{ required: true, message: '请输入项目来源' }]">
<a-input v-model="form.projectOrigin" placeholder="请输入项目来源" />
</a-form-item>
</a-col>
</a-row>
<a-divider orientation="left">任务设置</a-divider>
<div class="mb-2">
<a-button type="dashed" size="small" @click="addTask">
<template #icon><icon-plus /></template>
新增任务
</a-button>
</div>
<div v-if="form.tasks.length === 0" class="text-gray-500 mb-2">暂无任务请点击新增任务</div>
<a-space direction="vertical" fill>
<a-card v-for="(task, tIndex) in form.tasks" :key="tIndex" size="small">
<template #title>
<div class="flex items-center justify-between">
<span>任务 {{ tIndex + 1 }}</span>
<a-space>
<a-button size="mini" @click="addSubtask(tIndex)">新增子任务</a-button>
<a-button size="mini" status="danger" @click="removeTask(tIndex)">删除</a-button>
</a-space>
</div>
</template>
<a-row :gutter="12">
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.taskName`" label="任务名称" required>
<a-input v-model="task.taskName" placeholder="请输入任务名称" />
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item :field="`tasks.${tIndex}.taskCode`" label="任务编号">
<a-input v-model="task.taskCode" placeholder="编号" />
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item :field="`tasks.${tIndex}.mainUserId`" label="负责人">
<a-select v-model="task.mainUserId" placeholder="选择负责人" :loading="userLoading">
<a-option v-for="u in userOptions" :key="u.value" :value="u.value">{{ u.label }}</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="4">
<a-form-item :field="`tasks.${tIndex}.scales`" label="工量">
<a-input-number v-model="task.scales" :min="0" :max="9999" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="12">
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.planStartDate`" label="计划开始">
<a-date-picker v-model="task.planStartDate" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.planEndDate`" label="计划结束">
<a-date-picker v-model="task.planEndDate" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.taskGroupId`" label="任务组">
<a-input-number v-model="task.taskGroupId" :min="0" placeholder="可选" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<!-- 子任务 -->
<div v-if="task.children && task.children.length">
<a-divider orientation="left">子任务</a-divider>
<a-card
v-for="(sub, sIndex) in task.children"
:key="sIndex"
size="small"
class="mb-2"
>
<template #title>
<div class="flex items-center justify-between">
<span>子任务 {{ tIndex + 1 }}-{{ sIndex + 1 }}</span>
<a-button size="mini" status="danger" @click="removeSubtask(tIndex, sIndex)">删除</a-button>
</div>
</template>
<a-row :gutter="12">
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.children.${sIndex}.taskName`" label="任务名称" required>
<a-input v-model="sub.taskName" placeholder="请输入任务名称" />
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item :field="`tasks.${tIndex}.children.${sIndex}.taskCode`" label="任务编号">
<a-input v-model="sub.taskCode" placeholder="编号" />
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item :field="`tasks.${tIndex}.children.${sIndex}.mainUserId`" label="负责人">
<a-select v-model="sub.mainUserId" placeholder="选择负责人" :loading="userLoading">
<a-option v-for="u in userOptions" :key="u.value" :value="u.value">{{ u.label }}</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="4">
<a-form-item :field="`tasks.${tIndex}.children.${sIndex}.scales`" label="工量">
<a-input-number v-model="sub.scales" :min="0" :max="9999" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="12">
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.children.${sIndex}.planStartDate`" label="计划开始">
<a-date-picker v-model="sub.planStartDate" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.children.${sIndex}.planEndDate`" label="计划结束">
<a-date-picker v-model="sub.planEndDate" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.children.${sIndex}.taskGroupId`" label="任务组">
<a-input-number v-model="sub.taskGroupId" :min="0" placeholder="可选" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
</a-card>
</div>
</a-card>
</a-space>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="status" label="项目状态">
<a-select v-model="form.status" placeholder="请选择状态">
<a-option v-for="option in PROJECT_STATUS_OPTIONS" :key="option.value" :value="option.value">
{{ option.label }}
</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="scale" label="项目规模">
<a-input-number v-model="form.scale" placeholder="请输入项目规模" :min="0" :max="999" :step="1" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="startDate" label="开始时间">
<a-date-picker v-model="form.startDate" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="endDate" label="结束时间">
<a-date-picker v-model="form.endDate" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="turbineModel" label="风机型号">
<a-input v-model="form.turbineModel" placeholder="请输入风机型号" />
</a-form-item>
</a-col>
</a-row>
<!-- 风场信息 -->
<a-divider orientation="left">风场信息可视化</a-divider>
<a-row :gutter="16">
<a-col :span="24">
<a-form-item label="机组网格布局">
<a-space direction="vertical" style="width: 100%">
<TurbineGrid v-model:="form.turbineList"></TurbineGrid>
</a-space>
</a-form-item>
</a-col>
</a-row>
<a-divider orientation="middle">地图</a-divider>
</a-form> </a-form>
</a-modal> </a-modal>
@ -339,9 +103,10 @@
:ok-button-props="{ loading: submitLoading }" :ok-button-props="{ loading: submitLoading }"
width="800px" width="800px"
modal-class="project-form-modal" modal-class="project-form-modal"
@cancel="() => { editModalVisible.value = false }" @cancel="handleCancelEdit"
@ok="handleEditSubmit" @ok="handleEditSubmit"
> >
<a-spin :loading="editDetailLoading">
<a-form <a-form
ref="editFormRef" ref="editFormRef"
:model="editForm" :model="editForm"
@ -349,229 +114,21 @@
layout="vertical" layout="vertical"
:style="{ maxHeight: '70vh', overflow: 'auto', padding: '0 10px' }" :style="{ maxHeight: '70vh', overflow: 'auto', padding: '0 10px' }"
> >
<a-divider orientation="left">基本信息</a-divider> <ProjectFormFields
<a-row :gutter="16"> :model="editForm"
<a-col :span="12"> :user-options="userOptions"
<a-form-item field="projectName" label="项目名称" required> :user-loading="userLoading"
<a-input v-model="editForm.projectName" placeholder="请输入项目名称" /> :status-options="PROJECT_STATUS_OPTIONS"
</a-form-item> :add-task="addEditTask"
</a-col> :remove-task="removeEditTask"
<a-col :span="12"> :add-subtask="addEditSubtask"
<a-form-item field="farmAddress" label="地址"> :remove-subtask="removeEditSubtask"
<a-input v-model="editForm.farmAddress" placeholder="请输入地址" /> :on-map-select="() => {}"
</a-form-item> :show-turbine-grid="false"
</a-col> />
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="projectManagerId" label="项目经理" required>
<a-select v-model="editForm.projectManagerId" placeholder="请选择项目经理" :loading="userLoading">
<a-option v-for="user in userOptions" :key="user.value" :value="user.value">
{{ user.label }}
</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="inspectionUnit" label="业主">
<a-input v-model="editForm.inspectionUnit" placeholder="请输入业主单位" @input="(val) => (editForm.farmName = val)" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="inspectionContact" label="业主单位联系人">
<a-input v-model="editForm.inspectionContact" placeholder="请输入联系人" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="inspectionPhone" label="业主单位联系电话">
<a-input v-model="editForm.inspectionPhone" placeholder="请输入联系电话" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="client" label="委托单位">
<a-input v-model="editForm.client" placeholder="请输入委托单位" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="clientContact" label="委托单位联系人">
<a-input v-model="editForm.clientContact" placeholder="请输入联系人" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="clientPhone" label="委托单位联系电话">
<a-input v-model="editForm.clientPhone" placeholder="请输入联系电话" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="projectOrigin" label="项目来源" :rules="[{ required: true, message: '请输入项目来源' }]">
<a-input v-model="editForm.projectOrigin" placeholder="请输入项目来源" />
</a-form-item>
</a-col>
</a-row>
<a-divider orientation="left">任务设置</a-divider>
<div class="mb-2">
<a-button type="dashed" size="small" @click="addEditTask">
<template #icon><icon-plus /></template>
新增任务
</a-button>
</div>
<div v-if="editForm.tasks.length === 0" class="text-gray-500 mb-2">暂无任务</div>
<a-space direction="vertical" fill>
<a-card v-for="(task, tIndex) in editForm.tasks" :key="tIndex" size="small">
<template #title>
<div class="flex items-center justify-between">
<span>任务 {{ tIndex + 1 }}</span>
<a-space>
<a-button size="mini" @click="addEditSubtask(tIndex)">新增子任务</a-button>
<a-button size="mini" status="danger" @click="removeEditTask(tIndex)">删除</a-button>
</a-space>
</div>
</template>
<a-row :gutter="12">
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.taskName`" label="任务名称" required>
<a-input v-model="task.taskName" placeholder="请输入任务名称" />
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item :field="`tasks.${tIndex}.taskCode`" label="任务编号">
<a-input v-model="task.taskCode" placeholder="编号" />
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item :field="`tasks.${tIndex}.mainUserId`" label="负责人">
<a-select v-model="task.mainUserId" placeholder="选择负责人" :loading="userLoading">
<a-option v-for="u in userOptions" :key="u.value" :value="u.value">{{ u.label }}</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="4">
<a-form-item :field="`tasks.${tIndex}.scales`" label="工量">
<a-input-number v-model="task.scales" :min="0" :max="9999" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="12">
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.planStartDate`" label="计划开始">
<a-date-picker v-model="task.planStartDate" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.planEndDate`" label="计划结束">
<a-date-picker v-model="task.planEndDate" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.taskGroupId`" label="任务组">
<a-input-number v-model="task.taskGroupId" :min="0" placeholder="可选" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<!-- 子任务 -->
<div v-if="task.children && task.children.length">
<a-divider orientation="left">子任务</a-divider>
<a-card v-for="(sub, sIndex) in task.children" :key="sIndex" size="small" class="mb-2">
<template #title>
<div class="flex items-center justify-between">
<span>子任务 {{ tIndex + 1 }}-{{ sIndex + 1 }}</span>
<a-button size="mini" status="danger" @click="removeEditSubtask(tIndex, sIndex)">删除</a-button>
</div>
</template>
<a-row :gutter="12">
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.children.${sIndex}.taskName`" label="任务名称" required>
<a-input v-model="sub.taskName" placeholder="请输入任务名称" />
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item :field="`tasks.${tIndex}.children.${sIndex}.taskCode`" label="任务编号">
<a-input v-model="sub.taskCode" placeholder="编号" />
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item :field="`tasks.${tIndex}.children.${sIndex}.mainUserId`" label="负责人">
<a-select v-model="sub.mainUserId" placeholder="选择负责人" :loading="userLoading">
<a-option v-for="u in userOptions" :key="u.value" :value="u.value">{{ u.label }}</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="4">
<a-form-item :field="`tasks.${tIndex}.children.${sIndex}.scales`" label="工量">
<a-input-number v-model="sub.scales" :min="0" :max="9999" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="12">
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.children.${sIndex}.planStartDate`" label="计划开始">
<a-date-picker v-model="sub.planStartDate" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.children.${sIndex}.planEndDate`" label="计划结束">
<a-date-picker v-model="sub.planEndDate" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item :field="`tasks.${tIndex}.children.${sIndex}.taskGroupId`" label="任务组">
<a-input-number v-model="sub.taskGroupId" :min="0" placeholder="可选" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
</a-card>
</div>
</a-card>
</a-space>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="status" label="项目状态">
<a-select v-model="editForm.status" placeholder="请选择状态">
<a-option v-for="option in PROJECT_STATUS_OPTIONS" :key="option.value" :value="option.value">
{{ option.label }}
</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="scale" label="项目规模">
<a-input-number v-model="editForm.scale" placeholder="请输入项目规模" :min="0" :max="999" :step="1" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="startDate" label="开始时间">
<a-date-picker v-model="editForm.startDate" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="endDate" label="结束时间">
<a-date-picker v-model="editForm.endDate" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
</a-form> </a-form>
</a-spin>
</a-modal> </a-modal>
@ -651,8 +208,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import ProjectFormFields from './components/ProjectFormFields.vue'
const editModalVisible = ref(false) const editModalVisible = ref(false)
const editFormRef = ref() const editFormRef = ref()
const editDetailLoading = ref(false)
// //
const editForm = reactive({ const editForm = reactive({
@ -702,7 +262,6 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Message, Modal } from '@arco-design/web-vue' import { Message, Modal } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue' import type { TableColumnData } from '@arco-design/web-vue'
import TurbineGrid from './TurbineGrid.vue'
import { addProject, deleteProject, exportProject, importProject, listProject, updateProject, getProjectDetail } from '@/apis/project' import { addProject, deleteProject, exportProject, importProject, listProject, updateProject, getProjectDetail } from '@/apis/project'
import { isMobile } from '@/utils' import { isMobile } from '@/utils'
import http from '@/utils/http' import http from '@/utils/http'
@ -1046,6 +605,16 @@ const fetchData = async () => {
} }
} else { } else {
Message.error(res.msg || '获取数据失败') Message.error(res.msg || '获取数据失败')
}
} catch (error) {
console.error('获取项目列表失败:', error)
Message.error('获取数据失败')
dataList.value = []
pagination.total = 0
} finally {
loading.value = false
}
}
const handleEditSubmit = async () => { const handleEditSubmit = async () => {
submitLoading.value = true submitLoading.value = true
@ -1134,18 +703,6 @@ const handleEditSubmit = async () => {
} }
} }
dataList.value = []
pagination.total = 0
}
} catch (error) {
console.error('获取项目列表失败:', error)
Message.error('获取数据失败')
dataList.value = []
pagination.total = 0
} finally {
loading.value = false
}
}
const search = () => { const search = () => {
pagination.current = 1 pagination.current = 1
@ -1210,7 +767,29 @@ const resetForm = () => {
const openAddModal = () => { const openAddModal = () => {
resetForm() resetForm()
editModalVisible.value = true addModalVisible.value = true
}
//
const addEditTask = () => {
;(editForm.tasks as any[]).push({ taskName: '', taskCode: '', mainUserId: undefined, planStartDate: '', planEndDate: '', scales: undefined, taskGroupId: undefined, children: [] })
}
const removeEditTask = (index: number) => {
;(editForm.tasks as any[]).splice(index, 1)
}
const addEditSubtask = (parentIndex: number) => {
const list = (editForm.tasks as any[])
if (!list[parentIndex].children) list[parentIndex].children = []
list[parentIndex].children!.push({ taskName: '', taskCode: '', mainUserId: undefined, planStartDate: '', planEndDate: '', scales: undefined, taskGroupId: undefined })
}
const removeEditSubtask = (parentIndex: number, index: number) => {
const list = (editForm.tasks as any[])
list[parentIndex].children!.splice(index, 1)
}
const handleCancelAdd = () => {
addModalVisible.value = false
resetForm()
} }
// /使 // /使
const addTask = () => { const addTask = () => {
@ -1234,6 +813,10 @@ const openEditModal = async (record: T.ProjectResp) => {
isEdit.value = true isEdit.value = true
currentId.value = record.id || record.projectId || null currentId.value = record.id || record.projectId || null
//
editModalVisible.value = true
editDetailLoading.value = true
// //
Object.assign(editForm, { Object.assign(editForm, {
projectId: '', projectName: '', projectManagerId: '', client: '', clientContact: '', clientPhone: '', projectId: '', projectName: '', projectManagerId: '', client: '', clientContact: '', clientPhone: '',
@ -1242,6 +825,18 @@ const openEditModal = async (record: T.ProjectResp) => {
qualityOfficerId: '', auditorId: '', tasks: [], turbineList: [], qualityOfficerId: '', auditorId: '', tasks: [], turbineList: [],
}) })
//
if (record && typeof record === 'object') {
Object.keys(editForm).forEach((key) => {
if ((record as any)[key] !== undefined) {
// @ts-expect-error
editForm[key] = (record as any)[key]
}
})
}
//
editDetailLoading.value = false
try { try {
if (currentId.value) { if (currentId.value) {
const res = await getProjectDetail(currentId.value as any) const res = await getProjectDetail(currentId.value as any)
@ -1281,9 +876,9 @@ const openEditModal = async (record: T.ProjectResp) => {
const tasksSource: any[] = Array.isArray(detail.tasks) const tasksSource: any[] = Array.isArray(detail.tasks)
? detail.tasks ? detail.tasks
: (Array.isArray((detail as any).taskList) ? (detail as any).taskList : []) : (Array.isArray((detail as any).taskList) ? (detail as any).taskList : [])
if (Array.isArray(tasksSource) && tasksSource.length) {
;(editForm.tasks as any[]) = tasksSource.map(mapTask) //
} ;(editForm.tasks as any[]) = Array.isArray(tasksSource) ? tasksSource.map(mapTask) : []
// projectId // projectId
editForm.projectId = (detail.projectId ?? currentId.value ?? record.projectId ?? '').toString() editForm.projectId = (detail.projectId ?? currentId.value ?? record.projectId ?? '').toString()
@ -1297,10 +892,16 @@ const openEditModal = async (record: T.ProjectResp) => {
editForm[key] = (record as any)[key] editForm[key] = (record as any)[key]
} }
}) })
} finally {
editDetailLoading.value = false
} }
// //
editModalVisible.value = true
}
const handleCancelEdit = () => {
editModalVisible.value = false
} }
// //

View File

@ -22,8 +22,8 @@
</a-form-item> </a-form-item>
<a-form-item label="状态" field="status"> <a-form-item label="状态" field="status">
<a-select v-model="form.status" placeholder="请选择状态"> <a-select v-model="form.status" placeholder="请选择状态">
<a-option :value="0" label="正常" /> <a-option :value="1" label="正常" />
<a-option :value="1" label="停用" /> <a-option :value="0" label="停用" />
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label="描述" field="remark"> <a-form-item label="描述" field="remark">

View File

@ -15,6 +15,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Message, type TreeNodeData } from '@arco-design/web-vue' import { Message, type TreeNodeData } from '@arco-design/web-vue'
import { useWindowSize } from '@vueuse/core' import { useWindowSize } from '@vueuse/core'
import { nextTick } from 'vue'
import { addUserNew, getUserDetailNew, updateUserNew } from '@/apis/system/user-new' import { addUserNew, getUserDetailNew, updateUserNew } from '@/apis/system/user-new'
import { type ColumnItem, GiForm } from '@/components/GiForm' import { type ColumnItem, GiForm } from '@/components/GiForm'
import type { Status } from '@/types/global' import type { Status } from '@/types/global'
@ -186,7 +187,7 @@ const columns: ColumnItem[] = reactive([
field: 'roleIds', field: 'roleIds',
type: 'select', type: 'select',
span: 12, span: 12,
required: true, //required: true,
props: { props: {
options: roleList, options: roleList,
multiple: true, multiple: true,
@ -347,15 +348,12 @@ const save = async () => {
// //
const onAdd = async () => { const onAdd = async () => {
reset() reset()
if (!deptList.value.length) { // v-model
await getDeptList() await Promise.all([
} deptList.value.length ? Promise.resolve() : getDeptList(),
if (!roleList.value.length) { roleList.value.length ? Promise.resolve() : getRoleList(),
await getRoleList() postList.value.length ? Promise.resolve() : getPostList(),
} ])
if (!postList.value.length) {
await getPostList()
}
dataId.value = '' dataId.value = ''
visible.value = true visible.value = true
} }
@ -365,15 +363,12 @@ const onUpdate = async (id: string) => {
try { try {
reset() reset()
dataId.value = id dataId.value = id
if (!deptList.value.length) { // //
await getDeptList() await Promise.all([
} deptList.value.length ? Promise.resolve() : getDeptList(),
if (!roleList.value.length) { roleList.value.length ? Promise.resolve() : getRoleList(),
await getRoleList() postList.value.length ? Promise.resolve() : getPostList(),
} ])
if (!postList.value.length) {
await getPostList()
}
// 使API // 使API
const { data } = await getUserDetailNew(id) const { data } = await getUserDetailNew(id)
@ -381,13 +376,78 @@ const onUpdate = async (id: string) => {
return Message.error('获取用户详情失败') return Message.error('获取用户详情失败')
} }
// API // API
Object.keys(form).forEach(key => { Object.keys(form).forEach(key => {
if (data[key] !== undefined) { if ((data as any)[key] !== undefined) {
form[key] = data[key] ;(form as any)[key] = (data as any)[key]
} }
}) })
// / ID
const normalizeIdArray = (val: any): string[] => {
if (Array.isArray(val)) return val.map(v => String(v))
if (typeof val === 'string') return val.split(',').map(s => s.trim()).filter(Boolean)
return []
}
//
form.deptId = data.deptId ? String(data.deptId) : ''
//
if ((data as any).postIds !== undefined) {
form.postIds = normalizeIdArray((data as any).postIds)
}
// roleIds / roles / roleIdList
const roleIdsFromArray = normalizeIdArray((data as any).roleIds)
const roleIdsFromRoles = Array.isArray((data as any).roles)
? (data as any).roles.map((r: any) => String(r.roleId ?? r.id ?? r.role_id)).filter(Boolean)
: []
const roleIdsFromList = normalizeIdArray((data as any).roleIdList)
let mergedRoleIds = roleIdsFromArray.length
? roleIdsFromArray
: (roleIdsFromRoles.length ? roleIdsFromRoles : roleIdsFromList)
// IDID
if (!mergedRoleIds.length) {
const namesStr = (data as any).roleName || (data as any).roleNames
const roleNames: string[] = Array.isArray(namesStr)
? (namesStr as any[]).map(n => String(n))
: (typeof namesStr === 'string' ? namesStr.split(/,|/).map(s => s.trim()).filter(Boolean) : [])
if (roleNames.length && Array.isArray(roleList.value)) {
const labelToId = new Map<string, string>((roleList.value || []).map(opt => [String(opt.label), String(opt.value)]))
const ids = roleNames.map(n => labelToId.get(n)).filter((v): v is string => !!v)
if (ids.length) mergedRoleIds = ids
}
}
form.roleIds = mergedRoleIds
//
try {
const optionValSet = new Set((roleList.value || []).map(o => String(o.value)))
const fromRolesMap = new Map<string, string>()
if (Array.isArray((data as any).roles)) {
;(data as any).roles.forEach((r: any) => {
const id = String(r.roleId ?? r.id ?? r.role_id)
const name = r.roleName ?? r.name ?? r.role_key ?? id
if (id) fromRolesMap.set(id, String(name))
})
}
const toAppend: any[] = []
mergedRoleIds.forEach(id => {
const sId = String(id)
if (!optionValSet.has(sId)) {
toAppend.push({ label: fromRolesMap.get(sId) || sId, value: sId, disabled: false })
}
})
if (toAppend.length) {
//
roleList.value = [...(roleList.value || []), ...toAppend]
}
} catch (_) {}
visible.value = true visible.value = true
} catch (error) { } catch (error) {
console.error('获取用户详情失败', error) console.error('获取用户详情失败', error)

View File

@ -3,26 +3,49 @@
<div class="gantt-container"> <div class="gantt-container">
<div class="page-header"> <div class="page-header">
<h2 class="page-title">人力甘特图页面</h2> <h2 class="page-title">人力甘特图页面</h2>
<!-- 时间刻度切换 -->
<div class="scale-switch">
<button
v-for="scale in timeScales"
:key="scale.value"
:class="['scale-btn', { active: scale.value === currentScale }]"
@click="setScale(scale.value)"
>
{{ scale.label }}
</button>
<!-- 左右翻页按钮 -->
<button class="nav-btn" @click="shiftRange(-1)"></button>
<button class="nav-btn" @click="shiftRange(1)"></button>
</div>
</div> </div>
<!-- 人员列表区域 --> <!-- 人员列表 -->
<div class="person-container"> <div class="person-container">
<div v-for="(person, index) in personList" :key="person.id" class="person-gantt"> <div
<!-- 人员标题栏 --> v-for="(person, index) in personList"
:key="person.id"
class="person-gantt"
>
<!-- 头部 -->
<div class="person-header" @click="togglePerson(index)"> <div class="person-header" @click="togglePerson(index)">
<div class="name-container"> <div class="name-container">
<span class="avatar">{{ person.name.charAt(0) }}</span> <span class="avatar">{{ person.name.charAt(0) }}</span>
<span>{{ person.name }}</span> <span>{{ person.name }}</span>
<span class="task-count">({{ person.tasks.length }}个项目)</span> <span class="task-count">({{ person.tasks.length }}任务)</span>
</div> </div>
<button class="expand-button"> <button class="expand-button">
<i :class="person.expanded ? 'collapse-icon' : 'expand-icon'"></i> <i :class="person.expanded ? 'menu-fold-icon' : 'menu-unfold-icon'"></i>
</button> </button>
</div> </div>
<!-- 甘特图容器 - 根据展开状态控制显示 --> <!-- 甘特图 -->
<div v-show="person.expanded" class="chart-container"> <div v-show="person.expanded" class="chart-container">
<div :ref="el => chartRefs[index] = el" class="progress-chart"></div> <div
:ref="el => chartRefs[index] = el"
class="progress-chart"
></div>
</div> </div>
</div> </div>
</div> </div>
@ -30,205 +53,207 @@
</GiPageLayout> </GiPageLayout>
</template> </template>
<script setup lang='ts'> <script setup lang="ts">
import * as echarts from 'echarts' import * as echarts from "echarts";
import { ref, onMounted, onUnmounted, watch } from 'vue' import { ref, onMounted, onUnmounted } from "vue";
const timeScales = [
{ label: "周", value: "week", range: 14 },
{ label: "月", value: "month", range: 30 },
{ label: "季", value: "quarter", range: 90 },
{ label: "年", value: "year", range: 365 }
];
const currentScale = ref("month");
const currentStartDate = ref(new Date()); //
//
const personList = ref([ const personList = ref([
{ {
id: 1, id: 1,
name: '张三', name: "张三",
expanded: true, expanded: true,
tasks: [ tasks: [
{ name: '项目一', startDate: '2025-08-01', days: 5, color: '#5470C6' }, { name: "项目一", startDate: "2025-08-01", days: 5, expectedEndDate: "2025-08-07", color: "#5470C6" },
{ name: '项目二', startDate: '2025-08-10', days: 7, color: '#91CC75' }, { name: "项目二", startDate: "2025-08-10", days: 7, expectedEndDate: "2025-08-18", color: "#91CC75" },
{ name: '项目三', startDate: '2025-08-20', days: 4, color: '#FAC858' } { name: "项目三", startDate: "2025-08-20", days: 4, expectedEndDate: "2025-08-25", color: "#FAC858" }
] ]
}, },
{ {
id: 2, id: 2,
name: '李四', name: "李四",
expanded: true, expanded: true,
tasks: [ tasks: [
{ name: '产品设计', startDate: '2025-08-05', days: 8, color: '#EE6666' }, { name: "产品设计", startDate: "2025-08-05", days: 8, expectedEndDate: "2025-08-15", color: "#EE6666" },
{ name: '技术评审', startDate: '2025-08-15', days: 3, color: '#73C0DE' }, { name: "技术评审", startDate: "2025-08-15", days: 3, expectedEndDate: "2025-08-19", color: "#73C0DE" },
{ name: '系统测试', startDate: '2025-08-18', days: 6, color: '#3BA272' } { name: "系统测试", startDate: "2025-08-18", days: 6, expectedEndDate: "2025-08-27", color: "#3BA272" }
]
},
{
id: 3,
name: '王五',
expanded: true,
tasks: [
{ name: '需求分析', startDate: '2025-08-02', days: 4, color: '#FC8452' },
{ name: '前端开发', startDate: '2025-08-08', days: 7, color: '#9A60B4' },
{ name: '后端对接', startDate: '2025-08-17', days: 5, color: '#EA7CCC' }
]
},
{
id: 4,
name: '赵六',
expanded: false,
tasks: [
{ name: '文档编写', startDate: '2025-08-03', days: 6, color: '#5470C6' },
{ name: '用户培训', startDate: '2025-08-12', days: 4, color: '#91CC75' },
{ name: '上线支持', startDate: '2025-08-22', days: 7, color: '#FAC858' }
] ]
} }
]) ]);
//
const chartRefs = ref<HTMLElement[]>([]); const chartRefs = ref<HTMLElement[]>([]);
//
const chartInstances = ref<echarts.ECharts[]>([]); const chartInstances = ref<echarts.ECharts[]>([]);
// YYYY-MM-DD const setScale = (scale: string) => {
const formatDate = (date: Date): string => { currentScale.value = scale;
const year = date.getFullYear() currentStartDate.value = new Date();
const month = String(date.getMonth() + 1).padStart(2, '0') renderAllCharts();
const day = String(date.getDate()).padStart(2, '0') };
return `${year}-${month}-${day}`
} //
const shiftRange = (direction: number) => {
const rangeDays = timeScales.find(s => s.value === currentScale.value)?.range || 30;
const newDate = new Date(currentStartDate.value);
newDate.setDate(newDate.getDate() + direction * rangeDays);
currentStartDate.value = newDate;
renderAllCharts();
};
// /
const togglePerson = (index: number) => { const togglePerson = (index: number) => {
personList.value[index].expanded = !personList.value[index].expanded; personList.value[index].expanded = !personList.value[index].expanded;
//
if (personList.value[index].expanded) { if (personList.value[index].expanded) {
setTimeout(() => { setTimeout(() => {
if (chartRefs.value[index] && chartInstances.value[index]) {
chartInstances.value[index].resize();
} else {
initChart(index); initChart(index);
}
}, 10); }, 10);
} }
}; };
//
const initChart = (personIndex: number) => { const initChart = (personIndex: number) => {
const container = chartRefs.value[personIndex]; const container = chartRefs.value[personIndex];
if (!container) return; if (!container) return;
//
if (chartInstances.value[personIndex]) { if (chartInstances.value[personIndex]) {
chartInstances.value[personIndex].dispose(); chartInstances.value[personIndex].dispose();
} }
const person = personList.value[personIndex]; const person = personList.value[personIndex];
const tasks = person.tasks; const sortedTasks = [...person.tasks].sort(
(a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
);
// - const rangeDays = timeScales.find(s => s.value === currentScale.value)?.range || 30;
const today = new Date(2025, 7, 14); const startTime = currentStartDate.value.getTime();
const minTime = startTime;
const maxTime = startTime + rangeDays * 86400000;
// const projectNames = sortedTasks.map(t => t.name);
const projectNames: string[] = [];
const dataItems: any[] = [];
const colors: string[] = [];
tasks.forEach((task) => { //
const startDate = new Date(task.startDate); const expectedData = sortedTasks.map((task, idx) => {
const endDate = new Date(startDate); let start = new Date(task.startDate).getTime();
endDate.setDate(startDate.getDate() + task.days); let end = new Date(task.expectedEndDate).getTime();
projectNames.push(task.name); if (start < minTime) start = minTime;
colors.push(task.color); if (end > maxTime) end = maxTime;
dataItems.push({ return {
name: task.name + " (预期)",
value: [start, end, (end - start) / 86400000, idx, task.color]
};
});
//
const actualData = sortedTasks.map((task, idx) => {
let start = new Date(task.startDate).getTime();
let end = start + task.days * 86400000;
if (start < minTime) start = minTime;
if (end > maxTime) end = maxTime;
return {
name: task.name, name: task.name,
value: [ value: [start, end, (end - start) / 86400000, idx, task.color]
formatDate(startDate), };
formatDate(endDate),
task.days
],
itemStyle: {
color: task.color
}
});
}); });
//
const chart = echarts.init(container);
//
const option = { const option = {
tooltip: { tooltip: {
trigger: 'item', formatter: (params: any) => `
formatter: (params: any) => { <strong>${params.data.name}</strong><br>
const task = params.data; 开始: ${echarts.time.format(params.data.value[0], "{yyyy}-{MM}-{dd}", false)}<br>
return ` 结束: ${echarts.time.format(params.data.value[1], "{yyyy}-{MM}-{dd}", false)}<br>
<div class="task-tooltip"> 耗时: ${params.data.value[2]}
<strong>${task.name}</strong><br> `
开始: ${params.value[0]}<br>
结束: ${params.value[1]}<br>
耗时: ${task.value[2]}
</div>
`;
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
}, },
grid: { left: 80, right: 30, top: 20, bottom: 20 },
xAxis: { xAxis: {
type: 'time', type: "time",
min: formatDate(new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000)), min: minTime,
max: formatDate(new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000)), max: maxTime,
axisLabel: { splitNumber: 10,
formatter: function (value: number) { splitLine: { show: true, lineStyle: { type: "solid", opacity: 0.3 } }
return echarts.time.format(value, '{MM}/{dd}', false);
}
},
splitLine: {
show: true,
lineStyle: {
type: 'dashed',
opacity: 0.3
}
}
}, },
yAxis: { yAxis: {
type: 'category', type: "category",
data: projectNames, data: projectNames,
axisLine: { axisTick: { show: false }
show: true
}, },
axisTick: { series: [
show: false //
{
type: "custom",
renderItem: (params: any, api: any) => {
const categoryIndex = api.value(3);
const startCoord = api.coord([api.value(0), categoryIndex]);
const endCoord = api.coord([api.value(1), categoryIndex]);
const barHeight = 20;
return {
type: "rect",
shape: {
x: startCoord[0],
y: startCoord[1] - barHeight / 2,
width: Math.max(0, endCoord[0] - startCoord[0]),
height: barHeight
}, },
axisLabel: { style: {
margin: 16 fill: api.value(4),
opacity: 0.3
} }
};
}, },
dataZoom: [{ encode: { x: [0, 1], y: 3 },
type: 'inside', data: expectedData,
start: 20, z: 1
end: 100
}],
series: [{
name: '项目进度',
type: 'bar',
data: dataItems,
barCategoryGap: '40%',
label: {
show: true,
position: 'inside',
formatter: '{c[2]}天'
}, },
barWidth: '60%' //
}], {
animation: true, type: "custom",
animationDuration: 800 renderItem: (params: any, api: any) => {
const categoryIndex = api.value(3);
const startCoord = api.coord([api.value(0), categoryIndex]);
const endCoord = api.coord([api.value(1), categoryIndex]);
const barHeight = 12;
return {
type: "rect",
shape: {
x: startCoord[0],
y: startCoord[1] - barHeight / 2,
width: Math.max(0, endCoord[0] - startCoord[0]),
height: barHeight
},
style: {
fill: api.value(4)
}
};
},
encode: { x: [0, 1], y: 3 },
data: actualData,
z: 2
}
]
}; };
const chart = echarts.init(container);
chart.setOption(option); chart.setOption(option);
chartInstances.value[personIndex] = chart; chartInstances.value[personIndex] = chart;
} };
const renderAllCharts = () => {
personList.value.forEach((_, index) => {
if (personList.value[index].expanded) {
initChart(index);
}
});
};
//
const handleResize = () => { const handleResize = () => {
chartInstances.value.forEach((chart, index) => { chartInstances.value.forEach((chart, index) => {
if (chart && personList.value[index].expanded) { if (chart && personList.value[index].expanded) {
@ -237,97 +262,77 @@ const handleResize = () => {
}); });
}; };
//
onMounted(() => { onMounted(() => {
window.addEventListener('resize', handleResize); window.addEventListener("resize", handleResize);
personList.value.forEach((_, index) => { renderAllCharts();
if (personList.value[index].expanded) {
initChart(index);
}
});
}); });
//
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', handleResize); window.removeEventListener("resize", handleResize);
chartInstances.value.forEach(chart => { chartInstances.value.forEach(chart => chart.dispose());
if (chart) {
chart.dispose();
}
});
}); });
</script> </script>
<style lang='scss' scoped> <style lang="scss" scoped>
.gantt-container { .gantt-container {
height: 100%; height: 100%;
padding: 20px; padding: 20px;
box-sizing: border-box;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.page-header { .page-header {
margin-bottom: 20px; margin-bottom: 20px;
padding-bottom: 12px; padding-bottom: 12px;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb;
display: flex;
.page-title { justify-content: space-between;
margin: 0; align-items: center;
font-size: 1.5rem;
color: #2c3e50;
font-weight: 600;
} }
.scale-switch {
display: flex;
align-items: center;
}
.scale-btn {
padding: 6px 12px;
margin-left: 6px;
border: 1px solid #ccc;
background: white;
cursor: pointer;
}
.scale-btn.active {
background: #3a7afe;
color: white;
border-color: #3a7afe;
}
.nav-btn {
padding: 6px 12px;
margin-left: 6px;
border: 1px solid #ccc;
background: #f5f5f5;
cursor: pointer;
} }
.person-container { .person-container {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding-right: 8px;
&::-webkit-scrollbar {
width: 6px;
} }
&::-webkit-scrollbar-thumb {
background-color: #c2c6cc;
border-radius: 3px;
}
}
.person-gantt { .person-gantt {
margin-bottom: 20px; margin-bottom: 20px;
border-radius: 8px; border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
background-color: white; background-color: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
&:last-child {
margin-bottom: 0;
} }
}
.person-header { .person-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 12px 16px; padding: 12px 16px;
background-color: #f8f9fa; background-color: #f8f9fa;
border-bottom: 1px solid #e9ecef;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: #f1f3f5;
} }
.name-container { .name-container {
display: flex; display: flex;
align-items: center; align-items: center;
font-size: 1.1rem; }
font-weight: 500;
color: #495057;
.avatar { .avatar {
display: inline-flex; display: inline-flex;
justify-content: center; justify-content: center;
@ -338,57 +343,15 @@ onUnmounted(() => {
background-color: #3a7afe; background-color: #3a7afe;
color: white; color: white;
border-radius: 50%; border-radius: 50%;
font-weight: bold;
} }
.task-count { .task-count {
margin-left: 8px; margin-left: 8px;
font-size: 0.85rem; font-size: 0.85rem;
font-weight: normal;
color: #868e96; color: #868e96;
} }
}
.expand-button {
background: none;
border: none;
cursor: pointer;
width: 28px;
height: 28px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
&:hover {
background-color: #e9ecef;
}
i {
display: block;
width: 0;
height: 0;
border-style: solid;
}
.collapse-icon {
border-width: 0 8px 10px 8px;
border-color: transparent transparent #495057 transparent;
}
.expand-icon {
border-width: 10px 8px 0 8px;
border-color: #495057 transparent transparent transparent;
}
}
}
.chart-container { .chart-container {
padding: 15px; padding: 15px;
background-color: #fff;
} }
.progress-chart { .progress-chart {
width: 100%; width: 100%;
height: 250px; height: 250px;