浏览代码

feat:新增附件异步下载

master
leiyun 2 个月前
父节点
当前提交
86c962df19
共有 11 个文件被更改,包括 1199 次插入1 次删除
  1. +1
    -0
      package.json
  2. +304
    -0
      pnpm-lock.yaml
  3. +22
    -0
      sql/export_task.sql
  4. +19
    -0
      src/bootstrap/worker.js
  5. +11
    -0
      src/config/router.js
  6. +162
    -0
      src/controller/admin/export_task.js
  7. +64
    -0
      src/model/export_task.js
  8. +259
    -0
      src/service/export.js
  9. +10
    -0
      view/admin/common/_sidebar.html
  10. +255
    -0
      view/admin/export_task_index.html
  11. +92
    -1
      view/admin/patient_index.html

+ 1
- 0
package.json 查看文件

@@ -10,6 +10,7 @@
"lint-fix": "eslint --fix src/"
},
"dependencies": {
"archiver": "^7.0.1",
"axios": "^1.13.5",
"cheerio": "^1.2.0",
"cos-nodejs-sdk-v5": "^2.14.0",


+ 304
- 0
pnpm-lock.yaml 查看文件

@@ -8,6 +8,9 @@ importers:

.:
dependencies:
archiver:
specifier: ^7.0.1
version: 7.0.1
axios:
specifier: ^1.13.5
version: 1.13.5
@@ -264,6 +267,10 @@ packages:
cpu: [x64]
os: [win32]

'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}

'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}

@@ -277,6 +284,10 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}

'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}

'@puppeteer/browsers@2.13.0':
resolution: {integrity: sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==}
engines: {node: '>=18'}
@@ -309,6 +320,10 @@ packages:
a-sync-waterfall@1.0.1:
resolution: {integrity: sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==}

abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}

accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
@@ -375,6 +390,10 @@ packages:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}

ansi-regex@6.2.2:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'}

ansi-styles@1.0.0:
resolution: {integrity: sha512-3iF4FIKdxaVYT3JqQuY3Wat/T2t7TRbbQ94Fu50ZUCbLy4TFbTzr90NOHQodQkNqmeEGCw8WbeP78WNi6SKYUA==}
engines: {node: '>=0.8.0'}
@@ -391,6 +410,10 @@ packages:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}

ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}

any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}

@@ -405,10 +428,18 @@ packages:
resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==}
engines: {node: '>= 10'}

archiver-utils@5.0.2:
resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==}
engines: {node: '>= 14'}

archiver@5.3.2:
resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==}
engines: {node: '>= 10'}

archiver@7.0.1:
resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==}
engines: {node: '>= 14'}

argparse@1.0.10:
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}

@@ -784,6 +815,10 @@ packages:
buffer-crc32@0.2.13:
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}

buffer-crc32@1.0.0:
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
engines: {node: '>=8.0.0'}

buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}

@@ -797,6 +832,9 @@ packages:
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}

buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}

buffers@0.1.1:
resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==}
engines: {node: '>=0.2.0'}
@@ -1003,6 +1041,10 @@ packages:
resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==}
engines: {node: '>= 10'}

compress-commons@6.0.2:
resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==}
engines: {node: '>= 14'}

concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}

@@ -1081,6 +1123,10 @@ packages:
resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==}
engines: {node: '>= 10'}

crc32-stream@6.0.0:
resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==}
engines: {node: '>= 14'}

create-error-class@3.0.2:
resolution: {integrity: sha512-gYTKKexFO3kh200H1Nit76sRwRtOY32vQd3jpAQKpLtZqyNsSQNfI4N7o3eP2wUjV35pTWKRYqFUDBvUha/Pkw==}
engines: {node: '>=0.10.0'}
@@ -1092,6 +1138,10 @@ packages:
cross-spawn@5.1.0:
resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==}

cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}

css-select@5.2.2:
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}

@@ -1286,6 +1336,9 @@ packages:
duplexer2@0.1.4:
resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==}

eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}

ecc-jsbn@0.1.2:
resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==}

@@ -1298,6 +1351,9 @@ packages:
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}

emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}

empower-core@0.6.2:
resolution: {integrity: sha512-w9QJ4ROqcjJHWNw+TvpKVeLQV1GQtoFO6aqKoj5IlHi0qxG1Y2157Kg6+5ujs5Bxzm8AgOiOvBCRbNkt6RPe9Q==}

@@ -1484,9 +1540,17 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}

event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}

events-universal@1.0.1:
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}

events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}

exceljs@4.4.0:
resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==}
engines: {node: '>=8.3.0'}
@@ -1643,6 +1707,10 @@ packages:
resolution: {integrity: sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw==}
engines: {node: '>=0.10.0'}

foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}

forever-agent@0.6.1:
resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==}

@@ -1762,6 +1830,10 @@ packages:
glob-parent@2.0.0:
resolution: {integrity: sha512-JDYOvfxio/t42HKdxkAYaCiBN7oYiuxykOxKxdaUW5Qn0zaYN3gRQWolrwdnf0shM9/EP0ebuuTmyoXNr1cC5w==}

glob@10.4.5:
resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
hasBin: true

glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
@@ -2268,6 +2340,9 @@ packages:
isstream@0.1.2:
resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==}

jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}

jest-diff@18.1.0:
resolution: {integrity: sha512-PzsL3/aLCOfJyvF6cqp6N6kzkImNfDXAkWIU/9y84WPPTf82Dnhkxex/LD/3nR6Z38VBrsefGTQLSF4yoPlMgg==}

@@ -2537,6 +2612,9 @@ packages:
resolution: {integrity: sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==}
engines: {node: '>=0.10.0'}

lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}

lru-cache@4.1.5:
resolution: {integrity: sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==}

@@ -2637,9 +2715,17 @@ packages:
resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==}
engines: {node: '>=10'}

minimatch@9.0.9:
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
engines: {node: '>=16 || 14 >=14.17'}

minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}

minipass@7.1.3:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}

mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}

@@ -2919,6 +3005,9 @@ packages:
package-hash@1.2.0:
resolution: {integrity: sha512-W5ILqaI3G6bXDuYb7TrQ95TFHfFdjiunpp61PAXj7z32TgJ5NIBaoqZVI6AXUQy/qcqPoFnz0hAZY9KyKd4xNA==}

package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}

package-json@2.4.0:
resolution: {integrity: sha512-PRg65iXMTt/uK8Rfh5zvzkUbfAPitF17YaCY+IbHsYgksiLvtzWWTUildHth3mVaZ7871OJ7gtP4LBRBlmAdXg==}
engines: {node: '>=0.10.0'}
@@ -2990,9 +3079,17 @@ packages:
resolution: {integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==}
engines: {node: '>=4'}

path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}

path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}

path-scurry@1.11.1:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}

path-to-regexp@1.9.0:
resolution: {integrity: sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==}

@@ -3100,6 +3197,10 @@ packages:
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}

process@0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}

progress@2.0.3:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
@@ -3179,6 +3280,10 @@ packages:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}

readable-stream@4.7.0:
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}

readdir-glob@1.1.3:
resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==}

@@ -3417,10 +3522,18 @@ packages:
resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==}
engines: {node: '>=0.10.0'}

shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}

shebang-regex@1.0.0:
resolution: {integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==}
engines: {node: '>=0.10.0'}

shebang-regex@3.0.0:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}

side-channel-list@1.0.0:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
engines: {node: '>= 0.4'}
@@ -3440,6 +3553,10 @@ packages:
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}

signal-exit@4.1.0:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}

simple-swizzle@0.2.4:
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}

@@ -3581,6 +3698,10 @@ packages:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}

string-width@5.1.2:
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
engines: {node: '>=12'}

string.prototype.trim@1.2.10:
resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==}
engines: {node: '>= 0.4'}
@@ -3596,6 +3717,9 @@ packages:
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}

string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}

strip-ansi@0.1.1:
resolution: {integrity: sha512-behete+3uqxecWlDAm5lmskaSaISA+ThQ4oNNBDTBJt0x2ppR6IPqfZNuj6BLaLJ/Sji4TPZlcRyOis8wXQTLg==}
engines: {node: '>=0.8.0'}
@@ -3613,6 +3737,10 @@ packages:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}

strip-ansi@7.2.0:
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
engines: {node: '>=12'}

strip-bom-buf@1.0.0:
resolution: {integrity: sha512-1sUIL1jck0T1mhOLP2c696BIznzT525Lkub+n4jjMHjhjhoAQA6Ye659DxdlZBr0aLDMQoTxKIpnlqxgtwjsuQ==}
engines: {node: '>=4'}
@@ -4070,6 +4198,11 @@ packages:
resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
hasBin: true

which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true

widest-line@1.0.0:
resolution: {integrity: sha512-r5vvGtqsHUHn98V0jURY4Ts86xJf6+SzK9rpWdV8/73nURB3WFPIHd67aOvPw2fSuunIyHjAUqiJ2TY0x4E5gw==}
engines: {node: '>=0.10.0'}
@@ -4082,6 +4215,10 @@ packages:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}

wrap-ansi@8.1.0:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}

wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}

@@ -4160,6 +4297,10 @@ packages:
resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==}
engines: {node: '>= 10'}

zip-stream@6.0.1:
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
engines: {node: '>= 14'}

zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}

@@ -4341,6 +4482,15 @@ snapshots:
'@img/sharp-win32-x64@0.33.5':
optional: true

'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
string-width-cjs: string-width@4.2.3
strip-ansi: 7.2.0
strip-ansi-cjs: strip-ansi@6.0.1
wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0

'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -4355,6 +4505,9 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5

'@pkgjs/parseargs@0.11.0':
optional: true

'@puppeteer/browsers@2.13.0':
dependencies:
debug: 4.4.3
@@ -4397,6 +4550,10 @@ snapshots:

a-sync-waterfall@1.0.1: {}

abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1

accepts@1.3.8:
dependencies:
mime-types: 2.1.35
@@ -4459,6 +4616,8 @@ snapshots:

ansi-regex@5.0.1: {}

ansi-regex@6.2.2: {}

ansi-styles@1.0.0: {}

ansi-styles@2.2.1: {}
@@ -4471,6 +4630,8 @@ snapshots:
dependencies:
color-convert: 2.0.1

ansi-styles@6.2.3: {}

any-promise@1.3.0: {}

anymatch@1.3.2:
@@ -4504,6 +4665,16 @@ snapshots:
normalize-path: 3.0.0
readable-stream: 3.6.2

archiver-utils@5.0.2:
dependencies:
glob: 10.4.5
graceful-fs: 4.2.11
is-stream: 2.0.1
lazystream: 1.0.1
lodash: 4.17.23
normalize-path: 3.0.0
readable-stream: 4.7.0

archiver@5.3.2:
dependencies:
archiver-utils: 2.1.0
@@ -4514,6 +4685,20 @@ snapshots:
tar-stream: 2.2.0
zip-stream: 4.1.1

archiver@7.0.1:
dependencies:
archiver-utils: 5.0.2
async: 3.2.6
buffer-crc32: 1.0.0
readable-stream: 4.7.0
readdir-glob: 1.1.3
tar-stream: 3.1.8
zip-stream: 6.0.1
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
- react-native-b4a

argparse@1.0.10:
dependencies:
sprintf-js: 1.0.3
@@ -5128,6 +5313,8 @@ snapshots:

buffer-crc32@0.2.13: {}

buffer-crc32@1.0.0: {}

buffer-equal-constant-time@1.0.1: {}

buffer-from@1.1.2: {}
@@ -5139,6 +5326,11 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1

buffer@6.0.3:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1

buffers@0.1.1: {}

bytes@3.1.2: {}
@@ -5377,6 +5569,14 @@ snapshots:
normalize-path: 3.0.0
readable-stream: 3.6.2

compress-commons@6.0.2:
dependencies:
crc-32: 1.2.2
crc32-stream: 6.0.0
is-stream: 2.0.1
normalize-path: 3.0.0
readable-stream: 4.7.0

concat-map@0.0.1: {}

concat-stream@1.6.2:
@@ -5466,6 +5666,11 @@ snapshots:
crc-32: 1.2.2
readable-stream: 3.6.2

crc32-stream@6.0.0:
dependencies:
crc-32: 1.2.2
readable-stream: 4.7.0

create-error-class@3.0.2:
dependencies:
capture-stack-trace: 1.0.2
@@ -5481,6 +5686,12 @@ snapshots:
shebang-command: 1.2.0
which: 1.3.1

cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
shebang-command: 2.0.0
which: 2.0.2

css-select@5.2.2:
dependencies:
boolbase: 1.0.0
@@ -5659,6 +5870,8 @@ snapshots:
dependencies:
readable-stream: 2.3.8

eastasianwidth@0.2.0: {}

ecc-jsbn@0.1.2:
dependencies:
jsbn: 0.1.1
@@ -5672,6 +5885,8 @@ snapshots:

emoji-regex@8.0.0: {}

emoji-regex@9.2.2: {}

empower-core@0.6.2:
dependencies:
call-signature: 0.0.2
@@ -5954,12 +6169,16 @@ snapshots:

esutils@2.0.3: {}

event-target-shim@5.0.1: {}

events-universal@1.0.1:
dependencies:
bare-events: 2.8.2
transitivePeerDependencies:
- bare-abort-controller

events@3.3.0: {}

exceljs@4.4.0:
dependencies:
archiver: 5.3.2
@@ -6146,6 +6365,11 @@ snapshots:
dependencies:
for-in: 1.0.2

foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
signal-exit: 4.1.0

forever-agent@0.6.1: {}

form-data@2.3.3:
@@ -6281,6 +6505,15 @@ snapshots:
dependencies:
is-glob: 2.0.1

glob@10.4.5:
dependencies:
foreground-child: 3.3.1
jackspeak: 3.4.3
minimatch: 9.0.9
minipass: 7.1.3
package-json-from-dist: 1.0.1
path-scurry: 1.11.1

glob@7.2.3:
dependencies:
fs.realpath: 1.0.0
@@ -6792,6 +7025,12 @@ snapshots:

isstream@0.1.2: {}

jackspeak@3.4.3:
dependencies:
'@isaacs/cliui': 8.0.2
optionalDependencies:
'@pkgjs/parseargs': 0.11.0

jest-diff@18.1.0:
dependencies:
chalk: 1.1.3
@@ -7099,6 +7338,8 @@ snapshots:

lowercase-keys@1.0.1: {}

lru-cache@10.4.3: {}

lru-cache@4.1.5:
dependencies:
pseudomap: 1.0.2
@@ -7213,8 +7454,14 @@ snapshots:
dependencies:
brace-expansion: 2.0.2

minimatch@9.0.9:
dependencies:
brace-expansion: 2.0.2

minimist@1.2.8: {}

minipass@7.1.3: {}

mitt@3.0.1: {}

mixin-deep@1.3.2:
@@ -7490,6 +7737,8 @@ snapshots:
dependencies:
md5-hex: 1.3.0

package-json-from-dist@1.0.1: {}

package-json@2.4.0:
dependencies:
got: 5.7.1
@@ -7559,8 +7808,15 @@ snapshots:

path-key@2.0.1: {}

path-key@3.1.1: {}

path-parse@1.0.7: {}

path-scurry@1.11.1:
dependencies:
lru-cache: 10.4.3
minipass: 7.1.3

path-to-regexp@1.9.0:
dependencies:
isarray: 0.0.1
@@ -7646,6 +7902,8 @@ snapshots:

process-nextick-args@2.0.1: {}

process@0.11.10: {}

progress@2.0.3: {}

proxy-agent@6.5.0:
@@ -7785,6 +8043,14 @@ snapshots:
string_decoder: 1.1.1
util-deprecate: 1.0.2

readable-stream@4.7.0:
dependencies:
abort-controller: 3.0.0
buffer: 6.0.3
events: 3.3.0
process: 0.11.10
string_decoder: 1.3.0

readdir-glob@1.1.3:
dependencies:
minimatch: 5.1.9
@@ -8062,8 +8328,14 @@ snapshots:
dependencies:
shebang-regex: 1.0.0

shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0

shebang-regex@1.0.0: {}

shebang-regex@3.0.0: {}

side-channel-list@1.0.0:
dependencies:
es-errors: 1.3.0
@@ -8094,6 +8366,8 @@ snapshots:

signal-exit@3.0.7: {}

signal-exit@4.1.0: {}

simple-swizzle@0.2.4:
dependencies:
is-arrayish: 0.3.4
@@ -8261,6 +8535,12 @@ snapshots:
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1

string-width@5.1.2:
dependencies:
eastasianwidth: 0.2.0
emoji-regex: 9.2.2
strip-ansi: 7.2.0

string.prototype.trim@1.2.10:
dependencies:
call-bind: 1.0.8
@@ -8288,6 +8568,10 @@ snapshots:
dependencies:
safe-buffer: 5.1.2

string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1

strip-ansi@0.1.1: {}

strip-ansi@3.0.1:
@@ -8302,6 +8586,10 @@ snapshots:
dependencies:
ansi-regex: 5.0.1

strip-ansi@7.2.0:
dependencies:
ansi-regex: 6.2.2

strip-bom-buf@1.0.0:
dependencies:
is-utf8: 0.2.1
@@ -8942,6 +9230,10 @@ snapshots:
dependencies:
isexe: 2.0.0

which@2.0.2:
dependencies:
isexe: 2.0.0

widest-line@1.0.0:
dependencies:
string-width: 1.0.2
@@ -8954,6 +9246,12 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1

wrap-ansi@8.1.0:
dependencies:
ansi-styles: 6.2.3
string-width: 5.1.2
strip-ansi: 7.2.0

wrappy@1.0.2: {}

write-file-atomic@1.3.4:
@@ -9032,4 +9330,10 @@ snapshots:
compress-commons: 4.1.2
readable-stream: 3.6.2

zip-stream@6.0.1:
dependencies:
archiver-utils: 5.0.2
compress-commons: 6.0.2
readable-stream: 4.7.0

zod@3.25.76: {}

+ 22
- 0
sql/export_task.sql 查看文件

@@ -0,0 +1,22 @@
CREATE TABLE `export_task` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`task_no` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '任务编号',
`title` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '任务标题',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '0=待处理 1=打包中 2=已完成 3=失败',
`file_types` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '勾选的附件类型JSON',
`filter_params` TEXT COMMENT '筛选条件JSON',
`total_files` INT NOT NULL DEFAULT 0 COMMENT '总文件数',
`processed_files` INT NOT NULL DEFAULT 0 COMMENT '已处理文件数',
`file_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'COS下载地址',
`file_size` BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小(字节)',
`error_log` TEXT COMMENT '错误日志',
`started_at` DATETIME DEFAULT NULL COMMENT '开始打包时间',
`finished_at` DATETIME DEFAULT NULL COMMENT '完成时间',
`create_by` INT NOT NULL DEFAULT 0 COMMENT '创建人ID',
`create_by_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '创建人姓名',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`is_deleted` TINYINT NOT NULL DEFAULT 0,
KEY `idx_status` (`status`),
KEY `idx_create_by` (`create_by`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='导出任务表';

+ 19
- 0
src/bootstrap/worker.js 查看文件

@@ -1 +1,20 @@
// invoked in worker
think.beforeStartServer(async () => {
// 恢复中断的导出任务
try {
const taskModel = think.model('export_task');
const recovered = await taskModel.recoverStuckTasks();
if (recovered) {
think.logger.info(`[Bootstrap] 恢复了 ${recovered} 个中断的导出任务`);
}
// 处理待执行的任务
const pending = await taskModel.getPendingTask();
if (!think.isEmpty(pending)) {
think.logger.info('[Bootstrap] 发现待处理的导出任务,启动处理');
const exportService = think.service('export');
setImmediate(() => exportService.startProcessing());
}
} catch (e) {
think.logger.error('[Bootstrap] 导出任务恢复失败:', e);
}
});

+ 11
- 0
src/config/router.js 查看文件

@@ -91,6 +91,17 @@ module.exports = [
['/admin/content/edit', 'admin/content/edit', 'post'],
['/admin/content/delete', 'admin/content/delete', 'post'],

// 下载管理
['/admin/export_task', 'admin/export_task/index'],
['/admin/export_task/list', 'admin/export_task/list'],
['/admin/export_task/status', 'admin/export_task/status'],
['/admin/export_task/batchStatus', 'admin/export_task/batchStatus'],
['/admin/export_task/create', 'admin/export_task/create', 'post'],
['/admin/export_task/delete', 'admin/export_task/delete', 'post'],
['/admin/export_task/retry', 'admin/export_task/retry', 'post'],
// 患者附件导出(从患者页面触发)
['/admin/patient/exportFiles', 'admin/export_task/create', 'post'],

// 文件上传
['/admin/upload', 'admin/upload/index', 'post'],
['/admin/upload/config', 'admin/upload/config'],


+ 162
- 0
src/controller/admin/export_task.js 查看文件

@@ -0,0 +1,162 @@
const Base = require('../base.js');

module.exports = class extends Base {
// 权限校验:所有操作需要 patient:export 权限
async __before() {
const ret = await super.__before();
// 父类已处理(未登录等),直接返回
if (ret === false) return false;
// 非页面请求时 loadUserPermissions 未执行,需手动加载
if (this.isAjax() && this.adminUser && !this.userPermissions) {
await this.loadUserPermissions();
}
const hasPermission = this.isSuperAdmin || (this.userPermissions || []).includes('patient:export');
if (!hasPermission) {
if (this.isAjax()) {
return this.fail('暂无操作权限');
}
return this.redirect('/admin/dashboard.html');
}
}

// 下载管理页面
async indexAction() {
this.assign('currentPage', 'export_task');
this.assign('pageTitle', '下载管理');
this.assign('breadcrumb', [{ name: '下载管理' }]);
this.assign('adminUser', this.adminUser || {});
return this.display();
}

// 获取任务列表
async listAction() {
const { page = 1, pageSize = 10, status } = this.get();
const model = this.model('export_task');
const list = await model.getList({ page, pageSize, status });
return this.success(list);
}

// 查询单个任务状态(轮询用)
async statusAction() {
const { id } = this.get();
if (!id) return this.fail('参数错误');
const task = await this.model('export_task')
.field('id, task_no, status, total_files, processed_files, file_url, file_size, error_log, finished_at')
.where({ id, is_deleted: 0 })
.find();
if (think.isEmpty(task)) return this.fail('任务不存在');
return this.success(task);
}

// 批量查询任务状态(轮询用)
async batchStatusAction() {
const { ids } = this.get();
if (!ids) return this.success([]);
const idArr = ids.split(',').map(Number).filter(Boolean);
if (!idArr.length) return this.success([]);
const tasks = await this.model('export_task')
.field('id, status, processed_files, total_files, file_url, file_size')
.where({ id: ['in', idArr], is_deleted: 0 })
.select();
return this.success(tasks);
}

// 创建导出任务
async createAction() {
const { file_types, filter_params } = this.post();

if (!file_types || !file_types.length) {
return this.fail('请选择要导出的附件类型');
}

// 防重复:同一用户5分钟内不允许创建相同条件的任务
const fiveMinAgo = think.datetime(new Date(Date.now() - 5 * 60 * 1000));
const duplicate = await this.model('export_task').where({
create_by: this.adminUser?.id || 0,
file_types: JSON.stringify(file_types),
filter_params: JSON.stringify(filter_params || {}),
create_time: ['>', fiveMinAgo],
is_deleted: 0,
status: ['in', [0, 1]]
}).find();
if (!think.isEmpty(duplicate)) {
return this.fail('您有相同条件的任务正在处理中,请勿重复提交');
}

const model = this.model('export_task');
const taskNo = model.generateTaskNo();

// 构建标题
const typeLabels = { id_photos: '实名认证照片', documents: '上传资料', signs: '签字材料' };
const title = file_types.map(t => typeLabels[t] || t).join('、');

const id = await model.add({
task_no: taskNo,
title,
status: 0,
file_types: JSON.stringify(file_types),
filter_params: JSON.stringify(filter_params || {}),
create_by: this.adminUser?.id || 0,
create_by_name: this.adminUser?.nickname || this.adminUser?.username || ''
});

await this.log('export', '下载管理', `创建导出任务「${title}」编号:${taskNo}`);

// 触发后台处理
const exportService = this.service('export');
// 异步执行,不等待
setImmediate(() => exportService.startProcessing());

return this.success({ id, task_no: taskNo });
}

// 删除任务(软删除)
async deleteAction() {
const { id } = this.post();
if (!id) return this.fail('参数错误');

const task = await this.model('export_task')
.where({ id, is_deleted: 0 })
.find();
if (think.isEmpty(task)) return this.fail('任务不存在');

// 打包中的任务不允许删除
if (task.status === 1) {
return this.fail('打包中的任务不能删除');
}

await this.model('export_task').where({ id }).update({ is_deleted: 1 });
await this.log('delete', '下载管理', `删除导出任务(${task.task_no})`);
return this.success();
}

// 重试失败的任务
async retryAction() {
const { id } = this.post();
if (!id) return this.fail('参数错误');

const task = await this.model('export_task')
.where({ id, is_deleted: 0 })
.find();
if (think.isEmpty(task)) return this.fail('任务不存在');
if (task.status !== 3) return this.fail('只有失败的任务可以重试');

await this.model('export_task').where({ id }).update({
status: 0,
error_log: '',
started_at: null,
finished_at: null,
processed_files: 0,
total_files: 0,
file_url: '',
file_size: 0
});

await this.log('edit', '下载管理', `重试导出任务(${task.task_no})`);

const exportService = this.service('export');
setImmediate(() => exportService.startProcessing());

return this.success();
}
};

+ 64
- 0
src/model/export_task.js 查看文件

@@ -0,0 +1,64 @@
module.exports = class extends think.Model {
get tableName() {
return 'export_task';
}

/**
* 生成任务编号: EX + 时间戳 + 随机数
*/
generateTaskNo() {
const ts = String(Date.now()).slice(-10);
const rand = String(Math.floor(Math.random() * 1000)).padStart(3, '0');
return 'EX' + ts + rand;
}

/**
* 获取任务列表(分页)
*/
async getList({ page = 1, pageSize = 10, status, createBy }) {
const where = { is_deleted: 0 };
if (status !== undefined && status !== '') {
where.status = parseInt(status, 10);
}
if (createBy) {
where.create_by = createBy;
}
return this.where(where)
.order('id DESC')
.page(page, pageSize)
.countSelect();
}

/**
* 获取待处理的任务(用于后台执行)
*/
async getPendingTask() {
return this.where({ status: 0, is_deleted: 0 })
.order('id ASC')
.find();
}

/**
* 恢复中断的任务(processing 超过30分钟的标记为失败)
*/
async recoverStuckTasks() {
const thirtyMinAgo = think.datetime(new Date(Date.now() - 30 * 60 * 1000));
const stuck = await this.where({
status: 1,
started_at: ['<', thirtyMinAgo],
is_deleted: 0
}).select();
if (stuck.length) {
await this.where({
status: 1,
started_at: ['<', thirtyMinAgo],
is_deleted: 0
}).update({
status: 3,
error_log: '任务超时,服务重启后自动标记为失败'
});
think.logger.warn(`[ExportTask] 恢复了 ${stuck.length} 个中断任务`);
}
return stuck.length;
}
};

+ 259
- 0
src/service/export.js 查看文件

@@ -0,0 +1,259 @@
const fs = require('fs');
const path = require('path');
const archiver = require('archiver');
const axios = require('axios');
const COS = require('cos-nodejs-sdk-v5');
const dayjs = require('dayjs');
const cosConfig = require('../config/cos.js');

// 并发控制:同时最多执行的打包任务数
const MAX_CONCURRENT = 2;
let runningCount = 0;

module.exports = class extends think.Service {
/**
* 启动任务处理循环
*/
async startProcessing() {
if (runningCount >= MAX_CONCURRENT) return;
const taskModel = this.model('export_task');
const task = await taskModel.getPendingTask();
if (think.isEmpty(task)) return;

runningCount++;
try {
await this.processTask(task);
} catch (e) {
think.logger.error(`[ExportTask] 任务 ${task.task_no} 异常:`, e);
await taskModel.where({ id: task.id }).update({
status: 3,
error_log: e.message || '未知错误',
finished_at: think.datetime(new Date())
});
} finally {
runningCount--;
// 继续处理下一个
setTimeout(() => this.startProcessing(), 500);
}
}

/**
* 处理单个任务
*/
async processTask(task) {
const taskModel = this.model('export_task');
const patientModel = this.model('patient');

// 标记为打包中
await taskModel.where({ id: task.id }).update({
status: 1,
started_at: think.datetime(new Date())
});

// 解析参数
let fileTypes = [];
let filterParams = {};
try {
fileTypes = JSON.parse(task.file_types || '[]');
filterParams = JSON.parse(task.filter_params || '{}');
} catch (e) {
throw new Error('任务参数解析失败');
}

// 查询患者列表
const patients = await patientModel.getAll(filterParams);
if (!patients.length) {
await taskModel.where({ id: task.id }).update({
status: 2,
total_files: 0,
processed_files: 0,
finished_at: think.datetime(new Date()),
error_log: '没有符合条件的患者数据'
});
return;
}

// 收集所有需要下载的文件
const downloadList = this._buildDownloadList(patients, fileTypes);
const totalFiles = downloadList.reduce((sum, p) => sum + p.files.length, 0);

await taskModel.where({ id: task.id }).update({ total_files: totalFiles });

if (totalFiles === 0) {
await taskModel.where({ id: task.id }).update({
status: 2,
processed_files: 0,
finished_at: think.datetime(new Date()),
error_log: '所选类型下没有可导出的附件'
});
return;
}

// 创建临时 ZIP 文件
const tmpDir = path.join(think.ROOT_PATH, 'runtime/export');
if (!fs.existsSync(tmpDir)) {
fs.mkdirSync(tmpDir, { recursive: true });
}
const zipFileName = `${task.task_no}.zip`;
const zipFilePath = path.join(tmpDir, zipFileName);

// 打包
let processedFiles = 0;
const errors = [];

await new Promise((resolve, reject) => {
const output = fs.createWriteStream(zipFilePath);
const archive = archiver('zip', { zlib: { level: 5 } });

output.on('close', resolve);
archive.on('error', reject);
archive.pipe(output);

// 用 Promise 链串行处理每个患者
const processAll = async () => {
for (const patient of downloadList) {
for (const file of patient.files) {
try {
const response = await axios.get(file.url, {
responseType: 'arraybuffer',
timeout: 30000
});
archive.append(Buffer.from(response.data), {
name: `${patient.folder}/${file.name}`
});
processedFiles++;
// 每处理10个文件更新一次进度
if (processedFiles % 10 === 0) {
await taskModel.where({ id: task.id }).update({
processed_files: processedFiles
});
}
} catch (e) {
errors.push(`${patient.folder}/${file.name}: ${e.message}`);
think.logger.warn(`[ExportTask] 下载失败: ${file.url} - ${e.message}`);
}
}
}
};

processAll().then(() => {
archive.finalize();
}).catch(reject);
});

// 更新已处理数
await taskModel.where({ id: task.id }).update({ processed_files: processedFiles });

// 上传到 COS
const cosKey = `uploads/cytx/zip/${dayjs().format('YYYY/MM')}/${zipFileName}`;
const fileSize = fs.statSync(zipFilePath).size;

const cos = new COS({
SecretId: cosConfig.secretId,
SecretKey: cosConfig.secretKey
});

await new Promise((resolve, reject) => {
cos.putObject({
Bucket: cosConfig.bucket,
Region: cosConfig.region,
Key: cosKey,
Body: fs.createReadStream(zipFilePath),
ContentType: 'application/zip'
}, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});

// 生成下载 URL
const fileUrl = `${cosConfig.cdnUrl}/${cosKey}`;

// 更新任务状态
await taskModel.where({ id: task.id }).update({
status: 2,
file_url: fileUrl,
file_size: fileSize,
processed_files: processedFiles,
finished_at: think.datetime(new Date()),
error_log: errors.length ? errors.join('\n') : ''
});

// 删除本地临时文件
try {
if (fs.existsSync(zipFilePath)) fs.unlinkSync(zipFilePath);
} catch (e) {
think.logger.warn(`[ExportTask] 删除临时文件失败: ${e.message}`);
}

think.logger.info(`[ExportTask] 任务 ${task.task_no} 完成,共 ${processedFiles}/${totalFiles} 个文件,${errors.length} 个失败`);
}

/**
* 构建下载文件列表
*/
_buildDownloadList(patients, fileTypes) {
const list = [];
for (const p of patients) {
const folder = `${p.name}_${p.patient_no}`;
const files = [];

// 实名认证照片
if (fileTypes.includes('id_photos')) {
if (p.id_card_front) {
files.push({ name: `身份证人像面${this._getExt(p.id_card_front)}`, url: p.id_card_front });
}
if (p.id_card_back) {
files.push({ name: `身份证国徽面${this._getExt(p.id_card_back)}`, url: p.id_card_back });
}
if (p.photo) {
files.push({ name: `免冠照片${this._getExt(p.photo)}`, url: p.photo });
}
}

// 上传资料(检查报告/诊断证明)
if (fileTypes.includes('documents')) {
let docs = [];
try {
docs = JSON.parse(p.documents || '[]');
} catch (e) { /* ignore */ }
docs.forEach((url, idx) => {
if (url) {
files.push({ name: `检查报告_${idx + 1}${this._getExt(url)}`, url });
}
});
}

// 签字材料
if (fileTypes.includes('signs')) {
if (p.sign_income) {
files.push({ name: `个人可支配收入声明${this._getExt(p.sign_income)}`, url: p.sign_income });
}
if (p.sign_privacy) {
files.push({ name: `个人信息处理同意书${this._getExt(p.sign_privacy)}`, url: p.sign_privacy });
}
if (p.sign_promise) {
files.push({ name: `声明与承诺${this._getExt(p.sign_promise)}`, url: p.sign_promise });
}
if (p.sign_privacy_jhr) {
files.push({ name: `监护人个人信息处理同意书${this._getExt(p.sign_privacy_jhr)}`, url: p.sign_privacy_jhr });
}
}

if (files.length) {
list.push({ folder, files });
}
}
return list;
}

/**
* 从 URL 提取文件扩展名
*/
_getExt(url) {
if (!url) return '.jpg';
const pathname = url.split('?')[0];
const ext = path.extname(pathname);
return ext || '.jpg';
}
};

+ 10
- 0
view/admin/common/_sidebar.html 查看文件

@@ -42,6 +42,16 @@
</div>
{% endif %}

{# 下载管理 #}
{% if isSuperAdmin or (userPermissions and 'patient:export' in userPermissions) %}
<div class="menu-group">
<a class="menu-item {% if currentPage == 'export_task' %}active{% endif %}" href="/admin/export_task.html">
<span class="menu-icon"><svg viewBox="0 0 1024 1024" width="18" height="18"><path d="M544 864V672h128L512 480 352 672h128v192H320V672h-64v224c0 17.7 14.3 32 32 32h448c17.7 0 32-14.3 32-32V672h-64v192H544z" fill="currentColor"/><path d="M832 96H192c-17.7 0-32 14.3-32 32v256h64V160h576v256h64V128c0-17.7-14.3-32-32-32z" fill="currentColor"/></svg></span>
<span>下载管理</span>
</a>
</div>
{% endif %}

{# 系统设置 #}
{% set hasSysPerm = isSuperAdmin or (userPermissions and ('setting:system:user' in userPermissions or 'setting:system:role' in userPermissions or 'setting:system:log' in userPermissions or 'setting:system:sms' in userPermissions)) %}
{% if hasSysPerm %}


+ 255
- 0
view/admin/export_task_index.html 查看文件

@@ -0,0 +1,255 @@
{% extends "./layout.html" %}

{% block title %}下载管理{% endblock %}

{% block css %}
<style>
.task-progress { display: flex; align-items: center; gap: 8px; }
.task-progress .num { font-size: 12px; color: #909399; white-space: nowrap; }
</style>
{% endblock %}

{% block content %}
<div id="exportApp" v-cloak>
<el-card shadow="never" class="mb-4">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<div style="display:flex;align-items:center;gap:12px;">
<el-select v-model="statusFilter" placeholder="全部状态" clearable style="width:140px;" @change="loadList(1)">
<el-option label="待处理" :value="0"></el-option>
<el-option label="打包中" :value="1"></el-option>
<el-option label="已完成" :value="2"></el-option>
<el-option label="失败" :value="3"></el-option>
</el-select>
<el-button type="primary" @click="loadList(1)">查询</el-button>
</div>
</div>

<el-table :data="tableData" v-loading="loading" stripe border>
<el-table-column prop="task_no" label="任务编号" min-width="160"></el-table-column>
<el-table-column prop="title" label="附件类型" min-width="200"></el-table-column>
<el-table-column label="状态" width="120" align="center">
<template #default="{ row }">
<el-tag v-if="row.status === 0" type="info" size="small">待处理</el-tag>
<el-tag v-else-if="row.status === 1" type="warning" size="small">打包中</el-tag>
<el-tag v-else-if="row.status === 2" type="success" size="small">已完成</el-tag>
<el-tag v-else-if="row.status === 3" type="danger" size="small">失败</el-tag>
</template>
</el-table-column>
<el-table-column label="进度" min-width="180">
<template #default="{ row }">
<div class="task-progress" v-if="row.status === 1 && row.total_files > 0">
<el-progress :percentage="Math.round(row.processed_files / row.total_files * 100)" :stroke-width="8" style="flex:1;"></el-progress>
<span class="num">${ row.processed_files }/${ row.total_files }</span>
</div>
<span v-else-if="row.status === 2" style="color:#67C23A;">${ row.processed_files }/${ row.total_files } 个文件</span>
<span v-else-if="row.status === 0" style="color:#909399;">等待中</span>
<span v-else-if="row.status === 3" style="color:#F56C6C;">失败</span>
</template>
</el-table-column>
<el-table-column label="文件大小" width="110" align="center">
<template #default="{ row }">
<span v-if="row.file_size">${ formatSize(row.file_size) }</span>
<span v-else style="color:#C0C4CC;">—</span>
</template>
</el-table-column>
<el-table-column prop="create_by_name" label="创建人" width="100" align="center"></el-table-column>
<el-table-column prop="create_time" label="创建时间" width="170"></el-table-column>
<el-table-column prop="finished_at" label="完成时间" width="170">
<template #default="{ row }">
<span v-if="row.finished_at">${ row.finished_at }</span>
<span v-else style="color:#C0C4CC;">—</span>
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 2 && row.file_url" type="primary" link @click="downloadFile(row)">下载</el-button>
<el-button v-if="row.status === 3" type="warning" link @click="retryTask(row)">重试</el-button>
<el-button v-if="row.error_log" type="info" link @click="showErrorLog(row)">日志</el-button>
<el-button v-if="row.status !== 1" type="danger" link @click="deleteTask(row)">删除</el-button>
</template>
</el-table-column>
</el-table>

<div class="flex justify-end mt-4">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50]"
:total="pagination.total"
layout="total, sizes, prev, pager, next"
@current-change="loadList"
@size-change="onSizeChange"
/>
</div>
</el-card>

<!-- 错误日志弹窗 -->
<el-dialog v-model="logVisible" title="任务日志" width="600px" destroy-on-close>
<pre style="max-height:400px;overflow:auto;background:#f5f7fa;padding:16px;border-radius:6px;font-size:13px;line-height:1.6;white-space:pre-wrap;word-break:break-all;">${ logContent }</pre>
</el-dialog>
</div>
{% endblock %}

{% block js %}
<script>
var { createApp, ref, reactive, onMounted, onUnmounted } = Vue;

var app = createApp({
delimiters: ['${', '}'],
setup() {
var loading = ref(false);
var tableData = ref([]);
var pagination = reactive({ page: 1, pageSize: 10, total: 0 });
var statusFilter = ref('');
var logVisible = ref(false);
var logContent = ref('');
var pollTimer = null;

async function loadList(page) {
if (typeof page === 'number') pagination.page = page;
loading.value = true;
try {
var params = new URLSearchParams({
page: pagination.page,
pageSize: pagination.pageSize
});
if (statusFilter.value !== '' && statusFilter.value !== null) {
params.set('status', statusFilter.value);
}
var res = await fetch('/admin/export_task/list?' + params).then(function(r) { return r.json(); });
if (res.code === 0) {
tableData.value = res.data.data || [];
pagination.total = res.data.count || 0;
// 检查是否有进行中的任务,启动轮询
checkAndPoll();
}
} finally {
loading.value = false;
}
}

function checkAndPoll() {
var processingIds = tableData.value
.filter(function(t) { return t.status === 0 || t.status === 1; })
.map(function(t) { return t.id; });

if (processingIds.length > 0) {
startPoll(processingIds);
} else {
stopPoll();
}
}

function startPoll(ids) {
stopPoll();
pollTimer = setInterval(async function() {
try {
var res = await fetch('/admin/export_task/batchStatus?ids=' + ids.join(',')).then(function(r) { return r.json(); });
if (res.code === 0 && res.data) {
var changed = false;
res.data.forEach(function(updated) {
var row = tableData.value.find(function(t) { return t.id === updated.id; });
if (row) {
if (row.status !== updated.status) changed = true;
row.status = updated.status;
row.processed_files = updated.processed_files;
row.total_files = updated.total_files;
row.file_url = updated.file_url;
row.file_size = updated.file_size;
}
});
// 如果有状态变化,重新加载完整列表获取最新数据
if (changed) {
loadList();
} else {
checkAndPoll();
}
}
} catch(e) { /* ignore */ }
}, 3000);
}

function stopPoll() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}

function onSizeChange() {
pagination.page = 1;
loadList();
}

function formatSize(bytes) {
if (!bytes) return '0 B';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB';
return (bytes / 1024 / 1024 / 1024).toFixed(2) + ' GB';
}

function downloadFile(row) {
if (row.file_url) {
window.open(row.file_url, '_blank');
}
}

async function retryTask(row) {
try {
await ElementPlus.ElMessageBox.confirm('确定要重试该任务吗?', '确认重试', {
confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning'
});
var res = await fetch('/admin/export_task/retry', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: row.id })
}).then(function(r) { return r.json(); });
if (res.code === 0) {
ElementPlus.ElMessage.success('已重新提交');
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '操作失败');
}
} catch(e) {}
}

async function deleteTask(row) {
try {
await ElementPlus.ElMessageBox.confirm('确定要删除该任务吗?', '确认删除', {
confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning'
});
var res = await fetch('/admin/export_task/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: row.id })
}).then(function(r) { return r.json(); });
if (res.code === 0) {
ElementPlus.ElMessage.success('删除成功');
loadList();
} else {
ElementPlus.ElMessage.error(res.msg || '删除失败');
}
} catch(e) {}
}

function showErrorLog(row) {
logContent.value = row.error_log || '无日志';
logVisible.value = true;
}

onMounted(function() { loadList(); });
onUnmounted(function() { stopPoll(); });

return {
loading, tableData, pagination, statusFilter,
logVisible, logContent,
loadList, onSizeChange, formatSize, downloadFile, retryTask, deleteTask, showErrorLog
};
}
});

