| | |
| | | <app-header title="交通统计报表审核系统 - 数据导入"></app-header> |
| | | <el-container> |
| | | <el-aside width="200px"> |
| | | <el-menu router default-active="/import"> |
| | | <el-menu ref="sideMenu" router :default-active="$route.path" @select="onMenuSelect"> |
| | | <el-menu-item index="/dashboard">工作台</el-menu-item> |
| | | <el-menu-item index="/import">数据导入</el-menu-item> |
| | | <el-menu-item index="/data">数据查看</el-menu-item> |
| | |
| | | <el-main> |
| | | <div class="page-scroll"> |
| | | <h3>导入业务数据</h3> |
| | | <el-card shadow="hover" class="import-card" style="margin-bottom:15px"> |
| | | <div class="import-layout"> |
| | | <div class="import-main"> <el-card shadow="hover" class="import-card" style="margin-bottom:15px"> |
| | | <div class="card-head">报表期</div> |
| | | <el-date-picker v-model="period" type="month" placeholder="选择月份" value-format="yyyy-MM" |
| | | style="width:240px" @change="onPeriodChange"></el-date-picker> |
| | | <div class="tip">选择报表期后,下方显示该月各模块的导入进度;各模块下拉互不影响,可先选好类型、挂载文件,再加入待导入队列统一执行。</div> |
| | | </el-card> |
| | | |
| | | |
| | | <el-card shadow="hover" class="import-card" style="margin-bottom:15px"> |
| | | <div class="card-head">本月导入进度({{ period || '未选择报表期' }}) |
| | | <el-button type="text" icon="el-icon-refresh" style="float:right" @click="loadData">刷新</el-button> |
| | | </div> |
| | | <div v-if="!period" class="tip">请先在上方选择报表期</div> |
| | | <div v-else class="overview-grid"> |
| | | <div class="ov-row" v-for="g in groups" :key="g.key"> |
| | | <div class="ov-group">{{ g.name }}</div> |
| | | <div class="ov-item" v-for="t in g.types" :key="t.value" :class="overviewState(t.value).cls"> |
| | | <div class="ov-name">{{ t.label }}</div> |
| | | <div class="ov-status">{{ overviewState(t.value).text }}</div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </el-card> |
| | | <el-card shadow="hover" class="import-card" style="margin-bottom:15px"> |
| | | <div class="card-head">选择类型并挂载文件 |
| | | <span class="tip" style="float:right;line-height:24px">各模块互不影响:先选类型、挂载文件,可「立即导入」或「加入队列」统一执行;也可在下方粘贴目录路径点「导入该目录」,文件夹里多个月的 Excel 会按文件名自动识别月份、分别导入对应报表期</span> |
| | | </div> |
| | | <div class="card-head">选择数据类型</div> |
| | | <div class="module-tabs-head"> |
| | | <el-radio-group v-model="activeModule" size="small"> |
| | | <el-radio-button v-for="g in groups" :key="g.key" :label="g.key">{{ g.name }}</el-radio-button> |
| | | </el-radio-group> |
| | | </div> |
| | | <div v-for="g in groups" :key="g.key" v-show="activeModule === g.key" class="module-tab-pane"> |
| | | <div class="module-sel"> |
| | | <el-select v-model="moduleSel[g.key]" placeholder="选择导入类型" style="width:260px" |
| | | <el-radio-group v-model="activeModule" size="small" @change="onModuleSwitch"> |
| | | <el-radio-button v-for="g in groups" :key="g.key" :label="g.key">{{ g.name }}</el-radio-button> |
| | | </el-radio-group> |
| | | </div> |
| | | <div v-for="g in groups" :key="g.key" v-show="activeModule === g.key" class="module-tab-pane"> |
| | | <div class="module-sel"> |
| | | <div class="module-sel-row"> |
| | | <el-select v-model="moduleSel[g.key]" placeholder="选择数据类型" style="width:260px" |
| | | @change="onModuleChange(g.key)" clearable> |
| | | <el-option v-for="t in g.types" :key="t.value" :label="t.label" :value="t.value"></el-option> |
| | | </el-select> |
| | | <div v-if="isCityDirType(moduleSel[g.key])" class="module-file"> |
| | | <div class="tip">无需选文件:自动扫描 docs/投资/输入/…/{{ period ? Number(period.slice(5)) : 'X' }}月 目录下全部市州报表,逐文件容错解析入库(项目主档+月度,含本月完成/建设阶段/形象进度;十五五项目自动标记)。</div> |
| | | <el-button size="mini" type="primary" icon="el-icon-upload2" :loading="cityDirLoading" |
| | | @click="importCityDir(moduleSel[g.key])">从目录批量导入</el-button> |
| | | <div v-if="importType" class="tpl-box"> |
| | | <span class="tpl-name">模板:{{ templateName }}</span> |
| | | <el-link v-if="templateUrl" type="primary" icon="el-icon-download" :underline="false" |
| | | @click="downloadTemplate">下载模板</el-link> |
| | | <span v-if="importType === 'cityTaxiAuth'" class="slow-tip">该类型约 4 万+ 行,预计耗时 5 分钟左右</span> |
| | | </div> |
| | | <div v-else-if="moduleSel[g.key]" class="module-file"> |
| | | <div class="file-name" :title="fileNames[moduleSel[g.key]]">{{ fileNames[moduleSel[g.key]] || '未选择文件' }}</div> |
| | | <div class="file-btns"> |
| | | <el-upload action="#" :auto-upload="false" :show-file-list="false" accept=".xlsx,.xls" |
| | | :ref="'up' + g.key" :on-change="f => onFilePicked(g.key, f)" style="display:inline-block"> |
| | | <el-button size="mini" type="primary" plain icon="el-icon-folder-opened">选择文件</el-button> |
| | | </el-upload> |
| | | <el-button size="mini" type="primary" icon="el-icon-upload2" :disabled="!files[moduleSel[g.key]]" |
| | | @click="importNow(g.key)">立即导入</el-button> |
| | | <el-button size="mini" type="success" plain icon="el-icon-plus" :disabled="!files[moduleSel[g.key]]" |
| | | @click="addToQueue(g.key)">加入队列</el-button> |
| | | </div> |
| | | <div class="dir-import-bar"> |
| | | <el-input v-model="dirPath" size="small" placeholder="粘贴目录路径(如 D:\数据\2026),点「导入该目录」" style="width:330px" |
| | | @keyup.enter.native="quickDirImport(moduleSel[g.key])" clearable></el-input> |
| | | <el-button size="mini" type="warning" icon="el-icon-folder" :loading="dirImporting" |
| | | @click="quickDirImport(moduleSel[g.key])">导入该目录</el-button> |
| | | <el-button size="mini" type="text" icon="el-icon-search" @click="openDirBrowser(g.key)">浏览…</el-button> |
| | | <el-checkbox v-model="dirSkipImported" size="mini">跳过已导入文件</el-checkbox> |
| | | </div> |
| | | <div class="tip">目录导入:文件夹里的 Excel 会按文件名自动识别月份(支持 2026年7月 / 2026.7 / 2026-07 / 7月),分别导入对应报表期;识别不到月份的文件会报错提示。也可点「浏览…」查找目录。</div> |
| | | <div v-if="dirImportFiles.length" class="dir-progress"> |
| | | <div class="card-head" style="font-size:13px">目录导入进度</div> |
| | | <div v-for="f in dirImportFiles" :key="f.name" class="dir-progress-row"> |
| | | <i :class="progIcon(f.status)"></i> |
| | | <span class="prog-name" :title="f.name">{{ f.name }}</span> |
| | | <el-tag v-if="f.period" size="mini" type="info">{{ f.period }}</el-tag> |
| | | <el-tag size="mini" :type="progTag(f.status)">{{ progText(f.status) }}</el-tag> |
| | | <span v-if="f.msg" class="queue-msg" :style="f.status === 'fail' ? 'color:#F56C6C' : 'color:#67C23A'">{{ f.msg }}</span> |
| | | </div> |
| | | </div> |
| | | <div v-if="isCityDirType(moduleSel[g.key])" class="module-file"> |
| | | <div class="tip">无需选文件:自动扫描 docs/投资/输入/…/{{ period ? Number(period.slice(5)) : 'X' }}月 目录下全部市州报表,逐文件容错解析入库(项目主档+月度,含本月完成/建设阶段/形象进度;十五五项目自动标记)。</div> |
| | | <el-button size="mini" type="primary" icon="el-icon-upload2" :loading="cityDirLoading" |
| | | @click="importCityDir(moduleSel[g.key])">从目录批量导入</el-button> |
| | | </div> |
| | | <div v-else-if="moduleSel[g.key]" class="module-file"> |
| | | <div class="file-name" :title="fileNames[moduleSel[g.key]]">{{ fileNames[moduleSel[g.key]] || '未选择文件' }}</div> |
| | | <div class="file-btns"> |
| | | <el-upload action="#" :auto-upload="false" :show-file-list="false" accept=".xlsx,.xls" |
| | | :ref="'up' + g.key" :on-change="f => onFilePicked(g.key, f)" style="display:inline-block"> |
| | | <el-button size="mini" type="primary" plain icon="el-icon-folder-opened">选择文件</el-button> |
| | | </el-upload> |
| | | <el-button size="mini" type="primary" icon="el-icon-upload2" :disabled="!files[moduleSel[g.key]]" |
| | | @click="importNow(g.key)">立即导入</el-button> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </el-card> |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | <el-card shadow="never" style="margin-top:15px" class="batch-card"> |
| | | <div slot="header" class="card-head"> |
| | | 导入结果({{ period || '全部报表期' }}) |
| | | <el-button type="text" icon="el-icon-refresh" style="float:right" @click="loadData">刷新</el-button> |
| | | </div> |
| | | <div v-if="failBatches.length" class="tip" style="margin-bottom:6px">共 {{ failBatches.length }} 个批次存在失败数据,失败明细共 {{ failRowsFlat.length }} 条:</div> |
| | | <el-table v-if="failBatches.length" :data="failRowsFlat" border size="small" v-loading="batchLoading" style="width:100%"> |
| | | <el-table-column label="导入类型" width="140"> |
| | | <template slot-scope="s">{{ typeLabel(s.row.importType) }}</template> |
| | | </el-table-column> |
| | | <el-table-column prop="reportPeriod" label="报表期" width="90"></el-table-column> |
| | | <el-table-column label="文件名" min-width="200" show-overflow-tooltip> |
| | | <template slot-scope="s">{{ s.row.fileName }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="失败位置" width="230"> |
| | | <template slot-scope="s"><span v-if="s.row.position">{{ s.row.position }}</span><span v-else>—</span></template> |
| | | </el-table-column> |
| | | <el-table-column label="失败原因" min-width="300"> |
| | | <template slot-scope="s">{{ s.row.reason }}</template> |
| | | </el-table-column> |
| | | </el-table> |
| | | <div v-else class="empty-tip">暂无导入失败记录</div> |
| | | </el-card> |
| | | </div> |
| | | <div class="import-side" :class="{ 'side-open': sideOpen }"> |
| | | <div class="side-toggle" @click="sideOpen = !sideOpen" :title="sideOpen ? '收起进度' : '展开进度'"> |
| | | <i :class="sideOpen ? 'el-icon-d-arrow-right' : 'el-icon-d-arrow-left'"></i> |
| | | <span class="side-toggle-text">{{ sideOpen ? '收起进度' : '导入进度' }}</span> |
| | | </div> |
| | | <div v-show="sideOpen" class="side-content"> |
| | | <div class="side-head"> |
| | | <span>本月导入进度({{ period || '未选择报表期' }})</span> |
| | | <el-button type="text" icon="el-icon-refresh" @click="loadData">刷新</el-button> |
| | | </div> |
| | | <div v-if="!period" class="tip">请先在上方选择报表期</div> |
| | | <div v-else class="overview-grid"> |
| | | <div class="ov-row" v-for="g in groups" :key="g.key"> |
| | | <div class="ov-group">{{ g.name }}</div> |
| | | <div class="ov-item" v-for="t in g.types" :key="t.value" :class="overviewState(t.value).cls"> |
| | | <div class="ov-name">{{ t.label }}</div> |
| | | <div class="ov-status">{{ overviewState(t.value).text }}</div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <div v-if="importType" class="type-info"> |
| | | <div>模板:{{ templateName }} |
| | | <el-link v-if="templateUrl" type="primary" icon="el-icon-download" :underline="false" |
| | | style="margin-left:8px" @click="downloadTemplate">下载模板</el-link> |
| | | </div> |
| | | <div class="tip">{{ typeDesc }}</div> |
| | | <span v-if="importType === 'cityTaxiAuth'" class="slow-tip">该类型约 4 万+ 行,预计耗时 5 分钟左右</span> |
| | | </div> |
| | | </el-card> |
| | | |
| | | |
| | | <el-card v-if="queue.length" ref="queueCard" shadow="hover" class="import-card" style="margin-bottom:15px"> |
| | | <div class="card-head"> |
| | | 待导入队列({{ queue.length }} 项) |
| | | <el-button type="success" size="small" icon="el-icon-video-play" style="float:right" |
| | | :loading="queueLoading" :disabled="!pendingCount" @click="importQueue">全部导入({{ pendingCount }})</el-button> |
| | | </div> |
| | | <el-table :data="queue" border size="small" style="width:100%"> |
| | | <el-table-column label="模块" width="90"> |
| | | <template slot-scope="s">{{ s.row.moduleName }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="导入类型" width="150"> |
| | | <template slot-scope="s">{{ s.row.typeLabel }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="文件" min-width="200" show-overflow-tooltip> |
| | | <template slot-scope="s">{{ s.row.fileName }}</template> |
| | | </el-table-column> |
| | | <el-table-column label="状态" width="110" align="center"> |
| | | <template slot-scope="s"> |
| | | <el-tag v-if="s.row.status === 'pending'" type="info" size="mini">待导入</el-tag> |
| | | <el-tag v-else-if="s.row.status === 'uploading'" type="warning" size="mini" effect="dark">导入中…</el-tag> |
| | | <el-tag v-else-if="s.row.status === 'success'" type="success" size="mini">成功</el-tag> |
| | | <el-tag v-else type="danger" size="mini">失败</el-tag> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="结果" min-width="240"> |
| | | <template slot-scope="s"> |
| | | <span class="queue-msg">{{ s.row.message }}</span> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column label="操作" width="130" align="center"> |
| | | <template slot-scope="s"> |
| | | <el-button v-if="s.row.status === 'pending'" size="mini" type="primary" plain @click="importOne(s.row)">导入</el-button> |
| | | <el-button v-if="s.row.status !== 'uploading'" size="mini" type="danger" plain icon="el-icon-delete" |
| | | @click="removeQueueItem(s.row)"></el-button> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | <div class="tip" style="margin-top:8px">导入为逐个执行;同报表期重复导入会先删除旧数据再写入(幂等,不会重复存两份)。</div> |
| | | </el-card> |
| | | |
| | | <el-dialog :title="'浏览目录 — ' + (dirTypeLabel || '')" :visible.sync="dirDialog" width="640px" top="6vh" append-to-body> |
| | | <div class="dir-toolbar"> |
| | | <el-button size="mini" type="text" icon="el-icon-refresh" :disabled="dirLoading" @click="loadDirs(dirCurrent)">刷新</el-button> |
| | | <el-button size="mini" type="text" icon="el-icon-folder-opened" @click="loadDirs('')">数据目录 docs</el-button> |
| | | <el-button size="mini" type="text" icon="el-icon-back" :disabled="!dirParent" @click="goDirParent">上级目录</el-button> |
| | | </div> |
| | | <div class="dir-path" :title="dirCurrent">{{ dirCurrent || '(加载中…)' }}</div> |
| | | <div class="dir-body" v-loading="dirLoading"> |
| | | <div v-if="!dirDirs.length" class="dir-empty">当前目录下没有子文件夹,可直接选择当前文件夹导入</div> |
| | | <div v-for="d in dirDirs" :key="d.path" class="dir-row" :title="d.path + '(双击立即导入该文件夹)'" @click="loadDirs(d.path)" @dblclick="dblImportDir(d)"> |
| | | <i class="el-icon-folder"></i><span>{{ d.name }}</span> |
| | | </div> |
| | | </div> |
| | | <div class="dir-files"> |
| | | <div class="tip">单击文件夹进入子文件夹;<b>双击目标文件夹立即导入</b>其中全部 Excel;也可进入后点下方按钮。导入时会按文件名自动识别月份。</div> |
| | | <div v-if="dirExcelCount === 0" class="tip">当前文件夹内没有 Excel 文件(.xlsx/.xls)</div> |
| | | <div v-else class="tip">当前文件夹内 Excel:{{ dirExcelCount }} 个 —— {{ dirFiles.slice(0, 6).join('、') }}{{ dirFiles.length > 6 ? ' 等' : '' }}(仅导入直接位于该文件夹的文件)</div> |
| | | <div v-if="dirDirsTruncated" class="tip">子文件夹过多,仅显示前 300 个</div> |
| | | <div v-if="dirFilesTruncated" class="tip">Excel 文件过多(共 {{ dirExcelCount }} 个),仅显示前 300 个</div> |
| | | </div> |
| | | <span slot="footer"> |
| | | <el-button size="small" @click="dirDialog = false">取消</el-button> |
| | | <el-button size="small" type="primary" :disabled="dirExcelCount === 0 || dirImporting" :loading="dirImporting" @click="confirmDirImport">导入当前目录</el-button> |
| | | </span> |
| | | </el-dialog> |
| | | |
| | | <el-card shadow="never" style="margin-top:15px" class="batch-card"> |
| | | <div slot="header" class="card-head"> |
| | | 导入记录({{ period || '全部报表期' }}) |
| | | <el-button type="text" icon="el-icon-refresh" style="float:right" @click="loadData">刷新</el-button> |
| | | </div> |
| | | <el-table :data="batches" border size="small" v-loading="batchLoading" style="width:100%"> |
| | | <el-table-column prop="importType" label="导入类型" width="150"> |
| | | <template slot-scope="s">{{ typeLabel(s.row.importType) }}</template> |
| | | </el-table-column> |
| | | <el-table-column prop="reportPeriod" label="报表期" width="100"></el-table-column> |
| | | <el-table-column prop="fileName" label="文件名" min-width="220" show-overflow-tooltip></el-table-column> |
| | | <el-table-column prop="totalRows" label="总行数" width="90"></el-table-column> |
| | | <el-table-column prop="successRows" label="成功" width="80"> |
| | | <template slot-scope="s"> |
| | | <span style="color:#67C23A" v-if="s.row.successRows > 0">{{ s.row.successRows }}</span> |
| | | <span v-else>0</span> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column prop="failRows" label="失败" width="70"> |
| | | <template slot-scope="s"> |
| | | <el-tag v-if="s.row.failRows > 0" size="mini" type="danger">{{ s.row.failRows }}</el-tag> |
| | | <span v-else style="color:#909399">0</span> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column prop="createdAt" label="导入时间" width="165"></el-table-column> |
| | | <el-table-column label="状态" width="90"> |
| | | <template slot-scope="s"> |
| | | <el-tag v-if="s.row.failRows > 0" size="mini" type="warning" effect="plain">部分失败</el-tag> |
| | | <el-tag v-else-if="s.row.successRows > 0" size="mini" type="success" effect="plain">成功</el-tag> |
| | | <el-tag v-else size="mini" type="info" effect="plain">无数据</el-tag> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | </el-card> |
| | | </div> |
| | | </div> |
| | | </el-main> |
| | | </el-container> |
| | |
| | | return { |
| | | importType: 'h2032', |
| | | period: (() => { const d = new Date(); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') })(), |
| | | queue: [], |
| | | queueLoading: false, |
| | | cityDirLoading: false, |
| | | dirDialog: false, |
| | | dirKey: '', |
| | | dirCurrent: '', |
| | | dirParent: '', |
| | | dirDirs: [], |
| | | dirDirsTruncated: false, |
| | | dirExcelCount: 0, |
| | | dirFilesTruncated: false, |
| | | dirFiles: [], |
| | | dirLoading: false, |
| | | dirImporting: false, |
| | | dirPath: '', |
| | | dirImportFiles: [], |
| | | dirSkipImported: true, |
| | | sideOpen: false, |
| | | result: '', |
| | | error: '', |
| | | batches: [], |
| | |
| | | } |
| | | }, |
| | | computed: { |
| | | pendingCount() { return this.queue.filter(x => x.status === 'pending').length }, |
| | | dirTypeLabel() { |
| | | const t = this.moduleSel[this.dirKey] |
| | | if (!t) return '' |
| | | for (const g of this.groups) { |
| | | const found = g.types.find(x => x.value === t) |
| | | if (found) return g.name + ' / ' + found.label |
| | | failBatches() { return this.batches.filter(b => b.failRows > 0) }, |
| | | failRowsFlat() { |
| | | const out = [] |
| | | for (const b of this.failBatches) { |
| | | const rows = this.parseFailDetails(b) |
| | | if (!rows.length) { |
| | | out.push({ importType: b.importType, reportPeriod: b.reportPeriod, fileName: b.fileName, position: '', reason: b.errorDetail || '该批次无失败明细' }) |
| | | } else { |
| | | rows.forEach(r => out.push({ importType: b.importType, reportPeriod: b.reportPeriod, fileName: b.fileName, position: r.position, reason: r.reason })) |
| | | } |
| | | } |
| | | return t |
| | | }, |
| | | typeDesc() { |
| | | const map = { |
| | | h2032: '企业级货运月报:用于规上货运量审核与分市州汇总(17 市州企业明细)。', |
| | | transportAuth: '运政车辆档案:用于车辆信息核对(吨位/车型/燃料类型)。', |
| | | trackMileage: '轨迹里程数据:用于里程与运距校验。', |
| | | scaleSplit: '规上规下拆分:全省+17 市州货运量/周转量拆分结果。', |
| | | freightTurnover: '货运量周转量:全省+17 市州月度合计(今年 1-N 月 + 去年)。', |
| | | passengerMonthly: '公路旅客月报:企业级明细 + 运政车辆数两个 sheet(运政车辆数也可单独导入)。', |
| | | passengerAuth: '旅客运政车辆数:独立模板(企业代码/企业名称/车辆数/载客位数),用于运营车辆核对;也可随 H203-1 月报第二个 sheet 一并导入。', |
| | | passengerIndividual: '个体客运量/周转量:企业线下汇报数据(所属地区/企业代码/企业名称/个体客运量/个体旅客周转量),导入后自动填入分市州表“其中个体”行。', |
| | | energyMonthly: '能耗季报:能耗明细 + 运政车辆信息两个 sheet(运政信息也可单独导入,季度末月生成汇总表)。', |
| | | energyAuth: '能耗车辆运政信息:独立模板(车牌号/车辆类型/燃料类型/标记吨位/准牵引质量),用于能耗审核对比;也可随 H204 季报第二个 sheet 一并导入。', |
| | | investment: '投资月报(客运站场):市州汇总大表,导入后生成 4 张领导报表。', |
| | | investmentLogistics: '投资月报(物流园区):分项目投资完成情况 sheet。', |
| | | investmentSystem: '投资系统导出:用于与月报数据跨表校验。', |
| | | investCityPassenger: '无需选文件:自动扫描 docs/投资/输入/客运(客运站)/X月 目录全部市州报表,容错解析入库(含本月完成/建设阶段/形象进度,十五五项目自动标记)。', |
| | | investCityLogistics: '无需选文件:自动扫描 docs/投资/输入/物流(物流园区)/X月 目录全部市州报表,容错解析入库(含本月完成/建设阶段/形象进度,十五五项目自动标记)。', |
| | | cityBus: '城市公交月报:企业级运营数据,含轨道/轮渡字段。', |
| | | cityTaxi: '巡游出租月报:市州级 17 行运营数据。', |
| | | cityTaxiAuth: '出租车运政车辆:车辆级明细(约 4 万+ 行),用于运营车数对比。' |
| | | } |
| | | return map[this.importType] || '' |
| | | return out |
| | | }, |
| | | templateName() { |
| | | const map = { |
| | |
| | | }, |
| | | onModuleChange(key) { |
| | | this.importType = this.moduleSel[key] || '' |
| | | this.dirPath = (this.importType && localStorage.getItem('dirLast_' + this.importType)) || '' |
| | | }, |
| | | onModuleSwitch() { |
| | | this.importType = this.moduleSel[this.activeModule] || '' |
| | | }, |
| | | onPeriodChange() { this.loadData() }, |
| | | endpoint(t) { |
| | |
| | | } |
| | | return map[t] || '' |
| | | }, |
| | | openDirBrowser(key) { |
| | | const type = this.moduleSel[key] |
| | | if (!type) { this.$message.warning('请先选择导入类型'); return } |
| | | this.dirKey = key |
| | | this.dirDialog = true |
| | | const last = localStorage.getItem('dirLast_' + type) |
| | | this.loadDirs(last || '') |
| | | }, |
| | | loadDirs(path) { |
| | | this.dirLoading = true |
| | | api.get('/import/dirs', { params: { path: path || '' } }) |
| | | .then(res => { |
| | | const d = (res && res.data) || {} |
| | | this.dirCurrent = d.current || '' |
| | | this.dirParent = d.parent || '' |
| | | this.dirDirs = d.dirs || [] |
| | | this.dirDirsTruncated = !!d.dirsTruncated |
| | | this.dirExcelCount = d.excelCount || 0 |
| | | this.dirFiles = d.files || [] |
| | | this.dirFilesTruncated = !!d.filesTruncated |
| | | }) |
| | | .catch(e => { if (e && e._toast) return; this.$message.error((e.response && e.response.data && e.response.data.message) || '目录加载失败') }) |
| | | .finally(() => { this.dirLoading = false }) |
| | | }, |
| | | goDirParent() { if (this.dirParent) this.loadDirs(this.dirParent) }, |
| | | dblImportDir(d) { |
| | | this.dirDialog = false |
| | | this.runDirImport(this.moduleSel[this.dirKey], d.path) |
| | | }, |
| | | confirmDirImport() { |
| | | if (!this.dirCurrent) return |
| | | this.runDirImport(this.moduleSel[this.dirKey], this.dirCurrent) |
| | | }, |
| | | quickDirImport(type) { |
| | | if (!type) { this.$message.warning('请先选择导入类型'); return } |
| | | this.runDirImport(type, this.dirPath) |
| | | }, |
| | | runDirImport(type, dir) { |
| | | if (!type) { this.$message.warning('请先选择导入类型'); return } |
| | | if (!dir || !dir.trim()) { this.$message.warning('请先填写目录路径(或点「浏览…」选择)'); return } |
| | | dir = dir.trim() |
| | | this.dirImporting = true |
| | | this.dirImportFiles = [] |
| | | api.get('/import/dirs', { params: { path: dir } }) |
| | | .then(res => { |
| | | const d = (res && res.data) || {} |
| | | const files = (d.files || []).filter(n => /\.(xlsx|xls)$/i.test(n)) |
| | | if (!files.length) { this.$message.warning('该目录没有 Excel 文件(.xlsx/.xls):' + dir); this.dirImporting = false; return } |
| | | if (files.length > 60) { |
| | | this.dirImporting = false |
| | | this.$message.warning('目录内 Excel 文件过多(' + files.length + ' 个),为避免浏览器卡顿请分批导入(每批不超过 60 个)') |
| | | return |
| | | } |
| | | localStorage.setItem('dirLast_' + type, dir) |
| | | this.dirPath = dir |
| | | this.dirImportFiles = files.map(n => ({ name: n, status: 'pending', period: '', msg: '' })) |
| | | const run = (i) => { |
| | | if (i >= files.length) { |
| | | this.dirImporting = false |
| | | const ok = this.dirImportFiles.filter(x => x.status === 'success').length |
| | | const fail = this.dirImportFiles.filter(x => x.status === 'fail').length |
| | | const skip = this.dirImportFiles.filter(x => x.status === 'skipped').length |
| | | if (fail > 0) this.$message.warning('目录导入完成:成功 ' + ok + ',失败 ' + fail + ',跳过 ' + skip + '(失败原因见列表)') |
| | | else this.$message.success('目录导入完成:成功 ' + ok + ',跳过 ' + skip) |
| | | this.afterImport() |
| | | return |
| | | } |
| | | const row = this.dirImportFiles[i] |
| | | row.status = 'importing' |
| | | const fd = new FormData() |
| | | fd.append('type', type) |
| | | fd.append('period', this.period || '') |
| | | fd.append('skipImported', this.dirSkipImported ? 'true' : 'false') |
| | | fd.append('path', dir.replace(/[\\/]+$/, '') + '\\' + files[i]) |
| | | api.post('/import/fromDirFile', fd, { timeout: 600000 }) |
| | | .then(res => { |
| | | const r = (res && res.data) || {} |
| | | row.period = r.period || '' |
| | | if (r.skipped) { row.status = 'skipped'; row.msg = r.error || '已跳过' } |
| | | else if (r.fail > 0 || r.error) { row.status = 'fail'; row.msg = r.error || ('失败 ' + r.fail + ' 条') } |
| | | else { row.status = 'success'; row.msg = '解析 ' + r.success + ' 条' } |
| | | }) |
| | | .catch(e => { row.status = 'fail'; row.msg = (e.response && e.response.data && e.response.data.message) || '导入失败' }) |
| | | .finally(() => run(i + 1)) |
| | | } |
| | | run(0) |
| | | }) |
| | | .catch(e => { |
| | | this.dirImporting = false |
| | | if (e && e._toast) return |
| | | this.$message.error((e.response && e.response.data && e.response.data.message) || '目录不存在或无法访问:' + dir) |
| | | }) |
| | | }, |
| | | progText(s) { return s === 'pending' ? '待导入' : s === 'importing' ? '导入中…' : s === 'success' ? '成功' : s === 'skipped' ? '已跳过' : '失败' }, |
| | | progTag(s) { return s === 'pending' ? 'info' : s === 'importing' ? 'warning' : s === 'success' ? 'success' : s === 'skipped' ? 'info' : 'danger' }, |
| | | progIcon(s) { return s === 'pending' ? 'el-icon-time' : s === 'importing' ? 'el-icon-loading' : s === 'success' ? 'el-icon-success' : s === 'skipped' ? 'el-icon-minus' : 'el-icon-error' }, |
| | | onFilePicked(key, file) { |
| | | this.$set(this.files, this.moduleSel[key], file.raw) |
| | | this.$set(this.fileNames, this.moduleSel[key], file.name) |
| | |
| | | }) |
| | | .finally(() => { this.cityDirLoading = false }) |
| | | }, |
| | | addToQueue(key) { |
| | | const type = this.moduleSel[key] |
| | | if (!type) return |
| | | const file = this.files[type] |
| | | if (!file) { this.$message.warning('请先为该类型选择文件'); return } |
| | | const g = this.groups.find(x => x.key === key) |
| | | const t = g.types.find(x => x.value === type) |
| | | this.queue.push({ |
| | | id: Date.now() + Math.random(), |
| | | moduleKey: key, |
| | | moduleName: g.name, |
| | | type, |
| | | typeLabel: t.label, |
| | | file, |
| | | fileName: this.fileNames[type] || file.name, |
| | | status: 'pending', |
| | | message: '' |
| | | }) |
| | | this.moduleSel[key] = '' |
| | | this.importType = '' |
| | | this.files[type] = null |
| | | this.$delete(this.fileNames, type) |
| | | }, |
| | | removeQueueItem(row) { |
| | | const i = this.queue.indexOf(row) |
| | | if (i >= 0) this.queue.splice(i, 1) |
| | | }, |
| | | importOne(row) { |
| | | if (!this.period) { this.$message.warning('请先选择报表期'); return Promise.resolve() } |
| | | row.status = 'uploading' |
| | |
| | | .then(res => { row.status = 'success'; row.message = res.data || '导入成功' }) |
| | | .catch(e => { row.status = 'fail'; row.message = (e.response && e.response.data && e.response.data.message) || '导入失败' }) |
| | | .finally(() => {}) |
| | | }, |
| | | importQueue() { |
| | | if (!this.period) { this.$message.warning('请先选择报表期'); return Promise.resolve(false) } |
| | | const pending = this.queue.filter(x => x.status === 'pending') |
| | | if (pending.length === 0) { this.$message.warning('队列中没有待导入项'); return Promise.resolve(false) } |
| | | this.queueLoading = true |
| | | return new Promise(resolve => { |
| | | const run = (i) => { |
| | | if (i >= pending.length) { |
| | | this.queueLoading = false |
| | | this.afterImport() |
| | | resolve(true) |
| | | return |
| | | } |
| | | this.importOne(pending[i]).then(() => { |
| | | const t = pending[i].type |
| | | if ((t === 'investment' || t === 'investmentLogistics') && pending[i].status === 'success') { |
| | | this.checkInvest(this.period) |
| | | } |
| | | run(i + 1) |
| | | }) |
| | | } |
| | | run(0) |
| | | }) |
| | | }, |
| | | afterImport() { this.loadData() }, |
| | | loadData() { |
| | |
| | | if (o.successRows > 0) return { cls: 'ov-ok', text: '已导入 ' + o.successRows + ' 行' } |
| | | return { cls: 'ov-empty', text: '无数据' } |
| | | }, |
| | | loadBatches() { |
| | | this.batchLoading = true |
| | | const params = { limit: 50 } |
| | | if (this.period) params.period = this.period |
| | | api.get('/import/batches', { params }) |
| | | .then(res => { this.batches = (res.data && res.data.records) || [] }) |
| | | .catch(() => {}) |
| | | .finally(() => { this.batchLoading = false }) |
| | | }, |
| | | checkInvest(period) { |
| | | const fd = new FormData() |
| | | fd.append('period', period) |
| | |
| | | } |
| | | }) |
| | | .catch(() => {}) |
| | | } |
| | | }, |
| | | parseFailDetails(row) { |
| | | if (!row || !row.errorDetail) return [] |
| | | const lines = String(row.errorDetail).split(/\r?\n/) |
| | | const out = [] |
| | | lines.forEach((line, i) => { |
| | | if (!line || !line.trim()) return |
| | | const m = line.match(/^第\s*(\d+)\s*行[((]([^))]*)[))]\s*[::]\s*([\s\S]*)$/) |
| | | if (m) out.push({ no: i + 1, position: '第 ' + m[1] + ' 行(' + m[2] + ')', reason: m[3].trim() }) |
| | | else out.push({ no: i + 1, position: '', reason: line.trim() }) |
| | | }) |
| | | return out |
| | | }, |
| | | }, |
| | | mounted() { |
| | | this.loadData() |
| | |
| | | <style scoped> |
| | | .el-header { background: #409EFF; color: white; line-height: 60px; font-size: 18px; } |
| | | .import-card { margin-bottom: 0; } |
| | | .import-layout { display: flex; align-items: flex-start; gap: 15px; } |
| | | .import-main { flex: 1; min-width: 0; } |
| | | .import-side { position: relative; flex-shrink: 0; width: 34px; overflow: hidden; border: 1px solid #EBEEF5; border-radius: 4px; background: #FAFAFA; transition: width .2s ease; } |
| | | .import-side.side-open { width: 340px; } |
| | | .side-toggle { display: flex; flex-direction: column; align-items: center; cursor: pointer; color: #409EFF; padding: 10px 0; user-select: none; white-space: nowrap; } |
| | | .side-toggle i { font-size: 15px; } |
| | | .side-toggle-text { writing-mode: vertical-lr; letter-spacing: 4px; margin-top: 8px; font-size: 13px; } |
| | | .import-side.side-open .side-toggle { flex-direction: row; gap: 6px; padding: 8px 10px; border-bottom: 1px solid #EBEEF5; } |
| | | .import-side.side-open .side-toggle-text { writing-mode: horizontal-tb; letter-spacing: normal; margin-top: 0; } |
| | | .side-content { padding: 8px 12px 12px; max-height: calc(100vh - 220px); overflow-y: auto; overscroll-behavior: contain; } |
| | | .side-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-weight: 600; margin-bottom: 6px; } |
| | | .import-side .overview-grid { display: block; } |
| | | .import-side .ov-row { display: block; margin-bottom: 6px; } |
| | | .import-side .ov-group { width: auto; } |
| | | .import-side .ov-item { margin-bottom: 4px; } |
| | | .card-head { font-weight: 600; margin-bottom: 6px; } |
| | | .module-tabs >>> .el-tabs__header { margin-bottom: 12px; } |
| | | .module-sel { max-width: 480px; } |
| | | .module-sel { max-width: 1100px; } |
| | | .module-sel-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } |
| | | .tpl-box { display: flex; align-items: center; gap: 8px; min-width: 0; color: #606266; font-size: 13px; } |
| | | .tpl-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 480px; } |
| | | .group-label { font-size: 13px; color: #606266; font-weight: 600; margin-bottom: 6px; padding-left: 6px; border-left: 3px solid #409EFF; } |
| | | .module-file { margin-top: 8px; } |
| | | .file-name { font-size: 12px; color: #606266; background: #F5F7FA; border: 1px solid #EBEEF5; border-radius: 4px; padding: 4px 8px; margin-bottom: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } |
| | | .file-btns { display: flex; gap: 6px; } |
| | | .type-info { margin-top: 12px; color: #606266; font-size: 13px; background: #F5F7FA; border-radius: 4px; padding: 8px 10px; } |
| | | .tip { font-size: 12px; color: #909399; margin-top: 6px; line-height: 1.5; } |
| | | .slow-tip { font-size: 12px; color: #E6A23C; margin-left: 8px; } |
| | | .overview-grid { display: flex; flex-direction: column; gap: 8px; } |
| | |
| | | .ov-ok .ov-status { color: #67C23A; } |
| | | .ov-fail .ov-status { color: #E6A23C; } |
| | | .ov-empty .ov-status { color: #C0C4CC; } |
| | | .queue-msg { font-size: 12px; color: #606266; word-break: break-all; white-space: pre-line; } |
| | | .dir-import-bar { display: flex; align-items: center; gap: 8px; margin: 6px 0 4px; flex-wrap: wrap; } |
| | | .dir-progress { margin-top: 8px; border: 1px solid #EBEEF5; border-radius: 4px; padding: 8px; background: #FAFAFA; } |
| | | .dir-progress-row { display: flex; align-items: center; gap: 8px; padding: 4px 2px; font-size: 12px; color: #303133; flex-wrap: wrap; } |
| | | .dir-progress-row i { color: #409EFF; } |
| | | .dir-progress-row .prog-name { min-width: 0; max-width: 380px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| | | .dir-progress-row .queue-msg { width: 100%; padding-left: 22px; } |
| | | .dir-toolbar { margin-bottom: 6px; } |
| | | .dir-path { font-size: 12px; color: #606266; background: #F5F7FA; border: 1px solid #EBEEF5; border-radius: 4px; padding: 6px 8px; margin-bottom: 8px; word-break: break-all; } |
| | | .dir-body { max-height: 320px; overflow-y: auto; border: 1px solid #EBEEF5; border-radius: 4px; } |
| | | .dir-row { display: flex; align-items: center; gap: 6px; padding: 7px 10px; cursor: pointer; font-size: 13px; color: #303133; } |
| | | .dir-row:hover { background: #ECF5FF; color: #409EFF; } |
| | | .dir-row i { color: #E6A23C; } |
| | | .dir-empty { padding: 14px; font-size: 12px; color: #909399; text-align: center; } |
| | | .dir-files { margin-top: 8px; } |
| | | |
| | | .empty-tip { padding: 24px 0; text-align: center; color: #C0C4CC; font-size: 13px; } |
| | | .module-tabs-head { margin-bottom: 10px; } |
| | | .module-tab-pane { min-height: 120px; } |
| | | </style> |