月报管理页面完善

This commit is contained in:
BianLzhaoMin 2025-12-30 15:04:25 +08:00
parent 0e9570ca37
commit c873c2a143
6 changed files with 474 additions and 21 deletions

View File

@ -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"

View File

@ -0,0 +1,10 @@
import request from '@/utils/request'
// 获取月报
export function getMonthlyReportAPI(params) {
return request({
url: '/download/monthReport',
method: 'post',
params,
})
}

View File

@ -17,7 +17,7 @@ const service = axios.create({
// axios中请求配置有baseURL选项表示请求URL公共部分
baseURL: import.meta.env.VITE_APP_BASE_API,
// 超时
timeout: 10000,
timeout: 100000,
})
// request拦截器

View File

@ -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 BlobArrayBuffer 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>

View File

@ -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' },
{

View File

@ -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;
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%;
}
}
}