| | |
| | | ></right-toolbar> |
| | | </el-row> |
| | | |
| | | <el-table v-loading="loading" border :data="invoiceList"> |
| | | <!-- 手机端:卡片列表 --> |
| | | <div v-if="isMobile" class="invoice-cards" v-loading="loading"> |
| | | <div |
| | | v-for="row in invoiceList" |
| | | :key="row.id" |
| | | class="invoice-card" |
| | | > |
| | | <div class="invoice-card__head"> |
| | | <span |
| | | class="invoice-card__title" |
| | | @click="handleUpdate(row)" |
| | | >{{ row.invoiceName || "未命名发票" }}</span |
| | | > |
| | | <el-tag |
| | | size="mini" |
| | | :type=" |
| | | String(row.reimburseState) === '1' ? 'success' : 'info' |
| | | " |
| | | > |
| | | {{ String(row.reimburseState) === "1" ? "已报销" : "未报销" }} |
| | | </el-tag> |
| | | </div> |
| | | <div class="invoice-card__row"> |
| | | <span class="label">案例</span> |
| | | <span class="value">{{ row.caseId || "-" }}</span> |
| | | </div> |
| | | <div class="invoice-card__row"> |
| | | <span class="label">金额</span> |
| | | <span class="value">{{ amountText(row.invoiceAmount) }}</span> |
| | | </div> |
| | | <div class="invoice-card__row"> |
| | | <span class="label">日期</span> |
| | | <span class="value">{{ |
| | | normalizeDate(row.occurDate) || row.occurDate || "-" |
| | | }}</span> |
| | | </div> |
| | | <div class="invoice-card__row"> |
| | | <span class="label">号码</span> |
| | | <span class="value">{{ row.invoiceNo || "-" }}</span> |
| | | </div> |
| | | <div class="invoice-card__row"> |
| | | <span class="label">报销ID</span> |
| | | <span class="value">{{ row.reimburseId || "-" }}</span> |
| | | </div> |
| | | <div class="invoice-card__actions"> |
| | | <el-button |
| | | type="text" |
| | | icon="el-icon-view" |
| | | @click="openPreview(row.invoiceUrl, row.invoiceName)" |
| | | >预览</el-button |
| | | > |
| | | <el-button |
| | | v-if="String(row.reimburseState) !== '1'" |
| | | type="text" |
| | | icon="el-icon-check" |
| | | @click="handleReimburse(row)" |
| | | v-hasPermi="['project:invoice:edit']" |
| | | >已报销</el-button |
| | | > |
| | | <el-button |
| | | type="text" |
| | | icon="el-icon-edit" |
| | | @click="handleUpdate(row)" |
| | | v-hasPermi="['project:invoice:edit']" |
| | | >修改</el-button |
| | | > |
| | | <el-button |
| | | type="text" |
| | | icon="el-icon-delete" |
| | | @click="handleDelete(row)" |
| | | v-hasPermi="['project:invoice:remove']" |
| | | >删除</el-button |
| | | > |
| | | </div> |
| | | </div> |
| | | <div v-if="!invoiceList.length && !loading" class="invoice-empty"> |
| | | 暂无数据 |
| | | </div> |
| | | </div> |
| | | |
| | | <el-table v-else v-loading="loading" border :data="invoiceList"> |
| | | <el-table-column type="index" label="序号" width="55" align="center" /> |
| | | <el-table-column label="案例" align="center" prop="caseId" width="100"> |
| | | |
| | |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column |
| | | prop="reimburseId" |
| | | label="报销ID" |
| | | width="150" |
| | | align="center" |
| | | show-overflow-tooltip |
| | | /> |
| | | <el-table-column |
| | | label="操作" |
| | | width="200" |
| | | align="center" |
| | | fixed="right" |
| | | :fixed="isMobile ? false : 'right'" |
| | | > |
| | | <template slot-scope="scope"> |
| | | <el-button |
| | | type="text" |
| | | icon="el-icon-view" |
| | | @click="openPreview(scope.row.invoiceUrl)" |
| | | @click="openPreview(scope.row.invoiceUrl, scope.row.invoiceName)" |
| | | >文件预览</el-button |
| | | > |
| | | <el-button |
| | |
| | | :visible.sync="open" |
| | | width="760px" |
| | | append-to-body |
| | | :fullscreen="isMobile" |
| | | :close-on-click-modal="false" |
| | | @closed="revokeThumbObjectUrl" |
| | | > |
| | | <!-- 第一步:选案例、用户,上传发票 --> |
| | | <div v-if="mode === 'upload'"> |
| | |
| | | </el-select> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="12"> |
| | | <el-form-item label="报销ID" prop="reimburseId"> |
| | | <el-input |
| | | v-model="form.reimburseId" |
| | | maxlength="20" |
| | | placeholder="请输入报销ID" |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | | <el-col :span="24"> |
| | | <el-form-item label="备注" prop="remark"> |
| | | <el-input |
| | |
| | | type="text" |
| | | icon="el-icon-view" |
| | | :disabled="!form.invoiceUrl" |
| | | @click="openPreview(form.invoiceUrl)" |
| | | @click="openPreview(form.invoiceUrl, form.invoiceName)" |
| | | >文件预览</el-button |
| | | > |
| | | <el-image |
| | | <img |
| | | v-if=" |
| | | form.invoiceUrl && getFileType(form.invoiceUrl) === 'image' |
| | | " |
| | | :src="form.invoiceUrl" |
| | | :preview-src-list="[form.invoiceUrl]" |
| | | fit="contain" |
| | | :src="thumbObjectUrl || form.invoiceUrl" |
| | | class="invoice-thumb" |
| | | title="点击预览" |
| | | @click="openPreview(form.invoiceUrl, form.invoiceName)" |
| | | style="width: 400px;height: 240px;" |
| | | /> |
| | | </el-form-item> |
| | | </el-col> |
| | |
| | | <el-dialog |
| | | title="发票文件预览" |
| | | :visible.sync="previewOpen" |
| | | width="900px" |
| | | width="1000px" |
| | | append-to-body |
| | | :fullscreen="isMobile" |
| | | :destroy-on-close="true" |
| | | @closed="handlePreviewClosed" |
| | | > |
| | | <div class="invoice-preview"> |
| | | <img |
| | | v-if="previewType === 'image'" |
| | | :src="previewUrl" |
| | | class="preview-image" |
| | | <div class="invoice-preview-wrap" v-loading="previewLoading"> |
| | | <attachment-preview-pane |
| | | v-if="invoiceEmbeddedFile" |
| | | :file="invoiceEmbeddedFile" |
| | | style="flex: 1; min-width: 0;" |
| | | /> |
| | | <iframe |
| | | v-else-if="previewType === 'pdf'" |
| | | :src="previewUrl" |
| | | class="preview-frame" |
| | | ></iframe> |
| | | <div v-else class="preview-tip"> |
| | | 该文件类型不支持在线预览,请 |
| | | <el-link type="primary" :href="previewUrl" target="_blank" |
| | | >在新窗口打开</el-link |
| | | > |
| | | </div> |
| | | </div> |
| | | </el-dialog> |
| | | </div> |
| | |
| | | rules: { |
| | | |
| | | }, |
| | | // 文件预览 |
| | | // 文件预览(AttachmentPreviewPane 所需的 file 对象) |
| | | previewOpen: false, |
| | | previewUrl: "", |
| | | // image / pdf / other |
| | | previewType: "", |
| | | previewLoading: false, |
| | | invoiceEmbeddedFile: null, |
| | | // 预览用的 blob 地址,关闭时释放 |
| | | previewObjectUrl: "", |
| | | // 编辑弹窗缩略图的 blob 地址 |
| | | thumbObjectUrl: "", |
| | | // 查询参数 |
| | | queryParams: { |
| | | pageNum: 1, |
| | |
| | | }; |
| | | }, |
| | | computed: { |
| | | // 是否手机端(弹窗全屏、操作列不固定等适配) |
| | | isMobile() { |
| | | return this.$store.state.app.device === "mobile"; |
| | | }, |
| | | // 上传时携带的用户ID(当前登录用户)、案例ID(后端 uploadOCR 必传) |
| | | uploadData() { |
| | | return { |
| | |
| | | this.afterUploadBatch(); |
| | | }, |
| | | /** 打开发票信息确认弹窗 */ |
| | | openEditForm(record) { |
| | | async openEditForm(record) { |
| | | this.form = { |
| | | id: record.id, |
| | | caseId: record.caseId, |
| | |
| | | invoiceEndDate: this.normalizeDate(record.invoiceEndDate), |
| | | sellerName: record.sellerName, |
| | | reimburseState: record.reimburseState || "0", |
| | | reimburseId: record.reimburseId, |
| | | remark: record.remark |
| | | }; |
| | | this.mode = "edit"; |
| | | this.title = "发票信息确认"; |
| | | this.open = true; |
| | | this.refreshThumb(); |
| | | }, |
| | | /** 缩略图同样带 token 拉取,避免直接引用鉴权地址显示不出来 */ |
| | | async refreshThumb() { |
| | | this.revokeThumbObjectUrl(); |
| | | const url = this.form.invoiceUrl; |
| | | if (!url || this.getFileType(url) !== "image") { |
| | | return; |
| | | } |
| | | const objectUrl = await this.loadPreviewBlob(url); |
| | | if (objectUrl && this.form.invoiceUrl === url) { |
| | | this.thumbObjectUrl = objectUrl; |
| | | } else if (objectUrl) { |
| | | URL.revokeObjectURL(objectUrl); |
| | | } |
| | | }, |
| | | revokeThumbObjectUrl() { |
| | | if (this.thumbObjectUrl) { |
| | | URL.revokeObjectURL(this.thumbObjectUrl); |
| | | this.thumbObjectUrl = ""; |
| | | } |
| | | }, |
| | | /** 修改按钮操作 */ |
| | | handleUpdate(row) { |
| | |
| | | this.openEditForm(response.data); |
| | | }); |
| | | }, |
| | | /** 文件预览:图片、PDF 弹窗内预览,其它类型提示新窗口打开 */ |
| | | openPreview(url) { |
| | | /** 文件预览:带 token 拉取成 blob 再预览,兼容需要鉴权的 /profile 资源 */ |
| | | async openPreview(url, name) { |
| | | if (!url) { |
| | | this.$modal.msgWarning("该记录暂无发票文件"); |
| | | return; |
| | | } |
| | | this.previewUrl = url; |
| | | this.previewType = this.getFileType(url); |
| | | const type = this.getFileType(url); |
| | | const fileName = name || this.getFileName(url); |
| | | this.revokePreviewObjectUrl(); |
| | | this.invoiceEmbeddedFile = null; |
| | | this.previewLoading = true; |
| | | this.previewOpen = true; |
| | | const objectUrl = await this.loadPreviewBlob(url); |
| | | this.previewLoading = false; |
| | | if (objectUrl) { |
| | | this.previewObjectUrl = objectUrl; |
| | | this.invoiceEmbeddedFile = { name: fileName, url: objectUrl, type }; |
| | | } else { |
| | | // 拉取失败时退回原始地址,交给浏览器直接加载 |
| | | this.invoiceEmbeddedFile = { name: fileName, url: url, type }; |
| | | } |
| | | }, |
| | | /** 依次尝试原始地址、接口前缀地址,带 token 拉取成 blob */ |
| | | async loadPreviewBlob(url) { |
| | | const headers = { Authorization: "Bearer " + getToken() }; |
| | | const candidates = this.buildPreviewCandidates(url); |
| | | for (let i = 0; i < candidates.length; i++) { |
| | | try { |
| | | const response = await fetch(candidates[i], { headers }); |
| | | if (!response.ok) { |
| | | continue; |
| | | } |
| | | const blob = await response.blob(); |
| | | if (blob && blob.size > 0) { |
| | | return URL.createObjectURL(blob); |
| | | } |
| | | } catch (e) { |
| | | // 跨域/网络异常,继续尝试下一个地址 |
| | | } |
| | | } |
| | | return ""; |
| | | }, |
| | | /** 预览地址候选:原始地址 + 走接口前缀(/dev-api、/prod-api)的地址 */ |
| | | buildPreviewCandidates(url) { |
| | | const list = [url]; |
| | | if (/^https?:\/\//i.test(url)) { |
| | | const base = (process.env.VUE_APP_BASE_API || "").replace(/\/$/, ""); |
| | | let path = url.replace(/^https?:\/\/[^/]+/i, ""); |
| | | if (base && (path === base || path.indexOf(base + "/") === 0)) { |
| | | path = path.slice(base.length); |
| | | } |
| | | if (base) { |
| | | list.push(base + path); |
| | | } |
| | | } |
| | | return list.filter((item, index) => list.indexOf(item) === index); |
| | | }, |
| | | /** 关闭预览时释放 blob 地址 */ |
| | | handlePreviewClosed() { |
| | | this.revokePreviewObjectUrl(); |
| | | this.invoiceEmbeddedFile = null; |
| | | }, |
| | | revokePreviewObjectUrl() { |
| | | if (this.previewObjectUrl) { |
| | | URL.revokeObjectURL(this.previewObjectUrl); |
| | | this.previewObjectUrl = ""; |
| | | } |
| | | }, |
| | | /** 金额显示:保留两位小数 */ |
| | | amountText(value) { |
| | | if (value === null || value === undefined || value === "") { |
| | | return "0.00"; |
| | | } |
| | | const num = Number(value); |
| | | return Number.isFinite(num) ? num.toFixed(2) : value; |
| | | }, |
| | | /** 从地址中取文件名(无发票名称时兜底) */ |
| | | getFileName(url) { |
| | | const path = String(url || "").split("?")[0]; |
| | | return path.substring(path.lastIndexOf("/") + 1) || "发票文件"; |
| | | }, |
| | | /** 根据文件地址判断预览类型 */ |
| | | getFileType(url) { |
| | |
| | | margin-left: 12px; |
| | | border: 1px solid #ebeef5; |
| | | vertical-align: middle; |
| | | object-fit: contain; |
| | | background: #f5f7fa; |
| | | cursor: pointer; |
| | | } |
| | | |
| | | .invoice-preview { |
| | | min-height: 120px; |
| | | .invoice-preview-wrap { |
| | | display: flex; |
| | | width: 100%; |
| | | height: 70vh; |
| | | } |
| | | |
| | | .preview-image { |
| | | display: block; |
| | | max-width: 100%; |
| | | max-height: 70vh; |
| | | margin: 0 auto; |
| | | /* 手机端卡片列表 */ |
| | | .invoice-cards { |
| | | min-height: 60px; |
| | | } |
| | | |
| | | .invoice-card { |
| | | padding: 12px; |
| | | margin-bottom: 10px; |
| | | background: #fff; |
| | | border: 1px solid #ebeef5; |
| | | border-radius: 6px; |
| | | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); |
| | | |
| | | &__head { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | margin-bottom: 10px; |
| | | } |
| | | |
| | | .preview-frame { |
| | | &__title { |
| | | flex: 1; |
| | | min-width: 0; |
| | | margin-right: 8px; |
| | | font-size: 15px; |
| | | font-weight: 600; |
| | | color: #303133; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | &__row { |
| | | display: flex; |
| | | align-items: center; |
| | | margin-bottom: 6px; |
| | | font-size: 13px; |
| | | color: #606266; |
| | | |
| | | .label { |
| | | flex: 0 0 40px; |
| | | color: #909399; |
| | | } |
| | | |
| | | .value { |
| | | flex: 1; |
| | | min-width: 0; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | } |
| | | } |
| | | |
| | | &__actions { |
| | | display: flex; |
| | | justify-content: flex-end; |
| | | margin-top: 8px; |
| | | padding-top: 6px; |
| | | border-top: 1px dashed #ebeef5; |
| | | |
| | | ::v-deep .el-button + .el-button { |
| | | margin-left: 16px; |
| | | } |
| | | } |
| | | } |
| | | |
| | | .invoice-empty { |
| | | padding: 40px 0; |
| | | text-align: center; |
| | | font-size: 14px; |
| | | color: #909399; |
| | | } |
| | | |
| | | /* 手机端适配 */ |
| | | @media (max-width: 992px) { |
| | | .app-container { |
| | | padding: 8px; |
| | | } |
| | | |
| | | .opo-query-form { |
| | | ::v-deep .el-form-item { |
| | | display: flex; |
| | | width: 100%; |
| | | margin-right: 0; |
| | | margin-bottom: 10px; |
| | | } |
| | | |
| | | ::v-deep .el-form-item__content { |
| | | flex: 1; |
| | | min-width: 0; |
| | | } |
| | | |
| | | ::v-deep .el-select, |
| | | ::v-deep .el-input, |
| | | ::v-deep .el-date-editor { |
| | | width: 100% !important; |
| | | } |
| | | } |
| | | |
| | | /* 弹窗内两列表单在手机端变单列 */ |
| | | .el-col-12 { |
| | | width: 100%; |
| | | height: 70vh; |
| | | border: none; |
| | | } |
| | | |
| | | .preview-tip { |
| | | padding: 40px 0; |
| | | text-align: center; |
| | | color: #909399; |
| | | /* 全屏弹窗里的预览区占满剩余高度 */ |
| | | .invoice-preview-wrap { |
| | | height: calc(100vh - 120px); |
| | | } |
| | | |
| | | .invoice-thumb { |
| | | display: block; |
| | | margin: 8px 0 0; |
| | | } |
| | | } |
| | | </style> |