月报管理页面完善
This commit is contained in:
parent
0e9570ca37
commit
c873c2a143
|
|
@ -17,6 +17,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "2.3.1",
|
||||
"@vue-office/docx": "^1.6.3",
|
||||
"@vueup/vue-quill": "1.2.0",
|
||||
"@vueuse/core": "13.3.0",
|
||||
"axios": "1.9.0",
|
||||
|
|
@ -30,12 +31,14 @@
|
|||
"js-cookie": "3.0.5",
|
||||
"jsencrypt": "3.3.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mammoth": "^1.11.0",
|
||||
"mitt": "^3.0.1",
|
||||
"nprogress": "0.2.0",
|
||||
"pinia": "3.0.2",
|
||||
"splitpanes": "4.0.4",
|
||||
"vue": "3.5.16",
|
||||
"vue-cropper": "1.1.1",
|
||||
"vue-demi": "^0.14.10",
|
||||
"vue-draggable-plus": "^0.6.0",
|
||||
"vue-router": "4.5.1",
|
||||
"vuedraggable": "4.1.0"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
import request from '@/utils/request'
|
||||
|
||||
// 获取月报
|
||||
export function getMonthlyReportAPI(params) {
|
||||
return request({
|
||||
url: '/download/monthReport',
|
||||
method: 'post',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ const service = axios.create({
|
|||
// axios中请求配置有baseURL选项,表示请求URL公共部分
|
||||
baseURL: import.meta.env.VITE_APP_BASE_API,
|
||||
// 超时
|
||||
timeout: 10000,
|
||||
timeout: 100000,
|
||||
})
|
||||
|
||||
// request拦截器
|
||||
|
|
|
|||
|
|
@ -1,9 +1,416 @@
|
|||
<template>
|
||||
<div>
|
||||
<h1>月报管理</h1>
|
||||
<div class="app-container monthly-report-container">
|
||||
<splitpanes class="default-theme">
|
||||
<!-- 左侧:年份和月份选择 -->
|
||||
<pane :size="20" min-size="15" max-size="40">
|
||||
<el-card shadow="never" class="sidebar-card">
|
||||
<template #header>
|
||||
<div class="card-header">选择年月</div>
|
||||
</template>
|
||||
|
||||
<div class="year-selector">
|
||||
<el-date-picker
|
||||
v-model="selectedYear"
|
||||
type="year"
|
||||
placeholder="请选择年份"
|
||||
format="YYYY"
|
||||
value-format="YYYY"
|
||||
@change="onYearChange"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="month-list">
|
||||
<div
|
||||
v-for="month in monthOptions"
|
||||
:key="month.value"
|
||||
class="month-item"
|
||||
:class="{ active: selectedMonth === month.value }"
|
||||
@click="onMonthClick(month.value)"
|
||||
>
|
||||
{{ month.label }}
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</pane>
|
||||
|
||||
<!-- 右侧:PDF预览区域 -->
|
||||
<pane :size="80" min-size="60">
|
||||
<el-card shadow="never" class="preview-card">
|
||||
<template #header>
|
||||
<div class="card-header-wrapper">
|
||||
<div class="card-header">
|
||||
{{ reportTitle }}
|
||||
</div>
|
||||
<ComButton type="primary" icon="Download" @click="onDownload">
|
||||
下载
|
||||
</ComButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="docx-preview-container" v-loading="loading">
|
||||
<VueOfficeDocx
|
||||
v-if="docxFile"
|
||||
:src="docxFile"
|
||||
style="height: 100%"
|
||||
@rendered="onDocxRendered"
|
||||
@error="onDocxError"
|
||||
/>
|
||||
<el-empty v-else description="请选择年月查看月报" :image-size="120" />
|
||||
</div>
|
||||
</el-card>
|
||||
</pane>
|
||||
</splitpanes>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="monthlyReport"></script>
|
||||
<script setup name="MonthlyReport">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { getCurrentInstance } from 'vue'
|
||||
import { Splitpanes, Pane } from 'splitpanes'
|
||||
import 'splitpanes/dist/splitpanes.css'
|
||||
import VueOfficeDocx from '@vue-office/docx'
|
||||
import '@vue-office/docx/lib/index.css'
|
||||
import ComButton from '@/components/ComButton/index.vue'
|
||||
import { getToken } from '@/utils/auth'
|
||||
|
||||
<style></style>
|
||||
const { proxy } = getCurrentInstance()
|
||||
|
||||
const selectedYear = ref(String(new Date().getFullYear()))
|
||||
const selectedMonth = ref('')
|
||||
const loading = ref(false)
|
||||
const docxFile = ref(null)
|
||||
|
||||
// 生成月份选项
|
||||
const monthOptions = computed(() => {
|
||||
if (!selectedYear.value) {
|
||||
return []
|
||||
}
|
||||
const months = []
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
months.push({
|
||||
label: `${selectedYear.value}-${String(i).padStart(2, '0')}`,
|
||||
value: `${selectedYear.value}-${String(i).padStart(2, '0')}`,
|
||||
})
|
||||
}
|
||||
return months.reverse()
|
||||
})
|
||||
|
||||
// 报告标题
|
||||
const reportTitle = computed(() => {
|
||||
if (selectedMonth.value) {
|
||||
const [year, month] = selectedMonth.value.split('-')
|
||||
return `${year}年${parseInt(month)}月作业项目分析`
|
||||
}
|
||||
return '月报预览'
|
||||
})
|
||||
|
||||
// 年份变化
|
||||
const onYearChange = (year) => {
|
||||
if (year) {
|
||||
selectedYear.value = year
|
||||
}
|
||||
selectedMonth.value = ''
|
||||
docxFile.value = null
|
||||
}
|
||||
|
||||
// docx 渲染完成
|
||||
const onDocxRendered = () => {
|
||||
console.log('DOCX 文件渲染完成')
|
||||
}
|
||||
|
||||
// docx 渲染错误
|
||||
const onDocxError = (error) => {
|
||||
console.error('DOCX 文件渲染失败:', error)
|
||||
proxy.$modal.msgError('文档预览失败,请稍后重试')
|
||||
}
|
||||
|
||||
// 月份点击
|
||||
const onMonthClick = async (month) => {
|
||||
if (selectedMonth.value === month) {
|
||||
return
|
||||
}
|
||||
selectedMonth.value = month
|
||||
await fetchReport()
|
||||
}
|
||||
|
||||
// 检查是否是有效的 docx 文件(docx 文件是 ZIP 格式,以 PK 开头)
|
||||
const isValidDocx = async (blob) => {
|
||||
try {
|
||||
const arrayBuffer = await blob.slice(0, 4).arrayBuffer()
|
||||
const uint8Array = new Uint8Array(arrayBuffer)
|
||||
// ZIP 文件签名:PK\x03\x04 (50 4B 03 04)
|
||||
return (
|
||||
uint8Array[0] === 0x50 &&
|
||||
uint8Array[1] === 0x4b &&
|
||||
uint8Array[2] === 0x03 &&
|
||||
uint8Array[3] === 0x04
|
||||
)
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否是 JSON 错误响应
|
||||
const isJsonError = async (blob) => {
|
||||
try {
|
||||
const text = await blob.slice(0, 100).text()
|
||||
return text.trim().startsWith('{') || text.trim().startsWith('[')
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 使用原生 fetch 获取文件流,避免 axios 拦截器将二进制数据转换为字符串
|
||||
const fetchReport = async () => {
|
||||
if (!selectedMonth.value) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
docxFile.value = null
|
||||
|
||||
// 如果之前有 Object URL,先释放
|
||||
if (
|
||||
docxFile.value &&
|
||||
typeof docxFile.value === 'string' &&
|
||||
docxFile.value.startsWith('blob:')
|
||||
) {
|
||||
URL.revokeObjectURL(docxFile.value)
|
||||
}
|
||||
|
||||
try {
|
||||
const baseURL = import.meta.env.VITE_APP_BASE_API
|
||||
const url = `${baseURL}/download/monthReport`
|
||||
|
||||
console.log('使用 fetch 获取文件:', url, '参数:', { month: selectedMonth.value })
|
||||
|
||||
// 构建查询参数
|
||||
const params = new URLSearchParams({
|
||||
month: selectedMonth.value,
|
||||
})
|
||||
|
||||
const response = await fetch(`${url}?${params.toString()}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + getToken(),
|
||||
},
|
||||
})
|
||||
|
||||
// 检查响应状态
|
||||
if (!response.ok) {
|
||||
// 尝试读取错误信息
|
||||
const errorText = await response.text()
|
||||
try {
|
||||
const errorData = JSON.parse(errorText)
|
||||
proxy.$modal.msgError(errorData.msg || '获取月报失败')
|
||||
} catch (e) {
|
||||
proxy.$modal.msgError(`获取月报失败: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
docxFile.value = null
|
||||
return
|
||||
}
|
||||
|
||||
// 检查 Content-Type
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
console.log('响应 Content-Type:', contentType)
|
||||
|
||||
// 获取 Blob
|
||||
const blob = await response.blob()
|
||||
console.log('获取到 Blob,大小:', blob.size, '字节,类型:', blob.type)
|
||||
|
||||
// 检查 Blob 大小是否合理
|
||||
if (blob.size < 1024) {
|
||||
console.warn('警告:Blob 大小异常小,可能数据不完整')
|
||||
// 尝试读取文本看看是否是错误信息
|
||||
try {
|
||||
const text = await blob.text()
|
||||
const errorData = JSON.parse(text)
|
||||
if (errorData.code && errorData.code !== 200) {
|
||||
proxy.$modal.msgError(errorData.msg || '获取月报失败')
|
||||
docxFile.value = null
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
// 不是 JSON,继续处理
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否是有效的 docx 文件
|
||||
const isValid = await isValidDocx(blob)
|
||||
console.log('是否为有效的 docx 文件:', isValid)
|
||||
|
||||
if (!isValid) {
|
||||
// 如果不是有效的 docx,尝试读取文本看看是否是错误信息
|
||||
try {
|
||||
const text = await blob.text()
|
||||
console.log('文件内容预览(前500字符):', text.substring(0, 500))
|
||||
const errorData = JSON.parse(text)
|
||||
if (errorData.code && errorData.code !== 200) {
|
||||
proxy.$modal.msgError(errorData.msg || '获取月报失败')
|
||||
docxFile.value = null
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('文件格式检查失败:', e)
|
||||
proxy.$modal.msgError(
|
||||
'返回的文件不是有效的 docx 格式,请检查后端接口是否正确返回文件流',
|
||||
)
|
||||
docxFile.value = null
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// @vue-office/docx 支持 Blob、ArrayBuffer 或 URL
|
||||
// 直接使用 Blob 对象
|
||||
docxFile.value = blob
|
||||
console.log('DOCX 文件准备完成,Blob 大小:', blob.size, '字节')
|
||||
} catch (error) {
|
||||
console.error('获取月报失败:', error)
|
||||
proxy.$modal.msgError('获取月报失败,请稍后重试。错误:' + (error.message || error))
|
||||
docxFile.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 下载月报
|
||||
const onDownload = async () => {
|
||||
// if (!selectedMonth.value) {
|
||||
// proxy.$modal.msgWarning('请先选择年月')
|
||||
// return
|
||||
// }
|
||||
// const result = await downloadMonthlyReportAPI({
|
||||
// month: selectedMonth.value,
|
||||
// })
|
||||
// console.log('result', result)
|
||||
try {
|
||||
proxy.download(
|
||||
'/download/monthReport',
|
||||
{
|
||||
month: selectedMonth.value,
|
||||
},
|
||||
`${selectedYear.value}年${parseInt(
|
||||
selectedMonth.value.split('-')[1],
|
||||
)}月作业项目分析.docx`,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('下载月报失败:', error)
|
||||
proxy.$modal.msgError('下载月报失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化:默认选择当前月份
|
||||
onMounted(() => {
|
||||
const now = new Date()
|
||||
selectedYear.value = String(now.getFullYear())
|
||||
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
selectedMonth.value = currentMonth
|
||||
fetchReport()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.monthly-report-container {
|
||||
height: calc(100vh - 84px);
|
||||
padding: 20px;
|
||||
|
||||
:deep(.splitpanes__pane) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:deep(.el-card__body) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.year-selector {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.month-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.month-item {
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
background-color: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
|
||||
&:hover {
|
||||
background-color: #f3f4f6;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #e0f2fe;
|
||||
border-color: #1677ff;
|
||||
color: #1677ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:deep(.el-card__body) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.card-header-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.card-header {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: #1f2937;
|
||||
}
|
||||
}
|
||||
|
||||
.docx-preview-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: #f5f5f5;
|
||||
|
||||
:deep(.vue-office-docx) {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -55,15 +55,15 @@ export const buildFormColumns = (
|
|||
|
||||
// 月计划列表表格列
|
||||
export const tableColumns = [
|
||||
{ prop: 'inspectionStationName', label: '运检站', fixed: true, width: '180' },
|
||||
{ prop: 'inspectionStationName', label: '运检站', fixed: true, width: '200' },
|
||||
{ prop: 'planMajorName', label: '专业', fixed: true },
|
||||
{ prop: 'businessTypeName', label: '业务类型', fixed: true },
|
||||
{ prop: 'projectName', label: '项目名称', fixed: true, width: '180' },
|
||||
{ prop: 'workContent', label: '工作任务' },
|
||||
{ prop: 'projectName', label: '项目名称', fixed: true, width: '200' },
|
||||
{ prop: 'workContent', label: '工作任务', width: '200' },
|
||||
{ prop: 'riskLevel', label: '风险等级' },
|
||||
{ prop: 'planCategoryName', label: '类别' },
|
||||
{ prop: 'workAmount', label: '工作量', width: '180', slot: 'workAmount' },
|
||||
{ prop: 'towerBaseNumber', label: '基塔数' },
|
||||
{ prop: 'workAmount', label: '工作量', width: '200', slot: 'workAmount' },
|
||||
{ prop: 'towerBaseNumber', label: ' 塔基数' },
|
||||
{ prop: 'plannedStartTime', label: '计划开始时间', width: '140' },
|
||||
{ prop: 'plannedEndTime', label: '计划结束时间', width: '140' },
|
||||
{
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
size="large"
|
||||
label-width="auto"
|
||||
:disabled="isDetail"
|
||||
:validate-on-rule-change="false"
|
||||
>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
|
|
@ -168,12 +169,12 @@
|
|||
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="基塔数" prop="towerBaseNumber">
|
||||
<el-form-item label=" 塔基数" prop="towerBaseNumber">
|
||||
<el-input
|
||||
clearable
|
||||
show-word-limit
|
||||
maxlength="7"
|
||||
placeholder="请输入基塔数"
|
||||
placeholder="请输入 塔基数"
|
||||
v-model.trim="formData.towerBaseNumber"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
|
@ -388,6 +389,8 @@
|
|||
v-if="day.managers.length > maxShowInCell"
|
||||
placement="top"
|
||||
trigger="hover"
|
||||
:width="'auto'"
|
||||
popper-class="person-popover"
|
||||
>
|
||||
<template #reference>
|
||||
<div class="person-tags">
|
||||
|
|
@ -417,6 +420,7 @@
|
|||
:closable="!isDetail"
|
||||
v-for="person in day.managers"
|
||||
@close.stop="onRemovePersonFromDay(day.date, person)"
|
||||
class="popover-tag"
|
||||
>
|
||||
{{ person.name }}
|
||||
</el-tag>
|
||||
|
|
@ -668,7 +672,7 @@ const getInitFormData = () => ({
|
|||
// 表单字段(需要传给后端)
|
||||
businessTypeId: null, // 业务类型
|
||||
planCategoryId: null, // 类别
|
||||
towerBaseNumber: '', // 基塔数
|
||||
towerBaseNumber: '', // 塔基数
|
||||
planPersonnelList: [], // 计划投入管理人员(数组)
|
||||
planPersonnel: [], // 计划投入管理人员(数组)
|
||||
planCarNum: '', // 计划投入管理人员车辆数
|
||||
|
|
@ -700,7 +704,7 @@ const rules = computed(() => {
|
|||
riskLevel: [{ required: true, message: '请选择风险等级', trigger: 'change' }],
|
||||
planCategoryId: [{ required: true, message: '请选择类别', trigger: 'change' }],
|
||||
towerBaseNumber: [
|
||||
{ required: true, message: '请输入基塔数', trigger: 'blur' },
|
||||
{ required: true, message: '请输入 塔基数', trigger: 'blur' },
|
||||
{ pattern: /^[1-9]\d*$/, message: '请输入正整数', trigger: 'blur' },
|
||||
],
|
||||
planPersonnelList: [{ required: true, message: '请选择计划投入管理人员', trigger: 'blur' }],
|
||||
|
|
@ -1304,13 +1308,42 @@ onMounted(() => {
|
|||
}
|
||||
|
||||
.popover-list {
|
||||
.popover-item {
|
||||
display: flex;
|
||||
font-size: 12px;
|
||||
// padding: 2px 6px;
|
||||
background-color: #f3f4f6;
|
||||
border-radius: 2px;
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
max-width: 400px;
|
||||
min-width: 200px;
|
||||
|
||||
.popover-tag {
|
||||
max-width: 100%;
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
height: auto;
|
||||
line-height: 1.5;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.person-popover) {
|
||||
min-width: 200px;
|
||||
max-width: 400px;
|
||||
padding: 12px;
|
||||
|
||||
.el-tag {
|
||||
max-width: 100%;
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
height: auto;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
|
||||
.el-tag__content {
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
line-height: 1.5;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue