index.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705
  1. <!-- 使用 type="home" 属性设置首页,其他页面不需要设置,默认为page;推荐使用json5,更强大,且允许注释 -->
  2. <route lang="json5">
  3. {
  4. style: {
  5. navigationBarTitleText: '远程打印',
  6. },
  7. }
  8. </route>
  9. <template>
  10. <view class="page-form">
  11. <!-- 选择打印机--获取打印参数--上传打印 -->
  12. <wd-form ref="form" :model="formData">
  13. <wd-cell-group custom-class="form-group" border>
  14. <wd-cell title="上传文件"
  15. title-width="100px"
  16. required
  17. prop="fileList"
  18. custom-class="my-cell">
  19. <view class="file-list">
  20. <view v-for="(item, index) of fileList"
  21. :key="item.filePath"
  22. class="file-item"
  23. @click="previewFile(item.name, item.filePath)">
  24. <view class="item-icon">
  25. <wd-img
  26. :src="getFileIcon(item.name)"
  27. :width="20"
  28. :height="20"
  29. mode="aspectFit"
  30. ></wd-img>
  31. </view>
  32. <view class="item-name">{{ item.name }}</view>
  33. <view class="item-del" @click.stop="removeFile(index)">
  34. <wd-img
  35. :src="delSrc"
  36. :width="20"
  37. :height="20"
  38. mode="aspectFit"
  39. ></wd-img>
  40. </view>
  41. </view>
  42. <!-- 选择文件: 支持多选多个 (系统文件另外传入) -->
  43. <wd-button v-if="accept != 'all'" size="small" @click="selectFile()">选择文件</wd-button>
  44. </view>
  45. </wd-cell>
  46. <wd-select-picker
  47. v-model="formData.printer"
  48. label="打印机"
  49. label-width="100px"
  50. type="radio"
  51. :columns="printerList"
  52. value-key="id"
  53. label-key="showName"
  54. :max="1"
  55. :show-confirm="false"
  56. filterable
  57. placeholder="请选择打印机"
  58. prop="printer"
  59. :rules="[{ required: true, message: '请选择打印机' }]"
  60. @change="handlePrinterChange"
  61. />
  62. <!-- 动态属性 -->
  63. <template v-for="option of printerOptions" :key="option.key">
  64. <!-- type: select 下拉选项 -->
  65. <wd-select-picker
  66. v-if="option.type == 'select'"
  67. v-model="formData[option.key]"
  68. :label="option.text"
  69. label-width="100px"
  70. type="radio"
  71. :columns="option.values"
  72. value-key="key"
  73. label-key="text"
  74. :max="1"
  75. :show-confirm="false"
  76. filterable
  77. :placeholder="`请选择${option.text}`"
  78. :prop="option.key"
  79. :rules="[{ required: true, message: `请选择${option.text}` }]"
  80. />
  81. <!-- type: radio 单选项 -->
  82. <wd-cell v-if="option.type == 'radio'"
  83. :title="option.text"
  84. title-width="100px"
  85. custom-class="my-cell"
  86. :prop="option.key"
  87. :rules="[{ required: true, message: `请选择${option.text}` }]">
  88. <wd-radio-group v-model="formData[option.key]"
  89. shape="dot"
  90. inline>
  91. <wd-radio v-for="item of option.values"
  92. :key="item.key"
  93. :value="item.key">
  94. {{ item.text }}
  95. </wd-radio>
  96. </wd-radio-group>
  97. </wd-cell>
  98. <!-- type: rang 范围值 传值 方式为str,end -->
  99. <wd-cell v-if="option.type == 'rang'"
  100. :title="option.text"
  101. title-width="100px"
  102. custom-class="my-cell"
  103. :prop="option.key">
  104. <!-- TODO: 需增加 option.min option.max 控制; 默认全部 -->
  105. <!-- 输入框: 从 xx 到 xx -->
  106. <view style="text-align: left">
  107. <view class="inline-txt" style="margin-left: 0">从</view>
  108. <wd-input
  109. no-border
  110. custom-style="display: inline-block; width: 70px; vertical-align: middle"
  111. placeholder="起始值"
  112. v-model="formData[option.key][0]"
  113. />
  114. <view class="inline-txt">到</view>
  115. <wd-input
  116. no-border
  117. custom-style="display: inline-block; width: 70px; vertical-align: middle"
  118. placeholder="结束值"
  119. v-model="formData[option.key][1]"
  120. />
  121. </view>
  122. </wd-cell>
  123. <!-- type: number 数字 -->
  124. <wd-cell v-if="option.type == 'number'"
  125. :title="option.text"
  126. title-width="100px"
  127. custom-class="my-cell"
  128. :rules="[{ required: true, message: `请输入${option.text}` }]">
  129. <wd-input-number v-model="formData[option.key]"
  130. :min="option.min"
  131. :max="option.max"
  132. :step="1"
  133. step-strictly
  134. input-width="100px" />
  135. </wd-cell>
  136. </template>
  137. </wd-cell-group>
  138. <view class="form-footer">
  139. <wd-button type="primary" size="large" block @click="handleSubmit()">确认打印</wd-button>
  140. </view>
  141. </wd-form>
  142. </view>
  143. </template>
  144. <script lang="ts" setup>
  145. import { useToast, useMessage } from 'wot-design-uni'
  146. import { getUserHubPrints, getUserHubAttr, getInvoiceBatch } from '@/service/api'
  147. import { getEnvBaseUrl, redirectToUpload, reLaunchToHome } from '@/utils'
  148. import { useUserStore } from '@/store'
  149. const userStore = useUserStore()
  150. const token = userStore.token
  151. const baseUrl = getEnvBaseUrl()
  152. const toast = useToast()
  153. const message = useMessage()
  154. // 系统文件--all, 聊天文件--msg, 相册--image, 发票--invoice
  155. const accept = ref("all")
  156. const fileList = ref([])
  157. const form = ref()
  158. const formData: any = ref({
  159. printer: "",
  160. })
  161. const printerList: any = ref([])
  162. const printerOptions: any = ref([])
  163. const allowType = ['txt', 'pdf', 'doc', 'docx', 'xlsx', 'xls', 'ppt', 'pptx', 'gif', 'png', 'jpg', 'jpeg', 'webp']
  164. const maxSize = 10 * 1024 * 1024
  165. // 文件类型
  166. const imageSrc = '/static/images/image.png'
  167. const docSrc = '/static/images/doc.png'
  168. const xlsSrc = '/static/images/xls.png'
  169. const pptSrc = '/static/images/ppt.png'
  170. const pdfSrc = '/static/images/pdf.png'
  171. const txtSrc = '/static/images/txt.png'
  172. const otherSrc = '/static/images/other.png'
  173. const delSrc = '/static/images/deleteIcon.png'
  174. // 文件类型图片匹配
  175. const srcMap = {
  176. 'doc': docSrc,
  177. 'docx': docSrc,
  178. 'xls': xlsSrc,
  179. 'xlsx': xlsSrc,
  180. 'ppt': pptSrc,
  181. 'pptx': pptSrc,
  182. 'png': imageSrc,
  183. 'jpg': imageSrc,
  184. 'jpeg': imageSrc,
  185. 'gif': imageSrc,
  186. 'webp': imageSrc,
  187. 'txt': txtSrc,
  188. 'pdf': pdfSrc,
  189. }
  190. // 上传成功的任务
  191. const successPrint = ref([])
  192. // 上传失败的任务
  193. const failedFiles = ref([])
  194. defineOptions({
  195. name: 'Print',
  196. })
  197. // 获取打印机
  198. function getPrinterList() {
  199. let params = { id: "" }
  200. getUserHubPrints(params)
  201. .then((res: any) => {
  202. if (res.code === 0 && res.body) {
  203. printerList.value = res.body || []
  204. printerList.value.forEach(item => {
  205. item.showName = `${item.name} (${item.userHubName})`
  206. })
  207. }
  208. })
  209. .catch((e) => {})
  210. }
  211. // 处理更换打印机
  212. function handlePrinterChange(item: any) {
  213. printerOptions.value = []
  214. formData.value = {
  215. printer: item.value
  216. }
  217. let obj = printerList.value.find(i => i.id == item.value) || {}
  218. let params = {
  219. id: obj.userHubId,
  220. printer: obj.name,
  221. }
  222. getUserHubAttr(params)
  223. .then((res: any) => {
  224. if (res.code === 0 && res.body) {
  225. printerOptions.value = res.body || []
  226. let _formData = {
  227. printer: item.value,
  228. userHubId: obj.userHubId,
  229. printerName: obj.name,
  230. }
  231. if (printerOptions.value.length) {
  232. printerOptions.value.forEach(option => {
  233. switch(option.type) {
  234. case "rang":
  235. _formData[option.key] = option.def ? option.def.split(',') : [option.min, '']
  236. break;
  237. default:
  238. _formData[option.key] = option.def
  239. }
  240. });
  241. formData.value = _formData
  242. }
  243. }
  244. })
  245. .catch((e) => {
  246. })
  247. .finally(() => {})
  248. }
  249. // 获取文件格式
  250. function getFileType(fileName) {
  251. let type = ""
  252. const lastIndex = fileName.lastIndexOf('.')
  253. if (lastIndex != -1) {
  254. type = fileName.substring(lastIndex + 1)
  255. }
  256. return type
  257. }
  258. // 获取文件图标
  259. function getFileIcon(fileName) {
  260. return srcMap[getFileType(fileName)] || otherSrc
  261. }
  262. // 处理选择文件
  263. function selectFile() {
  264. switch(accept.value) {
  265. case "msg":
  266. uni.chooseMessageFile({
  267. count: 10,
  268. type: "all",
  269. success (res) {
  270. console.log('chooseMessageFile res: ', res);
  271. if (res.errMsg == "chooseMessageFile:ok") {
  272. let failList = []
  273. res.tempFiles.forEach(item => {
  274. if (item.size > maxSize || !allowType.includes(getFileType(item.name))) {
  275. failList.push(item)
  276. } else {
  277. fileList.value.push({
  278. name: item.name,
  279. filePath: item.path,
  280. })
  281. }
  282. })
  283. if (failList.length > 0) toast.warning(`文件大小限制为10MB, 文件格式仅支持 ${allowType.join('、')}, 所选文件有 ${failList.length} 格式不符合`)
  284. }
  285. },
  286. fail () {
  287. toast.warning("选择微信文件失败, 请重试")
  288. }
  289. })
  290. break;
  291. case "image":
  292. uni.chooseImage({
  293. count: 10,
  294. sourceType: ['album', 'camera'],
  295. sizeType: ['original', 'compressed'],
  296. extension: ['png', 'jpg', 'jpeg', 'gif', 'webp'],
  297. success (res) {
  298. console.log('chooseImage res: ', res);
  299. if (res.errMsg == "chooseImage:ok") {
  300. let failList = []
  301. res.tempFiles.forEach((item, index) => {
  302. if (item.size > maxSize) {
  303. failList.push(item)
  304. } else {
  305. fileList.value.push({
  306. // name: item.path.substring(item.path.lastIndexOf("/") + 1),
  307. name: `图片-${new Date().getTime() + index}.${getFileType(item.path)}`,
  308. filePath: item.path,
  309. })
  310. }
  311. })
  312. if (failList.length > 0) toast.warning(`文件大小限制为10MB, 所选文件有 ${failList.length} 格式不符合`)
  313. }
  314. },
  315. fail () {
  316. toast.warning("选择相册图片失败, 请重试")
  317. }
  318. })
  319. break;
  320. case "invoice":
  321. uni.chooseInvoice({
  322. success (res) {
  323. if (res.errMsg == "chooseInvoice:ok") {
  324. let list = JSON.parse(res.invoiceInfo)
  325. if (list && list.length > 0) {
  326. let params = {
  327. cardId: list.map(i => i.card_id).join(","),
  328. encryptCode: list.map(i => i.encrypt_code).join(","),
  329. }
  330. getInvoiceBatch(params).then(res => {
  331. if (res.code == 0 && res.body) {
  332. console.log('InvoiceBatch res.body: ', res.body);
  333. // 遍历下载pdf 存储在 fileList 打印
  334. res.body.forEach(item => {
  335. downloadFile(`发票-${item.payee}.png`, item.userInfo.pdfUrl)
  336. });
  337. }
  338. })
  339. }
  340. }
  341. },
  342. fail () {
  343. toast.warning("选择微信发票失败, 请重试")
  344. }
  345. })
  346. break;
  347. }
  348. }
  349. // 下载文件(用于处理发票)
  350. function downloadFile(fileName, fileUrl) {
  351. uni.downloadFile({
  352. url: fileUrl,
  353. filePath: uni.env.USER_DATA_PATH + '/' + fileName,
  354. success: (res) => {
  355. fileList.value.push({
  356. name: fileName,
  357. filePath: res.filePath,
  358. })
  359. },
  360. fail () {
  361. toast.warning("下载文件失败, 请重试")
  362. }
  363. })
  364. }
  365. // 预览文件 (区分文件&图片)
  366. function previewFile(fileName, filePath) {
  367. let fileType = getFileType(fileName)
  368. if (['png', 'jpg', 'jpeg', 'webp'].includes(fileType)) {
  369. // 图片预览
  370. let imgUrl = filePath
  371. uni.previewImage({
  372. current: 0,
  373. urls: [imgUrl],
  374. })
  375. } else {
  376. // 文件预览
  377. uni.openDocument({
  378. filePath: filePath,
  379. success () {},
  380. fail () {
  381. toast.warning("打开文件失败, 请重试")
  382. }
  383. })
  384. }
  385. }
  386. // 移除文件
  387. function removeFile(index) {
  388. fileList.value.splice(index, 1)
  389. if (accept.value == "all" && fileList.value.length == 0) {
  390. message.confirm({
  391. msg: "文件已全部移除, 是否重新选择?",
  392. closeOnClickModal: false,
  393. }).then(() => {
  394. redirectToUpload()
  395. }).catch((error) => {
  396. reLaunchToHome()
  397. })
  398. }
  399. }
  400. // 批量打印文件
  401. async function handleBatchPrint() {
  402. try {
  403. failedFiles.value = []; // 记录上传失败的文件
  404. for (let i = 0; i < fileList.value.length; i++) {
  405. const fileName = fileList.value[i].name;
  406. const filePath = fileList.value[i].filePath;
  407. toast.loading({
  408. loadingType: 'ring',
  409. msg: `开始上传文件 ${i + 1}: ${fileName}`
  410. })
  411. try {
  412. // 上传文件并监听进度
  413. const uploadRes = await handlePrint(filePath, (progress) => {
  414. toast.loading({
  415. loadingType: 'ring',
  416. msg: `文件 ${i + 1} 上传进度: ${progress}%`
  417. })
  418. });
  419. toast.success(`文件 ${i + 1} 上传成功`)
  420. } catch (error) {
  421. toast.error(`文件 ${i + 1} 上传失败`)
  422. failedFiles.value.push(fileList.value[i]); // 记录失败的文件
  423. }
  424. }
  425. // 检查是否有失败的文件
  426. if (failedFiles.value.length > 0) {
  427. message.confirm({
  428. title: '上传失败',
  429. msg: `有 ${failedFiles.value.length} 个文件上传失败,是否重新上传?`,
  430. closeOnClickModal: false,
  431. }).then(async () => {
  432. await retryUploadFiles(failedFiles.value); // 重新上传失败的文件
  433. }).catch((error) => {
  434. msgConfirm("上传完成", "取消重新上传, 其余文件已上传成功!")
  435. })
  436. } else {
  437. toast.success("所有文件上传成功")
  438. msgConfirm("上传完成", "所有文件上传成功")
  439. }
  440. } catch(error) {
  441. console.log("上传过程中发生错误", error);
  442. }
  443. }
  444. async function retryUploadFiles(failedFiles) {
  445. const newFailedFiles = []; // 记录重新上传失败的文件
  446. // 逐个重新上传失败的文件
  447. for (let i = 0; i < failedFiles.length; i++) {
  448. const fileName = failedFiles[i].name;
  449. const filePath = failedFiles[i].filePath;
  450. toast.loading({
  451. loadingType: 'ring',
  452. msg: `重新上传文件 ${i + 1}: ${fileName}`
  453. })
  454. try {
  455. const uploadRes = await handlePrint(filePath, (progress) => {
  456. toast.loading({
  457. loadingType: 'ring',
  458. msg: `文件 ${i + 1} 上传进度: ${progress}%`
  459. })
  460. });
  461. toast.success(`文件 ${i + 1} 重新上传成功`)
  462. } catch (error) {
  463. toast.error(`文件 ${i + 1} 重新上传成功`)
  464. newFailedFiles.push(failedFiles[i]); // 记录重新上传失败的文件
  465. }
  466. }
  467. // 检查是否还有失败的文件
  468. if (newFailedFiles.length > 0) {
  469. // 修改当前文件列表
  470. fileList.value = newFailedFiles
  471. message.alert({
  472. title: '重新上传失败',
  473. msg: `有 ${newFailedFiles.length} 个文件重新上传失败,请检查网络或文件后重试。`,
  474. })
  475. } else {
  476. toast.success("所有失败文件重新上传成功")
  477. msgConfirm("上传完成", "所有失败文件重新上传成功!")
  478. }
  479. }
  480. // 打印文件 (单个打印任务)
  481. function handlePrint(filePath, onProgress) {
  482. return new Promise((resolve, reject) => {
  483. // 构造提交数据
  484. let params = {
  485. id: formData.value.userHubId,
  486. printer: formData.value.printerName,
  487. }
  488. printerOptions.value.forEach(option => {
  489. switch(option.type) {
  490. case "rang":
  491. // 打印页数范围不传
  492. // params[option.key] = formData.value[option.key].join(",")
  493. break;
  494. default:
  495. params[option.key] = formData.value[option.key]
  496. }
  497. })
  498. const queryParams = Object.entries(params).map(([key, value]) => {
  499. return `${key}=${encodeURIComponent(value)}`;
  500. }).join('&');
  501. const uploadTask = uni.uploadFile({
  502. url: `${baseUrl}/sys/wx/userHub/print?${queryParams}`, // 上传接口地址
  503. filePath: filePath,
  504. name: 'file', // 文件对应的 key
  505. formData: {
  506. // 其他表单数据
  507. },
  508. success: (res) => {
  509. if (res.statusCode === 200 && JSON.parse(res.data).code === 0) {
  510. resolve(res.data);
  511. } else {
  512. reject(new Error(`上传失败,状态码: ${res.statusCode}`));
  513. }
  514. },
  515. fail: (err) => {
  516. reject(err);
  517. },
  518. });
  519. // 监听上传进度
  520. uploadTask.onProgressUpdate((res) => {
  521. if (onProgress) {
  522. onProgress(res.progress); // 回调上传进度
  523. }
  524. });
  525. });
  526. }
  527. // 处理提交打印
  528. function handleSubmit() {
  529. if (fileList.value.length == 0) {
  530. toast.warning('请先上传文件')
  531. return
  532. }
  533. form.value.validate().then(({ valid }) => {
  534. if (valid) {
  535. // 不区分单个/批量, 以批量打印处理
  536. handleBatchPrint()
  537. }
  538. })
  539. }
  540. // 打印完成后提示词
  541. function msgConfirm(title="提示", msg="文件已上传成功!") {
  542. message.confirm({
  543. title,
  544. msg,
  545. confirmButtonText: "查看任务",
  546. cancelButtonText: "继续打印",
  547. }).then(() => {
  548. // 查看任务
  549. uni.redirectTo({
  550. url: `/pages/print/job?tab=0`,
  551. })
  552. }).catch(() => {
  553. // 继续打印
  554. failedFiles.value = []
  555. fileList.value = []
  556. printerOptions.value = []
  557. formData.value = {
  558. printer: ""
  559. }
  560. if (accept.value == "all") {
  561. redirectToUpload()
  562. }
  563. })
  564. }
  565. // 处理系统文件webview传入
  566. function base64ToTempFilePath(fileName, base64Data, success, fail) {
  567. const fs = uni.getFileSystemManager()
  568. // const fileName = 'temp_' + Date.now() + '.png' // 自定义文件名,可根据需要修改
  569. const filePath = uni.env.USER_DATA_PATH + '/' + fileName
  570. const buffer = uni.base64ToArrayBuffer(base64Data)
  571. fs.writeFile({
  572. filePath,
  573. data: buffer,
  574. encoding: 'binary',
  575. success() {
  576. success && success(filePath)
  577. },
  578. fail() {
  579. fail && fail()
  580. }
  581. })
  582. }
  583. // 打开SSE webview页面
  584. function openWebViewSSE() {
  585. // let list = JSON.stringify(fileList.value.map(item => item.name).join(",") || "")
  586. console.log('list: ', fileList.value);
  587. let list = JSON.stringify(fileList.value)
  588. const url = `https://service.1ai.ltd/webview-sse/index.html?token=${token}&list=${list}&t=${new Date().getTime()}`
  589. uni.navigateTo({
  590. url: `/pages/webview/sse?url=${encodeURIComponent(url)}`,
  591. })
  592. }
  593. onLoad((option) => {
  594. if (option && option.accept) {
  595. accept.value = option.accept
  596. // 处理系统文件--all: 从webview上传的文件
  597. if (option.accept == "all" && uni.getStorageSync('fileList')) {
  598. fileList.value = uni.getStorageSync('fileList');
  599. uni.removeStorageSync('fileList');
  600. fileList.value.forEach(item => {
  601. item.file = item.file.split('base64,')[1]
  602. base64ToTempFilePath(item.name, item.file, (filePath) => {
  603. console.log('转换成功,临时地址为:', filePath)
  604. item.filePath = filePath
  605. }, function() {
  606. toast.warning('文件转换失败,请重试')
  607. })
  608. })
  609. }
  610. }
  611. getPrinterList()
  612. })
  613. </script>
  614. <style lang="scss" scoped>
  615. :deep(.my-cell) {
  616. .wd-cell__value {
  617. text-align: left !important;
  618. }
  619. }
  620. :deep(.wd-radio-group) {
  621. padding: 0 !important;
  622. font-size: unset !important;
  623. .wd-radio {
  624. padding-top: 0 !important;
  625. }
  626. }
  627. :deep(.wd-upload__mask) {
  628. display: none !important;
  629. }
  630. :deep(.img-btn .wd-img__image) {
  631. width: 160rpx;
  632. height: 160rpx;
  633. border-radius: 40rpx;
  634. }
  635. .file-item {
  636. margin-bottom: 8rpx;
  637. display: flex;
  638. align-items: center;
  639. .item-icon {
  640. display: flex;
  641. width: 40rpx;
  642. height: 40rpx;
  643. margin-right: 8rpx;
  644. }
  645. .item-name {
  646. flex: 1;
  647. word-break: break-all;
  648. }
  649. .item-del {
  650. display: flex;
  651. width: 40rpx;
  652. height: 40rpx;
  653. margin-left: 8rpx;
  654. }
  655. }
  656. .inline-txt {
  657. display: inline-block;
  658. font-size: 28rpx;
  659. margin: 0 16rpx;
  660. color: rgba(0, 0, 0, 0.45);
  661. vertical-align: middle;
  662. }
  663. </style>