app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
app.mount('#exportApp');
</script>
{% endblock %}

+ 92
- 1
view/admin/patient_index.html 查看文件

@@ -34,6 +34,7 @@
<div style="display:flex;gap:8px;margin-top:12px;">
<el-button v-if="perms.canAdd" type="success" :icon="Plus" @click="showAddDialog">新增患者</el-button>
<el-button v-if="perms.canExport" :icon="Download" @click="handleExport" :loading="exporting">导出</el-button>
<el-button v-if="perms.canExport" type="warning" :icon="Download" @click="showExportFilesDialog">异步下载附件</el-button>
<el-button v-if="perms.canDelete && selectedIds.length" type="danger" :icon="Delete" @click="handleBatchDelete">批量删除 (${ selectedIds.length })</el-button>
</div>
</el-card>
@@ -251,6 +252,40 @@
<el-button type="primary" @click="submitAdd" :loading="addSaving">${ editingId ? '保存修改' : '确认新增' }</el-button>
</template>
</el-dialog>

<!-- 导出附件弹窗 -->
<el-dialog v-model="exportFilesVisible" title="异步下载附件" width="480px" destroy-on-close :close-on-click-modal="false">
<p style="color:#606266;margin-bottom:16px;">将按当前筛选条件导出患者附件,打包为 ZIP 文件。请选择需要导出的附件类型:</p>
<el-checkbox-group v-model="exportFileTypes">
<div style="display:flex;flex-direction:column;gap:12px;">
<el-checkbox label="id_photos">实名认证照片(身份证人像面、国徽面、免冠照片)</el-checkbox>
<el-checkbox label="documents">上传资料(检查报告单、出院诊断证明书)</el-checkbox>
<el-checkbox label="signs">签字材料(收入声明、信息同意书、声明与承诺等)</el-checkbox>
</div>
</el-checkbox-group>
<template #footer>
<div style="display:flex;justify-content:space-between;align-items:center;width:100%;">
<el-button type="primary" link @click="goExportTask">查看已有任务</el-button>
<div>
<el-button @click="exportFilesVisible = false">取消</el-button>
<el-button type="primary" @click="submitExportFiles" :loading="exportFilesLoading">创建任务</el-button>
</div>
</div>
</template>
</el-dialog>

