Merge branch 'devlopment' of http://pms.dtyx.net:3000/wuxueyu/Industrial-image-management-system---web into devlopment
This commit is contained in:
commit
60b164f0c8
|
@ -3,17 +3,21 @@
|
||||||
VITE_API_PREFIX = '/dev-api'
|
VITE_API_PREFIX = '/dev-api'
|
||||||
|
|
||||||
# 接口地址
|
# 接口地址
|
||||||
VITE_API_BASE_URL = 'http://pms.dtyx.net:9158/'
|
# VITE_API_BASE_URL = 'http://pms.dtyx.net:9158/'
|
||||||
# VITE_API_BASE_URL = 'http://localhost:8888/'
|
# VITE_API_BASE_URL = 'http://localhost:8888/'
|
||||||
|
VITE_API_BASE_URL = 'http://10.18.34.163:8888/'
|
||||||
|
# VITE_API_BASE_URL = 'http://10.18.34.213:8888/'
|
||||||
|
|
||||||
# 接口地址 (WebSocket)
|
# 接口地址 (WebSocket)
|
||||||
VITE_API_WS_URL = 'ws://localhost:8000'
|
# VITE_API_WS_URL = 'ws://localhost:8000'
|
||||||
|
VITE_API_WS_URL = 'ws://10.18.34.163:8000'
|
||||||
|
# VITE_API_WS_URL = 'ws://10.18.34.213:8000'
|
||||||
|
|
||||||
# 地址前缀
|
# 地址前缀
|
||||||
VITE_BASE = '/'
|
VITE_BASE = '/'
|
||||||
|
|
||||||
# 是否开启开发者工具
|
# 是否开启开发者工具
|
||||||
VITE_OPEN_DEVTOOLS = true
|
VITE_OPEN_DEVTOOLS = false
|
||||||
|
|
||||||
# 应用配置面板
|
# 应用配置面板
|
||||||
VITE_APP_SETTING = true
|
VITE_APP_SETTING = true
|
||||||
|
|
|
@ -3,9 +3,10 @@ import http from '@/utils/http'
|
||||||
|
|
||||||
const BASE_URL = '/user'
|
const BASE_URL = '/user'
|
||||||
|
|
||||||
/** @desc 查询用户列表 */
|
/** @desc 分页查询用户列表 */
|
||||||
export function listUser(query: T.UserPageQuery) {
|
export function listUser(query: T.UserPageQuery) {
|
||||||
return http.get<PageRes<T.UserResp[]>>(`${BASE_URL}`, query)
|
// 后端分页接口为 /user/page
|
||||||
|
return http.get<PageRes<T.UserResp[]>>(`${BASE_URL}/page`, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @desc 查询所有用户列表 */
|
/** @desc 查询所有用户列表 */
|
||||||
|
|
|
@ -0,0 +1,620 @@
|
||||||
|
<template>
|
||||||
|
<div class="approval-assistant">
|
||||||
|
<!-- 助手头部 -->
|
||||||
|
<div class="assistant-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<IconRobot style="font-size: 20px; color: var(--color-primary); margin-right: 8px;" />
|
||||||
|
<h3>智能审批助手</h3>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<a-switch
|
||||||
|
v-model="assistantEnabled"
|
||||||
|
size="small"
|
||||||
|
@change="toggleAssistant"
|
||||||
|
>
|
||||||
|
<template #checked>启用</template>
|
||||||
|
<template #unchecked>禁用</template>
|
||||||
|
</a-switch>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 助手内容 -->
|
||||||
|
<div v-if="assistantEnabled && currentApproval" class="assistant-content">
|
||||||
|
<!-- 审批建议 -->
|
||||||
|
<div class="assistant-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<IconLightbulb style="color: var(--color-warning); margin-right: 8px;" />
|
||||||
|
<span>审批建议</span>
|
||||||
|
</div>
|
||||||
|
<div class="section-content">
|
||||||
|
<div class="recommendation-card" :class="getRecommendationClass()">
|
||||||
|
<div class="recommendation-icon">
|
||||||
|
<IconCheckCircle v-if="recommendation === 'APPROVE'" />
|
||||||
|
<IconCloseCircle v-else-if="recommendation === 'REJECT'" />
|
||||||
|
<IconExclamationCircle v-else />
|
||||||
|
</div>
|
||||||
|
<div class="recommendation-content">
|
||||||
|
<div class="recommendation-title">
|
||||||
|
{{ getRecommendationTitle() }}
|
||||||
|
</div>
|
||||||
|
<div class="recommendation-reason">
|
||||||
|
{{ getRecommendationReason() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 风险评估 -->
|
||||||
|
<div class="assistant-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<IconShield style="color: var(--color-danger); margin-right: 8px;" />
|
||||||
|
<span>风险评估</span>
|
||||||
|
</div>
|
||||||
|
<div class="section-content">
|
||||||
|
<div class="risk-indicators">
|
||||||
|
<div
|
||||||
|
v-for="risk in riskFactors"
|
||||||
|
:key="risk.type"
|
||||||
|
class="risk-item"
|
||||||
|
:class="getRiskClass(risk.level)"
|
||||||
|
>
|
||||||
|
<div class="risk-icon">
|
||||||
|
<IconExclamationCircle v-if="risk.level === 'HIGH'" />
|
||||||
|
<IconWarning v-else-if="risk.level === 'MEDIUM'" />
|
||||||
|
<IconCheckCircle v-else />
|
||||||
|
</div>
|
||||||
|
<div class="risk-info">
|
||||||
|
<div class="risk-title">{{ risk.title }}</div>
|
||||||
|
<div class="risk-description">{{ risk.description }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="risk-score">{{ risk.score }}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 快速审批 -->
|
||||||
|
<div class="assistant-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<IconThunderbolt style="color: var(--color-success); margin-right: 8px;" />
|
||||||
|
<span>快速审批</span>
|
||||||
|
</div>
|
||||||
|
<div class="section-content">
|
||||||
|
<div class="quick-actions">
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:disabled="recommendation !== 'APPROVE'"
|
||||||
|
@click="handleQuickApprove"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<IconCheckCircle />
|
||||||
|
</template>
|
||||||
|
快速通过
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
status="danger"
|
||||||
|
:disabled="recommendation !== 'REJECT'"
|
||||||
|
@click="handleQuickReject"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<IconCloseCircle />
|
||||||
|
</template>
|
||||||
|
快速拒绝
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
@click="handleManualReview"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<IconEdit />
|
||||||
|
</template>
|
||||||
|
手动审批
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 审批历史分析 -->
|
||||||
|
<div class="assistant-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<IconBarChart style="color: var(--color-info); margin-right: 8px;" />
|
||||||
|
<span>审批历史分析</span>
|
||||||
|
</div>
|
||||||
|
<div class="section-content">
|
||||||
|
<div class="history-stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-number">{{ historyStats.total }}</div>
|
||||||
|
<div class="stat-label">总申请数</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-number">{{ historyStats.approved }}</div>
|
||||||
|
<div class="stat-label">通过率</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-number">{{ historyStats.avgTime }}</div>
|
||||||
|
<div class="stat-label">平均审批时间</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 助手未启用提示 -->
|
||||||
|
<div v-else-if="!assistantEnabled" class="assistant-disabled">
|
||||||
|
<IconRobot style="font-size: 48px; color: var(--color-text-3); margin-bottom: 16px;" />
|
||||||
|
<p>智能审批助手已禁用</p>
|
||||||
|
<a-button type="primary" size="small" @click="assistantEnabled = true">
|
||||||
|
启用助手
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 无审批数据提示 -->
|
||||||
|
<div v-else class="no-approval-data">
|
||||||
|
<IconFile style="font-size: 48px; color: var(--color-text-3); margin-bottom: 16px;" />
|
||||||
|
<p>请选择要审批的申请</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
IconRobot,
|
||||||
|
IconLightbulb,
|
||||||
|
IconShield,
|
||||||
|
IconThunderbolt,
|
||||||
|
IconBarChart,
|
||||||
|
IconFile,
|
||||||
|
IconCheckCircle,
|
||||||
|
IconCloseCircle,
|
||||||
|
IconExclamationCircle,
|
||||||
|
IconWarning,
|
||||||
|
IconEdit
|
||||||
|
} from '@arco-design/web-vue/es/icon'
|
||||||
|
import message from '@arco-design-design/web-vue/es/message'
|
||||||
|
import type { EquipmentApprovalResp } from '@/apis/equipment/type'
|
||||||
|
import { BusinessType, ApprovalStatus } from '@/apis/equipment/type'
|
||||||
|
|
||||||
|
defineOptions({ name: 'ApprovalAssistant' })
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
approvalData?: EquipmentApprovalResp | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RiskFactor {
|
||||||
|
type: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
level: 'LOW' | 'MEDIUM' | 'HIGH'
|
||||||
|
score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HistoryStats {
|
||||||
|
total: number
|
||||||
|
approved: number
|
||||||
|
avgTime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
approvalData: null
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
quickApprove: [data: EquipmentApprovalResp]
|
||||||
|
quickReject: [data: EquipmentApprovalResp]
|
||||||
|
manualReview: [data: EquipmentApprovalResp]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const assistantEnabled = ref(true)
|
||||||
|
const currentApproval = computed(() => props.approvalData)
|
||||||
|
|
||||||
|
// 审批建议
|
||||||
|
const recommendation = computed(() => {
|
||||||
|
if (!currentApproval.value) return null
|
||||||
|
|
||||||
|
const approval = currentApproval.value
|
||||||
|
|
||||||
|
// 基于业务规则的建议逻辑
|
||||||
|
if (approval.businessType === BusinessType.PROCUREMENT) {
|
||||||
|
// 采购审批建议
|
||||||
|
if (approval.purchasePrice && approval.purchasePrice > 100000) {
|
||||||
|
return 'REJECT' // 高价值采购需要更严格审查
|
||||||
|
}
|
||||||
|
if (approval.applicantName && approval.applicantName.includes('测试')) {
|
||||||
|
return 'REJECT' // 测试用户申请
|
||||||
|
}
|
||||||
|
return 'APPROVE'
|
||||||
|
} else if (approval.businessType === BusinessType.BORROW) {
|
||||||
|
// 借用审批建议
|
||||||
|
if (approval.applyReason && approval.applyReason.length < 10) {
|
||||||
|
return 'REJECT' // 借用原因描述不充分
|
||||||
|
}
|
||||||
|
return 'APPROVE'
|
||||||
|
} else if (approval.businessType === BusinessType.RETURN) {
|
||||||
|
// 归还审批建议
|
||||||
|
return 'APPROVE' // 归还一般直接通过
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'REVIEW' // 需要人工审查
|
||||||
|
})
|
||||||
|
|
||||||
|
// 风险评估因素
|
||||||
|
const riskFactors = computed((): RiskFactor[] => {
|
||||||
|
if (!currentApproval.value) return []
|
||||||
|
|
||||||
|
const approval = currentApproval.value
|
||||||
|
const factors: RiskFactor[] = []
|
||||||
|
|
||||||
|
// 价格风险
|
||||||
|
if (approval.purchasePrice && approval.purchasePrice > 50000) {
|
||||||
|
factors.push({
|
||||||
|
type: 'PRICE',
|
||||||
|
title: '价格风险',
|
||||||
|
description: '采购价格较高,建议详细审查',
|
||||||
|
level: 'MEDIUM',
|
||||||
|
score: 70
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 申请人风险
|
||||||
|
if (approval.applicantName && approval.applicantName.includes('新员工')) {
|
||||||
|
factors.push({
|
||||||
|
type: 'APPLICANT',
|
||||||
|
title: '申请人风险',
|
||||||
|
description: '新员工申请,建议加强指导',
|
||||||
|
level: 'LOW',
|
||||||
|
score: 30
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设备类型风险
|
||||||
|
if (approval.equipmentType && ['危险设备', '精密设备'].includes(approval.equipmentType)) {
|
||||||
|
factors.push({
|
||||||
|
type: 'EQUIPMENT',
|
||||||
|
title: '设备类型风险',
|
||||||
|
description: '特殊设备类型,需要专业评估',
|
||||||
|
level: 'HIGH',
|
||||||
|
score: 85
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 申请时间风险
|
||||||
|
if (approval.applyTime) {
|
||||||
|
const applyDate = new Date(approval.applyTime)
|
||||||
|
const now = new Date()
|
||||||
|
const diffDays = Math.ceil((now.getTime() - applyDate.getTime()) / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
|
if (diffDays > 30) {
|
||||||
|
factors.push({
|
||||||
|
type: 'TIMING',
|
||||||
|
title: '时间风险',
|
||||||
|
description: '申请时间较长,可能影响业务',
|
||||||
|
level: 'MEDIUM',
|
||||||
|
score: 60
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return factors
|
||||||
|
})
|
||||||
|
|
||||||
|
// 审批历史统计
|
||||||
|
const historyStats = computed((): HistoryStats => {
|
||||||
|
// 这里应该从API获取真实的统计数据
|
||||||
|
// 暂时使用模拟数据
|
||||||
|
return {
|
||||||
|
total: 156,
|
||||||
|
approved: 89,
|
||||||
|
avgTime: 2.5
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
const toggleAssistant = (enabled: boolean) => {
|
||||||
|
if (enabled) {
|
||||||
|
message.success('智能审批助手已启用')
|
||||||
|
} else {
|
||||||
|
message.info('智能审批助手已禁用')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRecommendationClass = () => {
|
||||||
|
switch (recommendation.value) {
|
||||||
|
case 'APPROVE':
|
||||||
|
return 'recommendation-approve'
|
||||||
|
case 'REJECT':
|
||||||
|
return 'recommendation-reject'
|
||||||
|
default:
|
||||||
|
return 'recommendation-review'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRecommendationTitle = () => {
|
||||||
|
switch (recommendation.value) {
|
||||||
|
case 'APPROVE':
|
||||||
|
return '建议通过'
|
||||||
|
case 'REJECT':
|
||||||
|
return '建议拒绝'
|
||||||
|
default:
|
||||||
|
return '需要人工审查'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRecommendationReason = () => {
|
||||||
|
if (!currentApproval.value) return ''
|
||||||
|
|
||||||
|
const approval = currentApproval.value
|
||||||
|
|
||||||
|
switch (recommendation.value) {
|
||||||
|
case 'APPROVE':
|
||||||
|
if (approval.businessType === BusinessType.PROCUREMENT) {
|
||||||
|
return '采购申请信息完整,价格合理,建议通过'
|
||||||
|
} else if (approval.businessType === BusinessType.BORROW) {
|
||||||
|
return '借用申请理由充分,设备状态良好,建议通过'
|
||||||
|
} else {
|
||||||
|
return '申请信息完整,符合审批条件,建议通过'
|
||||||
|
}
|
||||||
|
case 'REJECT':
|
||||||
|
if (approval.businessType === BusinessType.PROCUREMENT) {
|
||||||
|
return '采购价格过高或信息不完整,建议拒绝'
|
||||||
|
} else if (approval.businessType === BusinessType.BORROW) {
|
||||||
|
return '借用理由不充分或设备状态不佳,建议拒绝'
|
||||||
|
} else {
|
||||||
|
return '申请信息不完整或不符合条件,建议拒绝'
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return '申请情况复杂,需要审批人员详细审查后决定'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRiskClass = (level: string) => {
|
||||||
|
switch (level) {
|
||||||
|
case 'HIGH':
|
||||||
|
return 'risk-high'
|
||||||
|
case 'MEDIUM':
|
||||||
|
return 'risk-medium'
|
||||||
|
default:
|
||||||
|
return 'risk-low'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleQuickApprove = () => {
|
||||||
|
if (currentApproval.value) {
|
||||||
|
emit('quickApprove', currentApproval.value)
|
||||||
|
message.success('已触发快速审批通过')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleQuickReject = () => {
|
||||||
|
if (currentApproval.value) {
|
||||||
|
emit('quickReject', currentApproval.value)
|
||||||
|
message.success('已触发快速审批拒绝')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleManualReview = () => {
|
||||||
|
if (currentApproval.value) {
|
||||||
|
emit('manualReview', currentApproval.value)
|
||||||
|
message.info('请进行手动审批')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听审批数据变化
|
||||||
|
watch(() => props.approvalData, (newData) => {
|
||||||
|
if (newData) {
|
||||||
|
console.log('审批助手收到新的审批数据:', newData)
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.approval-assistant {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.assistant-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--color-fill-1);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-content {
|
||||||
|
padding: 16px;
|
||||||
|
|
||||||
|
.assistant-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-content {
|
||||||
|
.recommendation-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
|
||||||
|
&.recommendation-approve {
|
||||||
|
background: var(--color-success-light-1);
|
||||||
|
border-color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.recommendation-reject {
|
||||||
|
background: var(--color-danger-light-1);
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.recommendation-review {
|
||||||
|
background: var(--color-warning-light-1);
|
||||||
|
border-color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation-icon {
|
||||||
|
margin-right: 12px;
|
||||||
|
font-size: 24px;
|
||||||
|
|
||||||
|
.arco-icon {
|
||||||
|
&.recommendation-approve & {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.recommendation-reject & {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.recommendation-review & {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation-content {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.recommendation-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendation-reason {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-indicators {
|
||||||
|
.risk-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.risk-high {
|
||||||
|
background: var(--color-danger-light-1);
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.risk-medium {
|
||||||
|
background: var(--color-warning-light-1);
|
||||||
|
border-color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.risk-low {
|
||||||
|
background: var(--color-success-light-1);
|
||||||
|
border-color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-icon {
|
||||||
|
margin-right: 12px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-info {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.risk-title {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-description {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-score {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
text-align: center;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-disabled,
|
||||||
|
.no-approval-data {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,870 @@
|
||||||
|
<template>
|
||||||
|
<div class="notification-center">
|
||||||
|
<!-- 消息中心图标和徽章 -->
|
||||||
|
<div class="notification-trigger" @click="toggleDropdown">
|
||||||
|
<a-badge :count="unreadCount" :dot="hasUrgentNotifications">
|
||||||
|
<a-button type="text" class="notification-btn" title="消息中心">
|
||||||
|
<template #icon>
|
||||||
|
<IconNotification />
|
||||||
|
</template>
|
||||||
|
<span class="notification-text">消息中心</span>
|
||||||
|
</a-button>
|
||||||
|
</a-badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 消息中心弹窗 -->
|
||||||
|
<a-modal
|
||||||
|
v-model:visible="modalVisible"
|
||||||
|
title="消息中心"
|
||||||
|
width="800px"
|
||||||
|
:footer="false"
|
||||||
|
:mask-closable="true"
|
||||||
|
:closable="true"
|
||||||
|
:destroy-on-close="false"
|
||||||
|
:z-index="999999"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
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 router = useRouter()
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const modalVisible = ref(false)
|
||||||
|
const activeTab = ref('all')
|
||||||
|
const reminderModalVisible = ref(false)
|
||||||
|
const currentNotification = ref<any>(null)
|
||||||
|
|
||||||
|
// 提醒表单
|
||||||
|
const reminderForm = ref({
|
||||||
|
type: 'IMMEDIATE' as 'IMMEDIATE' | 'DELAYED' | 'RECURRING',
|
||||||
|
time: null as Date | null,
|
||||||
|
interval: 30
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const notifications = computed(() => notificationService.getAllNotifications())
|
||||||
|
const unreadCount = computed(() => notificationService.unreadCount.value)
|
||||||
|
const totalCount = computed(() => notifications.value.length)
|
||||||
|
const pendingCount = computed(() => notificationService.pendingCount.value)
|
||||||
|
const equipmentCount = computed(() =>
|
||||||
|
notificationService.equipmentBorrowCount.value +
|
||||||
|
notificationService.equipmentReturnCount.value +
|
||||||
|
notificationService.equipmentMaintenanceCount.value +
|
||||||
|
notificationService.equipmentAlertCount.value
|
||||||
|
)
|
||||||
|
const urgentCount = computed(() => notificationService.urgentCount.value)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.notification-center {
|
||||||
|
position: relative;
|
||||||
|
z-index: 999999;
|
||||||
|
|
||||||
|
.notification-trigger {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.notification-btn {
|
||||||
|
color: var(--color-text-1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-text {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消息中心弹窗样式
|
||||||
|
.notification-modal {
|
||||||
|
:deep(.arco-modal) {
|
||||||
|
z-index: 999999 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.arco-modal-mask) {
|
||||||
|
z-index: 999998 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.arco-modal-wrapper) {
|
||||||
|
z-index: 999999 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-count {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-tabs {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
:deep(.arco-tabs-nav) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-list {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 0;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--color-border-2);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-fill-2);
|
||||||
|
border-color: var(--color-primary-light-3);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.unread {
|
||||||
|
background-color: var(--color-primary-light-1);
|
||||||
|
border-color: var(--color-primary-light-3);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-primary-light-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.urgent {
|
||||||
|
border-left: 4px solid var(--color-danger);
|
||||||
|
background-color: var(--color-danger-light-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.high {
|
||||||
|
border-left: 4px solid var(--color-warning);
|
||||||
|
background-color: var(--color-warning-light-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-icon {
|
||||||
|
margin-right: 16px;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.notification-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-message {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
|
||||||
|
.notification-time {
|
||||||
|
color: var(--color-text-2);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .notification-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 0;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
margin-top: 16px;
|
||||||
|
background-color: var(--color-fill-1);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保弹窗在最上层
|
||||||
|
:deep(.arco-modal) {
|
||||||
|
z-index: 999999 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.arco-modal-mask) {
|
||||||
|
z-index: 999998 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.arco-modal-wrapper) {
|
||||||
|
z-index: 999999 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 针对Arco Design v2的样式
|
||||||
|
:deep(.arco-overlay) {
|
||||||
|
z-index: 999999 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.arco-overlay-container) {
|
||||||
|
z-index: 999999 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保消息中心弹窗不被其他元素遮挡
|
||||||
|
:deep(.arco-modal) {
|
||||||
|
z-index: 999999 !important;
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 强制设置最高优先级
|
||||||
|
:deep(.arco-modal-wrapper) {
|
||||||
|
z-index: 999999 !important;
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -12,29 +12,8 @@
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
|
|
||||||
<!-- 消息通知 -->
|
<!-- 消息通知中心 -->
|
||||||
<a-popover
|
<NotificationCenter ref="notificationCenterRef" />
|
||||||
position="bottom"
|
|
||||||
trigger="click"
|
|
||||||
:content-style="{ marginTop: '-5px', padding: 0, border: 'none' }"
|
|
||||||
:arrow-style="{ width: 0, height: 0 }"
|
|
||||||
>
|
|
||||||
<a-badge
|
|
||||||
:count="unreadMessageCount"
|
|
||||||
:dot="unreadMessageCount > 0"
|
|
||||||
:show-zero="false"
|
|
||||||
class="notification-badge"
|
|
||||||
>
|
|
||||||
<a-button size="mini" class="gi_hover_btn notification-btn">
|
|
||||||
<template #icon>
|
|
||||||
<icon-notification :size="18" />
|
|
||||||
</template>
|
|
||||||
</a-button>
|
|
||||||
</a-badge>
|
|
||||||
<template #content>
|
|
||||||
<Message @readall-success="getMessageCount" />
|
|
||||||
</template>
|
|
||||||
</a-popover>
|
|
||||||
|
|
||||||
<!-- 全屏切换组件 -->
|
<!-- 全屏切换组件 -->
|
||||||
<a-tooltip v-if="!['xs', 'sm'].includes(breakpoint)" content="全屏切换" position="bottom">
|
<a-tooltip v-if="!['xs', 'sm'].includes(breakpoint)" content="全屏切换" position="bottom">
|
||||||
|
@ -81,10 +60,12 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Modal, Notification } from '@arco-design/web-vue'
|
import { Modal, Notification } from '@arco-design/web-vue'
|
||||||
import { useFullscreen } from '@vueuse/core'
|
import { useFullscreen } from '@vueuse/core'
|
||||||
import { onMounted, ref, nextTick, onBeforeUnmount } from 'vue'
|
import { onMounted, ref, nextTick, computed } from 'vue'
|
||||||
import Message from './Message.vue'
|
import NotificationCenter from '@/components/NotificationCenter/index.vue'
|
||||||
import SettingDrawer from './SettingDrawer.vue'
|
import SettingDrawer from './SettingDrawer.vue'
|
||||||
import Search from './Search.vue'
|
import Search from './Search.vue'
|
||||||
|
import notificationService from '@/services/notificationService'
|
||||||
|
import websocketService from '@/services/websocketService'
|
||||||
|
|
||||||
import { useUserStore } from '@/stores'
|
import { useUserStore } from '@/stores'
|
||||||
import { getToken } from '@/utils/auth'
|
import { getToken } from '@/utils/auth'
|
||||||
|
@ -94,23 +75,10 @@ defineOptions({ name: 'HeaderRight' })
|
||||||
|
|
||||||
const { isDesktop } = useDevice()
|
const { isDesktop } = useDevice()
|
||||||
const { breakpoint } = useBreakpoint()
|
const { breakpoint } = useBreakpoint()
|
||||||
let socket: WebSocket | null = null
|
const notificationCenterRef = ref()
|
||||||
|
|
||||||
// 清理函数
|
// 使用通知服务的未读消息数量
|
||||||
onBeforeUnmount(() => {
|
const unreadMessageCount = computed(() => notificationService.unreadCount.value)
|
||||||
if (socket) {
|
|
||||||
socket.close()
|
|
||||||
socket = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清理标题闪烁
|
|
||||||
if (titleFlashInterval) {
|
|
||||||
clearInterval(titleFlashInterval)
|
|
||||||
titleFlashInterval = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const unreadMessageCount = ref(0)
|
|
||||||
|
|
||||||
// 语音提示功能
|
// 语音提示功能
|
||||||
const playNotificationSound = () => {
|
const playNotificationSound = () => {
|
||||||
|
@ -167,189 +135,89 @@ const flashPageTitle = () => {
|
||||||
}, 500)
|
}, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设置通知监听器
|
||||||
|
const setupNotificationListeners = () => {
|
||||||
|
// 监听新通知添加事件
|
||||||
|
notificationService.on('add', (notification) => {
|
||||||
|
console.log('收到新通知:', notification)
|
||||||
|
|
||||||
|
// 播放语音提示
|
||||||
|
playNotificationSound()
|
||||||
|
|
||||||
|
// 触发页面标题闪烁
|
||||||
|
flashPageTitle()
|
||||||
|
|
||||||
|
// 显示桌面通知
|
||||||
|
if (notification.priority === 'HIGH' || notification.priority === 'URGENT') {
|
||||||
|
Notification.info({
|
||||||
|
title: notification.title,
|
||||||
|
content: notification.content,
|
||||||
|
duration: 5000,
|
||||||
|
closable: true,
|
||||||
|
position: 'topRight'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 暴露测试函数到全局,方便在控制台测试
|
// 暴露测试函数到全局,方便在控制台测试
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
(window as any).testNotification = {
|
(window as any).testNotification = {
|
||||||
playSound: playNotificationSound,
|
playSound: playNotificationSound,
|
||||||
flashTitle: flashPageTitle,
|
flashTitle: flashPageTitle,
|
||||||
showNotification: () => {
|
showNotification: () => {
|
||||||
Notification.info({
|
notificationService.addNotification({
|
||||||
|
type: 'SYSTEM',
|
||||||
title: '测试通知',
|
title: '测试通知',
|
||||||
content: '这是一个测试通知,用于验证通知功能是否正常工作。',
|
content: '这是一个测试通知,用于验证通知功能是否正常工作。',
|
||||||
duration: 5000,
|
priority: 'HIGH',
|
||||||
closable: true,
|
category: '测试',
|
||||||
position: 'topRight'
|
source: 'TEST'
|
||||||
})
|
})
|
||||||
unreadMessageCount.value++
|
|
||||||
},
|
},
|
||||||
testAll: () => {
|
testAll: () => {
|
||||||
playNotificationSound()
|
playNotificationSound()
|
||||||
flashPageTitle()
|
flashPageTitle()
|
||||||
Notification.info({
|
notificationService.addNotification({
|
||||||
|
type: 'SYSTEM',
|
||||||
title: '测试通知',
|
title: '测试通知',
|
||||||
content: '这是一个测试通知,用于验证通知功能是否正常工作。',
|
content: '这是一个测试通知,用于验证通知功能是否正常工作。',
|
||||||
duration: 5000,
|
priority: 'HIGH',
|
||||||
closable: true,
|
category: '测试',
|
||||||
position: 'topRight'
|
source: 'TEST'
|
||||||
})
|
})
|
||||||
unreadMessageCount.value++
|
|
||||||
},
|
},
|
||||||
// 添加调试函数
|
// 添加调试函数
|
||||||
debugWebSocket: () => {
|
debugNotification: () => {
|
||||||
console.log('=== WebSocket 调试信息 ===')
|
console.log('=== 通知服务调试信息 ===')
|
||||||
console.log('Socket对象:', socket)
|
console.log('未读消息数量:', unreadMessageCount.value)
|
||||||
console.log('Socket状态:', socket ? socket.readyState : '未连接')
|
console.log('所有通知:', notificationService.getAllNotifications())
|
||||||
console.log('Token:', getToken())
|
console.log('通知统计:', notificationService.getStats())
|
||||||
console.log('环境变量:', import.meta.env.VITE_API_WS_URL)
|
console.log('WebSocket状态:', websocketService.getStatus())
|
||||||
console.log('未读消息计数:', unreadMessageCount.value)
|
|
||||||
console.log('用户Token:', userStore.token)
|
|
||||||
},
|
},
|
||||||
// 手动触发WebSocket消息处理
|
// 手动添加测试通知
|
||||||
simulateWebSocketMessage: () => {
|
addTestNotification: (type = 'PROCUREMENT') => {
|
||||||
const mockMessage = {
|
const testNotification = {
|
||||||
type: "PROCUREMENT_APPLICATION",
|
type: type as any,
|
||||||
title: "新的采购申请",
|
title: `测试${type}通知`,
|
||||||
content: "收到来自 测试用户 的设备采购申请:测试设备"
|
content: `这是一个测试${type}通知,时间:${new Date().toLocaleString()}`,
|
||||||
|
priority: 'HIGH' as any,
|
||||||
|
category: '测试',
|
||||||
|
source: 'TEST',
|
||||||
|
actionRequired: true
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = new MessageEvent('message', {
|
notificationService.addNotification(testNotification)
|
||||||
data: JSON.stringify(mockMessage)
|
console.log('已添加测试通知:', testNotification)
|
||||||
})
|
|
||||||
|
|
||||||
if (socket && socket.onmessage) {
|
|
||||||
console.log('模拟WebSocket消息:', mockMessage)
|
|
||||||
socket.onmessage(event)
|
|
||||||
} else {
|
|
||||||
console.error('WebSocket连接不存在或onmessage未设置')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// 强制重新连接WebSocket
|
|
||||||
reconnectWebSocket: () => {
|
|
||||||
console.log('强制重新连接WebSocket')
|
|
||||||
const token = getToken()
|
|
||||||
if (token) {
|
|
||||||
if (socket) {
|
|
||||||
socket.close()
|
|
||||||
socket = null
|
|
||||||
}
|
|
||||||
initWebSocket(token)
|
|
||||||
} else {
|
|
||||||
console.error('Token不存在,无法重新连接')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 暴露socket对象到全局,方便调试
|
// 暴露通知服务到全局,方便调试
|
||||||
;(window as any).socket = socket
|
;(window as any).notificationService = notificationService
|
||||||
|
;(window as any).websocketService = websocketService
|
||||||
;(window as any).unreadMessageCount = unreadMessageCount
|
;(window as any).unreadMessageCount = unreadMessageCount
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化 WebSocket - 使用防抖避免重复连接
|
|
||||||
let initTimer: NodeJS.Timeout | null = null
|
|
||||||
const initWebSocket = (token: string) => {
|
|
||||||
if (initTimer) {
|
|
||||||
clearTimeout(initTimer)
|
|
||||||
}
|
|
||||||
|
|
||||||
initTimer = setTimeout(() => {
|
|
||||||
// 如果已有连接,先关闭
|
|
||||||
if (socket) {
|
|
||||||
socket.close()
|
|
||||||
socket = null
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 修复WebSocket URL,确保使用正确的端口
|
|
||||||
const wsUrl = import.meta.env.VITE_API_WS_URL || 'ws://localhost:8888'
|
|
||||||
const wsEndpoint = wsUrl.replace('8000', '8888') // 确保使用8888端口
|
|
||||||
console.log('正在连接WebSocket:', `${wsEndpoint}/websocket?token=${token}`)
|
|
||||||
|
|
||||||
socket = new WebSocket(`${wsEndpoint}/websocket?token=${token}`)
|
|
||||||
|
|
||||||
socket.onopen = () => {
|
|
||||||
console.log('WebSocket连接成功')
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.onmessage = (event) => {
|
|
||||||
console.log('收到WebSocket消息:', event.data)
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data)
|
|
||||||
|
|
||||||
// 处理通知消息
|
|
||||||
if (data.type && data.title && data.content) {
|
|
||||||
console.log('处理通知消息:', data)
|
|
||||||
|
|
||||||
// 播放语音提示
|
|
||||||
playNotificationSound()
|
|
||||||
|
|
||||||
// 显示通知
|
|
||||||
Notification.info({
|
|
||||||
title: data.title,
|
|
||||||
content: data.content,
|
|
||||||
duration: 5000,
|
|
||||||
closable: true,
|
|
||||||
position: 'topRight'
|
|
||||||
})
|
|
||||||
|
|
||||||
// 增加未读消息计数
|
|
||||||
unreadMessageCount.value++
|
|
||||||
|
|
||||||
// 触发页面标题闪烁
|
|
||||||
flashPageTitle()
|
|
||||||
} else {
|
|
||||||
// 处理简单的数字消息(兼容旧版本)
|
|
||||||
const count = Number.parseInt(event.data)
|
|
||||||
if (!isNaN(count)) {
|
|
||||||
unreadMessageCount.value = count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('解析WebSocket消息失败:', error)
|
|
||||||
// 尝试解析为数字(兼容旧版本)
|
|
||||||
const count = Number.parseInt(event.data)
|
|
||||||
if (!isNaN(count)) {
|
|
||||||
unreadMessageCount.value = count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.onerror = (error) => {
|
|
||||||
console.error('WebSocket连接错误:', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.onclose = (event) => {
|
|
||||||
console.log('WebSocket连接关闭:', event.code, event.reason)
|
|
||||||
socket = null
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('创建WebSocket连接失败:', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
initTimer = null
|
|
||||||
}, 100) // 100ms防抖
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查询未读消息数量
|
|
||||||
const getMessageCount = async () => {
|
|
||||||
try {
|
|
||||||
const token = getToken()
|
|
||||||
console.log('获取到token:', token ? '存在' : '不存在')
|
|
||||||
|
|
||||||
if (token && !socket) {
|
|
||||||
console.log('准备初始化WebSocket连接')
|
|
||||||
nextTick(() => {
|
|
||||||
initWebSocket(token)
|
|
||||||
})
|
|
||||||
} else if (!token) {
|
|
||||||
console.warn('Token不存在,无法建立WebSocket连接')
|
|
||||||
} else if (socket) {
|
|
||||||
console.log('WebSocket连接已存在')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to get message count:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { isFullscreen, toggle } = useFullscreen()
|
const { isFullscreen, toggle } = useFullscreen()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
@ -377,32 +245,16 @@ const logout = () => {
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
// 立即尝试初始化WebSocket
|
// 设置通知监听器
|
||||||
getMessageCount()
|
setupNotificationListeners()
|
||||||
|
|
||||||
// 如果第一次失败,1秒后重试
|
// 确保WebSocket服务已连接
|
||||||
setTimeout(() => {
|
if (getToken() && !websocketService.connected.value) {
|
||||||
if (!socket) {
|
console.log('初始化WebSocket连接')
|
||||||
console.log('首次连接失败,重试WebSocket连接')
|
websocketService.connect()
|
||||||
getMessageCount()
|
}
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听用户登录状态变化
|
|
||||||
watch(() => userStore.token, (newToken, oldToken) => {
|
|
||||||
console.log('Token变化:', { oldToken: oldToken ? '存在' : '不存在', newToken: newToken ? '存在' : '不存在' })
|
|
||||||
|
|
||||||
if (newToken && !socket) {
|
|
||||||
console.log('用户登录,初始化WebSocket连接')
|
|
||||||
getMessageCount()
|
|
||||||
} else if (!newToken && socket) {
|
|
||||||
console.log('用户登出,关闭WebSocket连接')
|
|
||||||
socket.close()
|
|
||||||
socket = null
|
|
||||||
}
|
|
||||||
}, { immediate: true })
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|
|
@ -0,0 +1,696 @@
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
export interface NotificationItem {
|
||||||
|
id: string
|
||||||
|
type: 'APPROVAL' | 'PENDING' | 'SYSTEM' | 'PROCUREMENT' | 'EQUIPMENT_BORROW' | 'EQUIPMENT_RETURN' | 'EQUIPMENT_MAINTENANCE' | 'EQUIPMENT_ALERT' | 'WORKFLOW'
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
createTime: string
|
||||||
|
read: boolean
|
||||||
|
targetUrl?: string
|
||||||
|
priority?: 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT'
|
||||||
|
category?: string
|
||||||
|
metadata?: Record<string, any>
|
||||||
|
// 新增字段
|
||||||
|
source?: string // 消息来源
|
||||||
|
actionRequired?: boolean // 是否需要操作
|
||||||
|
expiresAt?: string // 过期时间
|
||||||
|
relatedId?: string // 关联的业务ID
|
||||||
|
// 新增智能提醒字段
|
||||||
|
reminderTime?: string // 提醒时间
|
||||||
|
reminderType?: 'IMMEDIATE' | 'DELAYED' | 'RECURRING' // 提醒类型
|
||||||
|
reminderInterval?: number // 重复提醒间隔(分钟)
|
||||||
|
lastReminderTime?: string // 上次提醒时间
|
||||||
|
}
|
||||||
|
|
||||||
|
class NotificationService {
|
||||||
|
private notifications = ref<NotificationItem[]>([])
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
public readonly unreadCount = computed(() =>
|
||||||
|
this.notifications.value.filter(n => !n.read).length
|
||||||
|
)
|
||||||
|
|
||||||
|
public readonly pendingCount = computed(() =>
|
||||||
|
this.notifications.value.filter(n =>
|
||||||
|
n.type === 'PENDING' && !n.read
|
||||||
|
).length
|
||||||
|
)
|
||||||
|
|
||||||
|
public readonly approvalCount = computed(() =>
|
||||||
|
this.notifications.value.filter(n =>
|
||||||
|
n.type === 'APPROVAL' && !n.read
|
||||||
|
).length
|
||||||
|
)
|
||||||
|
|
||||||
|
public readonly procurementCount = computed(() =>
|
||||||
|
this.notifications.value.filter(n =>
|
||||||
|
n.type === 'PROCUREMENT' && !n.read
|
||||||
|
).length
|
||||||
|
)
|
||||||
|
|
||||||
|
// 新增:设备借用通知数量
|
||||||
|
public readonly equipmentBorrowCount = computed(() =>
|
||||||
|
this.notifications.value.filter(n =>
|
||||||
|
n.type === 'EQUIPMENT_BORROW' && !n.read
|
||||||
|
).length
|
||||||
|
)
|
||||||
|
|
||||||
|
// 新增:设备归还通知数量
|
||||||
|
public readonly equipmentReturnCount = computed(() =>
|
||||||
|
this.notifications.value.filter(n =>
|
||||||
|
n.type === 'EQUIPMENT_RETURN' && !n.read
|
||||||
|
).length
|
||||||
|
)
|
||||||
|
|
||||||
|
// 新增:设备维护通知数量
|
||||||
|
public readonly equipmentMaintenanceCount = computed(() =>
|
||||||
|
this.notifications.value.filter(n =>
|
||||||
|
n.type === 'EQUIPMENT_MAINTENANCE' && !n.read
|
||||||
|
).length
|
||||||
|
)
|
||||||
|
|
||||||
|
// 新增:设备告警通知数量
|
||||||
|
public readonly equipmentAlertCount = computed(() =>
|
||||||
|
this.notifications.value.filter(n =>
|
||||||
|
n.type === 'EQUIPMENT_ALERT' && !n.read
|
||||||
|
).length
|
||||||
|
)
|
||||||
|
|
||||||
|
// 新增:工作流通知数量
|
||||||
|
public readonly workflowCount = computed(() =>
|
||||||
|
this.notifications.value.filter(n =>
|
||||||
|
n.type === 'WORKFLOW' && !n.read
|
||||||
|
).length
|
||||||
|
)
|
||||||
|
|
||||||
|
// 新增:需要操作的通知数量
|
||||||
|
public readonly actionRequiredCount = computed(() =>
|
||||||
|
this.notifications.value.filter(n =>
|
||||||
|
n.actionRequired && !n.read
|
||||||
|
).length
|
||||||
|
)
|
||||||
|
|
||||||
|
// 新增:紧急通知数量
|
||||||
|
public readonly urgentCount = computed(() =>
|
||||||
|
this.notifications.value.filter(n =>
|
||||||
|
n.priority === 'URGENT' && !n.read
|
||||||
|
).length
|
||||||
|
)
|
||||||
|
|
||||||
|
// 新增:今日通知数量
|
||||||
|
public readonly todayCount = computed(() => {
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
return this.notifications.value.filter(n => {
|
||||||
|
const createDate = new Date(n.createTime)
|
||||||
|
return createDate >= today
|
||||||
|
}).length
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取所有通知
|
||||||
|
public getAllNotifications() {
|
||||||
|
return this.notifications.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取未读通知
|
||||||
|
public getUnreadNotifications() {
|
||||||
|
return this.notifications.value.filter(n => !n.read)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取特定类型的通知
|
||||||
|
public getNotificationsByType(type: NotificationItem['type']) {
|
||||||
|
return this.notifications.value.filter(n => n.type === type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:获取需要操作的通知
|
||||||
|
public getActionRequiredNotifications() {
|
||||||
|
return this.notifications.value.filter(n => n.actionRequired && !n.read)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:获取紧急通知
|
||||||
|
public getUrgentNotifications() {
|
||||||
|
return this.notifications.value.filter(n => n.priority === 'URGENT' && !n.read)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:获取今日通知
|
||||||
|
public getTodayNotifications() {
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
return this.notifications.value.filter(n => {
|
||||||
|
const createDate = new Date(n.createTime)
|
||||||
|
return createDate >= today
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:获取相关通知
|
||||||
|
public getRelatedNotifications(relatedId: string) {
|
||||||
|
return this.notifications.value.filter(n => n.relatedId === relatedId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:获取需要提醒的通知
|
||||||
|
public getReminderNotifications() {
|
||||||
|
const now = new Date()
|
||||||
|
return this.notifications.value.filter(n => {
|
||||||
|
if (!n.reminderTime) return false
|
||||||
|
|
||||||
|
const reminderDate = new Date(n.reminderTime)
|
||||||
|
if (reminderDate <= now) {
|
||||||
|
// 检查重复提醒
|
||||||
|
if (n.reminderType === 'RECURRING' && n.reminderInterval) {
|
||||||
|
const lastReminder = n.lastReminderTime ? new Date(n.lastReminderTime) : reminderDate
|
||||||
|
const nextReminder = new Date(lastReminder.getTime() + n.reminderInterval * 60 * 1000)
|
||||||
|
return nextReminder <= now
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:设置通知提醒
|
||||||
|
public setNotificationReminder(notificationId: string, reminderTime: string, reminderType: 'IMMEDIATE' | 'DELAYED' | 'RECURRING' = 'IMMEDIATE', reminderInterval?: number) {
|
||||||
|
const notification = this.notifications.value.find(n => n.id === notificationId)
|
||||||
|
if (notification) {
|
||||||
|
notification.reminderTime = reminderTime
|
||||||
|
notification.reminderType = reminderType
|
||||||
|
notification.reminderInterval = reminderInterval
|
||||||
|
this.saveToStorage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:取消通知提醒
|
||||||
|
public cancelNotificationReminder(notificationId: string) {
|
||||||
|
const notification = this.notifications.value.find(n => n.id === notificationId)
|
||||||
|
if (notification) {
|
||||||
|
notification.reminderTime = undefined
|
||||||
|
notification.reminderType = undefined
|
||||||
|
notification.reminderInterval = undefined
|
||||||
|
notification.lastReminderTime = undefined
|
||||||
|
this.saveToStorage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:标记提醒已处理
|
||||||
|
public markReminderProcessed(notificationId: string) {
|
||||||
|
const notification = this.notifications.value.find(n => n.id === notificationId)
|
||||||
|
if (notification && notification.reminderType === 'RECURRING') {
|
||||||
|
notification.lastReminderTime = new Date().toISOString()
|
||||||
|
this.saveToStorage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加通知
|
||||||
|
public addNotification(notification: Omit<NotificationItem, 'id' | 'createTime' | 'read'>) {
|
||||||
|
const newNotification: NotificationItem = {
|
||||||
|
...notification,
|
||||||
|
id: this.generateId(),
|
||||||
|
createTime: new Date().toISOString(),
|
||||||
|
read: false,
|
||||||
|
// 设置默认值
|
||||||
|
priority: notification.priority || 'NORMAL',
|
||||||
|
actionRequired: notification.actionRequired || false,
|
||||||
|
source: notification.source || 'SYSTEM'
|
||||||
|
}
|
||||||
|
|
||||||
|
this.notifications.value.unshift(newNotification)
|
||||||
|
|
||||||
|
// 限制通知数量,避免内存泄漏
|
||||||
|
if (this.notifications.value.length > 200) {
|
||||||
|
this.notifications.value = this.notifications.value.slice(0, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发事件
|
||||||
|
this.emitNotificationEvent('add', newNotification)
|
||||||
|
|
||||||
|
// 自动保存
|
||||||
|
this.saveToStorage()
|
||||||
|
|
||||||
|
// 检查是否需要播放提示音
|
||||||
|
if (newNotification.priority === 'URGENT' || newNotification.priority === 'HIGH') {
|
||||||
|
this.playNotificationSound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return newNotification
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记通知为已读
|
||||||
|
public markAsRead(id: string) {
|
||||||
|
const notification = this.notifications.value.find(n => n.id === id)
|
||||||
|
if (notification) {
|
||||||
|
notification.read = true
|
||||||
|
this.emitNotificationEvent('read', notification)
|
||||||
|
this.saveToStorage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记所有通知为已读
|
||||||
|
public markAllAsRead() {
|
||||||
|
this.notifications.value.forEach(n => n.read = true)
|
||||||
|
this.emitNotificationEvent('readAll', null)
|
||||||
|
this.saveToStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记特定类型通知为已读
|
||||||
|
public markTypeAsRead(type: NotificationItem['type']) {
|
||||||
|
this.notifications.value
|
||||||
|
.filter(n => n.type === type)
|
||||||
|
.forEach(n => n.read = true)
|
||||||
|
this.emitNotificationEvent('readType', { type })
|
||||||
|
this.saveToStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:标记相关通知为已读
|
||||||
|
public markRelatedAsRead(relatedId: string) {
|
||||||
|
this.notifications.value
|
||||||
|
.filter(n => n.relatedId === relatedId)
|
||||||
|
.forEach(n => n.read = true)
|
||||||
|
this.emitNotificationEvent('readRelated', { relatedId })
|
||||||
|
this.saveToStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除通知
|
||||||
|
public removeNotification(id: string) {
|
||||||
|
const index = this.notifications.value.findIndex(n => n.id === id)
|
||||||
|
if (index > -1) {
|
||||||
|
const notification = this.notifications.value[index]
|
||||||
|
this.notifications.value.splice(index, 1)
|
||||||
|
this.emitNotificationEvent('remove', notification)
|
||||||
|
this.saveToStorage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空所有通知
|
||||||
|
public clearAll() {
|
||||||
|
this.notifications.value = []
|
||||||
|
this.emitNotificationEvent('clear', null)
|
||||||
|
this.saveToStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空已读通知
|
||||||
|
public clearRead() {
|
||||||
|
this.notifications.value = this.notifications.value.filter(n => !n.read)
|
||||||
|
this.emitNotificationEvent('clearRead', null)
|
||||||
|
this.saveToStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:清空过期通知
|
||||||
|
public clearExpired() {
|
||||||
|
const now = new Date()
|
||||||
|
const beforeCount = this.notifications.value.length
|
||||||
|
|
||||||
|
this.notifications.value = this.notifications.value.filter(n => {
|
||||||
|
if (!n.expiresAt) return true
|
||||||
|
const expireDate = new Date(n.expiresAt)
|
||||||
|
return expireDate > now
|
||||||
|
})
|
||||||
|
|
||||||
|
const afterCount = this.notifications.value.length
|
||||||
|
if (beforeCount !== afterCount) {
|
||||||
|
this.emitNotificationEvent('clearExpired', { clearedCount: beforeCount - afterCount })
|
||||||
|
this.saveToStorage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取通知统计
|
||||||
|
public getStats() {
|
||||||
|
const total = this.notifications.value.length
|
||||||
|
const unread = this.unreadCount.value
|
||||||
|
const pending = this.pendingCount.value
|
||||||
|
const approval = this.approvalCount.value
|
||||||
|
const procurement = this.procurementCount.value
|
||||||
|
const actionRequired = this.actionRequiredCount.value
|
||||||
|
const urgent = this.urgentCount.value
|
||||||
|
const today = this.todayCount.value
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
unread,
|
||||||
|
pending,
|
||||||
|
approval,
|
||||||
|
procurement,
|
||||||
|
actionRequired,
|
||||||
|
urgent,
|
||||||
|
today,
|
||||||
|
read: total - unread
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索通知
|
||||||
|
public searchNotifications(keyword: string) {
|
||||||
|
if (!keyword.trim()) {
|
||||||
|
return this.notifications.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerKeyword = keyword.toLowerCase()
|
||||||
|
return this.notifications.value.filter(n =>
|
||||||
|
n.title.toLowerCase().includes(lowerKeyword) ||
|
||||||
|
n.content.toLowerCase().includes(lowerKeyword) ||
|
||||||
|
n.category?.toLowerCase().includes(lowerKeyword) ||
|
||||||
|
n.source?.toLowerCase().includes(lowerKeyword)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按优先级排序
|
||||||
|
public sortByPriority() {
|
||||||
|
const priorityOrder = { 'URGENT': 4, 'HIGH': 3, 'NORMAL': 2, 'LOW': 1 }
|
||||||
|
|
||||||
|
this.notifications.value.sort((a, b) => {
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:按时间排序
|
||||||
|
public sortByTime() {
|
||||||
|
this.notifications.value.sort((a, b) =>
|
||||||
|
new Date(b.createTime).getTime() - new Date(a.createTime).getTime()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:按类型分组
|
||||||
|
public groupByType() {
|
||||||
|
const groups: Record<string, NotificationItem[]> = {}
|
||||||
|
|
||||||
|
this.notifications.value.forEach(notification => {
|
||||||
|
const type = notification.type
|
||||||
|
if (!groups[type]) {
|
||||||
|
groups[type] = []
|
||||||
|
}
|
||||||
|
groups[type].push(notification)
|
||||||
|
})
|
||||||
|
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加采购申请通知
|
||||||
|
public addProcurementNotification(equipmentName: string, applicantName: string) {
|
||||||
|
return this.addNotification({
|
||||||
|
type: 'PROCUREMENT',
|
||||||
|
title: '新的设备采购申请',
|
||||||
|
content: `收到来自 ${applicantName} 的设备采购申请:${equipmentName}`,
|
||||||
|
targetUrl: '/asset-management/device-management/approval',
|
||||||
|
priority: 'HIGH',
|
||||||
|
category: '设备采购',
|
||||||
|
actionRequired: true,
|
||||||
|
source: 'PROCUREMENT_SYSTEM',
|
||||||
|
metadata: {
|
||||||
|
equipmentName,
|
||||||
|
applicantName,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加审批结果通知
|
||||||
|
public addApprovalNotification(equipmentName: string, result: 'APPROVED' | 'REJECTED', approverName: string) {
|
||||||
|
const isApproved = result === 'APPROVED'
|
||||||
|
return this.addNotification({
|
||||||
|
type: 'APPROVAL',
|
||||||
|
title: `采购申请${isApproved ? '已通过' : '已拒绝'}`,
|
||||||
|
content: `您的设备采购申请"${equipmentName}"${isApproved ? '已通过' : '被拒绝'},审批人:${approverName}`,
|
||||||
|
targetUrl: '/asset-management/device-management/procurement',
|
||||||
|
priority: isApproved ? 'NORMAL' : 'HIGH',
|
||||||
|
category: '审批结果',
|
||||||
|
actionRequired: !isApproved, // 被拒绝时需要操作
|
||||||
|
source: 'APPROVAL_SYSTEM',
|
||||||
|
metadata: {
|
||||||
|
equipmentName,
|
||||||
|
result,
|
||||||
|
approverName,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加系统通知
|
||||||
|
public addSystemNotification(title: string, content: string, priority: NotificationItem['priority'] = 'NORMAL') {
|
||||||
|
return this.addNotification({
|
||||||
|
type: 'SYSTEM',
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
priority,
|
||||||
|
category: '系统通知',
|
||||||
|
source: 'SYSTEM',
|
||||||
|
actionRequired: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:添加待审批通知
|
||||||
|
public addPendingApprovalNotification(equipmentName: string, applicantName: string, businessType: string) {
|
||||||
|
return this.addNotification({
|
||||||
|
type: 'PENDING',
|
||||||
|
title: '新的审批申请',
|
||||||
|
content: `收到来自 ${applicantName} 的${businessType}申请:${equipmentName}`,
|
||||||
|
targetUrl: '/asset-management/device-management/approval',
|
||||||
|
priority: 'HIGH',
|
||||||
|
category: '审批申请',
|
||||||
|
actionRequired: true,
|
||||||
|
source: 'APPROVAL_SYSTEM',
|
||||||
|
metadata: {
|
||||||
|
equipmentName,
|
||||||
|
applicantName,
|
||||||
|
businessType,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:添加设备借用通知
|
||||||
|
public addEquipmentBorrowNotification(equipmentName: string, borrowerName: string, borrowReason: string) {
|
||||||
|
return this.addNotification({
|
||||||
|
type: 'EQUIPMENT_BORROW',
|
||||||
|
title: '新的设备借用申请',
|
||||||
|
content: `收到来自 ${borrowerName} 的设备借用申请:${equipmentName},借用原因:${borrowReason}`,
|
||||||
|
targetUrl: '/asset-management/device-management/device-center',
|
||||||
|
priority: 'HIGH',
|
||||||
|
category: '设备借用',
|
||||||
|
actionRequired: true,
|
||||||
|
source: 'EQUIPMENT_SYSTEM',
|
||||||
|
metadata: {
|
||||||
|
equipmentName,
|
||||||
|
borrowerName,
|
||||||
|
borrowReason,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:添加设备归还通知
|
||||||
|
public addEquipmentReturnNotification(equipmentName: string, returnerName: string, returnCondition: string) {
|
||||||
|
return this.addNotification({
|
||||||
|
type: 'EQUIPMENT_RETURN',
|
||||||
|
title: '设备归还申请',
|
||||||
|
content: `收到来自 ${returnerName} 的设备归还申请:${equipmentName},归还状态:${returnCondition}`,
|
||||||
|
targetUrl: '/asset-management/device-management/device-center',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
category: '设备归还',
|
||||||
|
actionRequired: false,
|
||||||
|
source: 'EQUIPMENT_SYSTEM',
|
||||||
|
metadata: {
|
||||||
|
equipmentName,
|
||||||
|
returnerName,
|
||||||
|
returnCondition,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:添加设备维护通知
|
||||||
|
public addEquipmentMaintenanceNotification(equipmentName: string, maintenanceType: string, estimatedDuration: string) {
|
||||||
|
return this.addNotification({
|
||||||
|
type: 'EQUIPMENT_MAINTENANCE',
|
||||||
|
title: '设备维护申请',
|
||||||
|
content: `设备 ${equipmentName} 申请${maintenanceType}维护,预计时长:${estimatedDuration}`,
|
||||||
|
targetUrl: '/asset-management/device-management/device-center',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
category: '设备维护',
|
||||||
|
actionRequired: true,
|
||||||
|
source: 'EQUIPMENT_SYSTEM',
|
||||||
|
metadata: {
|
||||||
|
equipmentName,
|
||||||
|
maintenanceType,
|
||||||
|
estimatedDuration,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:添加工作流通知
|
||||||
|
public addWorkflowNotification(workflowName: string, currentNode: string, actionRequired: string) {
|
||||||
|
return this.addNotification({
|
||||||
|
type: 'WORKFLOW',
|
||||||
|
title: '工作流待处理',
|
||||||
|
content: `工作流"${workflowName}"当前节点:${currentNode},需要操作:${actionRequired}`,
|
||||||
|
targetUrl: '/asset-management/device-management/approval',
|
||||||
|
priority: 'HIGH',
|
||||||
|
category: '工作流',
|
||||||
|
actionRequired: true,
|
||||||
|
source: 'WORKFLOW_SYSTEM',
|
||||||
|
metadata: {
|
||||||
|
workflowName,
|
||||||
|
currentNode,
|
||||||
|
actionRequired,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:播放通知提示音
|
||||||
|
private playNotificationSound() {
|
||||||
|
try {
|
||||||
|
// 创建音频上下文
|
||||||
|
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||||
|
const oscillator = audioContext.createOscillator()
|
||||||
|
const gainNode = audioContext.createGain()
|
||||||
|
|
||||||
|
oscillator.connect(gainNode)
|
||||||
|
gainNode.connect(audioContext.destination)
|
||||||
|
|
||||||
|
oscillator.frequency.setValueAtTime(800, audioContext.currentTime)
|
||||||
|
oscillator.frequency.setValueAtTime(600, audioContext.currentTime + 0.1)
|
||||||
|
|
||||||
|
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime)
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3)
|
||||||
|
|
||||||
|
oscillator.start(audioContext.currentTime)
|
||||||
|
oscillator.stop(audioContext.currentTime + 0.3)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('无法播放通知提示音:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事件系统
|
||||||
|
private eventListeners: Map<string, Function[]> = new Map()
|
||||||
|
|
||||||
|
public on(event: string, callback: Function) {
|
||||||
|
if (!this.eventListeners.has(event)) {
|
||||||
|
this.eventListeners.set(event, [])
|
||||||
|
}
|
||||||
|
this.eventListeners.get(event)!.push(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
public off(event: string, callback: Function) {
|
||||||
|
const listeners = this.eventListeners.get(event)
|
||||||
|
if (listeners) {
|
||||||
|
const index = listeners.indexOf(callback)
|
||||||
|
if (index > -1) {
|
||||||
|
listeners.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitNotificationEvent(event: string, data: any) {
|
||||||
|
const listeners = this.eventListeners.get(event)
|
||||||
|
if (listeners) {
|
||||||
|
listeners.forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('通知事件回调执行失败:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成唯一ID
|
||||||
|
private generateId(): string {
|
||||||
|
return `notification_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 持久化存储
|
||||||
|
public saveToStorage() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('notifications', JSON.stringify(this.notifications.value))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存通知到本地存储失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public loadFromStorage() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('notifications')
|
||||||
|
if (stored) {
|
||||||
|
this.notifications.value = JSON.parse(stored)
|
||||||
|
|
||||||
|
// 清理过期通知
|
||||||
|
this.clearExpired()
|
||||||
|
|
||||||
|
// 重新排序
|
||||||
|
this.sortByPriority()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('从本地存储加载通知失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动保存
|
||||||
|
public enableAutoSave() {
|
||||||
|
// 监听通知变化,自动保存
|
||||||
|
this.on('add', () => this.saveToStorage())
|
||||||
|
this.on('read', () => this.saveToStorage())
|
||||||
|
this.on('remove', () => this.saveToStorage())
|
||||||
|
this.on('clear', () => this.saveToStorage())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:定期清理过期通知
|
||||||
|
public enableAutoCleanup() {
|
||||||
|
// 每小时清理一次过期通知
|
||||||
|
setInterval(() => {
|
||||||
|
this.clearExpired()
|
||||||
|
}, 60 * 60 * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:导出通知数据
|
||||||
|
public exportNotifications() {
|
||||||
|
try {
|
||||||
|
const data = JSON.stringify(this.notifications.value, null, 2)
|
||||||
|
const blob = new Blob([data], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `notifications_${new Date().toISOString().split('T')[0]}.json`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导出通知失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:导入通知数据
|
||||||
|
public importNotifications(data: string) {
|
||||||
|
try {
|
||||||
|
const notifications = JSON.parse(data)
|
||||||
|
if (Array.isArray(notifications)) {
|
||||||
|
this.notifications.value = [...this.notifications.value, ...notifications]
|
||||||
|
this.saveToStorage()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导入通知失败:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建单例实例
|
||||||
|
const notificationService = new NotificationService()
|
||||||
|
|
||||||
|
// 启用自动保存
|
||||||
|
notificationService.enableAutoSave()
|
||||||
|
|
||||||
|
// 启用自动清理
|
||||||
|
notificationService.enableAutoCleanup()
|
||||||
|
|
||||||
|
// 从本地存储加载通知
|
||||||
|
notificationService.loadFromStorage()
|
||||||
|
|
||||||
|
export default notificationService
|
|
@ -0,0 +1,492 @@
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import notificationService from './notificationService'
|
||||||
|
import websocketService from './websocketService'
|
||||||
|
|
||||||
|
export interface ProcurementItem {
|
||||||
|
id: string
|
||||||
|
equipmentName: string
|
||||||
|
applicantName: string
|
||||||
|
status: 'PENDING' | 'APPROVED' | 'REJECTED' | 'PROCESSING' | 'COMPLETED' | 'CANCELLED'
|
||||||
|
createTime: string
|
||||||
|
updateTime: string
|
||||||
|
priority: 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT'
|
||||||
|
category: string
|
||||||
|
description?: string
|
||||||
|
budget?: number
|
||||||
|
quantity: number
|
||||||
|
unit: string
|
||||||
|
expectedDelivery?: string
|
||||||
|
approvalHistory: ApprovalRecord[]
|
||||||
|
metadata?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalRecord {
|
||||||
|
id: string
|
||||||
|
approverName: string
|
||||||
|
action: 'APPROVE' | 'REJECT' | 'COMMENT'
|
||||||
|
comment?: string
|
||||||
|
timestamp: string
|
||||||
|
status: 'PENDING' | 'COMPLETED'
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProcurementSyncService {
|
||||||
|
private procurements = ref<ProcurementItem[]>([])
|
||||||
|
private isInitialized = ref(false)
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
public readonly pendingCount = computed(() =>
|
||||||
|
this.procurements.value.filter(p => p.status === 'PENDING').length
|
||||||
|
)
|
||||||
|
|
||||||
|
public readonly approvedCount = computed(() =>
|
||||||
|
this.procurements.value.filter(p => p.status === 'APPROVED').length
|
||||||
|
)
|
||||||
|
|
||||||
|
public readonly rejectedCount = computed(() =>
|
||||||
|
this.procurements.value.filter(p => p.status === 'REJECTED').length
|
||||||
|
)
|
||||||
|
|
||||||
|
public readonly processingCount = computed(() =>
|
||||||
|
this.procurements.value.filter(p => p.status === 'PROCESSING').length
|
||||||
|
)
|
||||||
|
|
||||||
|
public readonly completedCount = computed(() =>
|
||||||
|
this.procurements.value.filter(p => p.status === 'COMPLETED').length
|
||||||
|
)
|
||||||
|
|
||||||
|
public readonly urgentCount = computed(() =>
|
||||||
|
this.procurements.value.filter(p => p.priority === 'URGENT' && p.status === 'PENDING').length
|
||||||
|
)
|
||||||
|
|
||||||
|
public readonly highPriorityCount = computed(() =>
|
||||||
|
this.procurements.value.filter(p =>
|
||||||
|
(p.priority === 'HIGH' || p.priority === 'URGENT') &&
|
||||||
|
p.status === 'PENDING'
|
||||||
|
).length
|
||||||
|
)
|
||||||
|
|
||||||
|
// 获取所有采购项目
|
||||||
|
public getAllProcurements() {
|
||||||
|
return this.procurements.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取特定状态的采购项目
|
||||||
|
public getProcurementsByStatus(status: ProcurementItem['status']) {
|
||||||
|
return this.procurements.value.filter(p => p.status === status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取特定优先级的采购项目
|
||||||
|
public getProcurementsByPriority(priority: ProcurementItem['priority']) {
|
||||||
|
return this.procurements.value.filter(p => p.priority === priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取待审批的采购项目
|
||||||
|
public getPendingProcurements() {
|
||||||
|
return this.procurements.value.filter(p => p.status === 'PENDING')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取紧急采购项目
|
||||||
|
public getUrgentProcurements() {
|
||||||
|
return this.procurements.value.filter(p =>
|
||||||
|
p.priority === 'URGENT' && p.status === 'PENDING'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据ID获取采购项目
|
||||||
|
public getProcurementById(id: string) {
|
||||||
|
return this.procurements.value.find(p => p.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加采购申请
|
||||||
|
public addProcurement(procurement: Omit<ProcurementItem, 'id' | 'createTime' | 'updateTime' | 'approvalHistory'>) {
|
||||||
|
const newProcurement: ProcurementItem = {
|
||||||
|
...procurement,
|
||||||
|
id: this.generateId(),
|
||||||
|
createTime: new Date().toISOString(),
|
||||||
|
updateTime: new Date().toISOString(),
|
||||||
|
approvalHistory: []
|
||||||
|
}
|
||||||
|
|
||||||
|
this.procurements.value.unshift(newProcurement)
|
||||||
|
|
||||||
|
// 限制数量,避免内存泄漏
|
||||||
|
if (this.procurements.value.length > 500) {
|
||||||
|
this.procurements.value = this.procurements.value.slice(0, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加通知
|
||||||
|
notificationService.addProcurementNotification(
|
||||||
|
newProcurement.equipmentName,
|
||||||
|
newProcurement.applicantName
|
||||||
|
)
|
||||||
|
|
||||||
|
// 触发WebSocket更新
|
||||||
|
this.broadcastProcurementUpdate('ADD', newProcurement)
|
||||||
|
|
||||||
|
// 保存到本地存储
|
||||||
|
this.saveToStorage()
|
||||||
|
|
||||||
|
return newProcurement
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新采购状态
|
||||||
|
public updateProcurementStatus(id: string, status: ProcurementItem['status'], approverName?: string, comment?: string) {
|
||||||
|
const procurement = this.procurements.value.find(p => p.id === id)
|
||||||
|
if (!procurement) {
|
||||||
|
console.warn(`采购项目 ${id} 不存在`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldStatus = procurement.status
|
||||||
|
procurement.status = status
|
||||||
|
procurement.updateTime = new Date().toISOString()
|
||||||
|
|
||||||
|
// 添加审批记录
|
||||||
|
if (approverName) {
|
||||||
|
const approvalRecord: ApprovalRecord = {
|
||||||
|
id: this.generateId(),
|
||||||
|
approverName,
|
||||||
|
action: status === 'APPROVED' ? 'APPROVE' : 'REJECT',
|
||||||
|
comment,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
status: 'COMPLETED'
|
||||||
|
}
|
||||||
|
procurement.approvalHistory.push(approvalRecord)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加通知
|
||||||
|
if (status === 'APPROVED' || status === 'REJECTED') {
|
||||||
|
notificationService.addApprovalNotification(
|
||||||
|
procurement.equipmentName,
|
||||||
|
status,
|
||||||
|
approverName || '系统'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发WebSocket更新
|
||||||
|
this.broadcastProcurementUpdate('UPDATE', procurement)
|
||||||
|
|
||||||
|
// 保存到本地存储
|
||||||
|
this.saveToStorage()
|
||||||
|
|
||||||
|
console.log(`采购项目 ${id} 状态从 ${oldStatus} 更新为 ${status}`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量更新状态
|
||||||
|
public batchUpdateStatus(ids: string[], status: ProcurementItem['status'], approverName: string, comment?: string) {
|
||||||
|
const results = ids.map(id => this.updateProcurementStatus(id, status, approverName, comment))
|
||||||
|
return results.every(result => result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除采购项目
|
||||||
|
public removeProcurement(id: string) {
|
||||||
|
const index = this.procurements.value.findIndex(p => p.id === id)
|
||||||
|
if (index > -1) {
|
||||||
|
const procurement = this.procurements.value[index]
|
||||||
|
this.procurements.value.splice(index, 1)
|
||||||
|
|
||||||
|
// 触发WebSocket更新
|
||||||
|
this.broadcastProcurementUpdate('REMOVE', { id })
|
||||||
|
|
||||||
|
// 保存到本地存储
|
||||||
|
this.saveToStorage()
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索采购项目
|
||||||
|
public searchProcurements(keyword: string) {
|
||||||
|
if (!keyword.trim()) {
|
||||||
|
return this.procurements.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerKeyword = keyword.toLowerCase()
|
||||||
|
return this.procurements.value.filter(p =>
|
||||||
|
p.equipmentName.toLowerCase().includes(lowerKeyword) ||
|
||||||
|
p.applicantName.toLowerCase().includes(lowerKeyword) ||
|
||||||
|
p.description?.toLowerCase().includes(lowerKeyword) ||
|
||||||
|
p.category.toLowerCase().includes(lowerKeyword)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按优先级排序
|
||||||
|
public sortByPriority() {
|
||||||
|
const priorityOrder = { 'URGENT': 4, 'HIGH': 3, 'NORMAL': 2, 'LOW': 1 }
|
||||||
|
|
||||||
|
this.procurements.value.sort((a, b) => {
|
||||||
|
const aPriority = priorityOrder[a.priority] || 2
|
||||||
|
const bPriority = priorityOrder[b.priority] || 2
|
||||||
|
|
||||||
|
if (aPriority !== bPriority) {
|
||||||
|
return bPriority - aPriority
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先级相同时,按时间倒序
|
||||||
|
return new Date(b.createTime).getTime() - new Date(a.createTime).getTime()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按时间排序
|
||||||
|
public sortByTime() {
|
||||||
|
this.procurements.value.sort((a, b) =>
|
||||||
|
new Date(b.createTime).getTime() - new Date(a.createTime).getTime()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按状态分组
|
||||||
|
public groupByStatus() {
|
||||||
|
const groups: Record<string, ProcurementItem[]> = {}
|
||||||
|
|
||||||
|
this.procurements.value.forEach(procurement => {
|
||||||
|
const status = procurement.status
|
||||||
|
if (!groups[status]) {
|
||||||
|
groups[status] = []
|
||||||
|
}
|
||||||
|
groups[status].push(procurement)
|
||||||
|
})
|
||||||
|
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取统计信息
|
||||||
|
public getStats() {
|
||||||
|
const total = this.procurements.value.length
|
||||||
|
const pending = this.pendingCount.value
|
||||||
|
const approved = this.approvedCount.value
|
||||||
|
const rejected = this.rejectedCount.value
|
||||||
|
const processing = this.processingCount.value
|
||||||
|
const completed = this.completedCount.value
|
||||||
|
const urgent = this.urgentCount.value
|
||||||
|
const highPriority = this.highPriorityCount.value
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
pending,
|
||||||
|
approved,
|
||||||
|
rejected,
|
||||||
|
processing,
|
||||||
|
completed,
|
||||||
|
urgent,
|
||||||
|
highPriority,
|
||||||
|
approvalRate: total > 0 ? ((approved + completed) / total * 100).toFixed(1) : '0.0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出采购数据
|
||||||
|
public exportProcurements() {
|
||||||
|
try {
|
||||||
|
const data = JSON.stringify(this.procurements.value, null, 2)
|
||||||
|
const blob = new Blob([data], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `procurements_${new Date().toISOString().split('T')[0]}.json`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导出采购数据失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入采购数据
|
||||||
|
public importProcurements(data: string) {
|
||||||
|
try {
|
||||||
|
const procurements = JSON.parse(data)
|
||||||
|
if (Array.isArray(procurements)) {
|
||||||
|
this.procurements.value = [...this.procurements.value, ...procurements]
|
||||||
|
this.saveToStorage()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导入采购数据失败:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空所有数据
|
||||||
|
public clearAll() {
|
||||||
|
this.procurements.value = []
|
||||||
|
this.saveToStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化服务
|
||||||
|
public async initialize() {
|
||||||
|
if (this.isInitialized.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 从本地存储加载数据
|
||||||
|
this.loadFromStorage()
|
||||||
|
|
||||||
|
// 设置WebSocket监听
|
||||||
|
this.setupWebSocketListeners()
|
||||||
|
|
||||||
|
// 设置定期同步
|
||||||
|
this.setupPeriodicSync()
|
||||||
|
|
||||||
|
this.isInitialized.value = true
|
||||||
|
console.log('采购同步服务初始化完成')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('采购同步服务初始化失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置WebSocket监听
|
||||||
|
private setupWebSocketListeners() {
|
||||||
|
// 监听采购更新消息
|
||||||
|
websocketService.on('PROCUREMENT_UPDATE', (data: any) => {
|
||||||
|
this.handleWebSocketMessage(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听审批更新消息
|
||||||
|
websocketService.on('APPROVAL_UPDATE', (data: any) => {
|
||||||
|
this.handleApprovalUpdate(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听设备状态更新
|
||||||
|
websocketService.on('EQUIPMENT_STATUS_UPDATE', (data: any) => {
|
||||||
|
this.handleEquipmentStatusUpdate(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理WebSocket消息
|
||||||
|
private handleWebSocketMessage(data: any) {
|
||||||
|
try {
|
||||||
|
const { action, procurement } = data
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'ADD':
|
||||||
|
if (procurement && !this.procurements.value.find(p => p.id === procurement.id)) {
|
||||||
|
this.procurements.value.unshift(procurement)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'UPDATE':
|
||||||
|
if (procurement) {
|
||||||
|
const index = this.procurements.value.findIndex(p => p.id === procurement.id)
|
||||||
|
if (index > -1) {
|
||||||
|
this.procurements.value[index] = procurement
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'REMOVE':
|
||||||
|
if (data.id) {
|
||||||
|
const index = this.procurements.value.findIndex(p => p.id === data.id)
|
||||||
|
if (index > -1) {
|
||||||
|
this.procurements.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存到本地存储
|
||||||
|
this.saveToStorage()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('处理WebSocket采购消息失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理审批更新
|
||||||
|
private handleApprovalUpdate(data: any) {
|
||||||
|
try {
|
||||||
|
const { procurementId, status, approverName, comment } = data
|
||||||
|
this.updateProcurementStatus(procurementId, status, approverName, comment)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('处理审批更新失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理设备状态更新
|
||||||
|
private handleEquipmentStatusUpdate(data: any) {
|
||||||
|
try {
|
||||||
|
const { procurementId, equipmentStatus } = data
|
||||||
|
const procurement = this.procurements.value.find(p => p.id === procurementId)
|
||||||
|
|
||||||
|
if (procurement) {
|
||||||
|
// 根据设备状态更新采购状态
|
||||||
|
if (equipmentStatus === 'INSTALLED') {
|
||||||
|
this.updateProcurementStatus(procurementId, 'COMPLETED')
|
||||||
|
} else if (equipmentStatus === 'IN_TRANSIT') {
|
||||||
|
this.updateProcurementStatus(procurementId, 'PROCESSING')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('处理设备状态更新失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置定期同步
|
||||||
|
private setupPeriodicSync() {
|
||||||
|
// 每5分钟同步一次状态
|
||||||
|
setInterval(() => {
|
||||||
|
this.syncProcurementStatus()
|
||||||
|
}, 5 * 60 * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步采购状态
|
||||||
|
private async syncProcurementStatus() {
|
||||||
|
try {
|
||||||
|
// 这里可以调用后端API同步最新状态
|
||||||
|
// const response = await api.getProcurementStatus()
|
||||||
|
// 更新本地状态
|
||||||
|
console.log('定期同步采购状态...')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('同步采购状态失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 广播采购更新
|
||||||
|
private broadcastProcurementUpdate(action: string, data: any) {
|
||||||
|
if (websocketService.connected.value) {
|
||||||
|
websocketService.send({
|
||||||
|
type: 'PROCUREMENT_UPDATE',
|
||||||
|
data: { action, ...data },
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成唯一ID
|
||||||
|
private generateId(): string {
|
||||||
|
return `procurement_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 持久化存储
|
||||||
|
public saveToStorage() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('procurements', JSON.stringify(this.procurements.value))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存采购数据到本地存储失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public loadFromStorage() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('procurements')
|
||||||
|
if (stored) {
|
||||||
|
this.procurements.value = JSON.parse(stored)
|
||||||
|
|
||||||
|
// 重新排序
|
||||||
|
this.sortByPriority()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('从本地存储加载采购数据失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取初始化状态
|
||||||
|
public getInitializationStatus() {
|
||||||
|
return this.isInitialized.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建单例实例
|
||||||
|
const procurementSyncService = new ProcurementSyncService()
|
||||||
|
|
||||||
|
export default procurementSyncService
|
|
@ -0,0 +1,787 @@
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import notificationService from './notificationService'
|
||||||
|
|
||||||
|
interface WebSocketMessage {
|
||||||
|
type: 'NOTIFICATION' | 'APPROVAL_UPDATE' | 'PROCUREMENT_UPDATE' | 'SYSTEM' | 'EQUIPMENT_STATUS_UPDATE' | 'HEARTBEAT' | 'EQUIPMENT_BORROW_UPDATE' | 'EQUIPMENT_RETURN_UPDATE' | 'EQUIPMENT_MAINTENANCE_UPDATE' | 'EQUIPMENT_ALERT' | 'WORKFLOW_UPDATE'
|
||||||
|
data: any
|
||||||
|
timestamp: number
|
||||||
|
messageId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebSocketService {
|
||||||
|
private ws: WebSocket | null = null
|
||||||
|
private reconnectAttempts = 0
|
||||||
|
private maxReconnectAttempts = 5
|
||||||
|
private reconnectInterval = 3000 // 3秒
|
||||||
|
private heartbeatInterval: NodeJS.Timeout | null = null
|
||||||
|
private isConnected = ref(false)
|
||||||
|
private isConnecting = ref(false)
|
||||||
|
|
||||||
|
// 连接状态
|
||||||
|
public readonly connected = this.isConnected
|
||||||
|
public readonly connecting = this.isConnecting
|
||||||
|
|
||||||
|
// 连接配置
|
||||||
|
private wsUrl = import.meta.env.VITE_API_WS_URL || 'ws://localhost:8888/websocket'
|
||||||
|
private token = localStorage.getItem('token') || ''
|
||||||
|
|
||||||
|
// 事件监听器
|
||||||
|
private eventListeners: Map<string, Function[]> = new Map()
|
||||||
|
|
||||||
|
// 消息队列 - 用于离线时缓存消息
|
||||||
|
private messageQueue: WebSocketMessage[] = []
|
||||||
|
private maxQueueSize = 100
|
||||||
|
|
||||||
|
// 消息去重 - 避免重复处理
|
||||||
|
private processedMessages = new Set<string>()
|
||||||
|
private maxProcessedMessages = 1000
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.setupEventListeners()
|
||||||
|
this.loadMessageQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连接WebSocket
|
||||||
|
public connect() {
|
||||||
|
if (this.isConnecting.value || this.isConnected.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isConnecting.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 构建WebSocket URL,包含认证token
|
||||||
|
const url = `${this.wsUrl}?token=${encodeURIComponent(this.token)}`
|
||||||
|
this.ws = new WebSocket(url)
|
||||||
|
|
||||||
|
this.ws.onopen = this.handleOpen.bind(this)
|
||||||
|
this.ws.onmessage = this.handleMessage.bind(this)
|
||||||
|
this.ws.onclose = this.handleClose.bind(this)
|
||||||
|
this.ws.onerror = this.handleError.bind(this)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('WebSocket连接失败:', error)
|
||||||
|
this.isConnecting.value = false
|
||||||
|
this.scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 断开连接
|
||||||
|
public disconnect() {
|
||||||
|
if (this.heartbeatInterval) {
|
||||||
|
clearInterval(this.heartbeatInterval)
|
||||||
|
this.heartbeatInterval = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close()
|
||||||
|
this.ws = null
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isConnected.value = false
|
||||||
|
this.isConnecting.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送消息
|
||||||
|
public send(message: any) {
|
||||||
|
if (this.ws && this.isConnected.value) {
|
||||||
|
try {
|
||||||
|
this.ws.send(JSON.stringify(message))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('发送WebSocket消息失败:', error)
|
||||||
|
// 发送失败时,将消息加入队列
|
||||||
|
this.addToMessageQueue(message)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('WebSocket未连接,将消息加入队列')
|
||||||
|
this.addToMessageQueue(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送心跳
|
||||||
|
public sendHeartbeat() {
|
||||||
|
this.send({
|
||||||
|
type: 'HEARTBEAT',
|
||||||
|
data: { timestamp: Date.now() },
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理连接打开
|
||||||
|
private handleOpen() {
|
||||||
|
console.log('WebSocket连接已建立')
|
||||||
|
this.isConnected.value = true
|
||||||
|
this.isConnecting.value = false
|
||||||
|
this.reconnectAttempts = 0
|
||||||
|
|
||||||
|
// 启动心跳
|
||||||
|
this.startHeartbeat()
|
||||||
|
|
||||||
|
// 发送认证消息
|
||||||
|
this.send({
|
||||||
|
type: 'AUTH',
|
||||||
|
data: { token: this.token },
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 处理离线期间的消息队列
|
||||||
|
this.processMessageQueue()
|
||||||
|
|
||||||
|
// 触发连接事件
|
||||||
|
this.emit('connected', null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理消息接收
|
||||||
|
private handleMessage(event: MessageEvent) {
|
||||||
|
try {
|
||||||
|
const message: WebSocketMessage = JSON.parse(event.data)
|
||||||
|
this.processMessage(message)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解析WebSocket消息失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理连接关闭
|
||||||
|
private handleClose(event: CloseEvent) {
|
||||||
|
console.log('WebSocket连接已关闭:', event.code, event.reason)
|
||||||
|
this.isConnected.value = false
|
||||||
|
this.isConnecting.value = false
|
||||||
|
|
||||||
|
// 停止心跳
|
||||||
|
if (this.heartbeatInterval) {
|
||||||
|
clearInterval(this.heartbeatInterval)
|
||||||
|
this.heartbeatInterval = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发断开连接事件
|
||||||
|
this.emit('disconnected', { code: event.code, reason: event.reason })
|
||||||
|
|
||||||
|
// 如果不是主动关闭,尝试重连
|
||||||
|
if (event.code !== 1000) {
|
||||||
|
this.scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理连接错误
|
||||||
|
private handleError(error: Event) {
|
||||||
|
console.error('WebSocket连接错误:', error)
|
||||||
|
this.isConnecting.value = false
|
||||||
|
|
||||||
|
// 触发错误事件
|
||||||
|
this.emit('error', error)
|
||||||
|
|
||||||
|
// 尝试重连
|
||||||
|
this.scheduleReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理接收到的消息
|
||||||
|
private processMessage(message: WebSocketMessage) {
|
||||||
|
console.log('收到WebSocket消息:', message)
|
||||||
|
|
||||||
|
// 消息去重检查
|
||||||
|
if (message.messageId && this.processedMessages.has(message.messageId)) {
|
||||||
|
console.log('消息已处理,跳过:', message.messageId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加到已处理消息集合
|
||||||
|
if (message.messageId) {
|
||||||
|
this.processedMessages.add(message.messageId)
|
||||||
|
// 限制已处理消息数量
|
||||||
|
if (this.processedMessages.size > this.maxProcessedMessages) {
|
||||||
|
const firstKey = this.processedMessages.keys().next().value
|
||||||
|
if (firstKey) {
|
||||||
|
this.processedMessages.delete(firstKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case 'NOTIFICATION':
|
||||||
|
this.handleNotificationMessage(message.data)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'APPROVAL_UPDATE':
|
||||||
|
this.handleApprovalUpdateMessage(message.data)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'PROCUREMENT_UPDATE':
|
||||||
|
this.handleProcurementUpdateMessage(message.data)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'EQUIPMENT_STATUS_UPDATE':
|
||||||
|
this.handleEquipmentStatusUpdateMessage(message.data)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'EQUIPMENT_BORROW_UPDATE':
|
||||||
|
this.handleEquipmentBorrowUpdateMessage(message.data)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'EQUIPMENT_RETURN_UPDATE':
|
||||||
|
this.handleEquipmentReturnUpdateMessage(message.data)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'EQUIPMENT_MAINTENANCE_UPDATE':
|
||||||
|
this.handleEquipmentMaintenanceUpdateMessage(message.data)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'EQUIPMENT_ALERT':
|
||||||
|
this.handleEquipmentAlertMessage(message.data)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'WORKFLOW_UPDATE':
|
||||||
|
this.handleWorkflowUpdateMessage(message.data)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'SYSTEM':
|
||||||
|
this.handleSystemMessage(message.data)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'HEARTBEAT':
|
||||||
|
// 心跳响应,不需要特殊处理
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn('未知的WebSocket消息类型:', message.type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发消息接收事件
|
||||||
|
this.emit('message', message)
|
||||||
|
|
||||||
|
// 保存消息到本地存储
|
||||||
|
this.saveMessageToStorage(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理通知消息
|
||||||
|
private handleNotificationMessage(data: any) {
|
||||||
|
if (data.notification) {
|
||||||
|
notificationService.addNotification({
|
||||||
|
type: data.notification.type || 'SYSTEM',
|
||||||
|
title: data.notification.title || '新通知',
|
||||||
|
content: data.notification.content || '',
|
||||||
|
priority: data.notification.priority || 'NORMAL',
|
||||||
|
category: data.notification.category,
|
||||||
|
targetUrl: data.notification.targetUrl,
|
||||||
|
metadata: data.notification.metadata
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理审批更新消息
|
||||||
|
private handleApprovalUpdateMessage(data: any) {
|
||||||
|
if (data.approval) {
|
||||||
|
const approval = data.approval
|
||||||
|
|
||||||
|
// 根据审批状态添加相应通知
|
||||||
|
if (approval.status === 'APPROVED') {
|
||||||
|
notificationService.addApprovalNotification(
|
||||||
|
approval.equipmentName,
|
||||||
|
'APPROVED',
|
||||||
|
approval.approverName || '审批人'
|
||||||
|
)
|
||||||
|
|
||||||
|
// 触发审批状态更新事件
|
||||||
|
this.emit('approvalStatusChanged', {
|
||||||
|
type: 'APPROVED',
|
||||||
|
approvalId: approval.approvalId,
|
||||||
|
equipmentId: approval.equipmentId,
|
||||||
|
status: approval.status
|
||||||
|
})
|
||||||
|
} else if (approval.status === 'REJECTED') {
|
||||||
|
notificationService.addApprovalNotification(
|
||||||
|
approval.equipmentName,
|
||||||
|
'REJECTED',
|
||||||
|
approval.approverName || '审批人'
|
||||||
|
)
|
||||||
|
|
||||||
|
// 触发审批状态更新事件
|
||||||
|
this.emit('approvalStatusChanged', {
|
||||||
|
type: 'REJECTED',
|
||||||
|
approvalId: approval.approvalId,
|
||||||
|
equipmentId: approval.equipmentId,
|
||||||
|
status: approval.status
|
||||||
|
})
|
||||||
|
} else if (approval.status === 'SUBMITTED') {
|
||||||
|
// 新增待审批通知
|
||||||
|
notificationService.addNotification({
|
||||||
|
type: 'PENDING',
|
||||||
|
title: '新的审批申请',
|
||||||
|
content: `收到来自 ${approval.applicantName} 的${approval.businessType || '设备'}申请:${approval.equipmentName}`,
|
||||||
|
targetUrl: '/asset-management/device-management/approval',
|
||||||
|
priority: 'HIGH',
|
||||||
|
category: '审批申请',
|
||||||
|
metadata: {
|
||||||
|
approvalId: approval.approvalId,
|
||||||
|
equipmentName: approval.equipmentName,
|
||||||
|
applicantName: approval.applicantName,
|
||||||
|
businessType: approval.businessType,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 触发新审批申请事件
|
||||||
|
this.emit('newApprovalRequest', approval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理采购更新消息
|
||||||
|
private handleProcurementUpdateMessage(data: any) {
|
||||||
|
if (data.procurement) {
|
||||||
|
const procurement = data.procurement
|
||||||
|
|
||||||
|
if (procurement.status === 'SUBMITTED') {
|
||||||
|
notificationService.addProcurementNotification(
|
||||||
|
procurement.equipmentName,
|
||||||
|
procurement.applicantName || '申请人'
|
||||||
|
)
|
||||||
|
|
||||||
|
// 触发采购状态更新事件
|
||||||
|
this.emit('procurementStatusChanged', {
|
||||||
|
type: 'SUBMITTED',
|
||||||
|
procurementId: procurement.procurementId,
|
||||||
|
equipmentId: procurement.equipmentId,
|
||||||
|
status: procurement.status
|
||||||
|
})
|
||||||
|
} else if (procurement.status === 'APPROVED') {
|
||||||
|
// 采购申请被批准
|
||||||
|
notificationService.addNotification({
|
||||||
|
type: 'PROCUREMENT',
|
||||||
|
title: '采购申请已批准',
|
||||||
|
content: `您的设备采购申请"${procurement.equipmentName}"已获得批准`,
|
||||||
|
targetUrl: '/asset-management/device-management/procurement',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
category: '采购审批',
|
||||||
|
metadata: {
|
||||||
|
equipmentName: procurement.equipmentName,
|
||||||
|
status: procurement.status,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 触发采购状态更新事件
|
||||||
|
this.emit('procurementStatusChanged', {
|
||||||
|
type: 'APPROVED',
|
||||||
|
procurementId: procurement.procurementId,
|
||||||
|
equipmentId: procurement.equipmentId,
|
||||||
|
status: procurement.status
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理设备状态更新消息
|
||||||
|
private handleEquipmentStatusUpdateMessage(data: any) {
|
||||||
|
if (data.equipment) {
|
||||||
|
const equipment = data.equipment
|
||||||
|
|
||||||
|
// 触发设备状态更新事件
|
||||||
|
this.emit('equipmentStatusChanged', {
|
||||||
|
equipmentId: equipment.equipmentId,
|
||||||
|
oldStatus: equipment.oldStatus,
|
||||||
|
newStatus: equipment.newStatus,
|
||||||
|
updateTime: equipment.updateTime
|
||||||
|
})
|
||||||
|
|
||||||
|
// 如果状态变化涉及采购,添加相应通知
|
||||||
|
if (equipment.statusChangeType === 'PROCUREMENT') {
|
||||||
|
notificationService.addNotification({
|
||||||
|
type: 'PROCUREMENT',
|
||||||
|
title: '设备采购状态更新',
|
||||||
|
content: `设备"${equipment.equipmentName}"的采购状态已更新为:${equipment.newStatus}`,
|
||||||
|
targetUrl: '/asset-management/device-management/procurement',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
category: '设备状态',
|
||||||
|
metadata: {
|
||||||
|
equipmentId: equipment.equipmentId,
|
||||||
|
equipmentName: equipment.equipmentName,
|
||||||
|
oldStatus: equipment.oldStatus,
|
||||||
|
newStatus: equipment.newStatus,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理设备借用更新消息
|
||||||
|
private handleEquipmentBorrowUpdateMessage(data: any) {
|
||||||
|
if (data.borrow) {
|
||||||
|
const borrow = data.borrow
|
||||||
|
|
||||||
|
// 触发设备借用状态更新事件
|
||||||
|
this.emit('equipmentBorrowChanged', {
|
||||||
|
equipmentId: borrow.equipmentId,
|
||||||
|
borrowId: borrow.borrowId,
|
||||||
|
oldStatus: borrow.oldStatus,
|
||||||
|
newStatus: borrow.newStatus,
|
||||||
|
updateTime: borrow.updateTime
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加借用状态更新通知
|
||||||
|
notificationService.addNotification({
|
||||||
|
type: 'EQUIPMENT_BORROW',
|
||||||
|
title: '设备借用状态更新',
|
||||||
|
content: `设备"${borrow.equipmentName}"的借用状态已更新为:${borrow.newStatus}`,
|
||||||
|
targetUrl: '/asset-management/device-management/device-center',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
category: '设备借用',
|
||||||
|
metadata: {
|
||||||
|
equipmentId: borrow.equipmentId,
|
||||||
|
equipmentName: borrow.equipmentName,
|
||||||
|
borrowId: borrow.borrowId,
|
||||||
|
oldStatus: borrow.oldStatus,
|
||||||
|
newStatus: borrow.newStatus,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理设备归还更新消息
|
||||||
|
private handleEquipmentReturnUpdateMessage(data: any) {
|
||||||
|
if (data.return) {
|
||||||
|
const returnData = data.return
|
||||||
|
|
||||||
|
// 触发设备归还状态更新事件
|
||||||
|
this.emit('equipmentReturnChanged', {
|
||||||
|
equipmentId: returnData.equipmentId,
|
||||||
|
returnId: returnData.returnId,
|
||||||
|
oldStatus: returnData.oldStatus,
|
||||||
|
newStatus: returnData.newStatus,
|
||||||
|
updateTime: returnData.updateTime
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加归还状态更新通知
|
||||||
|
notificationService.addNotification({
|
||||||
|
type: 'EQUIPMENT_RETURN',
|
||||||
|
title: '设备归还状态更新',
|
||||||
|
content: `设备"${returnData.equipmentName}"的归还状态已更新为:${returnData.newStatus}`,
|
||||||
|
targetUrl: '/asset-management/device-management/device-center',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
category: '设备归还',
|
||||||
|
metadata: {
|
||||||
|
equipmentId: returnData.equipmentId,
|
||||||
|
equipmentName: returnData.equipmentName,
|
||||||
|
returnId: returnData.returnId,
|
||||||
|
oldStatus: returnData.oldStatus,
|
||||||
|
newStatus: returnData.newStatus,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理设备维护更新消息
|
||||||
|
private handleEquipmentMaintenanceUpdateMessage(data: any) {
|
||||||
|
if (data.maintenance) {
|
||||||
|
const maintenance = data.maintenance
|
||||||
|
|
||||||
|
// 触发设备维护状态更新事件
|
||||||
|
this.emit('equipmentMaintenanceChanged', {
|
||||||
|
equipmentId: maintenance.equipmentId,
|
||||||
|
maintenanceId: maintenance.maintenanceId,
|
||||||
|
oldStatus: maintenance.oldStatus,
|
||||||
|
newStatus: maintenance.newStatus,
|
||||||
|
updateTime: maintenance.updateTime
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加维护状态更新通知
|
||||||
|
notificationService.addNotification({
|
||||||
|
type: 'EQUIPMENT_MAINTENANCE',
|
||||||
|
title: '设备维护状态更新',
|
||||||
|
content: `设备"${maintenance.equipmentName}"的维护状态已更新为:${maintenance.newStatus}`,
|
||||||
|
targetUrl: '/asset-management/device-management/device-center',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
category: '设备维护',
|
||||||
|
metadata: {
|
||||||
|
equipmentId: maintenance.equipmentId,
|
||||||
|
equipmentName: maintenance.equipmentName,
|
||||||
|
maintenanceId: maintenance.maintenanceId,
|
||||||
|
oldStatus: maintenance.oldStatus,
|
||||||
|
newStatus: maintenance.newStatus,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理设备告警消息
|
||||||
|
private handleEquipmentAlertMessage(data: any) {
|
||||||
|
if (data.alert) {
|
||||||
|
const alert = data.alert
|
||||||
|
|
||||||
|
// 触发设备告警事件
|
||||||
|
this.emit('equipmentAlert', {
|
||||||
|
equipmentId: alert.equipmentId,
|
||||||
|
alertId: alert.alertId,
|
||||||
|
alertType: alert.alertType,
|
||||||
|
alertLevel: alert.alertLevel,
|
||||||
|
message: alert.message,
|
||||||
|
timestamp: alert.timestamp
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加设备告警通知
|
||||||
|
notificationService.addNotification({
|
||||||
|
type: 'EQUIPMENT_ALERT',
|
||||||
|
title: `设备告警 - ${alert.alertType}`,
|
||||||
|
content: `设备"${alert.equipmentName}"发生告警:${alert.message}`,
|
||||||
|
targetUrl: '/asset-management/device-management/device-center',
|
||||||
|
priority: alert.alertLevel === 'CRITICAL' ? 'URGENT' : 'HIGH',
|
||||||
|
category: '设备告警',
|
||||||
|
actionRequired: true,
|
||||||
|
metadata: {
|
||||||
|
equipmentId: alert.equipmentId,
|
||||||
|
equipmentName: alert.equipmentName,
|
||||||
|
alertId: alert.alertId,
|
||||||
|
alertType: alert.alertType,
|
||||||
|
alertLevel: alert.alertLevel,
|
||||||
|
message: alert.message,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理工作流更新消息
|
||||||
|
private handleWorkflowUpdateMessage(data: any) {
|
||||||
|
if (data.workflow) {
|
||||||
|
const workflow = data.workflow
|
||||||
|
|
||||||
|
// 触发工作流状态更新事件
|
||||||
|
this.emit('workflowStatusChanged', {
|
||||||
|
workflowId: workflow.workflowId,
|
||||||
|
oldStatus: workflow.oldStatus,
|
||||||
|
newStatus: workflow.newStatus,
|
||||||
|
currentNode: workflow.currentNode,
|
||||||
|
updateTime: workflow.updateTime
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加工作流状态更新通知
|
||||||
|
notificationService.addNotification({
|
||||||
|
type: 'WORKFLOW',
|
||||||
|
title: '工作流状态更新',
|
||||||
|
content: `工作流"${workflow.workflowName}"状态已更新为:${workflow.newStatus}`,
|
||||||
|
targetUrl: '/asset-management/device-management/approval',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
category: '工作流',
|
||||||
|
metadata: {
|
||||||
|
workflowId: workflow.workflowId,
|
||||||
|
workflowName: workflow.workflowName,
|
||||||
|
oldStatus: workflow.oldStatus,
|
||||||
|
newStatus: workflow.newStatus,
|
||||||
|
currentNode: workflow.currentNode,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理系统消息
|
||||||
|
private handleSystemMessage(data: any) {
|
||||||
|
if (data.system) {
|
||||||
|
const system = data.system
|
||||||
|
|
||||||
|
notificationService.addSystemNotification(
|
||||||
|
system.title || '系统通知',
|
||||||
|
system.content || '',
|
||||||
|
system.priority || 'NORMAL'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动心跳
|
||||||
|
private startHeartbeat() {
|
||||||
|
if (this.heartbeatInterval) {
|
||||||
|
clearInterval(this.heartbeatInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.heartbeatInterval = setInterval(() => {
|
||||||
|
if (this.isConnected.value) {
|
||||||
|
this.sendHeartbeat()
|
||||||
|
}
|
||||||
|
}, 30000) // 30秒发送一次心跳
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安排重连
|
||||||
|
private scheduleReconnect() {
|
||||||
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||||
|
console.error('WebSocket重连次数已达上限,停止重连')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reconnectAttempts++
|
||||||
|
console.log(`WebSocket将在 ${this.reconnectInterval / 1000} 秒后尝试重连 (${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.connect()
|
||||||
|
}, this.reconnectInterval)
|
||||||
|
|
||||||
|
// 递增重连间隔
|
||||||
|
this.reconnectInterval = Math.min(this.reconnectInterval * 1.5, 30000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消息队列管理
|
||||||
|
private addToMessageQueue(message: any) {
|
||||||
|
this.messageQueue.push(message)
|
||||||
|
if (this.messageQueue.length > this.maxQueueSize) {
|
||||||
|
this.messageQueue.shift() // 移除最旧的消息
|
||||||
|
}
|
||||||
|
this.saveMessageQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
private processMessageQueue() {
|
||||||
|
if (this.messageQueue.length > 0) {
|
||||||
|
console.log(`处理离线期间的消息队列,共 ${this.messageQueue.length} 条消息`)
|
||||||
|
const messages = [...this.messageQueue]
|
||||||
|
this.messageQueue = []
|
||||||
|
|
||||||
|
messages.forEach(message => {
|
||||||
|
this.processMessage(message)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.saveMessageQueue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveMessageQueue() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('websocket_message_queue', JSON.stringify(this.messageQueue))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存消息队列失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadMessageQueue() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('websocket_message_queue')
|
||||||
|
if (stored) {
|
||||||
|
this.messageQueue = JSON.parse(stored)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载消息队列失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消息存储管理
|
||||||
|
private saveMessageToStorage(message: WebSocketMessage) {
|
||||||
|
try {
|
||||||
|
const messages = this.getStoredMessages()
|
||||||
|
messages.unshift(message)
|
||||||
|
|
||||||
|
// 限制存储的消息数量
|
||||||
|
if (messages.length > 200) {
|
||||||
|
messages.splice(200)
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('websocket_messages', JSON.stringify(messages))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存消息到本地存储失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStoredMessages(): WebSocketMessage[] {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('websocket_messages')
|
||||||
|
return stored ? JSON.parse(stored) : []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取存储的消息失败:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置事件监听器
|
||||||
|
private setupEventListeners() {
|
||||||
|
// 监听token变化,重新连接
|
||||||
|
window.addEventListener('storage', (event) => {
|
||||||
|
if (event.key === 'token' && event.newValue !== this.token) {
|
||||||
|
this.token = event.newValue || ''
|
||||||
|
if (this.isConnected.value) {
|
||||||
|
this.disconnect()
|
||||||
|
this.connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事件系统
|
||||||
|
public on(event: string, callback: Function) {
|
||||||
|
if (!this.eventListeners.has(event)) {
|
||||||
|
this.eventListeners.set(event, [])
|
||||||
|
}
|
||||||
|
this.eventListeners.get(event)!.push(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
public off(event: string, callback: Function) {
|
||||||
|
const listeners = this.eventListeners.get(event)
|
||||||
|
if (listeners) {
|
||||||
|
const index = listeners.indexOf(callback)
|
||||||
|
if (index > -1) {
|
||||||
|
listeners.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(event: string, data: any) {
|
||||||
|
const listeners = this.eventListeners.get(event)
|
||||||
|
if (listeners) {
|
||||||
|
listeners.forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('WebSocket事件回调执行失败:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取连接状态
|
||||||
|
public getStatus() {
|
||||||
|
return {
|
||||||
|
connected: this.isConnected.value,
|
||||||
|
connecting: this.isConnecting.value,
|
||||||
|
reconnectAttempts: this.reconnectAttempts,
|
||||||
|
maxReconnectAttempts: this.maxReconnectAttempts,
|
||||||
|
messageQueueSize: this.messageQueue.length,
|
||||||
|
processedMessagesCount: this.processedMessages.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新认证token
|
||||||
|
public updateToken(newToken: string) {
|
||||||
|
this.token = newToken
|
||||||
|
if (this.isConnected.value) {
|
||||||
|
// 重新认证
|
||||||
|
this.send({
|
||||||
|
type: 'AUTH',
|
||||||
|
data: { token: this.token },
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取存储的消息
|
||||||
|
public getMessages() {
|
||||||
|
return this.getStoredMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空存储的消息
|
||||||
|
public clearMessages() {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem('websocket_messages')
|
||||||
|
localStorage.removeItem('websocket_message_queue')
|
||||||
|
this.messageQueue = []
|
||||||
|
this.processedMessages.clear()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('清空消息失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建单例实例
|
||||||
|
const websocketService = new WebSocketService()
|
||||||
|
|
||||||
|
// 自动连接
|
||||||
|
websocketService.connect()
|
||||||
|
|
||||||
|
export default websocketService
|
|
@ -33,6 +33,15 @@
|
||||||
padding: $padding;
|
padding: $padding;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 高z-index类,用于确保组件在最上层显示
|
||||||
|
.gi_z_index_high {
|
||||||
|
z-index: 10001 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gi_z_index_highest {
|
||||||
|
z-index: 100001 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.gi_relative {
|
.gi_relative {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ export {}
|
||||||
|
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
ApprovalAssistant: typeof import('./../components/ApprovalAssistant/index.vue')['default']
|
||||||
Avatar: typeof import('./../components/Avatar/index.vue')['default']
|
Avatar: typeof import('./../components/Avatar/index.vue')['default']
|
||||||
Breadcrumb: typeof import('./../components/Breadcrumb/index.vue')['default']
|
Breadcrumb: typeof import('./../components/Breadcrumb/index.vue')['default']
|
||||||
CellCopy: typeof import('./../components/CellCopy/index.vue')['default']
|
CellCopy: typeof import('./../components/CellCopy/index.vue')['default']
|
||||||
|
@ -55,6 +56,7 @@ declare module 'vue' {
|
||||||
JsonPretty: typeof import('./../components/JsonPretty/index.vue')['default']
|
JsonPretty: typeof import('./../components/JsonPretty/index.vue')['default']
|
||||||
MinuteForm: typeof import('./../components/GenCron/CronForm/component/minute-form.vue')['default']
|
MinuteForm: typeof import('./../components/GenCron/CronForm/component/minute-form.vue')['default']
|
||||||
MonthForm: typeof import('./../components/GenCron/CronForm/component/month-form.vue')['default']
|
MonthForm: typeof import('./../components/GenCron/CronForm/component/month-form.vue')['default']
|
||||||
|
NotificationCenter: typeof import('./../components/NotificationCenter/index.vue')['default']
|
||||||
ParentView: typeof import('./../components/ParentView/index.vue')['default']
|
ParentView: typeof import('./../components/ParentView/index.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
|
|
@ -37,7 +37,7 @@
|
||||||
|
|
||||||
<div class="folder-content">
|
<div class="folder-content">
|
||||||
<!-- 加载状态 -->
|
<!-- 加载状态 -->
|
||||||
<a-skeleton :loading="loading && folderList.length === 0" :rows="5" v-if="loading" animation="pulse">
|
<a-skeleton :loading="loading && folderList.length === 0" :rows="5" v-if="loading" :animation="true">
|
||||||
<template #skeleton>
|
<template #skeleton>
|
||||||
<div class="skeleton-item flex items-center px-4 py-3" v-for="i in 5" :key="i">
|
<div class="skeleton-item flex items-center px-4 py-3" v-for="i in 5" :key="i">
|
||||||
<div class="w-6 h-6 rounded bg-gray-200 mr-3"></div>
|
<div class="w-6 h-6 rounded bg-gray-200 mr-3"></div>
|
||||||
|
@ -53,51 +53,53 @@
|
||||||
</a-typography-text>
|
</a-typography-text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<a-empty v-if="!loading && folderList.length === 0" :description="searchKeyword ? '未找到匹配的文件夹' : '暂无文件夹'" />
|
<a-empty v-if="!loading && folderList.length === 0" :description="searchKeyword ? '未找到匹配的文件夹' : '暂无文件夹'" />
|
||||||
|
|
||||||
<a-list v-if="!loading && folderList.length > 0" class="folder-list">
|
<!-- 树形文件夹结构 -->
|
||||||
<!-- 文件夹列表 -->
|
<div v-if="!loading && folderList.length > 0" class="folder-tree-container">
|
||||||
<a-list-item
|
|
||||||
v-for="folder in folderList"
|
|
||||||
:key="folder.id"
|
|
||||||
:class="['folder-list-item', { 'active': currentFolderId === folder.id }]"
|
|
||||||
@click="handleFolderClick(folder.id)"
|
|
||||||
:tooltip="sidebarCollapsed ? folder.name : ''"
|
|
||||||
>
|
|
||||||
<!-- 第一行:文件夹图标和名称 -->
|
|
||||||
<div class="folder-main-info">
|
|
||||||
<div class="folder-icon-wrapper">
|
|
||||||
<IconFolder class="folder-icon" :style="{ color: folderColor }" />
|
|
||||||
</div>
|
|
||||||
<span v-if="!sidebarCollapsed" class="folder-name">{{ folder.name }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 第二行:文件夹操作按钮 -->
|
<a-tree
|
||||||
<div v-if="!sidebarCollapsed" class="folder-actions-row">
|
:data="folderTreeData"
|
||||||
|
:selected-keys="currentFolderId ? [currentFolderId] : []"
|
||||||
|
:field-names="{ key: 'key', title: 'title', children: 'children' }"
|
||||||
|
:show-line="!sidebarCollapsed"
|
||||||
|
:block-node="true"
|
||||||
|
:default-expand-all="true"
|
||||||
|
@select="handleFolderSelect"
|
||||||
|
@dblclick="handleFolderDoubleClick"
|
||||||
|
class="folder-tree"
|
||||||
|
:class="{ 'collapsed': sidebarCollapsed }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 文件夹操作按钮 -->
|
||||||
|
<div v-if="currentFolderId && currentFolderId !== '0'" class="folder-actions-bar" style="padding: 8px; border-top: 1px solid #e5e6eb; margin-top: 8px;">
|
||||||
|
<a-space>
|
||||||
<a-button
|
<a-button
|
||||||
type="text"
|
type="text"
|
||||||
shape="circle"
|
|
||||||
size="small"
|
size="small"
|
||||||
@click.stop="handleRenameFolder(folder, folder.id, folder.name)"
|
@click="handleRenameCurrentFolder"
|
||||||
tooltip="重命名"
|
tooltip="重命名"
|
||||||
class="action-btn"
|
|
||||||
>
|
>
|
||||||
<icon-edit />
|
<template #icon><icon-edit /></template>
|
||||||
|
重命名
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button
|
<a-button
|
||||||
type="text"
|
type="text"
|
||||||
shape="circle"
|
|
||||||
size="small"
|
size="small"
|
||||||
@click.stop="handleDeleteFolder(folder)"
|
@click="handleDeleteCurrentFolder"
|
||||||
tooltip="删除"
|
tooltip="删除"
|
||||||
status="danger"
|
status="danger"
|
||||||
class="action-btn"
|
|
||||||
>
|
>
|
||||||
<icon-delete />
|
<template #icon><icon-delete /></template>
|
||||||
|
删除
|
||||||
</a-button>
|
</a-button>
|
||||||
</div>
|
</a-space>
|
||||||
</a-list-item>
|
</div>
|
||||||
</a-list>
|
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 侧边栏底部分页控件 -->
|
<!-- 侧边栏底部分页控件 -->
|
||||||
|
@ -108,7 +110,8 @@
|
||||||
</a-typography-text>
|
</a-typography-text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pagination-controls">
|
<!-- 隐藏分页控件,因为现在获取所有文件夹 -->
|
||||||
|
<!-- <div class="pagination-controls">
|
||||||
<a-pagination
|
<a-pagination
|
||||||
:current="currentPage"
|
:current="currentPage"
|
||||||
:page-size="pageSize"
|
:page-size="pageSize"
|
||||||
|
@ -120,7 +123,7 @@
|
||||||
size="small"
|
size="small"
|
||||||
show-total
|
show-total
|
||||||
/>
|
/>
|
||||||
</div>
|
</div> -->
|
||||||
</div>
|
</div>
|
||||||
</a-layout-sider>
|
</a-layout-sider>
|
||||||
|
|
||||||
|
@ -128,8 +131,14 @@
|
||||||
<a-layout-header class="file-header">
|
<a-layout-header class="file-header">
|
||||||
<div class="breadcrumbs">
|
<div class="breadcrumbs">
|
||||||
<a-breadcrumb>
|
<a-breadcrumb>
|
||||||
<a-breadcrumb-item>知识库</a-breadcrumb-item>
|
<a-breadcrumb-item
|
||||||
<a-breadcrumb-item>{{ currentFolderName }}</a-breadcrumb-item>
|
v-for="(item, index) in breadcrumbPath"
|
||||||
|
:key="index"
|
||||||
|
:class="{ 'clickable': index < breadcrumbPath.length - 1 }"
|
||||||
|
@click="handleBreadcrumbClick(index)"
|
||||||
|
>
|
||||||
|
{{ item }}
|
||||||
|
</a-breadcrumb-item>
|
||||||
</a-breadcrumb>
|
</a-breadcrumb>
|
||||||
<a-button
|
<a-button
|
||||||
type="text"
|
type="text"
|
||||||
|
@ -369,20 +378,18 @@
|
||||||
<a-input v-model="folderForm.name" placeholder="输入文件夹名称" max-length="50" />
|
<a-input v-model="folderForm.name" placeholder="输入文件夹名称" max-length="50" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="父级目录" field="parentId" :rules="folderRules.parentId">
|
<a-form-item label="父级目录" field="parentId" :rules="folderRules.parentId">
|
||||||
<a-select
|
<a-tree-select
|
||||||
v-model="folderForm.parentId"
|
v-model="folderForm.parentId"
|
||||||
placeholder="请选择父级目录"
|
placeholder="请选择父级目录"
|
||||||
|
:data="folderTreeSelectData"
|
||||||
|
:field-names="{ key: 'id', title: 'name', children: 'children' }"
|
||||||
|
allow-clear
|
||||||
|
:tree-props="{ showLine: true }"
|
||||||
>
|
>
|
||||||
<a-option value="0">根目录</a-option>
|
<template #title="{ node }">
|
||||||
<a-option
|
<span>{{ node?.title || node?.name }}</span>
|
||||||
v-for="folder in folderList"
|
</template>
|
||||||
:key="folder.id"
|
</a-tree-select>
|
||||||
:value="folder.id"
|
|
||||||
v-if="!folderForm.id || folder.id !== folderForm.id"
|
|
||||||
>
|
|
||||||
{{ folder.name }}
|
|
||||||
</a-option>
|
|
||||||
</a-select>
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
|
@ -592,7 +599,8 @@ import {
|
||||||
const folderList = ref([]);
|
const folderList = ref([]);
|
||||||
const fileList = ref([]);
|
const fileList = ref([]);
|
||||||
const currentFolderId = ref('');
|
const currentFolderId = ref('');
|
||||||
const currentFolderName = ref('');
|
// 移除currentFolderName,现在使用面包屑导航
|
||||||
|
// const currentFolderName = ref('');
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const folderDialogVisible = ref(false);
|
const folderDialogVisible = ref(false);
|
||||||
const uploadDialogVisible = ref(false);
|
const uploadDialogVisible = ref(false);
|
||||||
|
@ -664,6 +672,111 @@ const canUpload = computed(() => {
|
||||||
return hasFiles.value && !uploading.value && uploadForm.folderId;
|
return hasFiles.value && !uploading.value && uploadForm.folderId;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 计算属性:将平铺的文件夹数据转换为树形结构
|
||||||
|
const folderTreeData = computed(() => {
|
||||||
|
console.log('=== folderTreeData计算属性执行 ===');
|
||||||
|
console.log('folderList.value:', folderList.value);
|
||||||
|
console.log('folderList.value.length:', folderList.value?.length);
|
||||||
|
|
||||||
|
if (!folderList.value || folderList.value.length === 0) {
|
||||||
|
console.log('folderList为空,返回空数组');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建文件夹映射表
|
||||||
|
const folderMap = new Map();
|
||||||
|
const rootFolders = [];
|
||||||
|
|
||||||
|
console.log('=== 开始创建文件夹映射 ===');
|
||||||
|
// 首先创建所有文件夹的映射
|
||||||
|
folderList.value.forEach((folder, index) => {
|
||||||
|
console.log(`处理第${index + 1}个文件夹:`, folder);
|
||||||
|
// 确保文件夹数据完整
|
||||||
|
if (folder && folder.id && folder.name) {
|
||||||
|
const node = {
|
||||||
|
key: folder.id, // Tree组件需要的key字段
|
||||||
|
title: folder.name, // Tree组件需要的title字段
|
||||||
|
children: [], // Tree组件需要的children字段
|
||||||
|
// 保留原始字段用于其他功能
|
||||||
|
id: folder.id,
|
||||||
|
name: folder.name,
|
||||||
|
parentId: folder.parentId
|
||||||
|
};
|
||||||
|
folderMap.set(folder.id, node);
|
||||||
|
console.log(`✅ 成功添加文件夹到映射: ${folder.name} (ID: ${folder.id})`);
|
||||||
|
} else {
|
||||||
|
console.warn('❌ 跳过不完整的文件夹数据:', folder);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('=== 开始构建树形结构 ===');
|
||||||
|
console.log('文件夹映射表大小:', folderMap.size);
|
||||||
|
|
||||||
|
// 构建树形结构
|
||||||
|
folderList.value.forEach((folder, index) => {
|
||||||
|
console.log(`构建第${index + 1}个文件夹的树形结构:`, folder);
|
||||||
|
// 确保文件夹数据完整
|
||||||
|
if (!folder || !folder.id || !folder.name) {
|
||||||
|
console.warn('❌ 跳过不完整的文件夹数据:', folder);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = folderMap.get(folder.id);
|
||||||
|
if (!node) {
|
||||||
|
console.warn('❌ 找不到文件夹节点:', folder.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`处理文件夹: ${folder.name} (ID: ${folder.id}, ParentID: ${folder.parentId})`);
|
||||||
|
|
||||||
|
if (folder.parentId === '0' || folder.parentId === 0) {
|
||||||
|
// 根文件夹
|
||||||
|
rootFolders.push(node);
|
||||||
|
console.log(`✅ 添加为根文件夹: ${folder.name}`);
|
||||||
|
} else {
|
||||||
|
// 子文件夹
|
||||||
|
const parent = folderMap.get(folder.parentId);
|
||||||
|
if (parent) {
|
||||||
|
parent.children.push(node);
|
||||||
|
console.log(`✅ 添加为子文件夹: ${folder.name} -> ${parent.name}`);
|
||||||
|
} else {
|
||||||
|
// 如果找不到父文件夹,当作根文件夹处理
|
||||||
|
console.warn('⚠️ 找不到父文件夹,将文件夹作为根文件夹:', folder.name, folder.parentId);
|
||||||
|
rootFolders.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('=== 树形结构构建完成 ===');
|
||||||
|
console.log('根文件夹数量:', rootFolders.length);
|
||||||
|
console.log('构建的树形结构:', rootFolders);
|
||||||
|
|
||||||
|
// 验证树形结构中的节点数据
|
||||||
|
rootFolders.forEach((root, index) => {
|
||||||
|
console.log(`根文件夹${index + 1}:`, {
|
||||||
|
id: root.id,
|
||||||
|
name: root.name,
|
||||||
|
childrenCount: root.children?.length || 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return rootFolders;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算属性:树形选择器数据(包含根目录选项)
|
||||||
|
const folderTreeSelectData = computed(() => {
|
||||||
|
const rootOption = {
|
||||||
|
key: '0', // Tree组件需要的key字段
|
||||||
|
title: '根目录', // Tree组件需要的title字段
|
||||||
|
children: [], // Tree组件需要的children字段
|
||||||
|
// 保留原始字段用于其他功能
|
||||||
|
id: '0',
|
||||||
|
name: '根目录'
|
||||||
|
};
|
||||||
|
|
||||||
|
return [rootOption, ...folderTreeData.value];
|
||||||
|
});
|
||||||
|
|
||||||
// 搜索相关
|
// 搜索相关
|
||||||
const searchKeyword = ref(''); // 文件夹搜索关键词
|
const searchKeyword = ref(''); // 文件夹搜索关键词
|
||||||
const fileSearchKeyword = ref(''); // 文件搜索关键词
|
const fileSearchKeyword = ref(''); // 文件搜索关键词
|
||||||
|
@ -682,7 +795,7 @@ const initData = async () => {
|
||||||
|
|
||||||
const apiParams = {
|
const apiParams = {
|
||||||
page: currentPage.value,
|
page: currentPage.value,
|
||||||
pageSize: pageSize.value,
|
pageSize: 1000, // 修改为足够大的值,确保获取所有文件夹
|
||||||
folderName: searchKeyword.value.trim() || undefined
|
folderName: searchKeyword.value.trim() || undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -701,11 +814,43 @@ const initData = async () => {
|
||||||
|
|
||||||
// 根据后端返回的数据结构处理
|
// 根据后端返回的数据结构处理
|
||||||
if (folderRes.code === 200 && folderRes.data) {
|
if (folderRes.code === 200 && folderRes.data) {
|
||||||
const processedFolders = folderRes.data.rows.map(folder => ({
|
console.log('=== 开始处理数据 ===');
|
||||||
id: String(folder.folderId),
|
console.log('folderRes.data:', folderRes.data);
|
||||||
name: folder.folderName,
|
console.log('folderRes.data.rows:', folderRes.data.rows);
|
||||||
parentId: String(folder.parentId || 0)
|
console.log('folderRes.data.rows类型:', typeof folderRes.data.rows);
|
||||||
}));
|
console.log('folderRes.data.rows长度:', folderRes.data.rows?.length);
|
||||||
|
|
||||||
|
// 检查数据结构
|
||||||
|
if (!folderRes.data.rows || !Array.isArray(folderRes.data.rows)) {
|
||||||
|
console.error('API返回的数据结构不正确,rows字段不存在或不是数组');
|
||||||
|
console.log('可用的字段:', Object.keys(folderRes.data));
|
||||||
|
folderList.value = [];
|
||||||
|
totalFolders.value = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const processedFolders = folderRes.data.rows.map((folder, index) => {
|
||||||
|
console.log(`处理第${index + 1}个原始文件夹数据:`, folder);
|
||||||
|
console.log(`原始数据字段:`, Object.keys(folder));
|
||||||
|
console.log(`folderId:`, folder.folderId);
|
||||||
|
console.log(`folderName:`, folder.folderName);
|
||||||
|
console.log(`parentId:`, folder.parentId);
|
||||||
|
|
||||||
|
// 确保所有必需字段都存在
|
||||||
|
if (!folder.folderId || !folder.folderName) {
|
||||||
|
console.warn('❌ 跳过不完整的文件夹数据:', folder);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const processedFolder = {
|
||||||
|
id: String(folder.folderId),
|
||||||
|
name: String(folder.folderName),
|
||||||
|
parentId: String(folder.parentId || 0)
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`✅ 处理后的文件夹数据:`, processedFolder);
|
||||||
|
return processedFolder;
|
||||||
|
}).filter(Boolean); // 过滤掉null值
|
||||||
|
|
||||||
folderList.value = processedFolders;
|
folderList.value = processedFolders;
|
||||||
totalFolders.value = Number(folderRes.data.total) || 0;
|
totalFolders.value = Number(folderRes.data.total) || 0;
|
||||||
|
@ -720,6 +865,8 @@ const initData = async () => {
|
||||||
totalFolders.value = 0;
|
totalFolders.value = 0;
|
||||||
console.log('API响应异常,清空列表');
|
console.log('API响应异常,清空列表');
|
||||||
console.log('响应码不是200或数据为空');
|
console.log('响应码不是200或数据为空');
|
||||||
|
console.log('folderRes.code:', folderRes.code);
|
||||||
|
console.log('folderRes.data:', folderRes.data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('初始化文件夹数据失败:', error);
|
console.error('初始化文件夹数据失败:', error);
|
||||||
|
@ -784,6 +931,7 @@ const handleSearchClear = () => {
|
||||||
}
|
}
|
||||||
currentPage.value = 1;
|
currentPage.value = 1;
|
||||||
console.log('重置页码为:', currentPage.value);
|
console.log('重置页码为:', currentPage.value);
|
||||||
|
// 清除搜索后立即刷新数据,显示所有文件夹
|
||||||
initData();
|
initData();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -854,13 +1002,6 @@ const loadFiles = async (folderId) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
currentFolderId.value = folderId;
|
currentFolderId.value = folderId;
|
||||||
|
|
||||||
if (folderId === '0') {
|
|
||||||
currentFolderName.value = '根目录';
|
|
||||||
} else {
|
|
||||||
const folder = folderList.value.find(f => f.id === folderId);
|
|
||||||
currentFolderName.value = folder ? folder.name : '未知文件夹';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载文件失败:', error);
|
console.error('加载文件失败:', error);
|
||||||
Message.error('服务开小差,请稍后再试');
|
Message.error('服务开小差,请稍后再试');
|
||||||
|
@ -873,14 +1014,141 @@ const loadFiles = async (folderId) => {
|
||||||
|
|
||||||
|
|
||||||
// 文件夹点击事件
|
// 文件夹点击事件
|
||||||
const handleFolderClick = (folderId) => {
|
// const handleFolderClick = (folderId) => {
|
||||||
const id = String(folderId);
|
// const id = String(folderId);
|
||||||
if (currentFolderId.value !== id) {
|
// if (currentFolderId.value !== id) {
|
||||||
fileCurrentPage.value = 1;
|
// fileCurrentPage.value = 1;
|
||||||
// 切换文件夹时清空文件搜索关键词
|
// // 切换文件夹时清空文件搜索关键词
|
||||||
fileSearchKeyword.value = '';
|
// fileSearchKeyword.value = '';
|
||||||
|
// }
|
||||||
|
// currentFolderId.value = id;
|
||||||
|
// };
|
||||||
|
|
||||||
|
// 树形文件夹选择事件
|
||||||
|
const handleFolderSelect = (selectedKeys, info) => {
|
||||||
|
if (selectedKeys.length > 0) {
|
||||||
|
const folderId = selectedKeys[0];
|
||||||
|
if (currentFolderId.value !== folderId) {
|
||||||
|
fileCurrentPage.value = 1;
|
||||||
|
// 切换文件夹时清空文件搜索关键词
|
||||||
|
fileSearchKeyword.value = '';
|
||||||
|
}
|
||||||
|
currentFolderId.value = folderId;
|
||||||
|
loadFiles(folderId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 文件夹双击处理
|
||||||
|
const handleFolderDoubleClick = (info) => {
|
||||||
|
console.log('文件夹双击:', info);
|
||||||
|
|
||||||
|
const { node } = info;
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
// 显示操作选项
|
||||||
|
Modal.confirm({
|
||||||
|
title: '文件夹操作',
|
||||||
|
content: `请选择对文件夹"${node.title}"的操作`,
|
||||||
|
okText: '重命名',
|
||||||
|
cancelText: '删除',
|
||||||
|
onOk: () => handleRenameFolder(node),
|
||||||
|
onCancel: () => handleDeleteFolder(node)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重命名当前选中的文件夹
|
||||||
|
const handleRenameCurrentFolder = () => {
|
||||||
|
if (!currentFolderId.value || currentFolderId.value === '0') {
|
||||||
|
Message.warning('请先选择一个文件夹');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从folderList中找到当前选中的文件夹
|
||||||
|
const currentFolder = folderList.value.find(folder => folder.id === currentFolderId.value);
|
||||||
|
if (!currentFolder) {
|
||||||
|
Message.error('找不到当前文件夹信息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造node对象
|
||||||
|
const node = {
|
||||||
|
key: currentFolder.id,
|
||||||
|
title: currentFolder.name,
|
||||||
|
id: currentFolder.id,
|
||||||
|
name: currentFolder.name
|
||||||
|
};
|
||||||
|
|
||||||
|
handleRenameFolder(node);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除当前选中的文件夹
|
||||||
|
const handleDeleteCurrentFolder = () => {
|
||||||
|
if (!currentFolderId.value || currentFolderId.value === '0') {
|
||||||
|
Message.warning('请先选择一个文件夹');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从folderList中找到当前选中的文件夹
|
||||||
|
const currentFolder = folderList.value.find(folder => folder.id === currentFolderId.value);
|
||||||
|
if (!currentFolder) {
|
||||||
|
Message.error('找不到当前文件夹信息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造node对象
|
||||||
|
const node = {
|
||||||
|
key: currentFolder.id,
|
||||||
|
title: currentFolder.name,
|
||||||
|
id: currentFolder.id,
|
||||||
|
name: currentFolder.name
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDeleteFolder(node);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 计算属性:面包屑导航路径
|
||||||
|
const breadcrumbPath = computed(() => {
|
||||||
|
if (!currentFolderId.value || currentFolderId.value === '0') {
|
||||||
|
return ['知识库', '根目录'];
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = ['知识库'];
|
||||||
|
let currentId = currentFolderId.value;
|
||||||
|
|
||||||
|
// 从当前文件夹向上查找父级路径
|
||||||
|
while (currentId && currentId !== '0') {
|
||||||
|
const folder = folderList.value.find(f => f.id === currentId);
|
||||||
|
if (folder) {
|
||||||
|
path.unshift(folder.name);
|
||||||
|
currentId = folder.parentId;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 面包屑点击事件处理
|
||||||
|
const handleBreadcrumbClick = (index) => {
|
||||||
|
if (index === 0) {
|
||||||
|
// 点击"知识库",回到根目录
|
||||||
|
currentFolderId.value = '0';
|
||||||
|
loadFiles('0');
|
||||||
|
} else {
|
||||||
|
// 点击其他路径项,需要找到对应的文件夹ID
|
||||||
|
const targetPath = breadcrumbPath.value.slice(0, index + 1);
|
||||||
|
const targetFolderName = targetPath[targetPath.length - 1];
|
||||||
|
|
||||||
|
// 查找对应的文件夹
|
||||||
|
const targetFolder = folderList.value.find(folder => folder.name === targetFolderName);
|
||||||
|
if (targetFolder) {
|
||||||
|
currentFolderId.value = targetFolder.id;
|
||||||
|
loadFiles(targetFolder.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
currentFolderId.value = id;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 重命名对话框状态
|
// 重命名对话框状态
|
||||||
|
@ -901,22 +1169,42 @@ const renameFileForm = reactive({
|
||||||
});
|
});
|
||||||
|
|
||||||
// 文件夹重命名处理函数
|
// 文件夹重命名处理函数
|
||||||
const handleRenameFolder = (folder, folderId, currentName) => {
|
const handleRenameFolder = (folder) => {
|
||||||
console.log('handleRenameFolder 被调用:', { folder, folderId, currentName });
|
console.log('重命名文件夹:', folder);
|
||||||
|
|
||||||
|
if (!folder) {
|
||||||
|
Message.error('文件夹信息不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderId = folder.key || folder.id;
|
||||||
|
const currentName = folder.title || folder.name;
|
||||||
|
|
||||||
// 验证参数
|
|
||||||
if (!folderId) {
|
if (!folderId) {
|
||||||
console.error('folderId 为空');
|
|
||||||
Message.error('文件夹ID不能为空');
|
Message.error('文件夹ID不能为空');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentName) {
|
if (!currentName) {
|
||||||
console.error('currentName 为空');
|
Message.error('文件夹名称不能为空');
|
||||||
Message.error('当前文件夹名称不能为空');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!currentName) {
|
||||||
|
console.error('❌ currentName 为空');
|
||||||
|
console.error('尝试从folder对象获取名称...');
|
||||||
|
const fallbackName = folder?.title || folder?.name;
|
||||||
|
console.error('fallbackName:', fallbackName);
|
||||||
|
|
||||||
|
if (!fallbackName) {
|
||||||
|
Message.error('当前文件夹名称不能为空');
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
console.log('✅ 使用fallbackName:', fallbackName);
|
||||||
|
currentName = fallbackName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 先显示一个简单的提示,确认函数被调用
|
// 先显示一个简单的提示,确认函数被调用
|
||||||
Message.info('重命名功能被触发');
|
Message.info('重命名功能被触发');
|
||||||
|
|
||||||
|
@ -961,13 +1249,14 @@ const confirmRename = async () => {
|
||||||
if (result && result.code === 200) {
|
if (result && result.code === 200) {
|
||||||
if (isRoot) {
|
if (isRoot) {
|
||||||
Message.success('根目录重命名成功');
|
Message.success('根目录重命名成功');
|
||||||
currentFolderName.value = newName.trim();
|
// 移除对currentFolderName的设置,现在使用面包屑导航
|
||||||
|
// currentFolderName.value = newName.trim();
|
||||||
} else {
|
} else {
|
||||||
Message.success('文件夹重命名成功');
|
Message.success('文件夹重命名成功');
|
||||||
// 如果重命名的是当前选中的文件夹,更新显示名称
|
// 移除对currentFolderName的设置,现在使用面包屑导航
|
||||||
if (currentFolderId.value === folderId) {
|
// if (currentFolderId.value === folderId) {
|
||||||
currentFolderName.value = newName.trim();
|
// currentFolderName.value = newName.trim();
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
initData(); // 刷新文件夹列表
|
initData(); // 刷新文件夹列表
|
||||||
|
@ -1015,6 +1304,10 @@ const handleFilePageSizeChange = (current, size) => {
|
||||||
const refreshData = async () => {
|
const refreshData = async () => {
|
||||||
refreshing.value = true;
|
refreshing.value = true;
|
||||||
try {
|
try {
|
||||||
|
// 强制清空搜索关键词,确保显示所有文件夹
|
||||||
|
searchKeyword.value = '';
|
||||||
|
currentPage.value = 1;
|
||||||
|
|
||||||
await initData();
|
await initData();
|
||||||
if (currentFolderId.value) {
|
if (currentFolderId.value) {
|
||||||
await loadFiles(currentFolderId.value);
|
await loadFiles(currentFolderId.value);
|
||||||
|
@ -1035,14 +1328,22 @@ const submitFolderForm = async () => {
|
||||||
await updateFolderApi(folderForm.id, folderForm.name);
|
await updateFolderApi(folderForm.id, folderForm.name);
|
||||||
Message.success('文件夹重命名成功');
|
Message.success('文件夹重命名成功');
|
||||||
} else {
|
} else {
|
||||||
await createFolderApi({
|
const result = await createFolderApi({
|
||||||
name: folderForm.name,
|
name: folderForm.name,
|
||||||
parentId: folderForm.parentId
|
parentId: folderForm.parentId
|
||||||
});
|
});
|
||||||
Message.success('文件夹创建成功');
|
Message.success('文件夹创建成功');
|
||||||
|
|
||||||
|
// 新建文件夹后,刷新数据并自动选中新建的文件夹
|
||||||
|
await initData();
|
||||||
|
|
||||||
|
// 如果有返回新建文件夹的ID,自动选中它
|
||||||
|
if (result.data && result.data.folderId) {
|
||||||
|
currentFolderId.value = String(result.data.folderId);
|
||||||
|
loadFiles(currentFolderId.value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
folderDialogVisible.value = false;
|
folderDialogVisible.value = false;
|
||||||
initData();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('文件夹操作失败:', error);
|
console.error('文件夹操作失败:', error);
|
||||||
Message.error(folderForm.id ? '重命名失败' : '创建失败');
|
Message.error(folderForm.id ? '重命名失败' : '创建失败');
|
||||||
|
@ -1669,7 +1970,7 @@ const showAudioPreview = (url, fileName) => {
|
||||||
marginBottom: '20px',
|
marginBottom: '20px',
|
||||||
color: '#165DFF'
|
color: '#165DFF'
|
||||||
}
|
}
|
||||||
}, '🎵'),
|
}, '<EFBFBD><EFBFBD>'),
|
||||||
|
|
||||||
// 音频播放器
|
// 音频播放器
|
||||||
h('audio', {
|
h('audio', {
|
||||||
|
@ -1883,16 +2184,17 @@ const confirmRenameFile = async () => {
|
||||||
const handleDeleteFolder = (folder) => {
|
const handleDeleteFolder = (folder) => {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '确认删除',
|
title: '确认删除',
|
||||||
content: `确定要删除文件夹「${folder.name}」吗?删除后无法恢复,文件夹内的所有文件也将被删除。`,
|
content: `确定要删除文件夹「${folder.title || folder.name}」吗?删除后无法恢复,文件夹内的所有文件也将被删除。`,
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
try {
|
try {
|
||||||
const result = await deleteFolderApi(folder.id);
|
const result = await deleteFolderApi(folder.key || folder.id);
|
||||||
if (result.code === 200) {
|
if (result.code === 200) {
|
||||||
Message.success('文件夹删除成功');
|
Message.success('文件夹删除成功');
|
||||||
// 如果删除的是当前选中的文件夹,切换到根目录
|
// 如果删除的是当前选中的文件夹,切换到根目录
|
||||||
if (currentFolderId.value === folder.id) {
|
if (currentFolderId.value === (folder.key || folder.id)) {
|
||||||
currentFolderId.value = '0';
|
currentFolderId.value = '0';
|
||||||
currentFolderName.value = '根目录';
|
// 移除对currentFolderName的设置,现在使用面包屑导航
|
||||||
|
// currentFolderName.value = '根目录';
|
||||||
fileList.value = [];
|
fileList.value = [];
|
||||||
totalFiles.value = 0;
|
totalFiles.value = 0;
|
||||||
}
|
}
|
||||||
|
@ -2067,6 +2369,8 @@ onMounted(() => {
|
||||||
background-color: var(--color-bg-2);
|
background-color: var(--color-bg-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* 侧边栏样式 */
|
/* 侧边栏样式 */
|
||||||
.folder-sidebar {
|
.folder-sidebar {
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
@ -3208,4 +3512,110 @@ onMounted(() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 树形文件夹结构 */
|
||||||
|
.folder-tree-container {
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--color-bg-2);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-name {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node-content:hover .folder-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-actions .action-btn {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: var(--color-fill-3);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-tree {
|
||||||
|
:deep(.arco-tree-node-content) {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-fill-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.arco-tree-node-selected .arco-tree-node-content) {
|
||||||
|
background-color: var(--color-primary-light-1);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.arco-tree-node-indent) {
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.collapsed {
|
||||||
|
:deep(.arco-tree-node-content) {
|
||||||
|
padding: 8px 4px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.arco-tree-node-title) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.arco-tree-node-switcher) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.arco-tree-node-indent) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 面包屑导航样式 */
|
||||||
|
.breadcrumbs {
|
||||||
|
.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-primary);
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-primary-light-1);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -7,8 +7,16 @@
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="12">
|
<a-col :span="12">
|
||||||
<a-form-item field="projectName" label="项目名称">
|
<a-form-item field="projectId" label="项目">
|
||||||
<a-input v-model="contractData.projectName" />
|
<a-select v-model="contractData.projectId"
|
||||||
|
:options="projectOptions"
|
||||||
|
:loading="projectLoading"
|
||||||
|
:virtual-list-props="virtualListProps"
|
||||||
|
placeholder="请选择项目"
|
||||||
|
allow-search allow-clear
|
||||||
|
@focus="handleProjectFocus"
|
||||||
|
@dropdown-visible-change="handleProjectDropdown"
|
||||||
|
@search="handleProjectSearch" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
|
@ -66,8 +74,27 @@
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="12">
|
<a-col :span="12">
|
||||||
<a-form-item field="productService" label="产品或服务">
|
<a-form-item field="departmentId" label="销售部门">
|
||||||
<a-input v-model="contractData.productService" />
|
<a-tree-select v-model="contractData.departmentId"
|
||||||
|
:data="deptTree"
|
||||||
|
:loading="deptLoading"
|
||||||
|
placeholder="请选择部门"
|
||||||
|
allow-search allow-clear
|
||||||
|
@dropdown-visible-change="handleDeptDropdown"
|
||||||
|
@focus="handleDeptFocus"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item field="salespersonId" label="销售人员">
|
||||||
|
<a-select v-model="contractData.salespersonId"
|
||||||
|
:options="userOptions"
|
||||||
|
:loading="userLoading"
|
||||||
|
:virtual-list-props="userVirtualListProps"
|
||||||
|
placeholder="请选择业务员"
|
||||||
|
allow-search allow-clear
|
||||||
|
@dropdown-visible-change="handleUserDropdown"
|
||||||
|
@search="handleUserSearch" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
|
@ -88,6 +115,10 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
|
import { Message } from '@arco-design/web-vue'
|
||||||
|
import { listProject, type ProjectResp } from '@/apis/project'
|
||||||
|
import { getDeptTree } from '@/apis/system/dept'
|
||||||
|
import { listUser } from '@/apis/system/user'
|
||||||
import type { ContractItem } from './index.vue'
|
import type { ContractItem } from './index.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
@ -100,6 +131,138 @@ const emit = defineEmits<{
|
||||||
|
|
||||||
const contractData = ref({ ...props.contractData })
|
const contractData = ref({ ...props.contractData })
|
||||||
|
|
||||||
|
// 项目选项与加载(支持无限滚动)
|
||||||
|
const projectOptions = ref<{ label: string; value: string }[]>([])
|
||||||
|
const projectLoading = ref(false)
|
||||||
|
const projPage = ref(1)
|
||||||
|
const pageSize = 20
|
||||||
|
const hasMore = ref(true)
|
||||||
|
const currentKeyword = ref('')
|
||||||
|
let searchTimer: number | undefined
|
||||||
|
|
||||||
|
const appendProjects = (list: ProjectResp[]) => {
|
||||||
|
const items = list.map(p => ({ label: p.projectName, value: String((p as any).projectId ?? (p as any).id) }))
|
||||||
|
projectOptions.value = projectOptions.value.concat(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractList = (data: any): ProjectResp[] => {
|
||||||
|
if (Array.isArray(data)) return data as ProjectResp[]
|
||||||
|
if (Array.isArray(data?.list)) return data.list as ProjectResp[]
|
||||||
|
if (Array.isArray(data?.rows)) return data.rows as ProjectResp[]
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadProjects = async (keyword = currentKeyword.value, reset = false) => {
|
||||||
|
try {
|
||||||
|
if (reset) {
|
||||||
|
projPage.value = 1
|
||||||
|
hasMore.value = true
|
||||||
|
projectOptions.value = []
|
||||||
|
}
|
||||||
|
if (!hasMore.value) return
|
||||||
|
projectLoading.value = true
|
||||||
|
const resp = await listProject({ page: projPage.value, size: pageSize, projectName: keyword } as any)
|
||||||
|
const list = extractList((resp as any).data)
|
||||||
|
appendProjects(list)
|
||||||
|
if (list.length < pageSize) hasMore.value = false
|
||||||
|
projPage.value += 1
|
||||||
|
} catch (e) {
|
||||||
|
Message.error('获取项目列表失败')
|
||||||
|
hasMore.value = false
|
||||||
|
} finally {
|
||||||
|
projectLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const virtualListProps = {
|
||||||
|
height: 240,
|
||||||
|
onReachBottom: () => {
|
||||||
|
if (hasMore.value && !projectLoading.value) loadProjects()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleProjectFocus = () => {
|
||||||
|
if (projectOptions.value.length === 0) loadProjects('', true)
|
||||||
|
}
|
||||||
|
const handleProjectDropdown = (visible: boolean) => {
|
||||||
|
if (visible && projectOptions.value.length === 0) loadProjects('', true)
|
||||||
|
}
|
||||||
|
const handleProjectSearch = (val: string) => {
|
||||||
|
if (searchTimer) window.clearTimeout(searchTimer)
|
||||||
|
currentKeyword.value = val || ''
|
||||||
|
searchTimer = window.setTimeout(() => loadProjects(currentKeyword.value, true), 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 部门树
|
||||||
|
const deptTree = ref([] as any[])
|
||||||
|
const deptLoading = ref(false)
|
||||||
|
const loadDept = async () => {
|
||||||
|
try {
|
||||||
|
deptLoading.value = true
|
||||||
|
const res = await getDeptTree()
|
||||||
|
const data = (res as any).data || []
|
||||||
|
// 转换为 a-tree-select 结构
|
||||||
|
const toTree = (arr: any[]): any[] => arr.map(i => ({
|
||||||
|
key: String(i.deptId),
|
||||||
|
title: i.deptName,
|
||||||
|
value: String(i.deptId),
|
||||||
|
children: Array.isArray(i.children) ? toTree(i.children) : [],
|
||||||
|
}))
|
||||||
|
deptTree.value = toTree(Array.isArray(data) ? data : (data.list || data.rows || []))
|
||||||
|
} finally {
|
||||||
|
deptLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleDeptDropdown = (visible: boolean) => { if (visible && deptTree.value.length === 0) loadDept() }
|
||||||
|
const handleDeptFocus = () => { if (deptTree.value.length === 0) loadDept() }
|
||||||
|
|
||||||
|
// 用户列表(分页 + 搜索 + 无限滚动)
|
||||||
|
const userOptions = ref<{ label: string; value: string }[]>([])
|
||||||
|
const userLoading = ref(false)
|
||||||
|
const userPage = ref(1)
|
||||||
|
const userHasMore = ref(true)
|
||||||
|
const userPageSize = 20
|
||||||
|
const userKeyword = ref('')
|
||||||
|
|
||||||
|
const appendUsers = (rows: any[]) => {
|
||||||
|
userOptions.value = userOptions.value.concat(
|
||||||
|
rows.map(u => ({ label: u.name || u.nickname || u.username, value: String(u.userId || u.id) }))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const extractUsers = (data: any): any[] => {
|
||||||
|
if (Array.isArray(data)) return data
|
||||||
|
if (Array.isArray(data?.rows)) return data.rows
|
||||||
|
if (Array.isArray(data?.list)) return data.list
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const loadUsers = async (reset = false) => {
|
||||||
|
try {
|
||||||
|
if (reset) { userOptions.value = []; userPage.value = 1; userHasMore.value = true }
|
||||||
|
if (!userHasMore.value) return
|
||||||
|
userLoading.value = true
|
||||||
|
const resp = await listUser({ page: userPage.value, pageSize: userPageSize, name: userKeyword.value } as any)
|
||||||
|
const rows = extractUsers((resp as any).data)
|
||||||
|
appendUsers(rows)
|
||||||
|
if (rows.length < userPageSize) userHasMore.value = false
|
||||||
|
userPage.value += 1
|
||||||
|
} finally {
|
||||||
|
userLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const userVirtualListProps = {
|
||||||
|
height: 240,
|
||||||
|
onReachBottom: () => { if (userHasMore.value && !userLoading.value) loadUsers() }
|
||||||
|
}
|
||||||
|
const handleUserDropdown = (visible: boolean) => { if (visible && userOptions.value.length === 0) { userKeyword.value=''; loadUsers(true) } }
|
||||||
|
const handleUserSearch = (val: string) => {
|
||||||
|
if (searchTimer) window.clearTimeout(searchTimer)
|
||||||
|
userKeyword.value = val || ''
|
||||||
|
searchTimer = window.setTimeout(() => loadUsers(true), 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化:首次展开项目/部门/用户时加载
|
||||||
|
// 不在 mounted 直接加载,减少无效请求
|
||||||
|
|
||||||
// 监听props变化更新内部数据
|
// 监听props变化更新内部数据
|
||||||
watch(
|
watch(
|
||||||
() => props.contractData,
|
() => props.contractData,
|
||||||
|
|
|
@ -55,11 +55,9 @@
|
||||||
|
|
||||||
<template #action="{ record }">
|
<template #action="{ record }">
|
||||||
<a-space>
|
<a-space>
|
||||||
<a-link @click="viewDetail(record)">详情</a-link>
|
<a-link @click="viewDetail(record)">查看</a-link>
|
||||||
<a-link v-if="record.contractStatus === '未确认'" @click="editRecord(record)">编辑</a-link>
|
<a-link @click="editRecord(record)">编辑</a-link>
|
||||||
<a-link v-if="record.contractStatus === '待审批'" @click="approveContract(record)">审批</a-link>
|
<a-link status="danger" @click="deleteContract(record)">删除</a-link>
|
||||||
<a-link @click="viewPayment(record)">收款记录</a-link>
|
|
||||||
<a-link v-if="record.contractStatus !== '已签署' && record.contractStatus !== '已完成'" @click="deleteContract(record)">删除</a-link>
|
|
||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
</GiTable>
|
</GiTable>
|
||||||
|
@ -89,6 +87,21 @@
|
||||||
@update:contract-data="handleContractDataUpdate"
|
@update:contract-data="handleContractDataUpdate"
|
||||||
/>
|
/>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
|
|
||||||
|
<!-- 新建合同弹窗 -->
|
||||||
|
<a-modal
|
||||||
|
v-model:visible="showAddModal"
|
||||||
|
title="新建合同"
|
||||||
|
:width="800"
|
||||||
|
@cancel="closeAddModal"
|
||||||
|
@before-ok="handleAddSubmit"
|
||||||
|
>
|
||||||
|
<ContractEdit
|
||||||
|
v-if="showAddModal"
|
||||||
|
:contract-data="newContractData"
|
||||||
|
@update:contract-data="handleNewContractDataUpdate"
|
||||||
|
/>
|
||||||
|
</a-modal>
|
||||||
</GiPageLayout>
|
</GiPageLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -135,25 +148,16 @@ interface ContractItem {
|
||||||
|
|
||||||
// 搜索表单
|
// 搜索表单
|
||||||
const searchForm = reactive({
|
const searchForm = reactive({
|
||||||
contractName: '',
|
|
||||||
contractCode: '',
|
contractCode: '',
|
||||||
client: '',
|
client: '',
|
||||||
status: '',
|
status: '',
|
||||||
signDate: '',
|
signDateRange: [] as [string, string] | [],
|
||||||
page: 1,
|
page: 1,
|
||||||
size: 10,
|
size: 10,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 查询条件配置
|
// 查询条件配置
|
||||||
const queryFormColumns = [
|
const queryFormColumns = [
|
||||||
{
|
|
||||||
field: 'contractName',
|
|
||||||
label: '合同名称',
|
|
||||||
type: 'input' as const,
|
|
||||||
props: {
|
|
||||||
placeholder: '请输入合同名称',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
field: 'client',
|
field: 'client',
|
||||||
label: '客户',
|
label: '客户',
|
||||||
|
@ -178,6 +182,16 @@ const queryFormColumns = [
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
field: 'signDateRange',
|
||||||
|
label: '签署时间',
|
||||||
|
type: 'range-picker' as const,
|
||||||
|
props: {
|
||||||
|
placeholder: ['开始时间', '结束时间'],
|
||||||
|
showTime: true,
|
||||||
|
format: 'YYYY-MM-DD HH:mm:ss',
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
// 表格列配置
|
// 表格列配置
|
||||||
|
@ -209,27 +223,43 @@ const fetchContractList = async () => {
|
||||||
const params = {
|
const params = {
|
||||||
page: searchForm.page,
|
page: searchForm.page,
|
||||||
pageSize: searchForm.size,
|
pageSize: searchForm.size,
|
||||||
contractName: searchForm.contractName,
|
|
||||||
code: searchForm.contractCode,
|
code: searchForm.contractCode,
|
||||||
customer: searchForm.client,
|
customer: searchForm.client,
|
||||||
contractStatus: searchForm.status,
|
contractStatus: searchForm.status,
|
||||||
signDate: searchForm.signDate,
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await http.get('/contract/list', params)
|
const response = await http.get('/contract/list', params)
|
||||||
|
|
||||||
if (response.code === 200) {
|
if (response.code === 200) {
|
||||||
// 过滤出类型为"收入合同"的数据
|
// 过滤出类型为"支出合同"的数据
|
||||||
const allContracts = response.rows || []
|
const allContracts = response.rows || []
|
||||||
const revenueContracts = allContracts.filter((item: ContractItem) => item.type === '支出合同')
|
let filtered = allContracts.filter((item: ContractItem) => item.type === '支出合同')
|
||||||
|
|
||||||
|
// 如果后端未按时间段过滤,则在前端按签署时间范围再次过滤
|
||||||
|
const range = Array.isArray(searchForm.signDateRange) && searchForm.signDateRange.length === 2 ? searchForm.signDateRange : null
|
||||||
|
if (range) {
|
||||||
|
const [start, end] = range
|
||||||
|
const startTime = new Date(start as any).getTime()
|
||||||
|
const endTime = new Date(end as any).getTime()
|
||||||
|
if (!Number.isNaN(startTime) && !Number.isNaN(endTime)) {
|
||||||
|
filtered = filtered.filter((item: ContractItem) => {
|
||||||
|
if (!item.signDate) return false
|
||||||
|
const t = new Date(item.signDate as any).getTime()
|
||||||
|
return !Number.isNaN(t) && t >= startTime && t <= endTime
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 计算未收款金额
|
// 计算未收款金额
|
||||||
dataList.value = revenueContracts.map((item: ContractItem) => ({
|
dataList.value = filtered.map((item: ContractItem) => ({
|
||||||
...item,
|
...item,
|
||||||
pendingAmount: (item.amount || 0) - (item.receivedAmount || 0),
|
pendingAmount: (item.amount || 0) - (item.receivedAmount || 0),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
pagination.total = Number.parseInt(response.total) || 0
|
// 更新分页总数(前端过滤后以过滤结果数为准)
|
||||||
|
pagination.total = dataList.value.length
|
||||||
} else {
|
} else {
|
||||||
Message.error(response.msg || '获取合同列表失败')
|
Message.error(response.msg || '获取合同列表失败')
|
||||||
dataList.value = []
|
dataList.value = []
|
||||||
|
@ -277,11 +307,10 @@ const search = async () => {
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
Object.assign(searchForm, {
|
Object.assign(searchForm, {
|
||||||
contractName: '',
|
|
||||||
contractCode: '',
|
contractCode: '',
|
||||||
client: '',
|
client: '',
|
||||||
status: '',
|
status: '',
|
||||||
signDate: '',
|
signDateRange: [],
|
||||||
page: 1,
|
page: 1,
|
||||||
size: 10,
|
size: 10,
|
||||||
})
|
})
|
||||||
|
@ -305,8 +334,131 @@ const onPageSizeChange = (size: number) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 操作方法
|
// 操作方法
|
||||||
|
const showAddModal = ref(false)
|
||||||
|
const newContractData = ref<ContractItem>({
|
||||||
|
contractId: '',
|
||||||
|
customer: '',
|
||||||
|
code: '',
|
||||||
|
projectId: '',
|
||||||
|
type: '支出合同',
|
||||||
|
productService: '',
|
||||||
|
paymentDate: null,
|
||||||
|
performanceDeadline: null,
|
||||||
|
paymentAddress: '',
|
||||||
|
amount: 0,
|
||||||
|
accountNumber: '',
|
||||||
|
notes: '',
|
||||||
|
contractStatus: '未确认',
|
||||||
|
contractText: '',
|
||||||
|
projectName: '',
|
||||||
|
salespersonName: null,
|
||||||
|
salespersonDeptName: '',
|
||||||
|
settlementAmount: null,
|
||||||
|
receivedAmount: null,
|
||||||
|
contractStatusLabel: null,
|
||||||
|
createBy: null,
|
||||||
|
updateBy: null,
|
||||||
|
createTime: '',
|
||||||
|
updateTime: '',
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
signDate: '',
|
||||||
|
duration: '',
|
||||||
|
})
|
||||||
|
|
||||||
const openAddModal = () => {
|
const openAddModal = () => {
|
||||||
Message.info('新建合同功能开发中...')
|
// 重置默认值
|
||||||
|
newContractData.value = {
|
||||||
|
contractId: '',
|
||||||
|
customer: '',
|
||||||
|
code: '',
|
||||||
|
projectId: '',
|
||||||
|
type: '支出合同',
|
||||||
|
productService: '',
|
||||||
|
paymentDate: null,
|
||||||
|
performanceDeadline: null,
|
||||||
|
paymentAddress: '',
|
||||||
|
amount: 0,
|
||||||
|
accountNumber: '',
|
||||||
|
notes: '',
|
||||||
|
contractStatus: '未确认',
|
||||||
|
contractText: '',
|
||||||
|
projectName: '',
|
||||||
|
salespersonName: null,
|
||||||
|
salespersonDeptName: '',
|
||||||
|
settlementAmount: null,
|
||||||
|
receivedAmount: null,
|
||||||
|
contractStatusLabel: null,
|
||||||
|
createBy: null,
|
||||||
|
updateBy: null,
|
||||||
|
createTime: '',
|
||||||
|
updateTime: '',
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
signDate: '',
|
||||||
|
duration: '',
|
||||||
|
}
|
||||||
|
showAddModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeAddModal = () => {
|
||||||
|
showAddModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNewContractDataUpdate = (data: ContractItem) => {
|
||||||
|
// 不更换引用,避免子组件watch到props变更导致重渲染丢失焦点
|
||||||
|
Object.assign(newContractData.value, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const payload: any = {
|
||||||
|
accountNumber: newContractData.value.accountNumber || '',
|
||||||
|
amount: newContractData.value.amount || 0,
|
||||||
|
code: newContractData.value.code || '',
|
||||||
|
contractStatus: newContractData.value.contractStatus || '',
|
||||||
|
contractText: newContractData.value.contractText || '',
|
||||||
|
customer: newContractData.value.customer || '',
|
||||||
|
departmentId: (newContractData.value as any).departmentId || '',
|
||||||
|
duration: newContractData.value.duration || '',
|
||||||
|
notes: newContractData.value.notes || '',
|
||||||
|
paymentAddress: newContractData.value.paymentAddress || '',
|
||||||
|
paymentDate: newContractData.value.paymentDate || null,
|
||||||
|
performanceDeadline: newContractData.value.performanceDeadline || null,
|
||||||
|
productService: newContractData.value.productService || '',
|
||||||
|
projectId: newContractData.value.projectId || '',
|
||||||
|
salespersonId: (newContractData.value as any).salespersonId || '',
|
||||||
|
signDate: newContractData.value.signDate || null,
|
||||||
|
type: newContractData.value.type || '支出合同',
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有合同ID则不传
|
||||||
|
if (!newContractData.value.contractId) delete payload.contractId
|
||||||
|
|
||||||
|
const response = await http.post('/contract', payload)
|
||||||
|
|
||||||
|
if ((response as any).status === 200 && response.code === 200) {
|
||||||
|
Message.success('新建合同成功')
|
||||||
|
closeAddModal()
|
||||||
|
search()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 某些接口只返回 code/msg
|
||||||
|
if (response.code === 200) {
|
||||||
|
Message.success('新建合同成功')
|
||||||
|
closeAddModal()
|
||||||
|
search()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
Message.error(response.msg || '新建合同失败')
|
||||||
|
return false
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('新建合同失败:', error)
|
||||||
|
Message.error('新建合同失败: ' + (error?.message || '请稍后再试'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const exportContract = () => {
|
const exportContract = () => {
|
||||||
|
@ -339,7 +491,12 @@ const closeEditModal = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleContractDataUpdate = (data: ContractItem) => {
|
const handleContractDataUpdate = (data: ContractItem) => {
|
||||||
editedContractData.value = data
|
// 避免替换引用导致子组件重建,保持编辑输入流畅
|
||||||
|
if (!editedContractData.value) {
|
||||||
|
editedContractData.value = { ...(data as any) } as ContractItem
|
||||||
|
} else {
|
||||||
|
Object.assign(editedContractData.value, data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditSubmit = async () => {
|
const handleEditSubmit = async () => {
|
||||||
|
@ -391,28 +548,29 @@ const handleEditSubmit = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 删除合同
|
// 删除合同:只有在确认框点击确定后才执行删除
|
||||||
const deleteContract = async (record: ContractItem) => {
|
const deleteContract = (record: ContractItem) => {
|
||||||
try {
|
Modal.confirm({
|
||||||
await Modal.confirm({
|
title: '确认删除',
|
||||||
title: '确认删除',
|
content: `确定要删除合同 "${record.projectName}" 吗?`,
|
||||||
content: `确定要删除合同 "${record.projectName}" 吗?`,
|
async onOk() {
|
||||||
})
|
try {
|
||||||
|
const response = await http.del(`/contract/${record.contractId}`)
|
||||||
const response = await http.delete(`/contract/${record.contractId}`)
|
if (response.code === 200) {
|
||||||
if (response.code === 200) {
|
Message.success('合同删除成功')
|
||||||
Message.success('合同删除成功')
|
search()
|
||||||
search() // 刷新列表
|
return true
|
||||||
} else {
|
} else {
|
||||||
Message.error(response.msg || '合同删除失败')
|
Message.error(response.msg || '合同删除失败')
|
||||||
}
|
return false
|
||||||
} catch (error) {
|
}
|
||||||
// 用户取消删除或请求失败
|
} catch (error) {
|
||||||
if (error !== 'cancel') {
|
console.error('合同删除失败:', error)
|
||||||
console.error('合同删除失败:', error)
|
Message.error('合同删除失败')
|
||||||
Message.error('合同删除失败')
|
return false
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示合同详情弹窗
|
// 显示合同详情弹窗
|
||||||
|
|
|
@ -7,8 +7,16 @@
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="12">
|
<a-col :span="12">
|
||||||
<a-form-item field="projectName" label="项目名称">
|
<a-form-item field="projectId" label="项目">
|
||||||
<a-input v-model="contractData.projectName" />
|
<a-select v-model="contractData.projectId"
|
||||||
|
:options="projectOptions"
|
||||||
|
:loading="projectLoading"
|
||||||
|
:virtual-list-props="virtualListProps"
|
||||||
|
placeholder="请选择项目"
|
||||||
|
allow-search allow-clear
|
||||||
|
@focus="handleProjectFocus"
|
||||||
|
@dropdown-visible-change="handleProjectDropdown"
|
||||||
|
@search="handleProjectSearch" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
|
@ -66,8 +74,27 @@
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="12">
|
<a-col :span="12">
|
||||||
<a-form-item field="productService" label="产品或服务">
|
<a-form-item field="departmentId" label="销售部门">
|
||||||
<a-input v-model="contractData.productService" />
|
<a-tree-select v-model="contractData.departmentId"
|
||||||
|
:data="deptTree"
|
||||||
|
:loading="deptLoading"
|
||||||
|
placeholder="请选择部门"
|
||||||
|
allow-search allow-clear
|
||||||
|
@dropdown-visible-change="handleDeptDropdown"
|
||||||
|
@focus="handleDeptFocus"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item field="salespersonId" label="销售人员">
|
||||||
|
<a-select v-model="contractData.salespersonId"
|
||||||
|
:options="userOptions"
|
||||||
|
:loading="userLoading"
|
||||||
|
:virtual-list-props="userVirtualListProps"
|
||||||
|
placeholder="请选择业务员"
|
||||||
|
allow-search allow-clear
|
||||||
|
@dropdown-visible-change="handleUserDropdown"
|
||||||
|
@search="handleUserSearch" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
|
@ -88,6 +115,10 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
|
import { Message } from '@arco-design/web-vue'
|
||||||
|
import { listProject, type ProjectResp } from '@/apis/project'
|
||||||
|
import { getDeptTree } from '@/apis/system/dept'
|
||||||
|
import { listUser } from '@/apis/system/user'
|
||||||
import type { ContractItem } from './index.vue'
|
import type { ContractItem } from './index.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
@ -100,6 +131,134 @@ const emit = defineEmits<{
|
||||||
|
|
||||||
const contractData = ref({ ...props.contractData })
|
const contractData = ref({ ...props.contractData })
|
||||||
|
|
||||||
|
// 项目选项与加载(支持无限滚动)
|
||||||
|
const projectOptions = ref<{ label: string; value: string }[]>([])
|
||||||
|
const projectLoading = ref(false)
|
||||||
|
const projPage = ref(1)
|
||||||
|
const pageSize = 20
|
||||||
|
const hasMore = ref(true)
|
||||||
|
const currentKeyword = ref('')
|
||||||
|
let searchTimer: number | undefined
|
||||||
|
|
||||||
|
const appendProjects = (list: ProjectResp[]) => {
|
||||||
|
const items = list.map(p => ({ label: p.projectName, value: String((p as any).projectId ?? (p as any).id) }))
|
||||||
|
projectOptions.value = projectOptions.value.concat(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractList = (data: any): ProjectResp[] => {
|
||||||
|
if (Array.isArray(data)) return data as ProjectResp[]
|
||||||
|
if (Array.isArray(data?.list)) return data.list as ProjectResp[]
|
||||||
|
if (Array.isArray(data?.rows)) return data.rows as ProjectResp[]
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadProjects = async (keyword = currentKeyword.value, reset = false) => {
|
||||||
|
try {
|
||||||
|
if (reset) {
|
||||||
|
projPage.value = 1
|
||||||
|
hasMore.value = true
|
||||||
|
projectOptions.value = []
|
||||||
|
}
|
||||||
|
if (!hasMore.value) return
|
||||||
|
projectLoading.value = true
|
||||||
|
const resp = await listProject({ page: projPage.value, size: pageSize, projectName: keyword } as any)
|
||||||
|
const list = extractList((resp as any).data)
|
||||||
|
appendProjects(list)
|
||||||
|
if (list.length < pageSize) hasMore.value = false
|
||||||
|
projPage.value += 1
|
||||||
|
} catch (e) {
|
||||||
|
Message.error('获取项目列表失败')
|
||||||
|
hasMore.value = false
|
||||||
|
} finally {
|
||||||
|
projectLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const virtualListProps = {
|
||||||
|
height: 240,
|
||||||
|
onReachBottom: () => {
|
||||||
|
if (hasMore.value && !projectLoading.value) loadProjects()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleProjectFocus = () => {
|
||||||
|
if (projectOptions.value.length === 0) loadProjects('', true)
|
||||||
|
}
|
||||||
|
const handleProjectDropdown = (visible: boolean) => {
|
||||||
|
if (visible && projectOptions.value.length === 0) loadProjects('', true)
|
||||||
|
}
|
||||||
|
const handleProjectSearch = (val: string) => {
|
||||||
|
if (searchTimer) window.clearTimeout(searchTimer)
|
||||||
|
currentKeyword.value = val || ''
|
||||||
|
searchTimer = window.setTimeout(() => loadProjects(currentKeyword.value, true), 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 部门树
|
||||||
|
const deptTree = ref([] as any[])
|
||||||
|
const deptLoading = ref(false)
|
||||||
|
const loadDept = async () => {
|
||||||
|
try {
|
||||||
|
deptLoading.value = true
|
||||||
|
const res = await getDeptTree()
|
||||||
|
const data = (res as any).data || []
|
||||||
|
const toTree = (arr: any[]): any[] => arr.map(i => ({
|
||||||
|
key: String(i.deptId),
|
||||||
|
title: i.deptName,
|
||||||
|
value: String(i.deptId),
|
||||||
|
children: Array.isArray(i.children) ? toTree(i.children) : [],
|
||||||
|
}))
|
||||||
|
deptTree.value = toTree(Array.isArray(data) ? data : (data.list || data.rows || []))
|
||||||
|
} finally {
|
||||||
|
deptLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleDeptDropdown = (visible: boolean) => { if (visible && deptTree.value.length === 0) loadDept() }
|
||||||
|
const handleDeptFocus = () => { if (deptTree.value.length === 0) loadDept() }
|
||||||
|
|
||||||
|
// 用户列表(分页 + 搜索 + 无限滚动)
|
||||||
|
const userOptions = ref<{ label: string; value: string }[]>([])
|
||||||
|
const userLoading = ref(false)
|
||||||
|
const userPage = ref(1)
|
||||||
|
const userHasMore = ref(true)
|
||||||
|
const userPageSize = 20
|
||||||
|
const userKeyword = ref('')
|
||||||
|
|
||||||
|
const appendUsers = (rows: any[]) => {
|
||||||
|
userOptions.value = userOptions.value.concat(
|
||||||
|
rows.map(u => ({ label: u.name || u.nickname || u.username, value: String(u.userId || u.id) }))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const extractUsers = (data: any): any[] => {
|
||||||
|
if (Array.isArray(data)) return data
|
||||||
|
if (Array.isArray(data?.rows)) return data.rows
|
||||||
|
if (Array.isArray(data?.list)) return data.list
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const loadUsers = async (reset = false) => {
|
||||||
|
try {
|
||||||
|
if (reset) { userOptions.value = []; userPage.value = 1; userHasMore.value = true }
|
||||||
|
if (!userHasMore.value) return
|
||||||
|
userLoading.value = true
|
||||||
|
const resp = await listUser({ page: userPage.value, pageSize: userPageSize, name: userKeyword.value } as any)
|
||||||
|
const rows = extractUsers((resp as any).data)
|
||||||
|
appendUsers(rows)
|
||||||
|
if (rows.length < userPageSize) userHasMore.value = false
|
||||||
|
userPage.value += 1
|
||||||
|
} finally {
|
||||||
|
userLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const userVirtualListProps = {
|
||||||
|
height: 240,
|
||||||
|
onReachBottom: () => { if (userHasMore.value && !userLoading.value) loadUsers() }
|
||||||
|
}
|
||||||
|
const handleUserDropdown = (visible: boolean) => { if (visible && userOptions.value.length === 0) { userKeyword.value=''; loadUsers(true) } }
|
||||||
|
const handleUserSearch = (val: string) => {
|
||||||
|
if (searchTimer) window.clearTimeout(searchTimer)
|
||||||
|
userKeyword.value = val || ''
|
||||||
|
searchTimer = window.setTimeout(() => loadUsers(true), 300)
|
||||||
|
}
|
||||||
|
|
||||||
// 监听props变化更新内部数据
|
// 监听props变化更新内部数据
|
||||||
watch(
|
watch(
|
||||||
() => props.contractData,
|
() => props.contractData,
|
||||||
|
|
|
@ -55,11 +55,9 @@
|
||||||
|
|
||||||
<template #action="{ record }">
|
<template #action="{ record }">
|
||||||
<a-space>
|
<a-space>
|
||||||
<a-link @click="viewDetail(record)">详情</a-link>
|
<a-link @click="viewDetail(record)">查看</a-link>
|
||||||
<a-link v-if="record.contractStatus === '未确认'" @click="editRecord(record)">编辑</a-link>
|
<a-link @click="editRecord(record)">编辑</a-link>
|
||||||
<a-link v-if="record.contractStatus === '待审批'" @click="approveContract(record)">审批</a-link>
|
<a-link status="danger" @click="deleteContract(record)">删除</a-link>
|
||||||
<a-link @click="viewPayment(record)">收款记录</a-link>
|
|
||||||
<a-link v-if="record.contractStatus !== '已签署' && record.contractStatus !== '已完成'" @click="deleteContract(record)">删除</a-link>
|
|
||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
</GiTable>
|
</GiTable>
|
||||||
|
@ -90,6 +88,21 @@
|
||||||
/>
|
/>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
</GiPageLayout>
|
</GiPageLayout>
|
||||||
|
|
||||||
|
<!-- 新建合同弹窗 -->
|
||||||
|
<a-modal
|
||||||
|
v-model:visible="showAddModal"
|
||||||
|
title="新建合同"
|
||||||
|
:width="800"
|
||||||
|
@cancel="closeAddModal"
|
||||||
|
@before-ok="handleAddSubmit"
|
||||||
|
>
|
||||||
|
<ContractEdit
|
||||||
|
v-if="showAddModal"
|
||||||
|
:contract-data="newContractData"
|
||||||
|
@update:contract-data="handleNewContractDataUpdate"
|
||||||
|
/>
|
||||||
|
</a-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
@ -305,14 +318,71 @@ const onPageSizeChange = (size: number) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 操作方法
|
// 操作方法
|
||||||
|
const showAddModal = ref(false)
|
||||||
|
const newContractData = ref<ContractItem>({
|
||||||
|
contractId: '', customer: '', code: '', projectId: '', type: '收入合同',
|
||||||
|
productService: '', paymentDate: null, performanceDeadline: null,
|
||||||
|
paymentAddress: '', amount: 0, accountNumber: '', notes: '',
|
||||||
|
contractStatus: '未确认', contractText: '', projectName: '',
|
||||||
|
salespersonName: null, salespersonDeptName: '', settlementAmount: null,
|
||||||
|
receivedAmount: null, contractStatusLabel: null, createBy: null, updateBy: null,
|
||||||
|
createTime: '', updateTime: '', page: 1, pageSize: 10, signDate: '', duration: '',
|
||||||
|
} as any)
|
||||||
|
|
||||||
const openAddModal = () => {
|
const openAddModal = () => {
|
||||||
Message.info('新建合同功能开发中...')
|
Object.assign(newContractData.value, {
|
||||||
|
contractId: '', customer: '', code: '', projectId: '', type: '收入合同',
|
||||||
|
productService: '', paymentDate: null, performanceDeadline: null,
|
||||||
|
paymentAddress: '', amount: 0, accountNumber: '', notes: '',
|
||||||
|
contractStatus: '未确认', contractText: '', projectName: '',
|
||||||
|
salespersonName: null, salespersonDeptName: '', settlementAmount: null,
|
||||||
|
receivedAmount: null, contractStatusLabel: null, createBy: null, updateBy: null,
|
||||||
|
createTime: '', updateTime: '', page: 1, pageSize: 10, signDate: '', duration: '',
|
||||||
|
})
|
||||||
|
showAddModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const exportContract = () => {
|
const closeAddModal = () => { showAddModal.value = false }
|
||||||
Message.info('导出合同功能开发中...')
|
|
||||||
|
const handleNewContractDataUpdate = (data: ContractItem) => { Object.assign(newContractData.value, data) }
|
||||||
|
|
||||||
|
const handleAddSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const payload: any = {
|
||||||
|
accountNumber: newContractData.value.accountNumber || '',
|
||||||
|
amount: newContractData.value.amount || 0,
|
||||||
|
code: newContractData.value.code || '',
|
||||||
|
contractStatus: newContractData.value.contractStatus || '',
|
||||||
|
contractText: newContractData.value.contractText || '',
|
||||||
|
customer: newContractData.value.customer || '',
|
||||||
|
departmentId: (newContractData.value as any).departmentId || '',
|
||||||
|
duration: newContractData.value.duration || '',
|
||||||
|
notes: newContractData.value.notes || '',
|
||||||
|
paymentAddress: newContractData.value.paymentAddress || '',
|
||||||
|
paymentDate: newContractData.value.paymentDate || null,
|
||||||
|
performanceDeadline: newContractData.value.performanceDeadline || null,
|
||||||
|
productService: newContractData.value.productService || '',
|
||||||
|
projectId: newContractData.value.projectId || '',
|
||||||
|
salespersonId: (newContractData.value as any).salespersonId || '',
|
||||||
|
signDate: newContractData.value.signDate || null,
|
||||||
|
type: newContractData.value.type || '收入合同',
|
||||||
|
}
|
||||||
|
if (!newContractData.value.contractId) delete payload.contractId
|
||||||
|
const response = await http.post('/contract', payload)
|
||||||
|
if ((response as any).status === 200 && response.code === 200) {
|
||||||
|
Message.success('新建合同成功'); closeAddModal(); search(); return true
|
||||||
|
}
|
||||||
|
if (response.code === 200) { Message.success('新建合同成功'); closeAddModal(); search(); return true }
|
||||||
|
Message.error(response.msg || '新建合同失败'); return false
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('新建合同失败:', error)
|
||||||
|
Message.error('新建合同失败: ' + (error?.message || '请稍后再试'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const exportContract = () => { Message.info('导出合同功能开发中...') }
|
||||||
|
|
||||||
// 显示合同编辑弹窗
|
// 显示合同编辑弹窗
|
||||||
const showEditModal = ref(false)
|
const showEditModal = ref(false)
|
||||||
const selectedContractData = ref<ContractItem | null>(null)
|
const selectedContractData = ref<ContractItem | null>(null)
|
||||||
|
@ -391,28 +461,29 @@ const handleEditSubmit = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 删除合同
|
// 删除合同:只有在确认框点击确定后才执行删除
|
||||||
const deleteContract = async (record: ContractItem) => {
|
const deleteContract = (record: ContractItem) => {
|
||||||
try {
|
Modal.confirm({
|
||||||
await Modal.confirm({
|
title: '确认删除',
|
||||||
title: '确认删除',
|
content: `确定要删除合同 "${record.projectName}" 吗?`,
|
||||||
content: `确定要删除合同 "${record.projectName}" 吗?`,
|
async onOk() {
|
||||||
})
|
try {
|
||||||
|
const response = await http.del(`/contract/${record.contractId}`)
|
||||||
const response = await http.delete(`/contract/${record.contractId}`)
|
if (response.code === 200) {
|
||||||
if (response.code === 200) {
|
Message.success('合同删除成功')
|
||||||
Message.success('合同删除成功')
|
search()
|
||||||
search() // 刷新列表
|
return true
|
||||||
} else {
|
} else {
|
||||||
Message.error(response.msg || '合同删除失败')
|
Message.error(response.msg || '合同删除失败')
|
||||||
}
|
return false
|
||||||
} catch (error) {
|
}
|
||||||
// 用户取消删除或请求失败
|
} catch (error) {
|
||||||
if (error !== 'cancel') {
|
console.error('合同删除失败:', error)
|
||||||
console.error('合同删除失败:', error)
|
Message.error('合同删除失败')
|
||||||
Message.error('合同删除失败')
|
return false
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示合同详情弹窗
|
// 显示合同详情弹窗
|
||||||
|
|
|
@ -82,6 +82,7 @@ import { IconInfoCircle, IconEdit } from '@arco-design/web-vue/es/icon'
|
||||||
import message from '@arco-design/web-vue/es/message'
|
import message from '@arco-design/web-vue/es/message'
|
||||||
import type { EquipmentApprovalResp, EquipmentApprovalReq } from '@/apis/equipment/type'
|
import type { EquipmentApprovalResp, EquipmentApprovalReq } from '@/apis/equipment/type'
|
||||||
import { equipmentApprovalApi } from '@/apis/equipment/approval'
|
import { equipmentApprovalApi } from '@/apis/equipment/approval'
|
||||||
|
import notificationService from '@/services/notificationService'
|
||||||
|
|
||||||
defineOptions({ name: 'ApprovalActionModal' })
|
defineOptions({ name: 'ApprovalActionModal' })
|
||||||
|
|
||||||
|
@ -173,9 +174,23 @@ const handleSubmit = async () => {
|
||||||
if (props.actionType === 'approve') {
|
if (props.actionType === 'approve') {
|
||||||
await equipmentApprovalApi.approve(approvalId, submitData)
|
await equipmentApprovalApi.approve(approvalId, submitData)
|
||||||
message.success('审批通过成功')
|
message.success('审批通过成功')
|
||||||
|
|
||||||
|
// 添加审批通过通知
|
||||||
|
notificationService.addApprovalNotification(
|
||||||
|
props.approvalData.equipmentName,
|
||||||
|
'APPROVED',
|
||||||
|
formData.approverName || '审批人'
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
await equipmentApprovalApi.reject(approvalId, submitData)
|
await equipmentApprovalApi.reject(approvalId, submitData)
|
||||||
message.success('审批拒绝成功')
|
message.success('审批拒绝成功')
|
||||||
|
|
||||||
|
// 添加审批拒绝通知
|
||||||
|
notificationService.addApprovalNotification(
|
||||||
|
props.approvalData.equipmentName,
|
||||||
|
'REJECTED',
|
||||||
|
formData.approverName || '审批人'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
emit('success')
|
emit('success')
|
||||||
|
|
|
@ -175,6 +175,7 @@
|
||||||
import { ref, reactive, watch } from 'vue'
|
import { ref, reactive, watch } from 'vue'
|
||||||
import { Message } from '@arco-design/web-vue'
|
import { Message } from '@arco-design/web-vue'
|
||||||
import { equipmentApprovalApi } from '@/apis/equipment/approval'
|
import { equipmentApprovalApi } from '@/apis/equipment/approval'
|
||||||
|
import notificationService from '@/services/notificationService'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
visible: boolean
|
visible: boolean
|
||||||
|
@ -296,6 +297,15 @@ const handleSubmit = async () => {
|
||||||
|
|
||||||
await equipmentApprovalApi.submitProcurementApplication(submitData)
|
await equipmentApprovalApi.submitProcurementApplication(submitData)
|
||||||
|
|
||||||
|
// 添加采购申请通知
|
||||||
|
const currentUser = JSON.parse(localStorage.getItem('userInfo') || '{}')
|
||||||
|
const applicantName = currentUser.nickname || currentUser.username || '未知用户'
|
||||||
|
|
||||||
|
notificationService.addProcurementNotification(
|
||||||
|
submitData.equipmentName,
|
||||||
|
applicantName
|
||||||
|
)
|
||||||
|
|
||||||
Message.success('采购申请提交成功')
|
Message.success('采购申请提交成功')
|
||||||
emit('success')
|
emit('success')
|
||||||
handleCancel()
|
handleCancel()
|
||||||
|
|
Loading…
Reference in New Issue