优化backend报销管理模块

This commit is contained in:
2026-08-13 17:08:38 +08:00
parent b45152599e
commit f818ecb74f
24 changed files with 2460 additions and 299 deletions
+466
View File
@@ -16,9 +16,12 @@
"docx-preview": "^0.3.7",
"echarts": "^6.0.0",
"element-plus": "^2.11.7",
"html2canvas": "^1.4.1",
"jspdf": "^4.2.1",
"less": "^4.4.2",
"marked": "^16.4.1",
"os": "^0.1.2",
"pdfjs-dist": "^6.2.108",
"pinia": "^3.0.3",
"v-viewer": "^3.0.11",
"vue": "^3.5.22",
@@ -82,6 +85,15 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz",
@@ -726,6 +738,256 @@
"@chevrotain/types": "~11.1.2"
}
},
"node_modules/@napi-rs/canvas": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas/-/canvas-1.0.5.tgz",
"integrity": "sha512-GaPlicMtnvgPr5SowFRprkEJicDSrV3qCq17U4jiF5u0kNORZo3IbdN2Bk4SfcZJAMYFHbMVJ81O3w21CYxazg==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "1.0.5",
"@napi-rs/canvas-darwin-arm64": "1.0.5",
"@napi-rs/canvas-darwin-x64": "1.0.5",
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.5",
"@napi-rs/canvas-linux-arm64-gnu": "1.0.5",
"@napi-rs/canvas-linux-arm64-musl": "1.0.5",
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.5",
"@napi-rs/canvas-linux-x64-gnu": "1.0.5",
"@napi-rs/canvas-linux-x64-musl": "1.0.5",
"@napi-rs/canvas-win32-arm64-msvc": "1.0.5",
"@napi-rs/canvas-win32-x64-msvc": "1.0.5"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.5.tgz",
"integrity": "sha512-ZzDlpKQocwFfCwhMh17UWre6Qt5yZN3kNIJoUpGfRZqwDDZ164IKOsPOHsRd3d8Tuj5KM6bDjGuPmZxuPuG3NQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.5.tgz",
"integrity": "sha512-Hr8v6CA/TBe+OJOePdV3sXWxzQHQKfQsTKPbc8wG7iPqVeAx6MMdzKGXlYID6SVvpfwV/zqkvGcdImYWSlhrZg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.5.tgz",
"integrity": "sha512-9BXlLHBXpYnK4jSae1MdFdyPq09Xi1I3PeCNpvRzqgmUUBhgJS7aC1z7SZEP8JUXDHtbfIrolCS3sFuT9IGP2A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.5.tgz",
"integrity": "sha512-zEW4fgvtYsOJ/N56Us4TQPfaFrUf0shGr9CgGSj3GACc+NHfUM3ci0YF9xFTjwJWGDmaEhYPL6KqCfFCxwm/qg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.5.tgz",
"integrity": "sha512-HFprwLspelJxCEtZvdMcz95Mwvfs63GzFVedLFmC/wslHnaOXhjXxgYxPCM/VdM4Jhx3CV4Lk0vVmh6hJv2etQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.5.tgz",
"integrity": "sha512-FNMGFAx8DvtDwlLfWyBJ+oQjgPXoIAqCnNTJYqtJCFRwwzK3AyAe5B1Ll3NZ6hcOzqw7HylZsq4nQxVyPCQX1Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.5.tgz",
"integrity": "sha512-2vd5v8Lui+37Hh/spITKIvTT384ip4dnUc5XBn0E+sMNS4b7au7IswyT5YDG2udPTjSh/eLUs3sGuB5YHooFOA==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.5.tgz",
"integrity": "sha512-iQIPy+Uey0expZTOszLri5n8rY7x4WUpMaY82mcXNIgbil30iHvc01OsiizhZC1KQHTK90RFMFWSpwFU+ON3aA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.5.tgz",
"integrity": "sha512-Npthji25t7FUqIAKsoEkFS0qY5CYVkHjvI3tuZjDTG92wX8g4dst+Lfb4hhubdqPazlDcIwalPzInsFCtf3FFg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.5.tgz",
"integrity": "sha512-bi+JsdCdbfVJDoAQybTYmkLKwh1xpYpptg5j/BNr2BB56u4/R26jrVvtjqff+CxYMXV6Kz1/jeDVhZCuj6bDng==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.5.tgz",
"integrity": "sha512-KQQwG9/sBmcGxqaLFIQf+k2OefREGoyEaIBmRuTM8bUuFKOEE9Xk5pel90hE6pmBwk/vo4RB0OdX+FxobJGMFw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2068,6 +2330,19 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmmirror.com/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmmirror.com/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -2992,6 +3267,15 @@
"proxy-from-env": "^1.1.0"
}
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/birpc": {
"version": "2.9.0",
"resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.9.0.tgz",
@@ -3060,6 +3344,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmmirror.com/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz",
@@ -3212,6 +3516,15 @@
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"license": "MIT"
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
@@ -4222,6 +4535,33 @@
"node": ">=8.6.0"
}
},
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmmirror.com/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"license": "MIT",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fast-png/node_modules/pako": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/pako/-/pako-2.2.0.tgz",
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "(MIT AND Zlib)"
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz",
@@ -4249,6 +4589,12 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/file-saver": {
"version": "2.0.5",
"resolved": "https://registry.npmmirror.com/file-saver/-/file-saver-2.0.5.tgz",
@@ -4619,6 +4965,19 @@
"url": "https://jaywcjlove.github.io/#/sponsor"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -4689,6 +5048,12 @@
"node": ">=12"
}
},
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmmirror.com/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmmirror.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -5076,6 +5441,23 @@
"integrity": "sha512-CuHU9hC6dPsHF5oVFMo8NW76uQVjH4L22CsP4hW+dNnGywJHC/B0ThA1CTDVLnxKLrrpYdicBLnd2xsgTfRnvg==",
"license": "MIT"
},
"node_modules/jspdf": {
"version": "4.2.1",
"resolved": "https://registry.npmmirror.com/jspdf/-/jspdf-4.2.1.tgz",
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.3.1",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmmirror.com/jszip/-/jszip-3.10.1.tgz",
@@ -5685,12 +6067,31 @@
"dev": true,
"license": "MIT"
},
"node_modules/pdfjs-dist": {
"version": "6.2.108",
"resolved": "https://registry.npmmirror.com/pdfjs-dist/-/pdfjs-dist-6.2.108.tgz",
"integrity": "sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^1.0.0"
}
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"license": "MIT"
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT",
"optional": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
@@ -6109,6 +6510,16 @@
],
"license": "MIT"
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmmirror.com/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/rangetouch": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/rangetouch/-/rangetouch-2.0.1.tgz",
@@ -6166,6 +6577,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmmirror.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -6202,6 +6620,16 @@
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/robust-predicates": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.3.tgz",
@@ -6573,6 +7001,16 @@
"node": ">=0.8"
}
},
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmmirror.com/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -6709,6 +7147,25 @@
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmmirror.com/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/tinyexec": {
"version": "1.2.4",
"resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.2.4.tgz",
@@ -7067,6 +7524,15 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz",
+3
View File
@@ -17,9 +17,12 @@
"docx-preview": "^0.3.7",
"echarts": "^6.0.0",
"element-plus": "^2.11.7",
"html2canvas": "^1.4.1",
"jspdf": "^4.2.1",
"less": "^4.4.2",
"marked": "^16.4.1",
"os": "^0.1.2",
"pdfjs-dist": "^6.2.108",
"pinia": "^3.0.3",
"v-viewer": "^3.0.11",
"vue": "^3.5.22",
+38
View File
@@ -45,6 +45,14 @@ export function submitReimbursement(id) {
})
}
export function updateReimbursementStatus(id, data) {
return request({
url: `/backend/reimbursements/${id}/status`,
method: 'post',
data
})
}
export function getReimbursementRecords(id) {
return request({
url: `/backend/reimbursements/${id}/records`,
@@ -88,3 +96,33 @@ export function getReimbursementExpenseTypes() {
method: 'get'
})
}
export function getReimbursementExpenseTypesManage() {
return request({
url: '/backend/reimbursements/types/manage',
method: 'get'
})
}
export function createReimbursementExpenseType(data) {
return request({
url: '/backend/reimbursements/types',
method: 'post',
data
})
}
export function updateReimbursementExpenseType(id, data) {
return request({
url: `/backend/reimbursements/types/${id}`,
method: 'post',
data
})
}
export function deleteReimbursementExpenseType(id) {
return request({
url: `/backend/reimbursements/types/${id}`,
method: 'delete'
})
}
@@ -244,7 +244,7 @@ onUnmounted(() => {
<style lang="less" scoped>
.erp-dashboard {
background-color: #f5f7fa;
min-height: calc(100vh - 84px);
// min-height: calc(100vh - 84px);
}
.stat-row {
@@ -244,7 +244,7 @@ onUnmounted(() => {
<style lang="less" scoped>
.erp-dashboard {
background-color: #f5f7fa;
min-height: calc(100vh - 84px);
// min-height: calc(100vh - 84px);
}
.stat-row {
@@ -1,11 +1,9 @@
<template>
<el-dialog v-model="visible" title="报销详情" width="980px" destroy-on-close>
<el-descriptions v-if="detail" :column="3" border>
<el-descriptions-item label="报销事由" :span="2">{{ detail.title }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusType(detail.status)">{{ statusLabel(detail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="费用类型">{{ detail.expense_type || '-' }}</el-descriptions-item>
<el-descriptions-item label="申请日期">{{ detail.apply_date || '-' }}</el-descriptions-item>
<el-descriptions-item label="合计金额">¥ {{ money(detail.total_amount) }}</el-descriptions-item>
<el-descriptions-item label="备注说明" :span="3">{{ detail.description || '-' }}</el-descriptions-item>
@@ -13,7 +11,9 @@
<div class="section-header">
<h4 class="detail-title">费用明细</h4>
<el-button v-if="isDraft" type="primary" size="small" @click="openAddItem">添加费用</el-button>
<div class="section-actions" style="margin-bottom: 12px;">
<el-button v-if="isDraft" type="primary" size="small" @click="openAddItem">添加费用</el-button>
</div>
</div>
<el-table :data="detail?.items || []" border size="small">
@@ -22,15 +22,17 @@
<el-table-column prop="amount" label="金额" width="110">
<template #default="{ row }">¥ {{ money(row.amount) }}</template>
</el-table-column>
<el-table-column prop="receipt_flag" label="发票" width="70">
<template #default="{ row }">{{ row.receipt_flag ? '有' : '无' }}</template>
<el-table-column prop="receipt_flag" label="发票" width="90">
<template #default="{ row }">
{{ itemHasReceipt(row) ? '有' : '无' }}{{ getItemReceiptFiles(row).length }}
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" :width="isDraft ? 176 : 70" fixed="right">
<template #default="{ row }">
<div class="item-actions">
<el-button
v-if="row.receipt_url"
v-if="itemHasReceipt(row)"
link
type="primary"
title="查看票据"
@@ -40,24 +42,18 @@
<i class="fa-solid fa-eye"></i>
</el-button>
<template v-if="isDraft">
<el-upload
:show-file-list="false"
:auto-upload="false"
accept="image/*,.pdf"
:on-change="file => uploadInvoice(row, file)"
:disabled="uploadingItemId === row.id"
<el-button
link
type="success"
title="上传发票(可多选,追加到当前费用项)"
aria-label="上传发票"
:disabled="batchUploading"
@click="openInvoiceFilePicker(row)"
>
<el-button
link
type="success"
title="上传发票"
aria-label="上传发票"
>
<i
:class="uploadingItemId === row.id ? 'fa-solid fa-spinner fa-spin' : 'fa-solid fa-file-arrow-up'"
></i>
</el-button>
</el-upload>
<i
:class="batchUploading ? 'fa-solid fa-spinner fa-spin' : 'fa-solid fa-file-arrow-up'"
></i>
</el-button>
<el-button link type="primary" title="编辑" aria-label="编辑" @click="openEditItem(row)">
<i class="fa-solid fa-pen-to-square"></i>
</el-button>
@@ -71,11 +67,36 @@
<template #empty><el-empty description="暂无费用明细" :image-size="60" /></template>
</el-table>
<input
ref="invoiceFileInputRef"
type="file"
multiple
accept="image/*,.pdf,application/pdf"
class="invoice-file-input"
@change="handleInvoiceFileInputChange"
/>
<el-dialog v-model="receiptPreviewVisible" title="查看票据" width="980px" append-to-body destroy-on-close>
<div v-if="receiptList.length" class="receipt-preview">
<aside class="receipt-list">
<el-select
v-model="receiptTypeFilter"
clearable
placeholder="筛选费用类型"
size="small"
class="receipt-filter"
@change="handleReceiptFilterChange"
>
<el-option label="全部" value="" />
<el-option
v-for="type in receiptTypeOptions"
:key="type"
:label="type"
:value="type"
/>
</el-select>
<button
v-for="receipt in receiptList"
v-for="receipt in filteredReceiptList"
:key="receipt.id"
type="button"
class="receipt-thumb"
@@ -90,7 +111,7 @@
lazy
/>
<i v-else class="fa-regular fa-file-pdf receipt-thumb-pdf"></i>
<span>{{ receipt.name }}</span>
<span class="receipt-thumb-name" :title="receipt.name">{{ receipt.name }}</span>
<el-button
v-if="isDraft"
link
@@ -101,9 +122,10 @@
<i class="fa-solid fa-trash"></i>
</el-button>
</button>
<el-empty v-if="!filteredReceiptList.length" description="暂无匹配的票据" :image-size="48" />
</aside>
<main v-if="receiptPreviewUrl" class="receipt-viewer">
<main v-if="receiptPreviewUrl && filteredReceiptList.some(item => item.id === receiptPreviewItemId)" class="receipt-viewer">
<el-image
v-if="isReceiptImage"
:src="receiptPreviewUrl"
@@ -112,7 +134,11 @@
/>
<iframe v-else :src="receiptPreviewUrl" title="票据 PDF 预览" class="receipt-pdf"></iframe>
</main>
<main v-else class="receipt-viewer receipt-viewer-empty">
<el-empty description="请选择左侧票据查看" :image-size="80" />
</main>
</div>
<el-empty v-else description="暂无票据" :image-size="80" />
</el-dialog>
<h4 class="detail-title" style="margin-top: 20px;">审批记录</h4>
@@ -137,6 +163,45 @@
:expense-type-options="expenseTypeOptions"
@saved="handleItemsSaved"
/>
<el-dialog
v-model="manualConfirmVisible"
title="手动确认"
width="420px"
append-to-body
destroy-on-close
>
<p class="manual-tip">选择要变更的单据状态保存后立即生效</p>
<el-radio-group v-model="manualStatus" class="manual-status-group">
<el-radio
v-for="item in statusOptions"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</el-radio>
</el-radio-group>
<template #footer>
<el-button @click="manualConfirmVisible = false">取消</el-button>
<el-button type="primary" :loading="manualSaving" @click="saveManualStatus">确认</el-button>
</template>
</el-dialog>
<template #footer>
<div class="detail-toolbar">
<el-button @click="openManualConfirm">手动确认</el-button>
<div class="toolbar-right">
<el-button
v-if="isDraft"
type="primary"
:loading="submitting"
@click="handleSubmit"
>
提交审核
</el-button>
</div>
</div>
</template>
</el-dialog>
</template>
@@ -144,7 +209,7 @@
import { computed, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { uploadFile } from '@/api/file'
import { deleteReimbursementItem, updateReimbursementItem } from '@/api/reimburse'
import { deleteReimbursementItem, submitReimbursement, updateReimbursementItem, updateReimbursementStatus } from '@/api/reimburse'
import ItemForm from './itemForm.vue'
const props = defineProps({
@@ -155,39 +220,101 @@ const props = defineProps({
})
const emit = defineEmits(['update:modelValue', 'changed'])
const statusOptions = [
{ value: 0, label: '草稿' },
{ value: 1, label: '审批中' },
{ value: 2, label: '已通过' },
{ value: 3, label: '已驳回' },
{ value: 4, label: '已撤回' },
{ value: 5, label: '已打款' }
]
const statusLabel = value => statusOptions.find(item => item.value === value)?.label || '未知'
const statusType = value => ({ 0: 'info', 1: 'warning', 2: 'success', 3: 'danger', 4: 'info', 5: 'success' })[value] || 'info'
const visible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value)
})
const isDraft = computed(() => props.detail?.status === 0)
const submitting = ref(false)
const manualConfirmVisible = ref(false)
const manualSaving = ref(false)
const manualStatus = ref(0)
const itemVisible = ref(false)
const uploadingItemId = ref(0)
const batchUploading = ref(false)
const invoiceFileInputRef = ref(null)
const pendingUploadRow = ref(null)
const editingItemId = ref(0)
const editingItem = ref(null)
const receiptPreviewVisible = ref(false)
const receiptPreviewUrl = ref('')
const receiptPreviewType = ref('')
const receiptPreviewItemId = ref(0)
const receiptPreviewItemId = ref('')
const receiptTypeFilter = ref('')
const getReceiptUrl = value => (value ? String(value).split(',').filter(Boolean)[0] || '' : '')
const getItemReceiptFiles = item => {
if (Array.isArray(item?.receipt_files) && item.receipt_files.length) {
return item.receipt_files
}
const ids = String(item?.receipt_file_ids || '')
.split(',')
.map(v => v.trim())
.filter(Boolean)
const urls = String(item?.receipt_urls || '')
.split(',')
.map(v => v.trim())
.filter(Boolean)
if (ids.length) {
return ids.map((id, index) => ({
file_id: Number(id),
url: urls[index] || urls[0] || '',
name: ''
}))
}
if (getReceiptUrl(item?.receipt_urls)) {
return [{ file_id: 0, url: item.receipt_urls, name: '' }]
}
return []
}
const itemHasReceipt = item => getItemReceiptFiles(item).length > 0
const receiptList = computed(() => {
const items = props.detail?.items || []
const list = []
items.forEach(item => {
if (!item.receipt_url) return
const urls = item.receipt_url.split(',').filter(Boolean)
urls.forEach((url, index) => {
const files = getItemReceiptFiles(item)
files.forEach((file, index) => {
const rawUrl = file.url || ''
if (!rawUrl) return
list.push({
id: `${item.id}-${index}`,
originalId: item.id, // 记录原始明细ID,用于删除时定位
url: resolveReceiptUrl(url),
isImage: /\.(jpe?g|png|gif|bmp|webp)(\?|#|$)/i.test(url || ''),
name: `${item.expense_type || '票据'} ${index + 1}`
id: `${item.id}-${file.file_id || index}`,
originalId: item.id,
fileId: file.file_id || 0,
expense_type: item.expense_type || '未分类',
url: resolveReceiptUrl(rawUrl),
rawUrl,
isImage: /\.(jpe?g|png|gif|bmp|webp)(\?|#|$)/i.test(rawUrl),
name: file.name || `${item.expense_type || '票据'} ${index + 1}`
})
})
})
return list
})
const receiptTypeOptions = computed(() => {
const types = new Set(receiptList.value.map(item => item.expense_type))
return Array.from(types).sort((a, b) => a.localeCompare(b, 'zh-CN'))
})
const filteredReceiptList = computed(() => {
if (!receiptTypeFilter.value) return receiptList.value
return receiptList.value.filter(item => item.expense_type === receiptTypeFilter.value)
})
const isReceiptImage = computed(() => receiptPreviewType.value.startsWith('image/'))
const resolveReceiptUrl = url => {
@@ -197,24 +324,32 @@ const resolveReceiptUrl = url => {
}
const selectReceipt = receipt => {
if (!receipt) return
receiptPreviewItemId.value = receipt.id
receiptPreviewUrl.value = receipt.url
receiptPreviewType.value = receipt.isImage ? 'image/*' : 'application/pdf'
}
const handleReceiptFilterChange = () => {
const current = filteredReceiptList.value.find(item => item.id === receiptPreviewItemId.value)
if (current) return
selectReceipt(filteredReceiptList.value[0] || null)
if (!filteredReceiptList.value.length) {
receiptPreviewUrl.value = ''
receiptPreviewType.value = ''
receiptPreviewItemId.value = ''
}
}
const openReceiptPreview = row => {
// 查找列表项,注意这里的 row.id 可能是原始记录ID,或者是多票据生成出的唯一ID
// 如果是从表格点击查看,row 对应的是明细项,其本身可能包含多个票据
// 我们直接找到该明细对应的第一张票据并打开预览窗口
const firstReceipt = receiptList.value.find(item => item.originalId === row.id)
if (!firstReceipt) {
const receipt = receiptList.value.find(item => item.originalId === row.id)
if (!receipt) {
ElMessage.warning('暂无票据可查看')
return
}
receiptTypeFilter.value = row.expense_type || ''
receiptPreviewVisible.value = true
selectReceipt(firstReceipt)
selectReceipt(receipt)
}
const openAddItem = () => {
@@ -233,83 +368,191 @@ const handleItemsSaved = reimbursementId => {
editingItem.value = null
emit('changed', reimbursementId)
}
const uploadInvoice = async (row, file) => {
if (!file?.raw) return
const rawFile = file.raw
const isAllowed = rawFile.type.startsWith('image/') || rawFile.type === 'application/pdf'
if (!isAllowed) {
ElMessage.error('发票仅支持图片或 PDF 文件')
return
}
uploadingItemId.value = row.id
const handleSubmit = async () => {
if (!props.detail?.id) return
try {
const formData = new FormData()
formData.append('file', rawFile)
formData.append('cate', 'reimbursement-invoice')
const uploadResponse = await uploadFile(formData)
if (uploadResponse?.code !== 200 && uploadResponse?.code !== 201) {
throw new Error(uploadResponse?.msg || '发票上传失败')
await ElMessageBox.confirm('提交后将进入审批流程,确认提交吗?', '提示', { type: 'warning' })
submitting.value = true
await submitReimbursement(props.detail.id)
ElMessage.success('提交成功')
emit('changed', props.detail.id)
} catch (error) {
if (error !== 'cancel' && error !== 'close') {
ElMessage.error(error?.message || '提交失败')
}
} finally {
submitting.value = false
}
}
const newUrl = uploadResponse?.data?.url || uploadResponse?.url || ''
// 获取当前已有的 URL 列表
const existingUrls = row.receipt_url ? row.receipt_url.split(',').filter(Boolean) : []
existingUrls.push(newUrl)
const resolveManualStatus = status => (status >= 0 && status <= 5 ? status : 0)
await updateReimbursementItem(props.detail.id, row.id, {
expense_type: row.expense_type,
expense_date: row.expense_date,
amount: row.amount,
quantity: row.quantity || 1,
unit_price: row.unit_price || 0,
description: row.description || '',
receipt_flag: 1,
receipt_url: existingUrls.join(',')
})
ElMessage.success('发票上传成功')
const openManualConfirm = () => {
manualStatus.value = resolveManualStatus(props.detail?.status ?? 0)
manualConfirmVisible.value = true
}
const saveManualStatus = async () => {
if (!props.detail?.id) return
manualSaving.value = true
try {
await updateReimbursementStatus(props.detail.id, { status: manualStatus.value })
ElMessage.success('状态已更新')
manualConfirmVisible.value = false
emit('changed', props.detail.id)
} catch (error) {
ElMessage.error(error?.message || '更新状态失败')
} finally {
manualSaving.value = false
}
}
const validateInvoiceFile = rawFile => {
if (!rawFile) return false
const name = rawFile.name.toLowerCase()
const byExt = /\.(jpe?g|png|gif|bmp|webp|pdf)$/i.test(name)
const byType = !rawFile.type || rawFile.type.startsWith('image/') || rawFile.type === 'application/pdf'
if (!byExt && !byType) {
ElMessage.error(`${rawFile.name}」格式不支持,仅支持图片或 PDF`)
return false
}
return true
}
const uploadFileToSystem = async rawFile => {
const formData = new FormData()
formData.append('file', rawFile)
formData.append('cate', 'reimbursement-invoice')
const uploadResponse = await uploadFile(formData)
if (uploadResponse?.code !== 200 && uploadResponse?.code !== 201) {
throw new Error(uploadResponse?.msg || `${rawFile.name}」上传失败`)
}
const url = uploadResponse?.data?.url || uploadResponse?.url || ''
const fileId = uploadResponse?.data?.id || uploadResponse?.id
if (!fileId) {
throw new Error(`${rawFile.name}」上传成功但未返回文件ID`)
}
return { id: fileId, url, name: rawFile.name }
}
const saveItemReceiptFiles = async (item, files, { append = true } = {}) => {
const existing = append ? getItemReceiptFiles(item) : []
const merged = [...existing]
const seen = new Set(merged.map(file => Number(file.file_id)).filter(Boolean))
files.forEach(file => {
const id = Number(file.id)
if (!id || seen.has(id)) return
seen.add(id)
merged.push({ file_id: id, url: file.url, name: file.name })
})
if (!merged.length) return
const ids = merged.map(file => file.file_id).filter(Boolean)
const urls = merged.map(file => file.url).filter(Boolean)
await updateReimbursementItem(props.detail.id, item.id, {
expense_type: item.expense_type,
expense_date: item.expense_date,
amount: item.amount,
quantity: item.quantity || 1,
unit_price: item.unit_price || 0,
description: item.description || '',
receipt_flag: 1,
receipt_file_ids: ids.join(','),
receipt_urls: urls.join(',')
})
}
const uploadFilesToItem = async (item, rawFiles) => {
const uploaded = []
for (const file of rawFiles) {
uploaded.push(await uploadFileToSystem(file))
}
await saveItemReceiptFiles(item, uploaded, { append: true })
return uploaded.length
}
const uploadInvoicesFromRow = async (startRow, rawFiles) => {
const files = rawFiles.filter(Boolean)
if (!files.length) return
const validFiles = files.filter(file => validateInvoiceFile(file))
if (!validFiles.length) return
batchUploading.value = true
try {
const count = await uploadFilesToItem(startRow, validFiles)
ElMessage.success(`已向「${startRow.expense_type || '当前费用项'}」追加 ${count} 张发票`)
emit('changed', props.detail.id)
} catch (error) {
ElMessage.error(error?.message || '发票上传失败')
} finally {
uploadingItemId.value = 0
batchUploading.value = false
}
}
const removeReceipt = async (receipt) => {
const openInvoiceFilePicker = row => {
pendingUploadRow.value = row
const input = invoiceFileInputRef.value
if (!input) return
input.value = ''
input.click()
}
const handleInvoiceFileInputChange = event => {
const row = pendingUploadRow.value
const files = Array.from(event.target.files || [])
pendingUploadRow.value = null
event.target.value = ''
if (!files.length || !row) return
uploadInvoicesFromRow(row, files)
}
const removeReceipt = async receipt => {
try {
await ElMessageBox.confirm('确认删除该票据吗?', '删除确认', { type: 'warning' })
// 通过原始 ID 查找对应的明细行
const row = props.detail.items.find(item => item.id === receipt.originalId)
if (!row) return
// 获取并过滤掉要删除的 URL
const existingUrls = row.receipt_url ? row.receipt_url.split(',').filter(Boolean) : []
const updatedUrls = existingUrls.filter(url => resolveReceiptUrl(url) !== receipt.url)
await updateReimbursementItem(props.detail.id, row.id, {
...row,
receipt_flag: updatedUrls.length > 0 ? 1 : 0,
receipt_url: updatedUrls.join(',')
const remaining = getItemReceiptFiles(row).filter(file => {
if (receipt.fileId) return Number(file.file_id) !== Number(receipt.fileId)
return resolveReceiptUrl(file.url) !== receipt.url
})
if (!remaining.length) {
await updateReimbursementItem(props.detail.id, row.id, {
expense_type: row.expense_type,
expense_date: row.expense_date,
amount: row.amount,
quantity: row.quantity || 1,
unit_price: row.unit_price || 0,
description: row.description || '',
receipt_flag: 0,
receipt_file_ids: '',
receipt_urls: ''
})
} else {
const ids = remaining.map(file => file.file_id).filter(Boolean)
const urls = remaining.map(file => file.url).filter(Boolean)
await updateReimbursementItem(props.detail.id, row.id, {
expense_type: row.expense_type,
expense_date: row.expense_date,
amount: row.amount,
quantity: row.quantity || 1,
unit_price: row.unit_price || 0,
description: row.description || '',
receipt_flag: 1,
receipt_file_ids: ids.join(','),
receipt_urls: urls.join(',')
})
}
ElMessage.success('票据已移除')
emit('changed', props.detail.id)
handleReceiptFilterChange()
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error('移除票据失败')
}
}
const statusOptions = [
{ value: 0, label: '草稿' },
{ value: 1, label: '审批中' },
{ value: 2, label: '已通过' },
{ value: 3, label: '已驳回' },
{ value: 4, label: '已撤回' },
{ value: 5, label: '已打款' }
]
const statusLabel = value => statusOptions.find(item => item.value === value)?.label || '未知'
const statusType = value => ({ 0: 'info', 1: 'warning', 2: 'success', 3: 'danger', 4: 'info', 5: 'success' })[value] || 'info'
const actionLabel = value => ({ 1: '提交申请', 2: '审批通过', 3: '审批驳回', 4: '转审', 5: '撤回' })[value] || '审批操作'
const money = value => Number(value || 0).toFixed(2)
const formatDateTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-'
@@ -323,6 +566,12 @@ const formatDateTime = value => value ? new Date(value).toLocaleString('zh-CN',
margin-top: 22px;
}
.section-actions {
display: flex;
align-items: center;
gap: 8px;
}
.detail-title {
margin: 0 0 12px;
color: #303133;
@@ -340,7 +589,7 @@ const formatDateTime = value => value ? new Date(value).toLocaleString('zh-CN',
}
.receipt-list {
flex: 0 0 200px;
flex: 0 0 220px;
display: flex;
flex-direction: column;
gap: 10px;
@@ -350,6 +599,10 @@ const formatDateTime = value => value ? new Date(value).toLocaleString('zh-CN',
max-height: 620px;
}
.receipt-filter {
width: 100%;
}
.receipt-thumb {
display: flex;
align-items: center;
@@ -371,10 +624,13 @@ const formatDateTime = value => value ? new Date(value).toLocaleString('zh-CN',
color: var(--el-color-primary);
}
span {
span,
.receipt-thumb-name {
flex: 1;
overflow: visible;
word-break: break-all;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@@ -401,6 +657,11 @@ const formatDateTime = value => value ? new Date(value).toLocaleString('zh-CN',
overflow: visible;
}
.receipt-viewer-empty {
background: var(--el-fill-color-lighter);
border-radius: 4px;
}
.receipt-image {
display: block;
width: 100%;
@@ -423,14 +684,39 @@ const formatDateTime = value => value ? new Date(value).toLocaleString('zh-CN',
gap: 4px;
white-space: nowrap;
:deep(.el-upload) {
display: inline-flex;
}
.el-button {
margin: 0;
padding: 4px 6px;
font-size: 15px;
}
}
.invoice-file-input {
display: none;
}
.detail-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.toolbar-right {
display: flex;
gap: 10px;
}
.manual-tip {
margin: 0 0 16px;
color: #909399;
font-size: 13px;
}
.manual-status-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
</style>
@@ -24,7 +24,7 @@
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">创建</el-button>
<el-button type="primary" :loading="saving" @click="save">{{ submitLabel }}</el-button>
</template>
</el-dialog>
</template>
@@ -36,6 +36,7 @@ const props = defineProps({
modelValue: { type: Boolean, default: false },
title: { type: String, default: '新建报销单' },
formData: { type: Object, required: true },
isEdit: { type: Boolean, default: false },
expenseTypeOptions: { type: Array, default: () => [] },
saving: { type: Boolean, default: false }
})
@@ -55,6 +56,7 @@ const cloneForm = data => ({
const form = reactive(cloneForm(props.formData))
const formRef = ref()
const submitLabel = computed(() => (props.isEdit ? '保存' : '创建'))
const formRules = {
apply_date: [{ required: true, message: '请选择申请日期', trigger: 'change' }]
}
@@ -0,0 +1,196 @@
<template>
<el-dialog
v-model="visible"
title="报销类别管理"
width="720px"
append-to-body
destroy-on-close
@open="loadList"
>
<div class="toolbar">
<p class="tip">通用费项由平台统一维护租户仅可管理自行添加的专有费项</p>
<el-button type="primary" :icon="Plus" @click="openForm()">新增专有费项</el-button>
</div>
<el-table v-loading="loading" :data="list" stripe max-height="420">
<el-table-column prop="name" label="费项名称" min-width="140" />
<el-table-column prop="sort_order" label="排序" width="80" align="center" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
{{ row.is_active ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="类型" width="110" align="center">
<template #default="{ row }">
<el-tag :type="row.is_global ? 'warning' : ''" size="small">
{{ row.is_global ? '通用' : '租户专有' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<template v-if="row.editable">
<el-button link type="primary" @click="openForm(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
<span v-else class="readonly-tip">平台维护</span>
</template>
</el-table-column>
<template #empty><el-empty description="暂无费项" /></template>
</el-table>
<el-dialog
v-model="formVisible"
:title="editingId ? '编辑专有费项' : '新增专有费项'"
width="420px"
append-to-body
destroy-on-close
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" maxlength="50" show-word-limit placeholder="请输入费项名称" />
</el-form-item>
<el-form-item label="排序" prop="sort_order">
<el-input-number v-model="form.sort_order" :min="0" :max="9999" controls-position="right" style="width: 100%" />
</el-form-item>
<el-form-item label="状态" prop="is_active">
<el-switch v-model="form.is_active" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveForm">保存</el-button>
</template>
</el-dialog>
</el-dialog>
</template>
<script setup>
import { reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import {
createReimbursementExpenseType,
deleteReimbursementExpenseType,
getReimbursementExpenseTypesManage,
updateReimbursementExpenseType
} from '@/api/reimburse'
const props = defineProps({
modelValue: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue', 'changed'])
const visible = ref(false)
const loading = ref(false)
const saving = ref(false)
const list = ref([])
const formVisible = ref(false)
const editingId = ref(0)
const formRef = ref(null)
const emptyForm = () => ({ name: '', sort_order: 0, is_active: 1 })
const form = reactive(emptyForm())
const rules = {
name: [
{ required: true, message: '请输入费项名称', trigger: 'blur' },
{ min: 1, max: 50, message: '名称长度 1-50 个字符', trigger: 'blur' }
]
}
const responseData = res => res?.data?.data ?? res?.data ?? res ?? []
watch(
() => props.modelValue,
val => { visible.value = val }
)
watch(visible, val => {
if (!val) emit('update:modelValue', false)
})
const loadList = async () => {
loading.value = true
try {
const data = responseData(await getReimbursementExpenseTypesManage())
list.value = Array.isArray(data) ? data : data.list || []
} catch (error) {
ElMessage.error(error?.message || '加载报销类别失败')
} finally {
loading.value = false
}
}
const openForm = row => {
editingId.value = row?.id || 0
Object.assign(form, row
? { name: row.name, sort_order: row.sort_order ?? 0, is_active: row.is_active ?? 1 }
: emptyForm())
formVisible.value = true
}
const saveForm = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return
}
saving.value = true
try {
const payload = { name: form.name.trim(), sort_order: form.sort_order, is_active: form.is_active }
if (editingId.value) {
await updateReimbursementExpenseType(editingId.value, payload)
ElMessage.success('更新成功')
} else {
await createReimbursementExpenseType(payload)
ElMessage.success('新增成功')
}
formVisible.value = false
await loadList()
emit('changed')
} catch (error) {
ElMessage.error(error?.message || '保存失败')
} finally {
saving.value = false
}
}
const handleDelete = async row => {
try {
await ElMessageBox.confirm(`确认删除费项「${row.name}」吗?`, '删除确认', { type: 'warning' })
await deleteReimbursementExpenseType(row.id)
ElMessage.success('删除成功')
await loadList()
emit('changed')
} catch (error) {
if (error !== 'cancel' && error !== 'close') {
ElMessage.error(error?.message || '删除失败')
}
}
}
</script>
<style lang="less" scoped>
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.tip {
margin: 0;
color: #909399;
font-size: 13px;
flex: 1;
}
.readonly-tip {
color: #c0c4cc;
font-size: 13px;
}
</style>
@@ -1,7 +1,7 @@
<template>
<el-dialog
v-model="visible"
:title="editingItemId ? '编辑费用明细' : '批量添加费用明细'"
:title="editingItemId ? '编辑费用明细' : '添加费用明细'"
width="860px"
append-to-body
destroy-on-close
@@ -60,7 +60,7 @@
<el-form-item label="金额" prop="amount">
<el-input-number
v-model="item.amount"
:min="0.01"
:min="0.00"
:precision="2"
:step="10"
controls-position="right"
@@ -123,7 +123,7 @@ const createRow = (source = {}) => ({
amount: Number(source.amount || 0),
description: source.description || '',
unit_price: Number(source.unit_price || 0),
receipt_url: source.receipt_url || ''
receipt_urls: source.receipt_urls || ''
})
const rules = {
@@ -169,7 +169,8 @@ const submit = async () => {
unit_price: item.unit_price || 0,
description: item.description || '',
receipt_flag: props.editingItem?.receipt_flag || 0,
receipt_url: item.receipt_url || ''
receipt_file_ids: props.editingItem?.receipt_file_ids || '',
receipt_urls: props.editingItem?.receipt_urls || ''
})
} else {
for (const item of items.value) {
@@ -181,7 +182,7 @@ const submit = async () => {
unit_price: item.unit_price || 0,
description: item.description || '',
receipt_flag: 0,
receipt_url: ''
receipt_urls: ''
})
}
}
+249 -143
View File
@@ -5,21 +5,45 @@
<h2>报销管理</h2>
<p>管理个人报销申请及审批进度</p>
</div>
<el-button type="primary" :icon="Plus" @click="openCreate">新建报销</el-button>
<div class="header-actions">
<el-button :icon="Setting" @click="expenseTypeVisible = true"
>报销类别</el-button
>
<el-button type="primary" :icon="Plus" @click="openCreate"
>新建报销</el-button
>
</div>
</div>
<div class="filter-card">
<el-form :inline="true" :model="filters" @submit.prevent>
<el-form-item label="关键词">
<el-input v-model="filters.keyword" clearable placeholder="请输入备注说明" @keyup.enter="loadList" />
<el-input
v-model="filters.keyword"
clearable
placeholder="请输入备注说明"
@keyup.enter="loadList"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" clearable placeholder="全部状态" style="width: 150px">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
<el-select
v-model="filters.status"
clearable
placeholder="全部状态"
style="width: 150px"
>
<el-option
v-for="item in statusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="loadList">查询</el-button>
<el-button type="primary" :icon="Search" @click="loadList"
>查询</el-button
>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
@@ -27,29 +51,69 @@
<div class="table-card">
<el-table v-loading="loading" :data="list" stripe>
<el-table-column prop="apply_date" label="申请日期" width="120">
<template #default="{ row }">{{ formatDate(row.apply_date) }}</template>
</el-table-column>
<el-table-column prop="description" label="备注说明" min-width="220" show-overflow-tooltip>
<template #default="{ row }">{{ row.description || '-' }}</template>
</el-table-column>
<el-table-column prop="total_amount" label="报销金额" width="130">
<template #default="{ row }">¥ {{ money(row.total_amount) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="110">
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag>
<el-tag :type="statusType(row.status)">{{
statusLabel(row.status)
}}</el-tag>
</template>
</el-table-column>
<el-table-column prop="create_time" label="创建时间" width="175">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
<el-table-column prop="apply_date" label="申请日期" width="120">
<template #default="{ row }">{{
formatDate(row.apply_date)
}}</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="245">
<el-table-column prop="total_amount" label="报销金额" width="150">
<template #default="{ row }"
>¥ {{ money(row.total_amount) }}</template
>
</el-table-column>
<el-table-column
prop="description"
label="备注说明"
min-width="220"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.description || "-" }}</template>
</el-table-column>
<el-table-column prop="create_time" label="创建时间" width="175">
<template #default="{ row }">{{
formatDateTime(row.create_time)
}}</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="300">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row.id)">详情</el-button>
<el-button v-if="row.status === 0" link type="primary" @click="openEdit(row.id)">编辑</el-button>
<el-button v-if="row.status === 0" link type="success" @click="handleSubmit(row)">提交</el-button>
<el-button v-if="row.status === 0" link type="danger" @click="handleDelete(row)">删除</el-button>
<el-button link type="primary" @click="openDetail(row.id)"
>详情</el-button
>
<el-button
link
type="primary"
:loading="exportingId === row.id"
@click="handleExport(row)"
>导出</el-button
>
<el-button
v-if="row.status === 0"
link
type="primary"
@click="openEdit(row.id)"
>编辑</el-button
>
<el-button
v-if="row.status === 0"
link
type="success"
@click="handleSubmit(row)"
>提交</el-button
>
<el-button
v-if="row.status === 0"
link
type="danger"
@click="handleDelete(row)"
>删除</el-button
>
</template>
</el-table-column>
<template #empty><el-empty description="暂无报销记录" /></template>
@@ -83,15 +147,22 @@
:expense-type-options="expenseTypeOptions"
@changed="reloadDetail"
/>
<ExpenseTypeDialog
v-model="expenseTypeVisible"
@changed="loadExpenseTypes"
/>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
import ReimburseEdit from './components/edit.vue'
import ReimburseDetail from './components/detail.vue'
import { computed, onMounted, reactive, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Refresh, Search, Setting } from "@element-plus/icons-vue";
import ReimburseEdit from "./components/edit.vue";
import ReimburseDetail from "./components/detail.vue";
import ExpenseTypeDialog from "./components/expenseTypeDialog.vue";
import { exportReimbursementPdf } from "./utils/exportReimbursementPdf";
import {
createReimbursement,
deleteReimbursement,
@@ -100,174 +171,203 @@ import {
getReimbursementList,
getReimbursementRecords,
submitReimbursement,
updateReimbursement
} from '@/api/reimburse'
updateReimbursement,
} from "@/api/reimburse";
const statusOptions = [
{ value: 0, label: '草稿' },
{ value: 1, label: '审批中' },
{ value: 2, label: '已通过' },
{ value: 3, label: '已驳回' },
{ value: 4, label: '已撤回' },
{ value: 5, label: '已打款' }
]
const filters = reactive({ keyword: '', status: undefined })
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const list = ref([])
const loading = ref(false)
const saving = ref(false)
const formVisible = ref(false)
const detailVisible = ref(false)
const editingId = ref(0)
const detail = ref(null)
const records = ref([])
const expenseTypeOptions = ref([])
{ value: 0, label: "草稿" },
{ value: 1, label: "审批中" },
{ value: 2, label: "已通过" },
{ value: 3, label: "已驳回" },
{ value: 4, label: "已撤回" },
{ value: 5, label: "已打款" },
];
const filters = reactive({ keyword: "", status: undefined });
const pagination = reactive({ page: 1, pageSize: 10, total: 0 });
const list = ref([]);
const loading = ref(false);
const saving = ref(false);
const formVisible = ref(false);
const detailVisible = ref(false);
const expenseTypeVisible = ref(false);
const editingId = ref(0);
const detail = ref(null);
const records = ref([]);
const expenseTypeOptions = ref([]);
const exportingId = ref(0);
const emptyForm = () => ({
apply_date: new Date().toISOString().slice(0, 10),
description: ''
})
const form = reactive(emptyForm())
const formTitle = computed(() => editingId.value ? '编辑报销单' : '新建报销单')
description: "",
});
const form = reactive(emptyForm());
const formTitle = computed(() =>
editingId.value ? "编辑报销单" : "新建报销单",
);
const responseData = res => res?.data?.data ?? res?.data ?? res ?? {}
const responseData = (res) => res?.data?.data ?? res?.data ?? res ?? {};
const loadExpenseTypes = async () => {
try {
const data = responseData(await getReimbursementExpenseTypes())
expenseTypeOptions.value = Array.isArray(data) ? data : data.list || []
const data = responseData(await getReimbursementExpenseTypes());
const rows = Array.isArray(data) ? data : data.list || [];
expenseTypeOptions.value = rows.filter((item) => item.is_active !== 0);
} catch (error) {
ElMessage.error(error?.message || '加载费用类型失败')
ElMessage.error(error?.message || "加载费用类型失败");
}
}
};
const loadList = async () => {
loading.value = true
loading.value = true;
try {
const data = responseData(await getReimbursementList({ ...filters, ...pagination }))
list.value = data.list || []
pagination.total = Number(data.total || 0)
const data = responseData(
await getReimbursementList({ ...filters, ...pagination }),
);
list.value = data.list || [];
pagination.total = Number(data.total || 0);
} catch (error) {
ElMessage.error(error?.message || '加载报销列表失败')
ElMessage.error(error?.message || "加载报销列表失败");
} finally {
loading.value = false
loading.value = false;
}
}
};
const resetFilters = () => {
filters.keyword = ''
filters.status = undefined
pagination.page = 1
loadList()
}
filters.keyword = "";
filters.status = undefined;
pagination.page = 1;
loadList();
};
const resetForm = () => Object.assign(form, emptyForm())
const resetForm = () => Object.assign(form, emptyForm());
const openCreate = () => {
editingId.value = 0
resetForm()
formVisible.value = true
}
editingId.value = 0;
resetForm();
formVisible.value = true;
};
const openEdit = async id => {
const openEdit = async (id) => {
try {
const data = responseData(await getReimbursementDetail(id))
editingId.value = id
const data = responseData(await getReimbursementDetail(id));
editingId.value = id;
Object.assign(form, {
apply_date: data.apply_date || new Date().toISOString().slice(0, 10),
description: data.description || ''
})
formVisible.value = true
description: data.description || "",
});
formVisible.value = true;
} catch (error) {
ElMessage.error(error?.message || '加载报销详情失败')
ElMessage.error(error?.message || "加载报销详情失败");
}
}
};
const saveForm = async payload => {
saving.value = true
const saveForm = async (payload) => {
saving.value = true;
try {
if (editingId.value) await updateReimbursement(editingId.value, payload)
else await createReimbursement(payload)
ElMessage.success('保存成功')
formVisible.value = false
loadList()
if (editingId.value) await updateReimbursement(editingId.value, payload);
else await createReimbursement(payload);
ElMessage.success("保存成功");
formVisible.value = false;
loadList();
} catch (error) {
ElMessage.error(error?.message || '保存失败')
ElMessage.error(error?.message || "保存失败");
} finally {
saving.value = false
saving.value = false;
}
}
};
const handleSubmit = async row => {
const handleSubmit = async (row) => {
try {
await ElMessageBox.confirm('提交后将进入审批流程,确认提交吗?', '提示', { type: 'warning' })
await submitReimbursement(row.id)
ElMessage.success('提交成功')
loadList()
await ElMessageBox.confirm("提交后将进入审批流程,确认提交吗?", "提示", {
type: "warning",
});
await submitReimbursement(row.id);
ElMessage.success("提交成功");
loadList();
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error?.message || '提交失败')
if (error !== "cancel" && error !== "close")
ElMessage.error(error?.message || "提交失败");
}
}
};
const handleDelete = async row => {
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm('确认删除这条报销记录吗?', '删除确认', { type: 'warning' })
await deleteReimbursement(row.id)
ElMessage.success('删除成功')
loadList()
await ElMessageBox.confirm("确认删除这条报销记录吗?", "删除确认", {
type: "warning",
});
await deleteReimbursement(row.id);
ElMessage.success("删除成功");
loadList();
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error?.message || '删除失败')
if (error !== "cancel" && error !== "close")
ElMessage.error(error?.message || "删除失败");
}
}
};
const reloadDetail = async id => {
const reloadDetail = async (id) => {
try {
const [detailRes, recordsRes] = await Promise.all([
getReimbursementDetail(id),
getReimbursementRecords(id)
])
detail.value = responseData(detailRes)
records.value = responseData(recordsRes) || []
await loadList()
getReimbursementRecords(id),
]);
detail.value = responseData(detailRes);
records.value = responseData(recordsRes) || [];
await loadList();
} catch (error) {
ElMessage.error(error?.message || '刷新详情失败')
ElMessage.error(error?.message || "刷新详情失败");
}
}
};
const openDetail = async id => {
await reloadDetail(id)
detailVisible.value = true
}
const openDetail = async (id) => {
await reloadDetail(id);
detailVisible.value = true;
};
const money = value => Number(value || 0).toFixed(2)
const formatDate = value => {
if (!value) return '-'
const text = String(value)
const match = text.match(/^(\d{4}-\d{2}-\d{2})/)
if (match) return match[1]
return text
}
const formatDateTime = value => value
? new Date(value).toLocaleString('zh-CN', { hour12: false })
: '-'
const statusLabel = value => statusOptions.find(item => item.value === value)?.label || '未知'
const statusType = value => ({
0: 'info',
1: 'warning',
2: 'success',
3: 'danger',
4: 'info',
5: 'success'
})[value] || 'info'
const handleExport = async (row) => {
exportingId.value = row.id;
try {
const data = responseData(await getReimbursementDetail(row.id));
await exportReimbursementPdf(data, {
statusLabel: statusLabel(data.status),
});
ElMessage.success("导出成功");
} catch (error) {
ElMessage.error(error?.message || "导出失败");
} finally {
exportingId.value = 0;
}
};
const money = (value) => Number(value || 0).toFixed(2);
const formatDate = (value) => {
if (!value) return "-";
const text = String(value);
const match = text.match(/^(\d{4}-\d{2}-\d{2})/);
if (match) return match[1];
return text;
};
const formatDateTime = (value) =>
value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "-";
const statusLabel = (value) =>
statusOptions.find((item) => item.value === value)?.label || "未知";
const statusType = (value) =>
({
0: "info",
1: "warning",
2: "success",
3: "danger",
4: "info",
5: "success",
})[value] || "info";
onMounted(() => {
loadExpenseTypes()
loadList()
})
loadExpenseTypes();
loadList();
});
</script>
<style lang="less" scoped>
.reimburse-page {
min-height: calc(100vh - 84px);
// min-height: calc(100vh - 84px);
background: #f5f7fa;
padding: 20px;
}
@@ -279,6 +379,12 @@ onMounted(() => {
margin-bottom: 16px;
}
.header-actions {
display: flex;
gap: 10px;
flex-shrink: 0;
}
.page-header h2 {
margin: 0 0 8px;
color: #303133;
@@ -296,7 +402,7 @@ onMounted(() => {
background: #fff;
border-radius: 6px;
padding: 18px;
box-shadow: 0 1px 4px rgba(0, 0, 0, .05);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
}
.filter-card {
@@ -0,0 +1,461 @@
import { jsPDF } from 'jspdf'
import html2canvas from 'html2canvas'
import * as pdfjsLib from 'pdfjs-dist'
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker
const PAGE_WIDTH = 794
const PAGE_HEIGHT = 1123
const RECEIPTS_PER_PAGE = 2
const money = value => Number(value || 0).toFixed(2)
const resolveUrl = url => {
if (!url) return ''
if (/^https?:\/\//i.test(url) || url.startsWith('blob:') || url.startsWith('data:')) return url
return `${window.location.origin}/${String(url).replace(/^\/+/, '')}`
}
const isImageUrl = url => /\.(jpe?g|png|gif|bmp|webp)(\?|#|$)/i.test(url || '')
const isPdfUrl = url => /\.pdf(\?|#|$)/i.test(String(url || '').toLowerCase())
const getReceiptFiles = item => {
if (Array.isArray(item?.receipt_files) && item.receipt_files.length) {
return item.receipt_files
}
const ids = String(item?.receipt_file_ids || '')
.split(',')
.map(v => v.trim())
.filter(Boolean)
const urls = String(item?.receipt_urls || '')
.split(',')
.map(v => v.trim())
.filter(Boolean)
if (ids.length) {
return ids.map((id, index) => ({
file_id: Number(id),
url: urls[index] || urls[0] || '',
name: ''
}))
}
const firstUrl = urls[0] || ''
if (firstUrl) {
return [{ file_id: 0, url: firstUrl, name: '' }]
}
return []
}
const fetchAuthorizedBlob = async url => {
const token = localStorage.getItem('token')
const response = await fetch(resolveUrl(url), {
headers: token ? { Authorization: `Bearer ${token}` } : {}
})
if (!response.ok) {
throw new Error(`文件加载失败 (${response.status})`)
}
return response.blob()
}
const blobToDataUrl = blob =>
new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result)
reader.onerror = reject
reader.readAsDataURL(blob)
})
const loadImageDataUrl = async url => {
const blob = await fetchAuthorizedBlob(url)
return blobToDataUrl(blob)
}
const loadPdfPreviewDataUrl = async url => {
const blob = await fetchAuthorizedBlob(url)
const buffer = await blob.arrayBuffer()
const pdf = await pdfjsLib.getDocument({ data: buffer }).promise
const page = await pdf.getPage(1)
const viewport = page.getViewport({ scale: 1.4 })
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
canvas.width = viewport.width
canvas.height = viewport.height
await page.render({ canvasContext: context, viewport }).promise
return canvas.toDataURL('image/jpeg', 0.92)
}
const resolveReceiptPreview = async file => {
const rawUrl = file.url || ''
const name = file.name || rawUrl.split('/').pop()?.split('?')[0] || '发票'
if (!rawUrl) {
return { name, dataUrl: '', note: '无可用文件地址' }
}
try {
if (isImageUrl(rawUrl)) {
return { name, dataUrl: await loadImageDataUrl(rawUrl), note: '' }
}
if (isPdfUrl(rawUrl)) {
return { name, dataUrl: await loadPdfPreviewDataUrl(rawUrl), note: '' }
}
return { name, dataUrl: '', note: '暂不支持预览该格式,请单独查看原文件' }
} catch (error) {
return { name, dataUrl: '', note: error?.message || '发票加载失败' }
}
}
const escapeHtml = value =>
String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
const EXPORT_STYLES = `
.reimburse-export-page {
width: ${PAGE_WIDTH}px;
height: ${PAGE_HEIGHT}px;
padding: 28px 32px 32px;
background: #fff;
color: #303133;
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
box-sizing: border-box;
overflow: hidden;
}
.reimburse-export-page h1 {
margin: 0 0 20px;
font-size: 24px;
text-align: center;
}
.reimburse-export-page h2 {
margin: 0 0 14px;
font-size: 16px;
border-left: 4px solid #409eff;
padding-left: 10px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 24px;
margin-bottom: 18px;
font-size: 13px;
}
.summary-item span,
.summary-desc span,
.item-meta span {
color: #909399;
margin-right: 8px;
}
.summary-desc {
grid-column: 1 / -1;
font-size: 13px;
line-height: 1.6;
word-break: break-word;
}
.items-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.items-table th,
.items-table td {
border: 1px solid #dcdfe6;
padding: 8px 10px;
text-align: left;
vertical-align: top;
word-break: break-word;
}
.items-table th {
background: #f5f7fa;
color: #606266;
}
.item-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 20px;
margin-bottom: 16px;
padding: 12px 14px;
border: 1px solid #ebeef5;
border-radius: 4px;
background: #fafafa;
font-size: 13px;
}
.item-meta-desc {
grid-column: 1 / -1;
line-height: 1.6;
word-break: break-word;
}
.reimburse-export-page.is-continuation {
height: auto;
min-height: 0;
padding-top: 28px;
}
.receipt-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.receipt-card {
padding: 10px;
border: 1px solid #ebeef5;
border-radius: 4px;
background: #fff;
}
.receipt-title {
margin-bottom: 8px;
font-size: 12px;
color: #606266;
}
.receipt-image {
display: block;
width: auto;
max-width: min(100%, 680px);
height: auto;
max-height: var(--receipt-max-height, 480px);
object-fit: contain;
margin: 0 auto;
}
.receipt-note,
.empty-tip {
color: #909399;
font-size: 12px;
line-height: 1.6;
}
`
const createPageElement = html => {
const page = document.createElement('div')
page.className = 'reimburse-export-page'
page.innerHTML = html
return page
}
const buildItemsTableHtml = items => {
if (!items.length) {
return '<p class="empty-tip">暂无费用明细</p>'
}
const rows = items
.map(item => {
const count = getReceiptFiles(item).length
return `<tr>
<td>${escapeHtml(item.expense_type || '-')}</td>
<td>${escapeHtml(item.expense_date || '-')}</td>
<td>¥ ${money(item.amount)}</td>
<td>${count > 0 ? `有(${count}` : '无(0'}</td>
<td>${escapeHtml(item.description || '-')}</td>
</tr>`
})
.join('')
return `<table class="items-table">
<thead>
<tr>
<th>费用类型</th>
<th>发生日期</th>
<th>金额</th>
<th>发票</th>
<th>描述</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>`
}
const buildSummaryPageElement = (detail, items, statusLabel) =>
createPageElement(`
<h1>报销单</h1>
<div class="summary-grid">
<div class="summary-item"><span>报销单号</span>${escapeHtml(detail.id)}</div>
<div class="summary-item"><span>状态</span>${escapeHtml(statusLabel)}</div>
<div class="summary-item"><span>申请日期</span>${escapeHtml(detail.apply_date || '-')}</div>
<div class="summary-item"><span>合计金额</span>¥ ${money(detail.total_amount)}</div>
<div class="summary-desc"><span>备注说明</span>${escapeHtml(detail.description || '-')}</div>
</div>
<h2>费用明细</h2>
${buildItemsTableHtml(items)}
`)
const chunkList = (list, size) => {
const chunks = []
for (let i = 0; i < list.length; i += size) {
chunks.push(list.slice(i, i + size))
}
return chunks
}
const buildItemMetaHtml = (item, index, total) => `
<h2>费用项 ${index + 1} / ${total}</h2>
<div class="item-meta">
<div><span>费用类型</span>${escapeHtml(item.expense_type || '-')}</div>
<div><span>发生日期</span>${escapeHtml(item.expense_date || '-')}</div>
<div><span>金额</span>¥ ${money(item.amount)}</div>
<div><span>发票</span>${getReceiptFiles(item).length ? `有(${getReceiptFiles(item).length}` : '无(0'}</div>
<div class="item-meta-desc"><span>描述</span>${escapeHtml(item.description || '-')}</div>
</div>
`
const buildReceiptCardsHtml = previews =>
previews
.map((preview, index) => `
<div class="receipt-card">
<div class="receipt-title">${escapeHtml(preview.name || `发票 ${index + 1}`)}</div>
${
preview.dataUrl
? `<img src="${preview.dataUrl}" alt="${escapeHtml(preview.name)}" class="receipt-image" />`
: `<div class="receipt-note">${escapeHtml(preview.note || '无法嵌入该发票')}</div>`
}
</div>
`)
.join('')
const getReceiptImageHeight = (countOnPage, hasFullMeta) => {
if (countOnPage <= 1) {
return hasFullMeta ? 760 : 860
}
return hasFullMeta ? 385 : 440
}
const buildItemReceiptPageElement = (
item,
index,
total,
previews,
{ showFullMeta }
) => {
const countOnPage = previews.length
const imageHeight = getReceiptImageHeight(countOnPage, showFullMeta)
const html = showFullMeta
? `
${buildItemMetaHtml(item, index, total)}
<h2>发票附件</h2>
<div class="receipt-list">${buildReceiptCardsHtml(previews)}</div>
`
: `<div class="receipt-list">${buildReceiptCardsHtml(previews)}</div>`
const page = createPageElement(html)
if (!showFullMeta) {
page.classList.add('is-continuation')
}
page.style.setProperty('--receipt-max-height', `${imageHeight}px`)
return page
}
const buildItemEmptyPageElement = (item, index, total) =>
createPageElement(`
${buildItemMetaHtml(item, index, total)}
<h2>发票附件</h2>
<p class="empty-tip">该费用项暂无发票</p>
`)
const buildItemPages = async (item, index, total) => {
const files = getReceiptFiles(item)
if (!files.length) {
return [buildItemEmptyPageElement(item, index, total)]
}
const previews = []
for (const file of files) {
previews.push(await resolveReceiptPreview(file))
}
const chunks = chunkList(previews, RECEIPTS_PER_PAGE)
return chunks.map((chunk, chunkIndex) =>
buildItemReceiptPageElement(item, index, total, chunk, {
showFullMeta: chunkIndex === 0
})
)
}
const waitForImages = async root => {
const images = Array.from(root.querySelectorAll('img'))
await Promise.all(
images.map(
img =>
new Promise(resolve => {
if (img.complete) {
resolve()
return
}
img.onload = () => resolve()
img.onerror = () => resolve()
})
)
)
}
const renderElementToCanvas = async (element, isContinuation = false) => {
await waitForImages(element)
const options = {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
logging: false,
windowWidth: PAGE_WIDTH
}
if (!isContinuation) {
options.width = PAGE_WIDTH
}
return html2canvas(element, options)
}
const addCanvasToPdf = (pdf, canvas, isFirstPage) => {
if (!isFirstPage) {
pdf.addPage()
}
const pageWidth = pdf.internal.pageSize.getWidth()
const pageHeight = pdf.internal.pageSize.getHeight()
const margin = 8
const maxWidth = pageWidth - margin * 2
const maxHeight = pageHeight - margin * 2
let drawWidth = maxWidth
let drawHeight = (canvas.height * drawWidth) / canvas.width
if (drawHeight > maxHeight) {
drawHeight = maxHeight
drawWidth = (canvas.width * drawHeight) / canvas.height
}
const x = (pageWidth - drawWidth) / 2
const y = margin
pdf.addImage(canvas.toDataURL('image/jpeg', 0.95), 'JPEG', x, y, drawWidth, drawHeight)
}
export async function exportReimbursementPdf(detail, { statusLabel }) {
if (!detail?.id) {
throw new Error('报销单数据无效')
}
const items = detail?.items || []
const host = document.createElement('div')
host.style.cssText = 'position:fixed;left:-10000px;top:0;z-index:-1;'
const style = document.createElement('style')
style.textContent = EXPORT_STYLES
host.appendChild(style)
document.body.appendChild(host)
const pdf = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4' })
let pageIndex = 0
try {
const summaryPage = buildSummaryPageElement(detail, items, statusLabel)
host.appendChild(summaryPage)
const summaryCanvas = await renderElementToCanvas(summaryPage)
addCanvasToPdf(pdf, summaryCanvas, pageIndex === 0)
pageIndex += 1
host.removeChild(summaryPage)
for (let index = 0; index < items.length; index += 1) {
const itemPages = await buildItemPages(items[index], index, items.length)
for (const itemPage of itemPages) {
const isContinuation = itemPage.classList.contains('is-continuation')
host.appendChild(itemPage)
const itemCanvas = await renderElementToCanvas(itemPage, isContinuation)
addCanvasToPdf(pdf, itemCanvas, pageIndex === 0)
pageIndex += 1
host.removeChild(itemPage)
}
}
pdf.save(`报销单-${detail.id}-${detail.apply_date || 'export'}.pdf`)
} finally {
document.body.removeChild(host)
}
}
+394 -31
View File
@@ -4,12 +4,14 @@ import (
"encoding/json"
"fmt"
"io"
"path"
"strconv"
"strings"
"time"
"server/models"
"server/pkg/jwtutil"
"server/services"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
@@ -28,14 +30,13 @@ type reimbursementItemPayload struct {
Quantity int `json:"quantity"`
UnitPrice float64 `json:"unit_price"`
Description string `json:"description"`
ReceiptFlag int8 `json:"receipt_flag"`
ReceiptURL string `json:"receipt_url"`
ReceiptFlag int8 `json:"receipt_flag"`
ReceiptFileIDs string `json:"receipt_file_ids"`
ReceiptURLs string `json:"receipt_urls"`
}
type reimbursementPayload struct {
Title string `json:"title"`
DepartmentID *uint64 `json:"department_id"`
ExpenseType string `json:"expense_type"`
ApplyDate string `json:"apply_date"`
Description string `json:"description"`
Items []reimbursementItemPayload `json:"items"`
@@ -96,26 +97,140 @@ func (c *BackendReimburseController) findOwned(id uint64, claims *jwtutil.Claims
return row, err
}
func reimbursementItemDTO(item models.BackendReimbursementItem) map[string]interface{} {
func parseReceiptFileIDs(raw string) []uint64 {
ids := make([]uint64, 0)
seen := map[uint64]bool{}
for _, part := range strings.Split(raw, ",") {
id, err := strconv.ParseUint(strings.TrimSpace(part), 10, 64)
if err != nil || id == 0 || seen[id] {
continue
}
seen[id] = true
ids = append(ids, id)
}
return ids
}
func joinReceiptFileIDs(ids []uint64) string {
if len(ids) == 0 {
return ""
}
parts := make([]string, 0, len(ids))
for _, id := range ids {
parts = append(parts, strconv.FormatUint(id, 10))
}
return strings.Join(parts, ",")
}
func resolveReceiptFiles(scheme, host string, item models.BackendReimbursementItem) []map[string]interface{} {
ids := parseReceiptFileIDs(item.ReceiptFileIDs)
if len(ids) == 0 {
return []map[string]interface{}{}
}
urlParts := strings.Split(item.ReceiptURLs, ",")
files := make([]map[string]interface{}, 0, len(ids))
for i, id := range ids {
url := ""
if i < len(urlParts) {
url = services.ResolvePublicURL(scheme, host, strings.TrimSpace(urlParts[i]))
}
if url == "" {
idCopy := id
url = services.ResolveSoftwareDownloadURL(scheme, host, nil, &idCopy)
}
name := ""
var f models.SystemFile
if err := models.Orm.QueryTable(new(models.SystemFile)).
Filter("id", id).Filter("delete_time__isnull", true).One(&f); err == nil {
name = f.Name
}
if name == "" && url != "" {
name = path.Base(strings.Split(url, "?")[0])
}
files = append(files, map[string]interface{}{
"file_id": id, "url": url, "name": name,
})
}
return files
}
func syncReceiptFields(item *models.BackendReimbursementItem, scheme, host string) {
files := resolveReceiptFiles(scheme, host, *item)
if len(files) == 0 {
item.ReceiptFileIDs = ""
item.ReceiptURLs = ""
item.ReceiptFlag = 0
return
}
ids := make([]uint64, 0, len(files))
urls := make([]string, 0, len(files))
for _, file := range files {
if id, ok := file["file_id"].(uint64); ok {
ids = append(ids, id)
}
if url, ok := file["url"].(string); ok && url != "" {
urls = append(urls, url)
}
}
item.ReceiptFileIDs = joinReceiptFileIDs(ids)
item.ReceiptURLs = strings.Join(urls, ",")
item.ReceiptFlag = 1
}
func applyReceiptToItem(item *models.BackendReimbursementItem, payload reimbursementItemPayload, scheme, host string) {
if payload.ReceiptFlag == 0 && strings.TrimSpace(payload.ReceiptFileIDs) == "" {
item.ReceiptFileIDs = ""
item.ReceiptURLs = ""
item.ReceiptFlag = 0
return
}
if strings.TrimSpace(payload.ReceiptFileIDs) != "" {
item.ReceiptFileIDs = strings.TrimSpace(payload.ReceiptFileIDs)
syncReceiptFields(item, scheme, host)
return
}
item.ReceiptFlag = payload.ReceiptFlag
item.ReceiptURLs = strings.TrimSpace(payload.ReceiptURLs)
if item.ReceiptURLs == "" && item.ReceiptFlag == 0 {
item.ReceiptFileIDs = ""
}
}
func reimbursementItemDTO(item models.BackendReimbursementItem, scheme, host string) map[string]interface{} {
receiptFiles := resolveReceiptFiles(scheme, host, item)
receiptURLs := strings.TrimSpace(item.ReceiptURLs)
receiptFlag := item.ReceiptFlag
if len(receiptFiles) > 0 {
receiptFlag = 1
if receiptURLs == "" {
if url, ok := receiptFiles[0]["url"].(string); ok {
receiptURLs = url
}
}
} else if receiptURLs != "" {
receiptFlag = 1
}
return map[string]interface{}{
"id": item.ID, "reimbursement_id": item.ReimbursementID,
"expense_type": item.ExpenseType, "expense_date": item.ExpenseDate.Format("2006-01-02"),
"amount": item.Amount, "quantity": item.Quantity, "unit_price": item.UnitPrice,
"description": item.Description, "receipt_flag": item.ReceiptFlag, "receipt_url": item.ReceiptURL,
"description": item.Description, "receipt_flag": receiptFlag,
"receipt_file_ids": item.ReceiptFileIDs,
"receipt_urls": receiptURLs, "receipt_files": receiptFiles,
}
}
func reimbursementDTO(row models.BackendReimbursement, items []models.BackendReimbursementItem) map[string]interface{} {
func reimbursementDTO(row models.BackendReimbursement, items []models.BackendReimbursementItem, scheme, host string) map[string]interface{} {
result := map[string]interface{}{
"id": row.ID, "tid": row.Tid, "uid": row.UID, "user_id": row.UserID,
"department_id": row.DepartmentID, "title": row.Title, "total_amount": row.TotalAmount,
"status": row.Status, "expense_type": row.ExpenseType, "apply_date": row.ApplyDate.Format("2006-01-02"),
"department_id": row.DepartmentID, "total_amount": row.TotalAmount,
"status": row.Status, "apply_date": row.ApplyDate.Format("2006-01-02"),
"description": row.Description, "current_approver_id": row.CurrentApproverID,
"current_step": row.CurrentStep, "create_time": row.CreateTime, "update_time": row.UpdateTime,
}
list := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
list = append(list, reimbursementItemDTO(item))
list = append(list, reimbursementItemDTO(item, scheme, host))
}
result["items"] = list
return result
@@ -170,10 +285,8 @@ func (c *BackendReimburseController) List() {
"uid": row.UID,
"user_id": row.UserID,
"department_id": row.DepartmentID,
"title": row.Title,
"total_amount": row.TotalAmount,
"status": row.Status,
"expense_type": row.ExpenseType,
"apply_date": row.ApplyDate.Format("2006-01-02"),
"description": row.Description,
"current_approver_id": row.CurrentApproverID,
@@ -207,10 +320,11 @@ func (c *BackendReimburseController) Detail() {
Filter("reimbursement_id", id).Filter("tid", claims.TenantId).Filter("is_deleted", 0).
OrderBy("id").All(&items)
if err != nil && err != orm.ErrNoRows {
c.reply(500, "查询明细失败", nil)
c.reply(500, "查询明细失败: "+err.Error(), nil)
return
}
c.reply(200, "success", reimbursementDTO(*row, items))
scheme, host := services.PublicRequestBaseURL(&c.Controller)
c.reply(200, "success", reimbursementDTO(*row, items, scheme, host))
}
// Create POST /backend/reimbursements
@@ -233,8 +347,7 @@ func (c *BackendReimburseController) Create() {
uid := uint64(claims.UserID)
row := &models.BackendReimbursement{
Tid: uint64(claims.TenantId), UID: &uid, UserID: uid, DepartmentID: payload.DepartmentID,
Title: "", TotalAmount: 0, Status: 0,
ExpenseType: strings.TrimSpace(payload.ExpenseType), ApplyDate: applyDate,
TotalAmount: 0, Status: 0, ApplyDate: applyDate,
Description: nullableReimburseString(payload.Description),
}
if err := saveReimbursement(row); err != nil {
@@ -419,13 +532,14 @@ func (c *BackendReimburseController) AddItem() {
if quantity <= 0 {
quantity = 1
}
scheme, host := services.PublicRequestBaseURL(&c.Controller)
item := &models.BackendReimbursementItem{
Tid: uint64(claims.TenantId), UID: &uid, ReimbursementID: reimbursementID,
ExpenseType: strings.TrimSpace(payload.ExpenseType), ExpenseDate: date,
Amount: payload.Amount, Quantity: quantity, UnitPrice: payload.UnitPrice,
Description: strings.TrimSpace(payload.Description), ReceiptFlag: payload.ReceiptFlag,
ReceiptURL: strings.TrimSpace(payload.ReceiptURL),
Description: strings.TrimSpace(payload.Description),
}
applyReceiptToItem(item, payload, scheme, host)
if _, err := models.Orm.Insert(item); err != nil {
c.reply(500, "新增费用明细失败", nil)
return
@@ -434,7 +548,7 @@ func (c *BackendReimburseController) AddItem() {
c.reply(500, "更新报销总额失败", nil)
return
}
c.reply(200, "success", reimbursementItemDTO(*item))
c.reply(200, "success", reimbursementItemDTO(*item, scheme, host))
}
// UpdateItem POST /backend/reimbursements/:id/items/:item_id
@@ -476,14 +590,14 @@ func (c *BackendReimburseController) UpdateItem() {
if quantity <= 0 {
quantity = 1
}
scheme, host := services.PublicRequestBaseURL(&c.Controller)
item.ExpenseType = strings.TrimSpace(payload.ExpenseType)
item.ExpenseDate = date
item.Amount = payload.Amount
item.Quantity = quantity
item.UnitPrice = payload.UnitPrice
item.Description = strings.TrimSpace(payload.Description)
item.ReceiptFlag = payload.ReceiptFlag
item.ReceiptURL = strings.TrimSpace(payload.ReceiptURL)
applyReceiptToItem(item, payload, scheme, host)
if _, err := models.Orm.Update(item); err != nil {
c.reply(500, "更新费用明细失败", nil)
return
@@ -492,7 +606,7 @@ func (c *BackendReimburseController) UpdateItem() {
c.reply(500, "更新报销总额失败", nil)
return
}
c.reply(200, "success", reimbursementItemDTO(*item))
c.reply(200, "success", reimbursementItemDTO(*item, scheme, host))
}
// DeleteItem DELETE /backend/reimbursements/:id/items/:item_id
@@ -583,6 +697,65 @@ func (c *BackendReimburseController) Submit() {
c.reply(200, "success", nil)
}
type reimbursementStatusPayload struct {
Status int8 `json:"status"`
}
var manualConfirmStatuses = map[int8]bool{
0: true, // 草稿
1: true, // 审批中
2: true, // 已通过
3: true, // 已驳回
4: true, // 已撤回
5: true, // 已打款
}
// UpdateStatus POST /backend/reimbursements/:id/status — 手动确认更改单据状态
func (c *BackendReimburseController) UpdateStatus() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
id, ok := c.getID()
if !ok {
c.reply(400, "无效ID", nil)
return
}
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
c.reply(400, "参数错误", nil)
return
}
var payload reimbursementStatusPayload
if err := json.Unmarshal(raw, &payload); err != nil {
c.reply(400, "参数错误", nil)
return
}
if !manualConfirmStatuses[payload.Status] {
c.reply(400, "不支持的状态值", nil)
return
}
row, err := c.findOwned(id, claims)
if err != nil {
c.reply(404, "报销单不存在", nil)
return
}
if row.Status == payload.Status {
c.reply(200, "success", map[string]interface{}{"status": row.Status})
return
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.BackendReimbursement)).Filter("id", id).Update(orm.Params{
"status": payload.Status, "update_time": now,
})
if err != nil {
c.reply(500, "更新状态失败", nil)
return
}
c.reply(200, "success", map[string]interface{}{"status": payload.Status})
}
// Records GET /backend/reimbursements/:id/records
func (c *BackendReimburseController) Records() {
claims, ok := c.claims()
@@ -613,25 +786,215 @@ func (c *BackendReimburseController) Records() {
c.reply(200, "success", rows)
}
// Types GET /backend/reimbursements/types
type expenseTypePayload struct {
Name string `json:"name"`
SortOrder int `json:"sort_order"`
IsActive *int8 `json:"is_active"`
}
func expenseTypeScopeCond(tenantID int) *orm.Condition {
cond := orm.NewCondition()
return cond.Or("tid", 0).Or("tid", tenantID)
}
func expenseTypeDTO(row models.BackendReimbursementExpenseType, tenantID int) map[string]interface{} {
isGlobal := row.Tid == 0
return map[string]interface{}{
"id": row.ID, "tid": row.Tid, "name": row.Name,
"sort_order": row.SortOrder, "is_active": row.IsActive,
"is_global": isGlobal, "editable": !isGlobal && row.Tid == uint64(tenantID),
"create_time": row.CreateTime, "update_time": row.UpdateTime,
}
}
func (c *BackendReimburseController) queryExpenseTypes(tenantID int, activeOnly bool) ([]models.BackendReimbursementExpenseType, error) {
qs := models.Orm.QueryTable(new(models.BackendReimbursementExpenseType)).
SetCond(expenseTypeScopeCond(tenantID)).
Filter("is_deleted", 0)
if activeOnly {
qs = qs.Filter("is_active", 1)
}
var rows []models.BackendReimbursementExpenseType
_, err := qs.OrderBy("sort_order", "id").All(&rows)
if err != nil && err != orm.ErrNoRows {
return nil, err
}
if rows == nil {
rows = []models.BackendReimbursementExpenseType{}
}
return rows, nil
}
func (c *BackendReimburseController) getTypeID() (uint64, bool) {
id, err := strconv.ParseUint(c.Ctx.Input.Param(":type_id"), 10, 64)
return id, err == nil && id > 0
}
func (c *BackendReimburseController) findTenantExpenseType(id uint64, tenantID int) (*models.BackendReimbursementExpenseType, error) {
row := &models.BackendReimbursementExpenseType{}
err := models.Orm.QueryTable(new(models.BackendReimbursementExpenseType)).
Filter("id", id).
Filter("tid", tenantID).
Filter("is_deleted", 0).
One(row)
return row, err
}
func (c *BackendReimburseController) parseExpenseTypePayload() (expenseTypePayload, error) {
var payload expenseTypePayload
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
return payload, err
}
err = json.Unmarshal(raw, &payload)
return payload, err
}
// Types GET /backend/reimbursements/types — 下拉选项:全局通用 + 当前租户专有(仅启用)
func (c *BackendReimburseController) Types() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
var rows []models.BackendReimbursementExpenseType
_, err := models.Orm.QueryTable(new(models.BackendReimbursementExpenseType)).
Filter("tid", claims.TenantId).Filter("is_active", 1).Filter("is_deleted", 0).
OrderBy("sort_order", "id").All(&rows)
if err != nil && err != orm.ErrNoRows {
rows, err := c.queryExpenseTypes(claims.TenantId, true)
if err != nil {
c.reply(500, "查询费用类型失败", nil)
return
}
if rows == nil {
rows = []models.BackendReimbursementExpenseType{}
list := make([]map[string]interface{}, 0, len(rows))
for _, row := range rows {
list = append(list, expenseTypeDTO(row, claims.TenantId))
}
c.reply(200, "success", rows)
c.reply(200, "success", list)
}
// TypesManage GET /backend/reimbursements/types/manage — 管理列表:含禁用项,标注是否可编辑
func (c *BackendReimburseController) TypesManage() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
rows, err := c.queryExpenseTypes(claims.TenantId, false)
if err != nil {
c.reply(500, "查询费用类型失败", nil)
return
}
list := make([]map[string]interface{}, 0, len(rows))
for _, row := range rows {
list = append(list, expenseTypeDTO(row, claims.TenantId))
}
c.reply(200, "success", list)
}
// CreateType POST /backend/reimbursements/types — 租户新增专有费项
func (c *BackendReimburseController) CreateType() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
payload, err := c.parseExpenseTypePayload()
if err != nil {
c.reply(400, "参数错误", nil)
return
}
name := strings.TrimSpace(payload.Name)
if name == "" {
c.reply(400, "费项名称不能为空", nil)
return
}
isActive := int8(1)
if payload.IsActive != nil {
isActive = *payload.IsActive
}
uid := uint64(claims.UserID)
row := &models.BackendReimbursementExpenseType{
Tid: uint64(claims.TenantId), UID: &uid,
Name: name, SortOrder: payload.SortOrder, IsActive: isActive,
}
if _, err := models.Orm.Insert(row); err != nil {
if strings.Contains(err.Error(), "Duplicate") || strings.Contains(err.Error(), "1062") {
c.reply(400, "该费项名称已存在", nil)
return
}
c.reply(500, "新增费项失败", nil)
return
}
c.reply(200, "success", expenseTypeDTO(*row, claims.TenantId))
}
// UpdateType POST /backend/reimbursements/types/:type_id — 仅可修改本租户专有费项
func (c *BackendReimburseController) UpdateType() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
id, ok := c.getTypeID()
if !ok {
c.reply(400, "无效ID", nil)
return
}
row, err := c.findTenantExpenseType(id, claims.TenantId)
if err != nil {
c.reply(404, "费项不存在或无权修改", nil)
return
}
payload, err := c.parseExpenseTypePayload()
if err != nil {
c.reply(400, "参数错误", nil)
return
}
if name := strings.TrimSpace(payload.Name); name != "" {
row.Name = name
}
if payload.SortOrder >= 0 {
row.SortOrder = payload.SortOrder
}
if payload.IsActive != nil {
row.IsActive = *payload.IsActive
}
now := time.Now()
row.UpdateTime = &now
if _, err := models.Orm.Update(row); err != nil {
if strings.Contains(err.Error(), "Duplicate") || strings.Contains(err.Error(), "1062") {
c.reply(400, "该费项名称已存在", nil)
return
}
c.reply(500, "更新费项失败", nil)
return
}
c.reply(200, "success", expenseTypeDTO(*row, claims.TenantId))
}
// DeleteType DELETE /backend/reimbursements/types/:type_id — 仅可删除本租户专有费项
func (c *BackendReimburseController) DeleteType() {
claims, ok := c.claims()
if !ok {
c.reply(401, "未登录或无权限", nil)
return
}
id, ok := c.getTypeID()
if !ok {
c.reply(400, "无效ID", nil)
return
}
row, err := c.findTenantExpenseType(id, claims.TenantId)
if err != nil {
c.reply(404, "费项不存在或无权删除", nil)
return
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.BackendReimbursementExpenseType)).
Filter("id", row.ID).
Update(orm.Params{"is_deleted": 1, "delete_time": now, "update_time": now})
if err != nil {
c.reply(500, "删除费项失败", nil)
return
}
c.reply(200, "success", nil)
}
// Flows GET /backend/reimbursements/flows
@@ -0,0 +1,207 @@
package controllers
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"server/models"
"server/pkg/jwtutil"
beego "github.com/beego/beego/v2/server/web"
"github.com/beego/beego/v2/client/orm"
)
// PlatformReimburseExpenseTypeController 平台维护全局通用报销费项(tid=0)。
type PlatformReimburseExpenseTypeController struct {
beego.Controller
}
func (c *PlatformReimburseExpenseTypeController) platformClaims() (*jwtutil.Claims, error) {
auth := c.Ctx.Request.Header.Get("Authorization")
if auth == "" {
return nil, fmt.Errorf("未登录")
}
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return nil, fmt.Errorf("认证信息格式错误")
}
claims, err := jwtutil.ParseToken(parts[1])
if err != nil {
return nil, fmt.Errorf("无效的token")
}
if claims.UserType != "platform" {
return nil, fmt.Errorf("无权访问")
}
return claims, nil
}
func (c *PlatformReimburseExpenseTypeController) jsonErr(httpStatus, bizCode int, msg string) {
c.Ctx.Output.SetStatus(httpStatus)
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
_ = c.ServeJSON()
}
func (c *PlatformReimburseExpenseTypeController) ok(data interface{}) {
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
_ = c.ServeJSON()
}
func globalExpenseTypeDTO(row models.BackendReimbursementExpenseType) map[string]interface{} {
return map[string]interface{}{
"id": row.ID, "tid": row.Tid, "name": row.Name,
"sort_order": row.SortOrder, "is_active": row.IsActive,
"is_global": true, "editable": true,
"create_time": row.CreateTime, "update_time": row.UpdateTime,
}
}
func (c *PlatformReimburseExpenseTypeController) findGlobalType(id uint64) (*models.BackendReimbursementExpenseType, error) {
row := &models.BackendReimbursementExpenseType{}
err := models.Orm.QueryTable(new(models.BackendReimbursementExpenseType)).
Filter("id", id).Filter("tid", 0).Filter("is_deleted", 0).One(row)
return row, err
}
// List GET /platform/reimburseExpenseTypes
func (c *PlatformReimburseExpenseTypeController) List() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
var rows []models.BackendReimbursementExpenseType
_, err := models.Orm.QueryTable(new(models.BackendReimbursementExpenseType)).
Filter("tid", 0).Filter("is_deleted", 0).
OrderBy("sort_order", "id").All(&rows)
if err != nil && err != orm.ErrNoRows {
c.jsonErr(500, 500, "查询失败: "+err.Error())
return
}
if rows == nil {
rows = []models.BackendReimbursementExpenseType{}
}
list := make([]map[string]interface{}, 0, len(rows))
for _, row := range rows {
list = append(list, globalExpenseTypeDTO(row))
}
c.ok(list)
}
type platformExpenseTypePayload struct {
Name string `json:"name"`
SortOrder int `json:"sort_order"`
IsActive *int8 `json:"is_active"`
}
// Create POST /platform/reimburseExpenseTypes
func (c *PlatformReimburseExpenseTypeController) Create() {
claims, err := c.platformClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
return
}
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p platformExpenseTypePayload
if err := json.Unmarshal(body, &p); err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
name := strings.TrimSpace(p.Name)
if name == "" {
c.jsonErr(400, 400, "费项名称不能为空")
return
}
isActive := int8(1)
if p.IsActive != nil {
isActive = *p.IsActive
}
uid := uint64(claims.UserID)
row := models.BackendReimbursementExpenseType{
Tid: 0, UID: &uid, Name: name, SortOrder: p.SortOrder, IsActive: isActive,
}
id, err := models.Orm.Insert(&row)
if err != nil {
if strings.Contains(err.Error(), "Duplicate") || strings.Contains(err.Error(), "1062") {
c.jsonErr(400, 400, "该费项名称已存在")
return
}
c.jsonErr(500, 500, "创建失败: "+err.Error())
return
}
row.ID = uint64(id)
c.ok(globalExpenseTypeDTO(row))
}
// Update POST /platform/reimburseExpenseTypes/:id
func (c *PlatformReimburseExpenseTypeController) Update() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.jsonErr(400, 400, "无效ID")
return
}
row, err := c.findGlobalType(id)
if err != nil {
c.jsonErr(404, 404, "费项不存在")
return
}
body, _ := io.ReadAll(c.Ctx.Request.Body)
var p platformExpenseTypePayload
if err := json.Unmarshal(body, &p); err != nil {
c.jsonErr(400, 400, "参数错误")
return
}
if name := strings.TrimSpace(p.Name); name != "" {
row.Name = name
}
if p.SortOrder >= 0 {
row.SortOrder = p.SortOrder
}
if p.IsActive != nil {
row.IsActive = *p.IsActive
}
now := time.Now()
row.UpdateTime = &now
if _, err := models.Orm.Update(row); err != nil {
if strings.Contains(err.Error(), "Duplicate") || strings.Contains(err.Error(), "1062") {
c.jsonErr(400, 400, "该费项名称已存在")
return
}
c.jsonErr(500, 500, "更新失败: "+err.Error())
return
}
c.ok(globalExpenseTypeDTO(*row))
}
// Delete DELETE /platform/reimburseExpenseTypes/:id
func (c *PlatformReimburseExpenseTypeController) Delete() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
return
}
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.jsonErr(400, 400, "无效ID")
return
}
row, err := c.findGlobalType(id)
if err != nil {
c.jsonErr(404, 404, "费项不存在")
return
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.BackendReimbursementExpenseType)).
Filter("id", row.ID).
Update(orm.Params{"is_deleted": 1, "delete_time": now, "update_time": now})
if err != nil {
c.jsonErr(500, 500, "删除失败: "+err.Error())
return
}
c.ok(nil)
}
+3 -4
View File
@@ -52,10 +52,8 @@ type BackendReimbursement struct {
UID *uint64 `orm:"column(uid);null" json:"uid"`
UserID uint64 `orm:"column(user_id)" json:"user_id"`
DepartmentID *uint64 `orm:"column(department_id);null" json:"department_id"`
Title string `orm:"column(title);size(200)" json:"title"`
TotalAmount float64 `orm:"column(total_amount);type(decimal);digits(10);decimals(2)" json:"total_amount"`
Status int8 `orm:"column(status);default(0)" json:"status"`
ExpenseType string `orm:"column(expense_type);size(50)" json:"expense_type"`
ApplyDate time.Time `orm:"column(apply_date);type(date)" json:"apply_date"`
Description *string `orm:"column(description);null;type(text)" json:"description"`
CurrentApproverID uint64 `orm:"column(current_approver_id);default(0)" json:"current_approver_id"`
@@ -82,8 +80,9 @@ type BackendReimbursementItem struct {
Quantity int `orm:"column(quantity);default(1)" json:"quantity"`
UnitPrice float64 `orm:"column(unit_price);type(decimal);digits(10);decimals(2)" json:"unit_price"`
Description string `orm:"column(description);size(200)" json:"description"`
ReceiptFlag int8 `orm:"column(receipt_flag);default(0)" json:"receipt_flag"`
ReceiptURL string `orm:"column(receipt_url);size(500)" json:"receipt_url"`
ReceiptFlag int8 `orm:"column(receipt_flag);default(0)" json:"receipt_flag"`
ReceiptFileIDs string `orm:"column(receipt_file_ids);size(500)" json:"receipt_file_ids"`
ReceiptURLs string `orm:"column(receipt_urls);size(500)" json:"receipt_urls"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);null;type(datetime)" json:"update_time"`
+4 -1
View File
@@ -156,12 +156,15 @@ func RegisterAuthRoutes() {
// 报销与审批管理
beego.Router("/backend/reimbursements", &controllers.BackendReimburseController{}, "get:List;post:Create")
beego.Router("/backend/reimbursements/types", &controllers.BackendReimburseController{}, "get:Types")
beego.Router("/backend/reimbursements/types/manage", &controllers.BackendReimburseController{}, "get:TypesManage")
beego.Router("/backend/reimbursements/types/:type_id", &controllers.BackendReimburseController{}, "post:UpdateType;delete:DeleteType")
beego.Router("/backend/reimbursements/types", &controllers.BackendReimburseController{}, "get:Types;post:CreateType")
beego.Router("/backend/reimbursements/flows", &controllers.BackendReimburseController{}, "get:Flows")
beego.Router("/backend/reimbursements/:id/items", &controllers.BackendReimburseController{}, "post:AddItem")
beego.Router("/backend/reimbursements/:id/items/:item_id", &controllers.BackendReimburseController{}, "post:UpdateItem;delete:DeleteItem")
beego.Router("/backend/reimbursements/:id", &controllers.BackendReimburseController{}, "get:Detail;post:Update;delete:Delete")
beego.Router("/backend/reimbursements/:id/submit", &controllers.BackendReimburseController{}, "post:Submit")
beego.Router("/backend/reimbursements/:id/status", &controllers.BackendReimburseController{}, "post:UpdateStatus")
beego.Router("/backend/reimbursements/:id/records", &controllers.BackendReimburseController{}, "get:Records")
// 日程提醒管理
+5
View File
@@ -108,6 +108,11 @@ func Register() {
beego.Router("/platform/complaintCategory/select", &controllers.PlatformComplaintCategoryController{}, "get:SelectList")
beego.Router("/platform/complaintCategory", &controllers.PlatformComplaintCategoryController{}, "post:Create")
beego.Router("/platform/complaintCategory/:id", &controllers.PlatformComplaintCategoryController{}, "post:Update;delete:Delete")
// 报销全局通用费项(tid=0,仅平台可维护)
beego.Router("/platform/reimburseExpenseTypes", &controllers.PlatformReimburseExpenseTypeController{}, "get:List;post:Create")
beego.Router("/platform/reimburseExpenseTypes/:id", &controllers.PlatformReimburseExpenseTypeController{}, "post:Update;delete:Delete")
beego.Router("/platform/complaint/list", &controllers.PlatformComplaintController{}, "get:List")
beego.Router("/platform/complaint", &controllers.PlatformComplaintController{}, "post:Create")
beego.Router("/platform/complaint/:id", &controllers.PlatformComplaintController{}, "get:Detail;post:Update;delete:Delete")
@@ -244,7 +244,7 @@ onUnmounted(() => {
<style lang="less" scoped>
.erp-dashboard {
background-color: #f5f7fa;
min-height: calc(100vh - 84px);
// min-height: calc(100vh - 84px);
}
.stat-row {
@@ -0,0 +1,3 @@
-- 报销明细发票关联统一文件表 yz_system_files(逗号分隔多个文件 ID)
ALTER TABLE `yz_backend_reimbursement_items`
ADD COLUMN `receipt_file_ids` varchar(500) NOT NULL DEFAULT '' COMMENT '关联 yz_system_files.id,逗号分隔' AFTER `receipt_flag`;
@@ -1,3 +0,0 @@
ALTER TABLE `yz_backend_reimbursement_items`
ADD COLUMN `receipt_url` varchar(500) NOT NULL DEFAULT '' COMMENT '票据文件地址' AFTER `receipt_flag`,
ADD KEY `idx_receipt_url` (`receipt_url`);
@@ -0,0 +1,3 @@
-- 票据地址(多个 URL 逗号分隔,与 receipt_file_ids 对应)
ALTER TABLE `yz_backend_reimbursement_items`
ADD COLUMN `receipt_urls` varchar(500) NOT NULL DEFAULT '' COMMENT '票据文件地址,逗号分隔' AFTER `receipt_file_ids`;
@@ -0,0 +1,12 @@
-- 报销明细发票:统一使用 receipt_file_ids,移除 receipt_file_id
-- 若已存在 receipt_file_id 列,先合并数据再删除
UPDATE `yz_backend_reimbursement_items`
SET `receipt_file_ids` = CAST(`receipt_file_id` AS CHAR)
WHERE (`receipt_file_ids` = '' OR `receipt_file_ids` IS NULL)
AND `receipt_file_id` IS NOT NULL
AND `receipt_file_id` > 0;
ALTER TABLE `yz_backend_reimbursement_items`
DROP INDEX `idx_receipt_file_id`,
DROP COLUMN `receipt_file_id`;
@@ -0,0 +1,6 @@
-- receipt_url 重命名为 receipt_urls(与代码字段一致)
ALTER TABLE `yz_backend_reimbursement_items`
CHANGE COLUMN `receipt_url` `receipt_urls` varchar(500) NOT NULL DEFAULT '' COMMENT '票据文件地址,逗号分隔';
ALTER TABLE `yz_backend_reimbursement_items`
DROP INDEX `idx_receipt_url`;
@@ -0,0 +1,7 @@
-- 移除报销主表不再使用的字段:报销事由(title)、主表费用类型(expense_type)
-- 费用明细表 yz_backend_reimbursement_items 的 expense_type 保留不变
ALTER TABLE `yz_backend_reimbursements`
DROP INDEX `idx_expense_type`,
DROP COLUMN `title`,
DROP COLUMN `expense_type`;
-3
View File
@@ -31,10 +31,8 @@ CREATE TABLE IF NOT EXISTS `yz_backend_reimbursements` (
`uid` int(11) DEFAULT NULL COMMENT '用户ID(数据隔离)',
`user_id` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '申请人ID',
`department_id` int(11) unsigned DEFAULT '0' COMMENT '部门ID',
`title` varchar(200) NOT NULL DEFAULT '' COMMENT '报销事由',
`total_amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '总金额',
`status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '状态:0-草稿 1-审批中 2-已通过 3-已驳回 4-已撤回 5-已打款',
`expense_type` varchar(50) NOT NULL DEFAULT '' COMMENT '费用类型主分类',
`apply_date` date NOT NULL COMMENT '申请日期',
`description` text COMMENT '备注说明',
`current_approver_id` int(11) unsigned DEFAULT '0' COMMENT '当前审批人ID',
@@ -47,7 +45,6 @@ CREATE TABLE IF NOT EXISTS `yz_backend_reimbursements` (
KEY `idx_user_id` (`user_id`) USING BTREE,
KEY `idx_department_id` (`department_id`) USING BTREE,
KEY `idx_status` (`status`) USING BTREE,
KEY `idx_expense_type` (`expense_type`) USING BTREE,
KEY `idx_apply_date` (`apply_date`) USING BTREE,
KEY `idx_current_approver_id` (`current_approver_id`) USING BTREE,
KEY `idx_create_time` (`create_time`) USING BTREE,