<!-- 导出任务创建成功弹窗 -->
<el-dialog v-model="exportSuccessVisible" title="任务创建成功" width="420px" :close-on-click-modal="false">
<div style="text-align:center;padding:16px 0;">
<div style="font-size:48px;margin-bottom:12px;">✅</div>
<p style="font-size:15px;color:#303133;">导出任务已创建,系统正在后台打包中。</p>
<p style="font-size:13px;color:#909399;margin-top:8px;">您可以前往「下载管理」页面查看进度和下载文件。</p>
</div>
<template #footer>
<el-button @click="exportSuccessVisible = false">稍后再看</el-button>
<el-button type="primary" @click="goExportTask">前往下载管理</el-button>
</template>
</el-dialog>
</div>
{% endblock %}

@@ -590,6 +625,60 @@ const app = createApp({
window.open('/admin/patient/export?' + params.toString(), '_blank');
}

// 导出附件
const exportFilesVisible = ref(false);
const exportFilesLoading = ref(false);
const exportFileTypes = ref(['id_photos', 'documents', 'signs']);
const exportSuccessVisible = ref(false);

function showExportFilesDialog() {
exportFileTypes.value = ['id_photos', 'documents', 'signs'];
exportFilesVisible.value = true;
}

async function submitExportFiles() {
if (!exportFileTypes.value.length) {
return ElementPlus.ElMessage.warning('请至少选择一种附件类型');
}
exportFilesLoading.value = true;
try {
var filterObj = {
keyword: keyword.value,
tag: tagFilter.value,
status: tabStatusMap[activeTab.value] || ''
};
if (dateRange.value && dateRange.value.length === 2) {
filterObj.startDate = dateRange.value[0];
filterObj.endDate = dateRange.value[1];
}
if (regionFilter.value && regionFilter.value.length >= 1) filterObj.province_code = regionFilter.value[0];
if (regionFilter.value && regionFilter.value.length >= 2) filterObj.city_code = regionFilter.value[1];
if (regionFilter.value && regionFilter.value.length >= 3) filterObj.district_code = regionFilter.value[2];

var res = await fetch('/admin/patient/exportFiles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
file_types: exportFileTypes.value,
filter_params: filterObj
})
}).then(function(r) { return r.json(); });

if (res.code === 0) {
exportFilesVisible.value = false;
exportSuccessVisible.value = true;
} else {
ElementPlus.ElMessage.error(res.msg || '创建任务失败');
}
} finally {
exportFilesLoading.value = false;
}
}

function goExportTask() {
window.location.href = '/admin/export_task.html';
}

onMounted(function() {
loadList();
loadRegionTree();
@@ -600,8 +689,10 @@ const app = createApp({
keyword, dateRange, tagFilter, regionFilter, activeTab, loading, tableData, pagination, counts,
uploadHeaders, addVisible, addSaving, addForm, exporting, editingId, perms, selectedIds,
regionTree, tagOptions, isMinorComputed, Plus, Download, Delete,
exportFilesVisible, exportFilesLoading, exportFileTypes, exportSuccessVisible,
loadList, resetFilter, onTabChange, onSizeChange, onSelectionChange, viewDetail, showAddDialog, showEditDialog, handleExport,
onIdCardInput, onDocUpload, onSignUpload, submitAdd, handleDelete, handleBatchDelete
onIdCardInput, onDocUpload, onSignUpload, submitAdd, handleDelete, handleBatchDelete,
showExportFilesDialog, submitExportFiles, goExportTask
};
}
});


正在加载...
取消
保存