进度保存

master
2358328281@qq.com 2 months ago
parent 6b7f627c7c
commit 64af1aa70e

@ -1,9 +1,11 @@
{
"compilerOptions": {
"target": "es5",
"target": "esnext",
"module": "esnext",
"baseUrl": "./",
"moduleResolution": "node",
"allowJs": true,
"checkJs": false,
"paths": {
"@/*": [
"src/*"
@ -15,5 +17,13 @@
"dom.iterable",
"scripthost"
]
}
},
"include": [
"src/**/*",
"src/**/*.vue"
],
"exclude": [
"node_modules",
"dist"
]
}

401
package-lock.json generated

@ -14,8 +14,12 @@
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.13.2",
"dayjs": "^1.11.21",
"docx": "^9.7.1",
"echarts": "^6.0.0",
"element-plus": "^2.11.8",
"file-saver": "^2.0.5",
"html2canvas": "^1.4.1",
"jspdf": "^4.2.1",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"qs": "^6.12.1",
@ -26,6 +30,7 @@
"vue-router": "^4.6.3"
},
"devDependencies": {
"@types/file-saver": "^2.0.7",
"@vitejs/plugin-vue": "^6.0.1",
"fast-glob": "^3.3.3",
"sass-embedded": "^1.93.3",
@ -67,9 +72,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@ -1604,6 +1609,13 @@
"integrity": "sha512-zx2/Gg0Eg7gwEiOIIh5w9TrhKKTeQh7CPCOPNc0el4pLSwzebA8SmnHwZs2dWlLONvyulykSwGSQxQHLhjGLvQ==",
"license": "MIT"
},
"node_modules/@types/file-saver": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz",
"integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.20",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz",
@ -1623,12 +1635,24 @@
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.19.0"
}
},
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@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.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/svgo": {
"version": "2.6.4",
"resolved": "https://registry.npmjs.org/@types/svgo/-/svgo-2.6.4.tgz",
@ -1639,6 +1663,13 @@
"@types/node": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.16",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz",
@ -2294,6 +2325,15 @@
"node": ">=0.10.0"
}
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/big.js": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
@ -2426,6 +2466,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/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/chalk": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
@ -2617,6 +2677,24 @@
"node": ">=0.10.0"
}
},
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@ -2635,6 +2713,15 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/css-select": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz",
@ -2946,6 +3033,41 @@
"node": ">=0.10"
}
},
"node_modules/docx": {
"version": "9.7.1",
"resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz",
"integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==",
"license": "MIT",
"dependencies": {
"@types/node": "^25.2.3",
"hash.js": "^1.1.7",
"jszip": "^3.10.1",
"nanoid": "^5.1.3",
"xml": "^1.0.1",
"xml-js": "^1.6.8"
},
"engines": {
"node": ">=10"
}
},
"node_modules/docx/node_modules/nanoid": {
"version": "5.1.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz",
"integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/dom-serializer": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz",
@ -3006,6 +3128,16 @@
"domelementtype": "1"
}
},
"node_modules/dompurify": {
"version": "3.4.11",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz",
@ -3481,6 +3613,17 @@
"node": ">=8.6.0"
}
},
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/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/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@ -3509,6 +3652,18 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/file-saver": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
"license": "MIT"
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@ -3961,6 +4116,16 @@
"node": ">=0.10.0"
}
},
"node_modules/hash.js": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"minimalistic-assert": "^1.0.1"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@ -3999,6 +4164,19 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/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/htmlparser2": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz",
@ -4043,6 +4221,12 @@
"node": ">=0.10.0"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/immer": {
"version": "9.0.21",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
@ -4064,7 +4248,6 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true,
"license": "ISC"
},
"node_modules/internal-slot": {
@ -4082,6 +4265,12 @@
"node": ">= 0.4"
}
},
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/is-accessor-descriptor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.1.tgz",
@ -4586,7 +4775,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"dev": true,
"license": "MIT"
},
"node_modules/isobject": {
@ -4635,6 +4823,71 @@
"graceful-fs": "^4.1.6"
}
},
"node_modules/jspdf": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/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.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/jszip/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/jszip/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/jszip/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/kind-of": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz",
@ -4645,6 +4898,15 @@
"node": ">=0.10.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/loader-utils": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
@ -4860,6 +5122,12 @@
"node": ">= 0.6"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@ -5223,6 +5491,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/pako": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/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/pascalcase": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz",
@ -5258,6 +5542,13 @@
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"license": "MIT"
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/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.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -5467,6 +5758,12 @@
"node": ">=6"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@ -5523,6 +5820,16 @@
],
"license": "MIT"
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@ -5576,6 +5883,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/regex-not": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz",
@ -5693,6 +6007,16 @@
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/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/rollup": {
"version": "4.53.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz",
@ -6244,7 +6568,6 @@
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
@ -6324,6 +6647,12 @@
"node": ">=0.10.0"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@ -6676,6 +7005,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/static-extend": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz",
@ -7035,6 +7374,16 @@
"node": ">=0.10.0"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/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/svgo": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.2.tgz",
@ -7080,6 +7429,15 @@
"node": ">=16.0.0"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/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/tiny-warning": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
@ -7364,7 +7722,6 @@
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"dev": true,
"license": "MIT"
},
"node_modules/union-value": {
@ -7477,9 +7834,17 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT"
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/varint": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz",
@ -7772,6 +8137,24 @@
"integrity": "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==",
"license": "MIT"
},
"node_modules/xml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
"license": "MIT"
},
"node_modules/xml-js": {
"version": "1.6.11",
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
"license": "MIT",
"dependencies": {
"sax": "^1.2.4"
},
"bin": {
"xml-js": "bin/cli.js"
}
},
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",

@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "node --max-old-space-size=8192 node_modules/vite/bin/vite.js build",
"preview": "vite preview"
},
"dependencies": {
@ -15,8 +15,12 @@
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.13.2",
"dayjs": "^1.11.21",
"docx": "^9.7.1",
"echarts": "^6.0.0",
"element-plus": "^2.11.8",
"file-saver": "^2.0.5",
"html2canvas": "^1.4.1",
"jspdf": "^4.2.1",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"qs": "^6.12.1",
@ -27,6 +31,7 @@
"vue-router": "^4.6.3"
},
"devDependencies": {
"@types/file-saver": "^2.0.7",
"@vitejs/plugin-vue": "^6.0.1",
"fast-glob": "^3.3.3",
"sass-embedded": "^1.93.3",

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="icon">
<rect id="Rectangle 1430107587" x="3.5" y="7.5" width="10" height="2" rx="1" fill="white"/>
<rect id="Rectangle 1430107588" x="9.5" y="3.5" width="10" height="2" rx="1" transform="rotate(90 9.5 3.5)" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 337 B

@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame">
<path id="Vector" d="M13.9266 6.86919C14.0109 6.30393 13.9832 5.72523 13.8437 5.16879C13.6594 4.43373 13.2798 3.73759 12.705 3.16276C12.1301 2.58793 11.434 2.20836 10.699 2.02404C10.1425 1.88452 9.5638 1.85688 8.99854 1.94114" stroke="#333333" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M11.3965 6.60115C11.4868 5.99562 11.2989 5.35662 10.8327 4.89049C10.3666 4.42435 9.72757 4.23638 9.12207 4.32665" stroke="#333333" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_3" d="M4.79189 2.93164C5.03409 2.93164 5.25723 3.06298 5.37476 3.27473L6.19026 4.74366C6.29703 4.936 6.30203 5.16863 6.20366 5.3654L5.41806 6.9366C5.41806 6.9366 5.64573 8.1071 6.59853 9.0599C7.55133 10.0127 8.71789 10.2364 8.71789 10.2364L10.2889 9.45096C10.4858 9.3525 10.7186 9.3576 10.911 9.46456L12.3841 10.2836C12.5956 10.4012 12.7268 10.6242 12.7268 10.8662V12.5574C12.7268 13.4186 11.9268 14.0407 11.1108 13.7653C9.43483 13.1998 6.83326 12.1231 5.18433 10.4741C3.53536 8.82516 2.45863 6.2236 1.89311 4.54763C1.61777 3.7316 2.2398 2.93164 3.10102 2.93164H4.79189Z" stroke="#333333" stroke-width="1.2" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@ -0,0 +1,14 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame" clip-path="url(#clip0_1445_6177)">
<path id="Vector" d="M13.6668 14.667V6.66699H2.3335V14.667H13.6668Z" stroke="#333333" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M8 14.667V6.66699" stroke="#333333" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_3" d="M13.6668 14.667H2.3335" stroke="#333333" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_4" d="M14.6668 4H1.3335V6.66667H14.6668V4Z" stroke="#333333" stroke-width="1.2" stroke-linejoin="round"/>
<path id="Vector_5" d="M5.3335 1.33301L8.00016 3.99967L10.6668 1.33301" stroke="#333333" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1445_6177">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 953 B

@ -0,0 +1,19 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="&#231;&#188;&#150;&#231;&#187;&#132; 4 1" clip-path="url(#clip0_1445_6121)">
<g id="&#229;&#144;&#142;&#229;&#143;&#176;&#239;&#188;&#136;&#230;&#183;&#177;&#232;&#137;&#178;&#239;&#188;&#137;">
<g id="&#233;&#166;&#150;&#233;&#161;&#181;">
<g id="&#231;&#188;&#150;&#231;&#187;&#132;-2&#229;&#164;&#135;&#228;&#187;&#189;">
<g id="&#231;&#188;&#150;&#231;&#187;&#132;-4">
<path id="Vector 1090" d="M3 3L13 13" stroke="#FF383C" stroke-width="2" stroke-linecap="round"/>
<path id="Vector 1091" d="M13 3L3 13" stroke="#FF383C" stroke-width="2" stroke-linecap="round"/>
</g>
</g>
</g>
</g>
</g>
<defs>
<clipPath id="clip0_1445_6121">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 805 B

@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame">
<path id="Vector" d="M2.3335 12.3337C2.3335 9.76589 2.3335 3.66699 2.3335 3.66699C2.3335 2.56242 3.22893 1.66699 4.3335 1.66699H11.6668V10.3337C11.6668 10.3337 6.0777 10.3337 4.3335 10.3337C3.2335 10.3337 2.3335 11.2284 2.3335 12.3337Z" stroke="#333333" stroke-width="1.2" stroke-linejoin="round"/>
<path id="Vector_2" d="M11.6668 10.333C11.6668 10.333 4.71806 10.333 4.3335 10.333C3.22893 10.333 2.3335 11.2284 2.3335 12.333C2.3335 13.4376 3.22893 14.333 4.3335 14.333C5.06986 14.333 8.62543 14.333 13.6668 14.333V2.33301" stroke="#333333" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_3" d="M4.6665 12.333H11.3332" stroke="#333333" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 864 B

@ -0,0 +1,9 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame">
<path id="Vector" d="M11.3335 3.33301L14.0002 5.99967" stroke="#333333" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M14.0002 3.33301L11.3335 5.99967" stroke="#333333" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_3" d="M14.6667 10L12.3333 12.6667L11 11.3333" stroke="#333333" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_4" d="M8.66683 3.33301H1.3335V5.99967H8.66683V3.33301Z" stroke="#333333" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_5" d="M8.66683 10H1.3335V12.6667H8.66683V10Z" stroke="#333333" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 859 B

@ -0,0 +1,18 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="&#231;&#188;&#150;&#231;&#187;&#132; 4 1" clip-path="url(#clip0_1445_6148)">
<g id="&#229;&#144;&#142;&#229;&#143;&#176;&#239;&#188;&#136;&#230;&#183;&#177;&#232;&#137;&#178;&#239;&#188;&#137;">
<g id="&#233;&#166;&#150;&#233;&#161;&#181;">
<g id="&#231;&#188;&#150;&#231;&#187;&#132;-2&#229;&#164;&#135;&#228;&#187;&#189;">
<g id="&#231;&#188;&#150;&#231;&#187;&#132;-4">
<path id="Vector 1091" d="M15.0002 3L5.99032 12L1 7.13962" stroke="#34C759" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</g>
</g>
</g>
</g>
<defs>
<clipPath id="clip0_1445_6148">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 753 B

@ -0,0 +1,16 @@
<svg width="71" height="70" viewBox="0 0 71 70" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame" clip-path="url(#clip0_1445_7161)">
<path id="Vector" d="M62.5157 63.3503C62.5157 64.2253 62.1603 65.0653 61.5215 65.6951C60.8819 66.3248 60.0308 66.6752 59.1425 66.6752H11.8215C10.934 66.6752 10.0465 66.3248 9.44332 65.6951C8.80457 65.0645 8.44922 64.2253 8.44922 63.3503V3.32483C8.44922 2.44983 8.8037 1.57483 9.44332 0.980103C10.0465 0.350342 10.9349 0 11.8215 0H40.8612C41.7487 0 42.6362 0.350342 43.2749 0.980103L61.5571 19.0056C62.1958 19.6354 62.5512 20.4753 62.5512 21.3854V63.3503H62.5157Z" fill="#EBECF0"/>
<path id="Vector_2" d="M62.5156 63.3506V66.6754C62.5156 67.5504 62.1603 68.4254 61.5215 69.0201C60.8819 69.6508 60.0308 70.0002 59.1425 70.0002H11.8215C9.93994 70.0002 8.44922 68.4955 8.44922 66.6754V63.3506C8.44922 64.2256 8.8037 65.0656 9.44332 65.6953C10.0821 66.3251 10.934 66.6754 11.8215 66.6754H59.1433C61.0249 66.6754 62.5156 65.1707 62.5156 63.3506Z" fill="#C1C7D0"/>
<path id="Vector_3" d="M8.44913 36.6797V29.9941L1.66895 36.6797H8.44913ZM62.5156 36.6797L62.5866 29.9941L69.3313 36.6797H62.5156Z" fill="#D30000"/>
<path id="Vector_4" d="M62.5157 21.3854V21.84H44.2327C42.3511 21.84 40.8604 20.3352 40.8604 18.5151V0C41.7479 0 42.6354 0.350342 43.2741 0.980103L61.5927 19.0056C62.1959 19.6354 62.5512 20.4753 62.5157 21.3854Z" fill="#C1C7D0"/>
<path id="Vector_5" d="M34.8708 41.7754H32.0896V51.5499H34.8214C37.9242 51.5499 39.6013 49.8264 39.6013 46.5016C39.6013 43.6083 38.0672 41.8138 34.8708 41.7754ZM19.064 41.7805H15.085V45.9342H19.064C20.6942 45.9342 21.7247 45.3831 21.7247 43.8296C21.7247 42.5632 20.9352 41.7805 19.064 41.7805Z" fill="#FF1D1D"/>
<path id="Vector_6" d="M1.7041 36.6797V53.3047C1.7041 54.2147 2.05858 55.0547 2.69821 55.6844C3.30143 56.3151 4.18893 56.6645 5.07643 56.6645H65.9239C67.77 56.6645 69.2962 55.1948 69.2962 53.3397V36.6797H1.7041ZM19.3622 48.8801H15.0851V54.4907H11.2161V38.8535H19.4229C23.1107 38.8535 25.5626 40.6915 25.5626 43.7686C25.5626 46.8456 23.2528 48.8801 19.3622 48.8801ZM35.3286 54.4907H28.2407V38.8535H35.41C40.7541 38.8535 43.6783 42.2732 43.6783 46.3987C43.6783 51.4043 39.9446 54.4907 35.3286 54.4907ZM59.8969 41.8724H50.6934V45.4288H59.2781V48.47H50.6934V54.4899H46.8444V38.8527H59.8969V41.8716V41.8724Z" fill="#FF1D1D"/>
<path id="Vector_7" d="M19.4227 38.8535H11.2168V54.4907H15.0857V48.8801H19.3629C23.2535 48.8801 25.5633 46.9661 25.5633 43.7686C25.5633 40.571 23.1114 38.8535 19.4236 38.8535H19.4227ZM19.0639 45.9338H15.0849V41.7802H19.0639C20.9351 41.7802 21.7247 42.5629 21.7247 43.8292C21.7247 45.3818 20.6933 45.9338 19.0639 45.9338ZM35.4107 38.8535H28.2414V54.4907H35.3292C39.9453 54.4907 43.679 51.4043 43.679 46.3987C43.679 42.2732 40.7556 38.8535 35.4107 38.8535ZM34.8214 51.5496H32.0895V41.775H34.8708C38.0663 41.8143 39.6012 43.6088 39.6012 46.5012C39.6012 49.826 37.9241 51.5496 34.8214 51.5496ZM46.8451 54.4907H50.6941V48.4708H59.2787V45.4297H50.6941V41.8733H59.8976V38.8535H46.8451V54.4907Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_1445_7161">
<rect width="71" height="70" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 374 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 374 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.6 MiB

@ -0,0 +1,10 @@
<svg width="70" height="70" viewBox="0 0 70 70" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame">
<path id="Vector" d="M61.5978 63.2586C61.5996 63.6955 61.5144 64.1283 61.3472 64.5319C61.1799 64.9355 60.934 65.3017 60.6237 65.6092C59.9998 66.2319 59.1546 66.5821 58.2731 66.5833H11.6944C11.2577 66.585 10.8249 66.4998 10.4214 66.3326C10.0179 66.1653 9.65169 65.9195 9.34419 65.6092C8.72138 64.9854 8.37114 64.1402 8.37014 63.2586V3.37818C8.3684 2.94119 8.45363 2.50821 8.62086 2.10448C8.78808 1.70074 9.03397 1.33431 9.34419 1.02653C9.96802 0.403934 10.8131 0.0538255 11.6944 0.0528325H40.2684C40.7066 0.0494643 41.1412 0.132837 41.547 0.298148C41.9528 0.463459 42.3219 0.707439 42.633 1.01603L60.6353 19.0172C60.9435 19.3286 61.1871 19.6978 61.3523 20.1036C61.5175 20.5093 61.6009 20.9437 61.5978 21.3818V63.2586Z" fill="#EBECF0"/>
<path id="Vector_2" d="M61.5984 63.2594V66.5875C61.6001 67.0244 61.5148 67.4574 61.3476 67.861C61.1804 68.2647 60.9345 68.6311 60.6243 68.9388C60.0004 69.5615 59.1552 69.9117 58.2737 69.9129H11.695C10.8124 69.9124 9.96606 69.5615 9.34205 68.9373C8.71803 68.3131 8.3674 67.4666 8.36722 66.584V63.2594C8.36538 63.6962 8.45053 64.129 8.6177 64.5326C8.78487 64.9361 9.03072 65.3024 9.34092 65.61C9.96477 66.2328 10.81 66.583 11.6915 66.584H58.2667C59.1487 66.5817 59.9939 66.2303 60.6175 65.6067C61.2412 64.983 61.5926 64.1378 61.5949 63.2559L61.5984 63.2594Z" fill="#C1C7D0"/>
<path id="Vector_3" d="M1.7168 36.6475H68.2472V53.2819C68.2449 54.163 67.8941 55.0074 67.2714 55.6309C66.6487 56.2543 65.8048 56.6062 64.9236 56.6097H5.04565C4.60874 56.6114 4.17584 56.5263 3.77217 56.3591C3.3685 56.1919 3.00212 55.9461 2.69435 55.636C2.07153 55.0122 1.72129 54.1669 1.7203 53.2854L1.7168 36.6475Z" fill="#317BFF"/>
<path id="Vector_4" d="M8.37345 36.6462V29.9902L1.7168 36.6462H8.37345ZM61.5976 36.6462L61.6613 29.9902L68.2826 36.6462H61.5976Z" fill="#234AE8"/>
<path id="Vector_5" d="M61.5976 21.3789V21.8465H43.5971C42.7144 21.8465 41.8679 21.4959 41.2437 20.8719C40.6195 20.2478 40.2687 19.4014 40.2686 18.5187V0.0499002C40.7067 0.0465782 41.1411 0.129974 41.5469 0.295283C41.9527 0.460592 42.3217 0.704549 42.6328 1.0131L60.6634 19.0073C61.2731 19.6452 61.6084 20.4966 61.5976 21.3789Z" fill="#C1C7D0"/>
<path id="Vector_6" d="M17.7514 41.4655L19.1041 48.4757L20.3956 41.4652H23.9621L21.195 52.7803H17.5053L16.2138 46.2007H16.1522L14.8611 52.7803H11.233L8.4043 41.4655H12.0324L13.2623 48.4757H13.3239L14.6766 41.4652L17.7514 41.4655ZM30.6034 41.1582C34.3332 41.3225 36.3215 43.2902 36.5681 47.0613C36.4036 50.8336 34.4154 52.8014 30.6034 52.9648C26.7907 52.8418 24.8229 50.8534 24.6999 46.9997C24.9046 43.3105 26.8724 41.3633 30.6034 41.1582ZM30.6034 43.9253C29.209 43.9666 28.4711 45.0119 28.3896 47.0613C28.4713 49.1935 29.2092 50.2799 30.6034 50.3205C32.0792 50.2799 32.8376 49.1935 32.8784 47.0613C32.7958 44.9706 32.0375 43.9253 30.6034 43.9253ZM42.9633 48.8449H41.8566V52.78H38.1672V41.4652H43.517C47.0007 41.3835 48.6608 42.4494 48.4975 44.6628C48.4567 46.1388 47.8212 47.0613 46.591 47.4302C47.984 47.7172 48.6401 48.7215 48.5591 50.443V50.9351C48.518 52.0418 48.6411 52.5338 48.9283 52.4111V52.78H45.1767C45.0544 52.5338 44.9932 51.8779 44.9929 50.8123C45.1563 49.3777 44.4797 48.7218 42.9633 48.8446V48.8449ZM41.8566 44.1098V46.3235H43.3325C44.5622 46.3648 45.1564 46.0163 45.1151 45.2781C45.0743 44.5403 44.5621 44.1508 43.5786 44.1098H41.8566ZM55.3232 52.78H50.4652V41.4655H55.4464C59.3407 41.5068 61.3086 43.3107 61.3498 46.8772C61.3498 50.8126 59.341 52.7803 55.3232 52.7803V52.78ZM54.1552 44.1098V50.136H54.9543C56.7164 50.136 57.5977 49.1111 57.5982 47.0613C57.7214 44.9303 56.8196 43.9464 54.8927 44.1098H54.1552Z" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame">
<path id="Vector" d="M14.2511 12.9624H9.27535L14.3045 7.98956C14.7499 7.54847 15 6.95078 15 6.32764C15 5.70449 14.7499 5.1068 14.3045 4.66571L10.2798 0.688151C9.83353 0.247515 9.22853 0 8.59771 0C7.9669 0 7.36189 0.247515 6.91565 0.688151L0.696596 6.83261C0.475749 7.05085 0.300563 7.30995 0.18104 7.5951C0.0615177 7.88026 0 8.18589 0 8.49454C0 8.80319 0.0615177 9.10882 0.18104 9.39398C0.300563 9.67913 0.475749 9.93823 0.696596 10.1565L3.53577 12.9624H0.567981C0.428752 12.9624 0.295226 13.017 0.196776 13.1143C0.0983268 13.2116 0.0430185 13.3436 0.0430185 13.4812C0.0430185 13.6188 0.0983268 13.7507 0.196776 13.848C0.295226 13.9453 0.428752 14 0.567981 14H14.2511C14.3903 14 14.5239 13.9453 14.6223 13.848C14.7208 13.7507 14.7761 13.6188 14.7761 13.4812C14.7761 13.3436 14.7208 13.2116 14.6223 13.1143C14.5239 13.017 14.3903 12.9624 14.2511 12.9624ZM1.44292 7.56587L7.65934 1.41881C7.78248 1.29679 7.92882 1.20005 8.08994 1.13416C8.25106 1.06827 8.42378 1.03453 8.59815 1.03489H8.6524L7.17463 6.91044C7.16687 6.94256 7.15051 6.97203 7.12727 6.99577C7.10402 7.0195 7.07475 7.03662 7.04251 7.04533L1.05532 8.51529V8.49281C1.05482 8.32067 1.08881 8.15013 1.15533 7.99104C1.22186 7.83194 1.3196 7.68744 1.44292 7.56587ZM1.49804 9.47769L7.2945 8.05269C7.51278 7.99943 7.71219 7.88824 7.87117 7.73112C8.03016 7.574 8.14267 7.37692 8.19656 7.1612L9.62183 1.49836L13.5687 5.39896C13.8174 5.64535 13.957 5.97918 13.957 6.3272C13.957 6.67523 13.8174 7.00905 13.5687 7.25545L7.79408 12.9624H5.02053L1.49804 9.47769Z" fill="#333333"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame">
<path id="Vector" d="M5.1249 3.26562C5.3671 3.26562 5.59023 3.39697 5.70777 3.6087L6.52327 5.07767C6.63003 5.27 6.63503 5.5026 6.53667 5.69937L5.75107 7.2706C5.75107 7.2706 5.97873 8.44107 6.93153 9.39387C7.88433 10.3467 9.0509 10.5704 9.0509 10.5704L10.6219 9.78494C10.8188 9.6865 11.0516 9.6916 11.244 9.79857L12.7171 10.6176C12.9286 10.7352 13.0598 10.9582 13.0598 11.2002V12.8914C13.0598 13.7526 12.2598 14.3746 11.4438 14.0993C9.76783 13.5338 7.16627 12.457 5.51733 10.8081C3.86837 9.15914 2.79163 6.5576 2.22612 4.8816C1.95078 4.0656 2.57281 3.26562 3.43403 3.26562H5.1249Z" stroke="#1D2129" stroke-width="1.2" stroke-linejoin="round"/>
<path id="Vector_2" d="M9.66699 6.66665L14.0003 2.5" stroke="#1D2129" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_3" d="M14.0003 6.66535H9.66699V2.33203" stroke="#1D2129" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@ -0,0 +1,9 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame">
<path id="Vector" d="M11.333 3.33203L13.9997 5.9987" stroke="#333333" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M13.9997 3.33203L11.333 5.9987" stroke="#333333" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_3" d="M14.6667 10L12.3333 12.6667L11 11.3333" stroke="#333333" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_4" d="M8.66634 3.33203H1.33301V5.9987H8.66634V3.33203Z" stroke="#333333" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_5" d="M8.66634 10H1.33301V12.6667H8.66634V10Z" stroke="#333333" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 856 B

@ -0,0 +1,18 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Group 1707486099">
<rect id="Rectangle 1430107575" width="48" height="48" rx="24" fill="url(#paint0_linear_1445_6607)"/>
<g id="Frame">
<path id="Vector" d="M26.7397 23.4055L24.3361 25.8091L26.3534 33.205L24.0002 35.8947L21.6471 33.205L23.6644 25.8091L21.2608 23.4055C15.4922 24.6245 11.5615 29.4829 11.5615 32.8692C11.5615 36.7676 20.1011 38.5837 24.0002 38.5837C27.8994 38.5837 36.439 36.7676 36.439 32.8692C36.439 29.4829 32.5083 24.6245 26.7397 23.4055ZM24.0002 23.1201C24.9274 23.1201 25.8454 22.9375 26.702 22.5827C27.5586 22.2279 28.3369 21.7079 28.9925 21.0523C29.6481 20.3967 30.1681 19.6184 30.5229 18.7618C30.8777 17.9053 31.0603 16.9872 31.0603 16.0601C31.0603 15.1329 30.8777 14.2149 30.5229 13.3583C30.1681 12.5017 29.6481 11.7234 28.9925 11.0678C28.3369 10.4123 27.5586 9.89222 26.702 9.53742C25.8454 9.18261 24.9274 9 24.0002 9C22.1403 9.01894 20.3631 9.77106 19.0545 11.093C17.746 12.4149 17.012 14.1997 17.012 16.0597C17.012 17.9197 17.746 19.7046 19.0545 21.0265C20.3631 22.3483 22.1403 23.1005 24.0002 23.1194V23.1201Z" fill="url(#paint1_linear_1445_6607)"/>
</g>
</g>
<defs>
<linearGradient id="paint0_linear_1445_6607" x1="24" y1="0" x2="24" y2="48" gradientUnits="userSpaceOnUse">
<stop stop-color="#6155F5"/>
<stop offset="1" stop-color="#CDC9FF"/>
</linearGradient>
<linearGradient id="paint1_linear_1445_6607" x1="24.0002" y1="9" x2="24.0002" y2="38.5837" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0.6"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame">
<path id="Union" d="M12.5 0C13.327 0 14 0.673 14 1.5V13.5C14 14.327 13.327 15 12.5 15H2.5C1.673 15 1 14.327 1 13.5V1.5C1 0.673 1.673 0 2.5 0H12.5ZM2.5 1C2.2245 1 2 1.2245 2 1.5V13.5C2 13.776 2.2245 14 2.5 14H12.5C12.6326 14 12.7597 13.9473 12.8535 13.8535C12.9473 13.7597 13 13.6326 13 13.5V1.5C13 1.2245 12.776 1 12.5 1H11.5V2.5C11.5 3.0515 11.0515 3.5 10.5 3.5H4.5C3.9485 3.5 3.5 3.0515 3.5 2.5V1H2.5ZM11.0889 5.08887C11.1545 5.08887 11.2196 5.10184 11.2803 5.12695C11.3409 5.15208 11.396 5.18893 11.4424 5.23535C11.4888 5.28177 11.5257 5.33681 11.5508 5.39746C11.5759 5.4581 11.5889 5.52324 11.5889 5.58887C11.5888 5.65444 11.5759 5.71969 11.5508 5.78027C11.5257 5.84079 11.4887 5.89605 11.4424 5.94238L7.19238 10.1924C7.09864 10.286 6.97134 10.3389 6.83887 10.3389C6.70637 10.3388 6.57907 10.2861 6.48535 10.1924L4.25293 7.95898C4.15927 7.86524 4.10645 7.73799 4.10645 7.60547C4.10649 7.47294 4.15921 7.34567 4.25293 7.25195C4.34665 7.15824 4.47391 7.1055 4.60645 7.10547C4.73897 7.10547 4.86622 7.15829 4.95996 7.25195L6.83887 9.13184L10.7354 5.23535C10.7817 5.18901 10.8369 5.15207 10.8975 5.12695C10.9581 5.10186 11.0233 5.08889 11.0889 5.08887ZM4.5 1V2.5H10.5V1H4.5Z" fill="#1D2129"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@ -0,0 +1,18 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Group 1707486100">
<rect id="Rectangle 1430107575" width="48" height="48" rx="24" fill="url(#paint0_linear_1445_6615)"/>
<g id="Frame">
<path id="Union" d="M15.8926 12.4814V16.1104C15.8926 18.0246 17.4246 19.556 19.3389 19.5713H28.3281C30.2424 19.5713 31.7734 18.0402 31.7734 16.126V12.4814H32.3555C34.2697 12.4814 35.8017 14.0125 35.8018 15.9268V26.6777C34.6685 25.9273 33.3205 25.3447 31.7891 25.3447C31.4062 25.3447 30.8397 25.3455 30.2578 25.5293H17.2402C16.6738 25.5295 16.291 25.912 16.291 26.4785C16.2911 27.0449 16.6586 27.4276 17.2402 27.4277H26.9961C26.2305 28.1934 25.6631 29.3418 25.2803 30.4902H17.2402C16.6738 30.4904 16.291 30.8586 16.291 31.4404C16.2911 32.0068 16.6586 32.3895 17.2402 32.3896H25.0811C25.2648 35.4525 27.3789 37.949 30.0742 38.5156H14.9434C13.0292 38.5155 11.498 36.9836 11.498 35.0693V15.9268C11.4981 14.0126 13.0292 12.4815 14.9434 12.4814H15.8926ZM31.9727 26.8457C33.5041 26.8457 35.0359 27.6112 35.9854 28.7598C36.5673 29.7092 37.1496 30.8583 36.9658 32.0068C36.9658 33.339 36.5821 34.5027 35.8164 35.4521C34.867 36.6006 33.336 37.3661 31.8047 37.3662C29.1095 37.3662 26.8118 35.2686 26.6279 32.3896V32.2061C26.6279 31.6394 26.6287 31.0722 26.8125 30.4902C27.3791 29.1427 28.3437 28.0096 29.4922 27.4277C30.0741 27.0449 30.4415 26.8457 31.0234 26.8457H31.9727ZM34.8369 30.0918C34.4542 29.7092 34.0712 29.7094 33.6885 30.0918L31.207 32.5732L29.875 31.2412C29.4921 30.8584 29.1094 30.8584 28.7266 31.2412C28.3438 31.624 28.3437 32.0068 28.7266 32.3896L30.6406 34.3037C31.0234 34.6865 31.4063 34.6865 31.7891 34.3037L34.8516 31.2412C35.2191 30.8584 35.2198 30.4747 34.8369 30.0918ZM23.7334 10C25.2647 10 26.4293 11.1483 26.6133 12.4805H30.0586V16.126C30.0584 17.0752 29.293 17.8407 28.3438 17.8408H19.3389C18.3742 17.8408 17.6086 17.0753 17.6084 16.126V12.4805H21.0537C21.2376 11.1485 22.3861 10.0003 23.7334 10Z" fill="url(#paint1_linear_1445_6615)"/>
</g>
</g>
<defs>
<linearGradient id="paint0_linear_1445_6615" x1="24" y1="0" x2="24" y2="48" gradientUnits="userSpaceOnUse">
<stop stop-color="#2DB74F"/>
<stop offset="1" stop-color="#ACE5BA"/>
</linearGradient>
<linearGradient id="paint1_linear_1445_6615" x1="24.2492" y1="10" x2="24.2492" y2="38.5156" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0.6"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

@ -0,0 +1,18 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Group 1707486101">
<path id="Rectangle 1430107575" d="M0 24C0 10.7452 10.7452 0 24 0C37.2548 0 48 10.7452 48 24C48 37.2548 37.2548 48 24 48C10.7452 48 0 37.2548 0 24Z" fill="url(#paint0_linear_1445_6625)"/>
<g id="Frame">
<path id="Vector" d="M10 24C10 27.713 11.475 31.274 14.1005 33.8995C16.726 36.525 20.287 38 24 38C27.713 38 31.274 36.525 33.8995 33.8995C36.525 31.274 38 27.713 38 24C38 20.287 36.525 16.726 33.8995 14.1005C31.274 11.475 27.713 10 24 10C20.287 10 16.726 11.475 14.1005 14.1005C11.475 16.726 10 20.287 10 24ZM31.2471 20.4722L22.4568 28.8483C22.2189 29.0747 21.9041 29.2025 21.5758 29.2061C21.2474 29.2098 20.9299 29.0889 20.687 28.8679L17.2686 25.762C17.0134 25.5304 16.8607 25.2069 16.8441 24.8628C16.8358 24.6923 16.8612 24.522 16.9188 24.3614C16.9764 24.2008 17.065 24.0531 17.1797 23.9267C17.2944 23.8004 17.4328 23.6979 17.5871 23.6251C17.7414 23.5522 17.9085 23.5105 18.0789 23.5022C18.4231 23.4856 18.7598 23.6063 19.0149 23.8379L21.5387 26.1331L29.4578 18.593C29.5796 18.4677 29.7254 18.3682 29.8866 18.3006C30.0477 18.233 30.2208 18.1986 30.3956 18.1994C30.5704 18.2003 30.7431 18.2364 30.9036 18.3056C31.0641 18.3748 31.2089 18.4757 31.3295 18.6022C31.4501 18.7286 31.5439 18.8782 31.6053 19.0418C31.6668 19.2054 31.6946 19.3797 31.6871 19.5543C31.6795 19.7289 31.6369 19.9001 31.5616 20.0579C31.4864 20.2156 31.3801 20.3565 31.2491 20.4722H31.2471Z" fill="url(#paint1_linear_1445_6625)"/>
</g>
</g>
<defs>
<linearGradient id="paint0_linear_1445_6625" x1="24" y1="0" x2="24" y2="48" gradientUnits="userSpaceOnUse">
<stop stop-color="#00C3D0"/>
<stop offset="1" stop-color="#ACF6FB"/>
</linearGradient>
<linearGradient id="paint1_linear_1445_6625" x1="24" y1="10" x2="24" y2="38" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0.6"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@ -0,0 +1,18 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Group 1707486098">
<rect id="Rectangle 1430107575" width="48" height="48" rx="24" fill="url(#paint0_linear_1445_6596)"/>
<g id="Frame">
<path id="Union" d="M34.9297 28.6836C35.5059 29.3815 35.8242 30.1492 35.8242 30.9551C35.8242 34.1692 30.7543 36.7744 24.5 36.7744C18.2456 36.7744 13.1758 34.1692 13.1758 30.9551C13.1758 30.1492 13.4941 29.3815 14.0703 28.6836C15.7924 30.769 19.8139 32.2314 24.5 32.2314C29.186 32.2314 33.2076 30.769 34.9297 28.6836ZM34.8613 22.1494C35.4803 22.8685 35.8242 23.6641 35.8242 24.501C35.8242 27.7151 30.7543 30.3213 24.5 30.3213C18.2456 30.3213 13.1758 27.7151 13.1758 24.501C13.1758 23.6641 13.5197 22.8685 14.1387 22.1494C15.8968 24.1919 19.8744 25.6172 24.5 25.6172C29.1256 25.6172 33.1032 24.192 34.8613 22.1494ZM24.5 12.2266C27.5034 12.2266 30.3841 12.8393 32.5078 13.9307C34.6314 15.022 35.824 16.5025 35.8242 18.0459C35.8242 18.8101 35.5319 19.5674 34.9629 20.2734C34.3938 20.9795 33.5593 21.6208 32.5078 22.1611C31.4563 22.7015 30.2078 23.1304 28.834 23.4229C27.46 23.7153 25.9871 23.8662 24.5 23.8662C23.0128 23.8662 21.54 23.7153 20.166 23.4229C18.7922 23.1304 17.5437 22.7015 16.4922 22.1611C15.4407 21.6207 14.6062 20.9795 14.0371 20.2734C13.4681 19.5674 13.1758 18.8101 13.1758 18.0459C13.176 16.5025 14.3686 15.022 16.4922 13.9307C18.6159 12.8393 21.4966 12.2266 24.5 12.2266Z" fill="url(#paint1_linear_1445_6596)"/>
</g>
</g>
<defs>
<linearGradient id="paint0_linear_1445_6596" x1="24" y1="0" x2="24" y2="48" gradientUnits="userSpaceOnUse">
<stop stop-color="#3289FB"/>
<stop offset="1" stop-color="#74B0FF"/>
</linearGradient>
<linearGradient id="paint1_linear_1445_6596" x1="24.5" y1="12.2266" x2="24.5" y2="36.7744" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0.6"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame">
<path id="Vector" d="M11.9705 13.8493L13.7604 7.51594C13.8806 7.09047 13.5609 6.66797 13.1188 6.66797H9.29157C9.05713 6.66797 8.88367 6.4499 8.93637 6.22147L9.10287 5.5C9.2556 4.8382 9.30463 4.15674 9.2482 3.4799L9.21757 3.1122C9.1847 2.71775 9.0131 2.34772 8.7332 2.06784C8.47717 1.81181 8.12993 1.66797 7.76783 1.66797H7.68813C7.46913 1.66797 7.26737 1.78669 7.16103 1.97811L6.275 3.57294C5.87763 4.28824 5.2853 4.87597 4.56697 5.2678L2.34743 6.47847C2.13326 6.59527 2 6.81977 2 7.06374V13.668C2 14.0362 2.29848 14.3346 2.66667 14.3346H11.329C11.6273 14.3346 11.8894 14.1364 11.9705 13.8493Z" stroke="#1D2129" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 803 B

@ -0,0 +1,183 @@
$primaryBlue: #2a7bfa;
$primaryLightBlue: #3289fb;
// ==========================================================
// orders / hospitals / accounts
// !important el-table /
// ==========================================================
// + +
.admin-page-card {
background: #ffffff !important;
border-radius: 16px !important;
border: 1px solid #e0e0e0 !important;
padding: 24px !important;
// order-list
// - el-table cell 线
// - +
.admin-table {
border-radius: 12px !important;
border: 1px solid #e0e0e0 !important;
overflow: hidden !important;
&::before,
&::after {
display: none !important;
}
.el-table__inner-wrapper::before,
.el-table__inner-wrapper::after {
display: none !important;
}
.admin-table-header-cell {
background: #f9faff !important;
font-weight: 500 !important;
font-size: 14px !important;
color: #666666 !important;
line-height: 18px !important;
}
}
//
.admin-filter-form {
margin-bottom: 16px !important;
display: flex !important;
flex-wrap: wrap !important;
gap: 12px !important;
align-items: center !important;
.el-form-item,
.el-button {
margin: 0 !important;
}
.el-button:last-child {
margin-left: auto !important;
}
}
//
.admin-pagination {
margin-top: 16px !important;
display: flex !important;
justify-content: flex-end !important;
}
}
// ==========================================================
// label / value
// ==========================================================
.admin-detail-block {
.detail-row + .detail-row {
margin-top: 15px !important;
}
.detail-row {
display: grid !important;
gap: 15px !important;
&.row-1 {
grid-template-columns: 1fr !important;
}
&.row-2 {
grid-template-columns: repeat(2, 1fr) !important;
}
&.row-3 {
grid-template-columns: repeat(3, 1fr) !important;
}
&.row-4 {
grid-template-columns: repeat(4, 1fr) !important;
}
.detail-item {
display: flex !important;
align-items: center !important;
gap: 10px !important;
.label {
font-weight: 400 !important;
font-size: 14px !important;
color: #1d2129 !important;
width: 4em !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
flex-shrink: 0 !important;
flex-grow: 0 !important;
}
.value {
flex-shrink: 1 !important;
flex-grow: 1 !important;
font-weight: 400 !important;
font-size: 14px !important;
color: #666666 !important;
padding: 10px !important;
background: #f8f8f8 !important;
border-radius: 8px !important;
border: 1px solid #e0e0e0 !important;
min-width: 0 !important;
}
}
}
}
// plain hover/active
.el-button.is-plain {
background-color: transparent !important;
--el-button-hover-bg-color: var(--el-button-bg-color) !important;
--el-button-hover-text-color: var(--el-button-text-color) !important;
--el-button-hover-border-color: var(--el-button-border-color) !important;
--el-button-active-bg-color: var(--el-button-bg-color) !important;
--el-button-active-text-color: var(--el-button-text-color) !important;
--el-button-active-border-color: var(--el-button-border-color) !important;
}
.el-button {
font-weight: 500 !important;
font-size: 14px !important;
padding: 8px 30px !important;
line-height: 18px !important;
border-radius: 8px !important;
&.smaller {
padding: 6px 15px !important;
}
&.rosein-sign {
--el-button-border-color: #cb30e0 !important;
&.is-plain {
--el-button-text-color: #cb30e0 !important;
}
}
&.purple-sign {
--el-button-border-color: #6155f5 !important;
&.is-plain {
--el-button-text-color: #6155f5 !important;
}
}
&.blue-sign {
--el-button-border-color: #3289fb !important;
&.is-plain {
--el-button-text-color: #3289fb !important;
}
}
&.cyan-sign {
--el-button-border-color: #00c0e8 !important;
&.is-plain {
--el-button-text-color: #00c0e8 !important;
}
}
}

@ -831,9 +831,10 @@ img {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
.body-title {
font-weight: normal;
font-weight: 600;
font-size: 20px;
color: #1d2129;
}
@ -857,3 +858,21 @@ img {
border-top: 1px solid #e0e0e0;
}
}
.border-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 16px;
color: #1d2129;
&::before {
content: "";
display: block;
width: 4px;
height: 16px;
background: #3289fb;
border-radius: 2px 2px 2px 2px;
}
}

@ -281,7 +281,7 @@ const handleCommand = async (cmd) => {
width: 100vw;
height: 100vh;
background-color: #f9faff;
padding: 32px 32px 84px;
padding: 32px;
gap: 23px;
background-image: url("@/assets/image/layout-bg.svg");
background-repeat: no-repeat;
@ -410,7 +410,7 @@ const handleCommand = async (cmd) => {
align-items: center;
&:hover {
background-color: rgba(50, 137, 251, 0.08) !important;
// background-color: rgba(50, 137, 251, 0.08) !important;
color: #3289fb !important;
}
}
@ -432,7 +432,8 @@ const handleCommand = async (cmd) => {
.admin-content {
flex: 1;
overflow: auto;
overflow-x: hidden;
overflow-y: auto;
}
}
@ -461,6 +462,15 @@ const handleCommand = async (cmd) => {
</style>
<style lang="scss">
.admin-layout {
.el-menu-item:not(.is-disabled):focus,
.el-menu--horizontal .el-menu-item:not(.is-disabled):hover {
background-color: transparent !important;
}
}
.theme-dialog {
width: max-content;
border-radius: 24px 24px 24px 24px;

@ -0,0 +1,250 @@
/**
* DOCX composable
*
* useReportPdfExport , .docx :
* - html2canvas .body-wrap canvas
* - canvas PNG bytes
* - docx PNG A4 docx
*
* :
* const { exportReportDocx } = useReportDocxExport()
* await exportReportDocx(bodyWrapEl, 'report.docx')
*/
import html2canvas from 'html2canvas'
import {
AlignmentType,
Document,
HeightRule,
ImageRun,
Packer,
PageOrientation,
Paragraph,
} from 'docx'
import { saveAs } from 'file-saver'
import { ElMessage } from 'element-plus'
// A4 尺寸(单位 twip,1 inch = 1440 twip, 1mm ≈ 56.6929 twip)
const A4_WIDTH_TWIP = 11906
const A4_HEIGHT_TWIP = 16838
const MARGIN_TWIP = 850 // 约 15mm
export interface ExportReportDocxOptions {
/** 离屏容器宽度,默认 800 */
width?: number
/** 缩放比例,值越大越清晰,默认 2 */
scale?: number
/** 临时隐藏元素的选择器列表 */
hideSelectors?: string[]
/** 是否不显示内部 loading 提示 */
silent?: boolean
}
/**
* canvas PNG ArrayBuffer (docx ImageRun Uint8Array)
*/
function canvasToPngBuffer(canvas: HTMLCanvasElement): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
canvas.toBlob(async (blob) => {
if (!blob) {
reject(new Error('canvas 转 Blob 失败'))
return
}
try {
const buf = await blob.arrayBuffer()
resolve(new Uint8Array(buf))
} catch (e) {
reject(e)
}
}, 'image/png')
})
}
export function useReportDocxExport() {
/**
* DOM DOCX
*/
const exportReportDocx = async (
sourceEl: HTMLElement,
fileName: string,
options: ExportReportDocxOptions = {},
): Promise<void> => {
if (!sourceEl) {
ElMessage.error('未找到可导出的报告内容')
return
}
const {
width = 800,
scale = 2,
hideSelectors = ['.body-header', '.el-loading-mask', '.el-loading-spinner'],
silent = false,
} = options
// 1) 记录原父节点 + 下一个兄弟节点,截图完毕后还原
const originalParent = sourceEl.parentNode as HTMLElement
const originalNext = sourceEl.nextSibling as ChildNode | null
// 2) 读取 sourceEl 的计算样式,移动后强制 inline 注入 padding
const originalStyleAttr = sourceEl.getAttribute('style') || ''
const computed = window.getComputedStyle(sourceEl)
sourceEl.style.paddingTop = computed.paddingTop
sourceEl.style.paddingRight = computed.paddingRight
sourceEl.style.paddingBottom = computed.paddingBottom
sourceEl.style.paddingLeft = computed.paddingLeft
// 3) 临时隐藏指定元素
const hiddenDisplays: Array<{ el: HTMLElement; prev: string }> = []
hideSelectors.forEach((sel) => {
sourceEl.querySelectorAll<HTMLElement>(sel).forEach((el) => {
hiddenDisplays.push({ el, prev: el.style.display })
el.style.display = 'none'
})
})
// 4) 创建离屏容器
const offScreen = document.createElement('div')
offScreen.style.cssText = [
'position: fixed',
'left: -99999px',
'top: 0',
`width: ${width}px`,
'background: #ffffff',
'z-index: -1',
'pointer-events: none',
].join(';')
document.body.appendChild(offScreen)
offScreen.appendChild(sourceEl)
// 4.1) 强制 sourceEl 宽度,让 ECharts 按目标宽度重绘 canvas
const prevWidth = sourceEl.style.width
const prevMaxWidth = sourceEl.style.maxWidth
const prevBoxSizing = sourceEl.style.boxSizing
sourceEl.style.width = `${width}px`
sourceEl.style.maxWidth = `${width}px`
sourceEl.style.boxSizing = 'border-box'
let loadingMsg: { close: () => void } | null = null
if (!silent) {
loadingMsg = ElMessage({
message: '正在生成 Word…',
type: 'info',
duration: 0,
})
}
try {
// 给浏览器一帧时间让样式/layout 生效
await new Promise((r) => requestAnimationFrame(() => r(null)))
await new Promise((r) => requestAnimationFrame(() => r(null)))
// 等 ECharts canvas 完成重绘
await new Promise((r) => setTimeout(r, 1500))
const canvas = await html2canvas(sourceEl, {
scale,
backgroundColor: '#ffffff',
logging: false,
useCORS: false,
allowTaint: true,
windowWidth: width,
foreignObjectRendering: false,
})
// canvas → PNG bytes
const pngBytes = await canvasToPngBuffer(canvas)
// docx 库 IMediaTransformation.width/height 单位是 **像素(px)**
// A4 可用宽度: 210mm - 2*15mm(边距) = 180mm
// 按 96 DPI: 180mm = 7.09 inch = 680 px
const usableWidthPx = 680
const ratio = canvas.height / canvas.width
const imgHeightPx = Math.round(usableWidthPx * ratio)
// 构造 docx 文档
const doc = new Document({
creator: 'CSM Report',
title: fileName.replace(/\.docx$/i, ''),
styles: {
default: {
document: {
run: {
font: 'Microsoft YaHei', // 中文字体
},
},
},
},
sections: [
{
properties: {
page: {
size: {
orientation: PageOrientation.PORTRAIT,
width: A4_WIDTH_TWIP,
height: A4_HEIGHT_TWIP,
},
margin: {
top: MARGIN_TWIP,
right: MARGIN_TWIP,
bottom: MARGIN_TWIP,
left: MARGIN_TWIP,
},
},
},
children: [
new Paragraph({
alignment: AlignmentType.CENTER,
spacing: {
after: 0,
line: 240,
lineRule: HeightRule.AUTO,
},
children: [
new ImageRun({
type: 'png', // ← 必填,指定图片类型,否则 Word/WPS 解析失败
data: pngBytes,
transformation: {
width: usableWidthPx, // ← 像素
height: imgHeightPx, // ← 像素
},
}),
],
}),
],
},
],
})
// 生成 .docx blob 并下载
const blob = await Packer.toBlob(doc)
saveAs(blob, fileName || 'report.docx')
if (!silent) ElMessage.success('Word 下载成功')
} catch (err) {
console.error('[useReportDocxExport] Word 生成失败:', err)
if (!silent) ElMessage.error((err as Error)?.message || 'Word 生成失败')
throw err
} finally {
loadingMsg?.close()
// 恢复隐藏元素的 display
hiddenDisplays.forEach(({ el, prev }) => {
el.style.display = prev
})
// 把原 DOM 移回原位置
if (offScreen.parentNode) {
offScreen.parentNode.removeChild(offScreen)
}
if (originalNext && originalNext.parentNode === originalParent) {
originalParent.insertBefore(sourceEl, originalNext)
} else {
originalParent.appendChild(sourceEl)
}
// 恢复 sourceEl 原 inline style
sourceEl.setAttribute('style', originalStyleAttr)
sourceEl.style.width = prevWidth
sourceEl.style.maxWidth = prevMaxWidth
sourceEl.style.boxSizing = prevBoxSizing
}
}
return { exportReportDocx }
}

@ -0,0 +1,194 @@
/**
* PDF composable
*
* DOM ,
* jsPDF A4 , PDF
*
* :
* - DOM( canvas/svg scoped )
* - DOM ,
* - ( .body-header / .el-loading-mask)
*
* :
* const { exportReportPdf } = useReportPdfExport()
* await exportReportPdf(bodyWrapEl, 'report.pdf')
*/
import html2canvas from 'html2canvas'
import jsPDF from 'jspdf'
import { ElMessage } from 'element-plus'
export interface ExportReportOptions {
/** 离屏容器宽度,默认 800 */
width?: number
/** 缩放比例,值越大越清晰,默认 2 */
scale?: number
/** JPEG 质量 0~1,默认 0.95 */
quality?: number
/** 临时隐藏元素的选择器列表(这些元素在截图前 display:none,完成后恢复) */
hideSelectors?: string[]
/** 是否不显示内部 loading 提示(由调用方自己控制) */
silent?: boolean
}
export function useReportPdfExport() {
/**
* DOM PDF
* @param sourceEl DOM ( .body-wrap)
* @param fileName , .pdf
* @param options
*/
const exportReportPdf = async (
sourceEl: HTMLElement,
fileName: string,
options: ExportReportOptions = {},
): Promise<void> => {
if (!sourceEl) {
ElMessage.error('未找到可导出的报告内容')
return
}
const {
width = 800,
scale = 2,
quality = 0.95,
hideSelectors = ['.body-header', '.el-loading-mask', '.el-loading-spinner'],
silent = false,
} = options
// 1) 记录原父节点 + 下一个兄弟节点,截图完毕后还原
const originalParent = sourceEl.parentNode as HTMLElement
const originalNext = sourceEl.nextSibling as ChildNode | null
// 2) 读取 sourceEl 的计算样式,移动后强制 inline 注入 padding(防止全局样式在离屏环境下失效)
const originalStyleAttr = sourceEl.getAttribute('style') || ''
const computed = window.getComputedStyle(sourceEl)
sourceEl.style.paddingTop = computed.paddingTop
sourceEl.style.paddingRight = computed.paddingRight
sourceEl.style.paddingBottom = computed.paddingBottom
sourceEl.style.paddingLeft = computed.paddingLeft
// 3) 临时隐藏指定元素(完成后恢复原 display 值)
const hiddenDisplays: Array<{ el: HTMLElement; prev: string }> = []
hideSelectors.forEach((sel) => {
sourceEl.querySelectorAll<HTMLElement>(sel).forEach((el) => {
hiddenDisplays.push({ el, prev: el.style.display })
el.style.display = 'none'
})
})
// 4) 创建离屏容器,把原 DOM 移进去(不克隆,保留 canvas/svg/scoped 样式)
const offScreen = document.createElement('div')
offScreen.style.cssText = [
'position: fixed',
'left: -99999px',
'top: 0',
`width: ${width}px`,
'background: #ffffff',
'z-index: -1',
'pointer-events: none',
].join(';')
document.body.appendChild(offScreen)
offScreen.appendChild(sourceEl)
// 4.1) 强制 sourceEl 宽度,让 ECharts/vue-echarts 按目标宽度重绘 canvas
const prevWidth = sourceEl.style.width
const prevMaxWidth = sourceEl.style.maxWidth
const prevBoxSizing = sourceEl.style.boxSizing
sourceEl.style.width = `${width}px`
sourceEl.style.maxWidth = `${width}px`
sourceEl.style.boxSizing = 'border-box'
let loadingMsg: { close: () => void } | null = null
if (!silent) {
loadingMsg = ElMessage({
message: '正在生成 PDF…',
type: 'info',
duration: 0,
})
}
try {
// 给浏览器一帧时间让样式/layout 生效
await new Promise((r) => requestAnimationFrame(() => r(null)))
await new Promise((r) => requestAnimationFrame(() => r(null)))
// 给 ECharts canvas 充分时间完成 resize + 重绘(异步绘制,800ms 不够,延长到 1500ms)
await new Promise((r) => setTimeout(r, 1500))
const canvas = await html2canvas(sourceEl, {
scale,
backgroundColor: '#ffffff',
logging: true,
useCORS: false, // 全部本地资源
allowTaint: true,
windowWidth: width,
foreignObjectRendering: false,
// onclone 兜底:html2canvas 克隆出的 DOM 里,保护 canvas 不被丢弃 + 表格样式正常
onclone: (clonedDoc) => {
clonedDoc.querySelectorAll('canvas').forEach((c) => {
;(c as HTMLElement).style.display = 'block'
;(c as HTMLElement).style.visibility = 'visible'
})
clonedDoc.querySelectorAll('.report-table').forEach((t) => {
;(t as HTMLElement).style.tableLayout = 'auto'
})
},
})
const imgData = canvas.toDataURL('image/jpeg', quality)
const pdf = new jsPDF('p', 'mm', 'a4')
const pdfWidth = pdf.internal.pageSize.getWidth() // 210mm
const pdfHeight = pdf.internal.pageSize.getHeight() // 297mm
const imgWidth = pdfWidth
const imgHeight = (canvas.height * imgWidth) / canvas.width
let heightLeft = imgHeight
let position = 0
// 第一页(从顶部开始)
pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight)
heightLeft -= pdfHeight
// 超出 A4 一页时,继续分页
while (heightLeft > 0) {
position = heightLeft - imgHeight
pdf.addPage()
pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight)
heightLeft -= pdfHeight
}
pdf.save(fileName)
if (!silent) ElMessage.success('PDF 下载成功')
} catch (err) {
console.error('[useReportPdfExport] PDF 生成失败:', err)
if (!silent) ElMessage.error((err as Error)?.message || 'PDF 生成失败')
throw err
} finally {
loadingMsg?.close()
// 恢复隐藏元素的 display
hiddenDisplays.forEach(({ el, prev }) => {
el.style.display = prev
})
// 把原 DOM 移回原位置
if (offScreen.parentNode) {
offScreen.parentNode.removeChild(offScreen)
}
if (originalNext && originalNext.parentNode === originalParent) {
originalParent.insertBefore(sourceEl, originalNext)
} else {
originalParent.appendChild(sourceEl)
}
// 恢复 sourceEl 原 inline style(去掉临时注入的 padding 和宽度)
sourceEl.setAttribute('style', originalStyleAttr)
sourceEl.style.width = prevWidth
sourceEl.style.maxWidth = prevMaxWidth
sourceEl.style.boxSizing = prevBoxSizing
}
}
return { exportReportPdf }
}

@ -68,3 +68,102 @@ export const resetAdminAccountPassword = (id) => {
export const changeMyPassword = (data) => {
return kcRequest.request(`/admin/accounts/change-password`, "put", data, true, "json", true);
};
// ============== 服务报告 ==============
// 报告列表
export const getAdminReports = (params) => {
return kcRequest.request(`/admin/reports`, "get", params);
};
// 报告详情
export const getAdminReportDetail = (id) => {
return kcRequest.request(`/admin/reports/${id}`, "get");
};
// 生成报告
export const generateAdminReport = (data) => {
return kcRequest.request(`/admin/reports/generate`, "post", data);
};
// 更新备注
export const updateAdminReportRemark = (id, data) => {
return kcRequest.request(`/admin/reports/${id}/remark`, "put", data);
};
// 切换发送状态
export const toggleAdminReportSendStatus = (id) => {
return kcRequest.request(`/admin/reports/${id}/send-status`, "put");
};
// 下载报告文件(返回 Blob
export const downloadAdminReport = (id, format) => {
return kcRequest.downloadFile(`/admin/reports/${id}/download?format=${format}`);
};
// ============== 客户服务1.6 ==============
// 维保列表
export const getAdminMaintenance = (params) => {
return kcRequest.request(`/admin/customer-service/maintenance`, "get", params);
};
// 维保详情
export const getAdminMaintenanceDetail = (hospitalId) => {
return kcRequest.request(`/admin/customer-service/maintenance/${hospitalId}`, "get");
};
// 更新维保信息
export const updateAdminMaintenance = (hospitalId, data) => {
return kcRequest.request(`/admin/customer-service/maintenance/${hospitalId}`, "put", data);
};
// 月视图数据
export const getAdminMonthlyView = (params) => {
return kcRequest.request(`/admin/customer-service/monthly-view`, "get", params);
};
// 本月到期医院
export const getAdminExpiring = () => {
return kcRequest.request(`/admin/customer-service/expiring`, "get");
};
// 电话回访记录
export const getAdminCalls = (hospitalId, params) => {
return kcRequest.request(`/admin/customer-service/${hospitalId}/calls`, "get", params);
};
export const addAdminCall = (hospitalId, data) => {
return kcRequest.request(`/admin/customer-service/${hospitalId}/calls`, "post", data);
};
// 现场巡检记录
export const getAdminInspections = (hospitalId) => {
return kcRequest.request(`/admin/customer-service/${hospitalId}/inspections`, "get");
};
export const addAdminInspection = (hospitalId, data) => {
// 新增现场巡检multipart/form-data承载表单字段与巡检报告 files[]
return kcRequest.uploadFile(`/admin/customer-service/${hospitalId}/inspections`, data);
};
// 培训记录
export const getAdminTrainings = (hospitalId) => {
return kcRequest.request(`/admin/customer-service/${hospitalId}/trainings`, "get");
};
export const addAdminTraining = (hospitalId, data) => {
return kcRequest.request(`/admin/customer-service/${hospitalId}/trainings`, "post", data);
};
// 纪念品记录
export const getAdminGifts = (hospitalId) => {
return kcRequest.request(`/admin/customer-service/${hospitalId}/gifts`, "get");
};
export const addAdminGift = (hospitalId, data) => {
return kcRequest.request(`/admin/customer-service/${hospitalId}/gifts`, "post", data);
};
// ============== 客户经理积分1.7 ==============
// 积分统计
export const getManagerPointsStats = (params) => {
return kcRequest.request(`/admin/manager-points/stats`, "get", params);
};
// 积分排行榜
export const getManagerPointsLeaderboard = (params) => {
return kcRequest.request(`/admin/manager-points/leaderboard`, "get", params);
};
// 积分规则
export const getManagerPointsRules = () => {
return kcRequest.request(`/admin/manager-points/rules`, "get");
};
// 客户经理积分明细
export const getManagerPointsDetails = (managerId, params) => {
return kcRequest.request(`/admin/manager-points/${managerId}/details`, "get", params);
};
// 手动调整积分
export const awardManagerPoints = (data) => {
return kcRequest.request(`/admin/manager-points/award`, "post", data);
};

@ -0,0 +1,38 @@
// 医院列表 Store管理后台通用
// 多个页面orders/list.vue、orders/report.vue 等)需要按医院筛选,
// 这里把"加载医院列表 + 缓存"抽出来,避免每个页面重复请求。
import { defineStore } from "pinia";
import { ref } from "vue";
import { getAdminHospitals } from "@/service/modular/admin";
export const useHospitalStore = defineStore("hospital", () => {
const list = ref([]);
const loaded = ref(false);
const loading = ref(false);
// 加载医院列表(仅首次加载;之后可手动调用 refresh 强制刷新)
const ensureLoaded = async (force = false) => {
if (loading.value) return;
// if (loaded.value && !force) return;
loading.value = true;
try {
const res = await getAdminHospitals({ page: 1, pageSize: 1000 });
list.value = res?.list || [];
loaded.value = true;
} catch (_) {
list.value = [];
} finally {
loading.value = false;
}
};
const refresh = () => ensureLoaded(true);
return {
list,
loaded,
loading,
ensureLoaded,
refresh,
};
});

@ -0,0 +1,448 @@
<template>
<el-dialog
:model-value="visible"
class="account-edit-dialog"
top="6vh"
width="640px"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="(v) => !v && close()"
>
<div class="body-wrap">
<div class="body-header">
<span class="body-title">
{{ isEdit ? $t('modal.editAccount') : $t('modal.addAccount') }}
</span>
<img src="@/assets/image/close-btn.svg" alt="" class="close-btn" @click="close" />
</div>
<div class="account-edit" v-loading="loading">
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
label-position="right"
>
<!-- 医院账号字段编辑时 医院名称/账号(只读)/姓名/性别新增时 医院名称/账号/密码/姓名/性别 -->
<template v-if="props.userType === 'Hospital'">
<!-- 新增模式 -->
<template v-if="!isEdit">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item :label="$t('table.hospital')" prop="hospitalId">
<el-select
v-model="form.hospitalId"
:placeholder="$t('msg.pleaseSelectHospital')"
filterable
clearable
style="width: 100%"
>
<el-option
v-for="item in hospitalOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="$t('table.account')" prop="username">
<el-input v-model="form.username" :placeholder="$t('placeholder.account')" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item :label="$t('label.password')" prop="password">
<el-input
v-model="form.password"
:type="showPwd ? 'text' : 'password'"
autocomplete="new-password"
:placeholder="$t('placeholder.password')"
>
<template #suffix>
<el-icon class="pwd-toggle-icon" @click="showPwd = !showPwd">
<View v-if="showPwd" />
<Hide v-else />
</el-icon>
</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="$t('table.name')" prop="name">
<el-input v-model="form.name" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item :label="$t('table.gender')">
<el-select v-model="form.gender" style="width: 100%">
<el-option label="男" value="男" />
<el-option label="女" value="女" />
</el-select>
</el-form-item>
</el-col>
</el-row>
</template>
<!-- 编辑模式一行两个 -->
<template v-else>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item :label="$t('table.hospital')" prop="hospitalId">
<el-select
:key="`hospital-${hospitalOptions.length}`"
v-model="form.hospitalId"
:placeholder="$t('msg.pleaseSelectHospital')"
filterable
clearable
style="width: 100%"
>
<el-option
v-for="item in hospitalOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="$t('table.account')" prop="username">
<el-input v-model="form.username" disabled :placeholder="$t('placeholder.account')" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item :label="$t('table.name')" prop="name">
<el-input v-model="form.name" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="$t('table.gender')">
<el-select v-model="form.gender" style="width: 100%">
<el-option label="男" value="男" />
<el-option label="女" value="女" />
</el-select>
</el-form-item>
</el-col>
</el-row>
</template>
</template>
<!-- 管理员账号字段编辑时 账号(只读)/姓名/性别/角色新增时 账号/密码/姓名/性别/角色 -->
<template v-else>
<!-- 新增模式 -->
<template v-if="!isEdit">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item :label="$t('table.account')" prop="username">
<el-input v-model="form.username" :placeholder="$t('placeholder.account')" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="$t('label.password')" prop="password">
<el-input
v-model="form.password"
:type="showPwd ? 'text' : 'password'"
autocomplete="new-password"
:placeholder="$t('placeholder.password')"
>
<template #suffix>
<el-icon class="pwd-toggle-icon" @click="showPwd = !showPwd">
<View v-if="showPwd" />
<Hide v-else />
</el-icon>
</template>
</el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item :label="$t('table.name')" prop="name">
<el-input v-model="form.name" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="$t('table.gender')">
<el-select v-model="form.gender" style="width: 100%">
<el-option label="男" value="男" />
<el-option label="女" value="女" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item :label="$t('table.role')" prop="role">
<el-select v-model="form.role" :placeholder="$t('table.role')" style="width: 100%">
<el-option label="超级管理员" value="超级管理员" />
<el-option label="客户经理" value="客户经理" />
</el-select>
</el-form-item>
</el-col>
</el-row>
</template>
<!-- 编辑模式一行两个 -->
<template v-else>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item :label="$t('table.account')" prop="username">
<el-input v-model="form.username" disabled :placeholder="$t('placeholder.account')" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="$t('table.name')" prop="name">
<el-input v-model="form.name" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item :label="$t('table.gender')">
<el-select v-model="form.gender" style="width: 100%">
<el-option label="男" value="男" />
<el-option label="女" value="女" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="$t('table.role')" prop="role">
<el-select v-model="form.role" :placeholder="$t('table.role')" style="width: 100%">
<el-option label="超级管理员" value="超级管理员" />
<el-option label="客户经理" value="客户经理" />
</el-select>
</el-form-item>
</el-col>
</el-row>
</template>
</template>
</el-form>
</div>
</div>
<div class="body-footer">
<el-button @click="close">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="submitting" @click="submit">
{{ isEdit ? $t('common.save') : $t('common.add') }}
</el-button>
</div>
</el-dialog>
</template>
<script setup>
import { computed, reactive, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { View, Hide } from "@element-plus/icons-vue";
import { useI18n } from "vue-i18n";
import {
createAdminAccount,
updateAdminAccount,
getAdminHospitals,
} from "@/service/modular/admin";
const { t: $t } = useI18n();
const props = defineProps({
visible: { type: Boolean, default: false },
accountId: { type: [String, Number], default: "" },
//
accountData: { type: Object, default: () => ({}) },
userType: { type: String, default: "Hospital" },
});
const emit = defineEmits(["update:visible", "success"]);
const loading = ref(false);
const submitting = ref(false);
const formRef = ref();
const isEdit = computed(() => !!props.accountId);
// el-input show-password
const showPwd = ref(false);
const hospitalOptions = ref([]);
const hospitalsLoaded = ref(false);
const buildForm = () => ({
username: "",
password: "",
name: "",
gender: "男",
userType: props.userType,
hospitalId: undefined,
role: "",
});
const form = reactive(buildForm());
const rules = {
username: [
{ required: true, message: () => $t('placeholder.account'), trigger: "blur" },
],
password: [
{ required: true, message: () => $t('placeholder.password'), trigger: "blur" },
],
name: [
{ required: true, message: () => $t('account.pleaseInputName'), trigger: "blur" },
],
hospitalId: [
{
validator: (_, value, cb) => {
if (props.userType === 'Hospital' && !value) {
return cb(new Error($t('msg.pleaseSelectHospital')));
}
cb();
},
trigger: "change",
},
],
role: [
{
validator: (_, value, cb) => {
if (props.userType === 'Admin' && !value) {
return cb(new Error($t('table.role')));
}
cb();
},
trigger: "change",
},
],
};
const resetForm = () => {
Object.assign(form, buildForm());
formRef.value?.clearValidate();
};
const loadHospitals = async () => {
if (hospitalsLoaded.value) return;
try {
const res = await getAdminHospitals();
hospitalOptions.value = res?.list || [];
hospitalsLoaded.value = true;
} catch (e) {
// request
}
};
const fillFormFromRow = async () => {
loading.value = true;
try {
// el-select option
if (props.userType === 'Hospital') {
await loadHospitals();
}
const row = props.accountData || {};
// hospitalName name id
const matched = hospitalOptions.value.find((h) => h.name === row.hospitalName);
Object.assign(form, {
username: row.username || "",
name: row.name,
gender: row.gender || "男",
userType: row.userType || props.userType,
hospitalId: matched?.id,
role: row.role,
});
} finally {
loading.value = false;
}
};
const submit = async () => {
await formRef.value.validate(async (valid) => {
if (!valid) return;
submitting.value = true;
try {
if (isEdit.value) {
await updateAdminAccount(props.accountId, {
name: form.name,
gender: form.gender,
hospitalId: form.hospitalId,
role: form.role,
});
ElMessage.success($t('msg.accountEditSuccess'));
} else {
await createAdminAccount({
username: form.username,
password: form.password,
name: form.name,
gender: form.gender,
userType: props.userType,
hospitalId: form.hospitalId,
role: form.role,
});
ElMessage.success($t('msg.accountAddSuccess'));
}
emit("success");
emit("update:visible", false);
} finally {
submitting.value = false;
}
});
};
const close = () => emit("update:visible", false);
watch(
() => [props.visible, props.accountId, props.userType],
([vis, id]) => {
if (vis) {
showPwd.value = false;
resetForm();
if (id) fillFormFromRow();
}
},
{ immediate: true }
);
</script>
<style scoped lang="scss">
.account-edit {
max-height: 60vh;
overflow-y: auto;
overflow-x: hidden;
padding-right: 4px;
}
.pwd-toggle-icon {
cursor: pointer;
color: #909399;
display: inline-flex;
align-items: center;
height: 100%;
font-size: 16px;
}
</style>
<style lang="scss">
.account-edit-dialog {
min-width: 520px;
max-width: 90vw;
.body-header {
margin-bottom: 18px;
}
// +
.el-input.is-show-password .el-input__suffix,
.el-input.is-show-password .el-input__suffix-inner {
width: 30px !important;
min-width: 30px !important;
}
.el-input.is-show-password .el-input__inner {
padding-right: 50px !important;
box-sizing: border-box;
}
// /
.el-input.is-show-password,
.el-input.is-show-password .el-input__inner,
.el-input.is-show-password .el-input__suffix {
transition: none !important;
}
}
</style>

@ -0,0 +1,102 @@
<template>
<el-dialog :model-value="visible" class="reset-password-dialog" top="15vh" width="460px" destroy-on-close
:close-on-click-modal="false" @update:model-value="(v) => !v && close()">
<div class="body-wrap">
<div class="body-header">
<span class="body-title">{{ $t('account.resetPassword') }}</span>
<img src="@/assets/image/close-btn.svg" alt="" class="close-btn" @click="close" />
</div>
<div class="reset-password-content">
<p class="confirm-line">
{{ $t('account.confirmReset', { id: ` ${username} ` }) }}
</p>
<p class="default-line">{{ $t('account.defaultPassword') }}</p>
</div>
</div>
<div class="body-footer">
<el-button @click="close">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="submitting" @click="submit">
{{ $t('account.resetPassword') }}
</el-button>
</div>
</el-dialog>
</template>
<script setup>
import { ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { useI18n } from "vue-i18n";
import { resetAdminAccountPassword } from "@/service/modular/admin";
const { t: $t } = useI18n();
const props = defineProps({
visible: { type: Boolean, default: false },
// username
accountData: { type: Object, default: () => ({}) },
});
const emit = defineEmits(["update:visible", "success"]);
const submitting = ref(false);
// dialog username
const username = ref("");
watch(
() => [props.visible, props.accountData],
([vis]) => {
if (vis) {
username.value = props.accountData?.username || "";
}
},
{ immediate: true }
);
const close = () => emit("update:visible", false);
const submit = async () => {
if (!props.accountData?.id) return;
submitting.value = true;
try {
await resetAdminAccountPassword(props.accountData.id);
ElMessage.success($t('msg.passwordResetSuccess'));
emit("success");
emit("update:visible", false);
} finally {
submitting.value = false;
}
};
</script>
<style scoped lang="scss">
.reset-password-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
.confirm-line {
font-weight: 400;
font-size: 16px;
color: #1D2129;
}
.default-line {
background: #EAF3FF;
border-radius: 8px 8px 8px 8px;
font-weight: 400;
font-size: 15px;
color: #3289FB;
padding: 10px 12px;
}
}
</style>
<style lang="scss">
.reset-password-dialog {
min-width: 410px;
max-width: 90vw;
}
</style>

@ -1,90 +1,91 @@
<template>
<div class="account-list">
<el-card shadow="never">
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item :label="$t('label.accountType')">
<el-select v-model="filters.type" :placeholder="$t('common.all')" clearable style="width:140px">
<el-option label="管理员" value="Admin" />
<el-option label="医院端" value="Hospital" />
</el-select>
</el-form-item>
<el-form-item :label="$t('label.nameAccount')">
<el-input v-model="filters.keyword" :placeholder="$t('placeholder.nameOrAccount')" clearable style="width:200px" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSearch">{{ $t('btn.query') }}</el-button>
<el-button @click="onReset">{{ $t('btn.reset') }}</el-button>
<el-button type="success" @click="goAdd">{{ $t('common.add') }}</el-button>
</el-form-item>
</el-form>
<el-table :data="list" v-loading="loading" border stripe>
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
<el-table-column :label="$t('table.account')" prop="username" width="140" />
<el-table-column :label="$t('table.name')" prop="name" width="120" />
<el-table-column :label="$t('label.accountType')" width="100">
<div class="account-list admin-page-card">
<div class="header-bar">
<div class="period-tabs">
<div v-for="t in accountTabs" :key="t.value" :class="['period-tab', { active: activeType === t.value }]"
@click="onPickType(t.value)">
{{ t.label }}
</div>
</div>
<el-button type="primary" class="blue-sign add-btn" @click="goAdd">
<img class="add-icon" src="@/assets/image/admin/accounts/add.svg" alt=""> {{ addBtnText }}
</el-button>
</div>
<el-table class="admin-table" :data="list" v-loading="loading" header-cell-class-name="admin-table-header-cell"
stripe>
<el-table-column :label="$t('table.account')" prop="username" width="auto" />
<el-table-column v-if="activeType === 'Hospital'" :label="$t('table.hospital')" prop="hospitalName"
min-width="180" show-overflow-tooltip />
<el-table-column :label="$t('table.name')" prop="name" width="auto" />
<el-table-column :label="$t('table.gender')" prop="gender" width="auto" />
<el-table-column v-if="activeType === 'Admin'" :label="$t('table.role')" prop="role" width="auto" />
<el-table-column :label="$t('label.status')" width="auto">
<template #default="{ row }">
<el-tag :type="row.userType === 'Admin' ? 'danger' : 'primary'" size="small">
{{ row.userType === "Admin" ? "管理员" : "医院端" }}
</el-tag>
<el-switch :model-value="row.status === ''" @change="toggleStatus(row)" :loading="row.__loading"
:active-text="$t('account.enable')" :inactive-text="$t('account.disable')" inline-prompt />
</template>
</el-table-column>
<el-table-column :label="$t('table.role')" prop="role" width="120" />
<el-table-column :label="$t('table.hospital')" prop="hospitalName" min-width="160" show-overflow-tooltip />
<el-table-column :label="$t('label.status')" width="100">
<el-table-column :label="$t('table.createTime')" width="auto">
<template #default="{ row }">
<el-switch
:model-value="row.status === '启用' ? true : false"
@change="toggleStatus(row)"
:loading="row.__loading"
:active-text="$t('account.enable')"
:inactive-text="$t('account.disable')"
inline-prompt
/>
{{ formatDate(row.createdAt) }}
</template>
</el-table-column>
<el-table-column :label="$t('table.action')" width="200" fixed="right">
<el-table-column :label="$t('table.action')" width="220" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="goEdit(row.id)">{{ $t('btn.edit') }}</el-button>
<el-button link type="warning" @click="resetPwd(row)">{{ $t('account.resetPassword') }}</el-button>
<el-button type="primary" plain class="purple-sign smaller" @click="goEdit(row)">
{{ $t('btn.edit') }}
</el-button>
<el-button type="warning" plain class="blue-sign smaller" @click="resetPwd(row)">
{{ $t('account.resetPassword') }}
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadList"
@current-change="loadList"
/>
<div class="admin-pagination">
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :page-sizes="[10, 20, 50]" :total="total"
layout="total, sizes, prev, pager, next, jumper" @size-change="loadList" @current-change="loadList" />
</div>
</el-card>
<AccountEditDialog v-model:visible="editVisible" :account-id="editId" :account-data="editRow"
:user-type="activeType" @success="loadList" />
<ResetPasswordDialog v-model:visible="resetVisible" :account-data="resetRow" @success="loadList" />
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import dayjs from "dayjs";
import { useI18n } from "vue-i18n";
import {
getAdminAccounts,
toggleAdminAccountStatus,
resetAdminAccountPassword,
} from "@/service/modular/admin";
import AccountEditDialog from "./components/AccountEditDialog.vue";
import ResetPasswordDialog from "./components/ResetPasswordDialog.vue";
const { t: $t } = useI18n();
const router = useRouter();
const accountTabs = [
{ label: "医院账号", value: "Hospital" },
{ label: "管理员账号", value: "Admin" },
];
const activeType = ref("Hospital");
// tab
const addBtnText = computed(() =>
activeType.value === "Admin" ? "添加管理员账号" : "添加医院账号"
);
const list = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const loading = ref(false);
const filters = reactive({ type: "", keyword: "" });
const loadList = async () => {
loading.value = true;
@ -92,7 +93,7 @@ const loadList = async () => {
const res = await getAdminAccounts({
page: page.value,
pageSize: pageSize.value,
...filters,
type: activeType.value,
});
list.value = res?.list || [];
total.value = res?.total || 0;
@ -101,18 +102,38 @@ const loadList = async () => {
}
};
const onSearch = () => {
const onPickType = (val) => {
if (activeType.value === val) return;
activeType.value = val;
page.value = 1;
loadList();
};
const onReset = () => {
filters.type = "";
filters.keyword = "";
onSearch();
const formatDate = (val) => (val ? dayjs(val).format("YYYY-MM-DD") : "");
// /
const editVisible = ref(false);
const editId = ref("");
const editRow = ref({});
const goAdd = () => {
editId.value = "";
editRow.value = {};
editVisible.value = true;
};
const goEdit = (row) => {
editId.value = row.id;
editRow.value = row;
editVisible.value = true;
};
const goAdd = () => router.push("/admin/accounts/edit");
const goEdit = (id) => router.push(`/admin/accounts/edit/${id}`);
//
const resetVisible = ref(false);
const resetRow = ref({});
const resetPwd = (row) => {
resetRow.value = row;
resetVisible.value = true;
};
const toggleStatus = async (row) => {
row.__loading = true;
@ -125,30 +146,55 @@ const toggleStatus = async (row) => {
}
};
const resetPwd = async (row) => {
try {
await ElMessageBox.confirm(
`${row.username} - ${$t('account.defaultPassword')}`,
$t('common.confirm'),
{ type: "warning" },
);
await resetAdminAccountPassword(row.id);
ElMessage.success($t('msg.passwordResetSuccess'));
} catch (_) {
/* canceled */
}
};
onMounted(loadList);
</script>
<style scoped lang="scss">
.filter-form {
margin-bottom: 12px;
.account-list {
.header-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.pagination {
margin-top: 16px;
.period-tabs {
display: flex;
justify-content: flex-end;
gap: 32px;
}
.period-tab {
cursor: pointer;
user-select: none;
font-weight: 400;
font-size: 16px;
line-height: 21px;
color: #1d2129;
padding: 0 0 16px 0;
border-bottom: 3px solid transparent;
&:hover {
color: #3289fb;
}
&.active {
color: #3289fb;
border-bottom-color: #3289fb;
}
}
.add-btn {
margin-left: auto;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
width: 155px;
height: 35px;
}
}
</style>
<style lang="scss">
@use "@/assets/style/admin-common.scss" as *;
</style>

@ -1,65 +1,71 @@
<template>
<el-dialog :model-value="visible" class="hospital-detail-dialog" top="6vh" width="880px" destroy-on-close
:close-on-click-modal="false" @update:model-value="(v) => !v && close()">
<div class="body-wrap">
<el-dialog :model-value="visible" class="hospital-detail-dialog" top="5vh" width="900px"
:close-on-click-modal="true" @update:model-value="(v) => !v && close()">
<div class="body-wrap" v-loading="loading">
<div class="body-header">
<span class="body-title">
{{ $t("modal.detailHospital") }}
</span>
<div class="body-title">{{ $t("modal.detailHospital") }}</div>
<img src="@/assets/image/close-btn.svg" alt="" class="close-btn" @click="close">
</div>
<div class="hospital-detail" v-loading="loading">
<el-card shadow="never" v-if="detail.id">
<template #header>
<div class="card-header">
<span class="title">{{ detail.name }}</span>
<el-tag size="small">
{{ detail.customerCategory }}
{{ $t("category.AShort").replace("A", "") }}
</el-tag>
<el-button type="primary" size="small" style="margin-left: auto" @click="goEdit">
{{ $t("btn.edit") }}
</el-button>
<div v-if="detail.id" class="detail-container">
<div class="detail-row row-3">
<div class="detail-item">
<div class="label">{{ $t('label.contactDept') }}</div>
<div class="value">{{ detail.contactDept || '\u2014' }}</div>
</div>
<div class="detail-item">
<div class="label">{{ $t('label.contactPerson') }}</div>
<div class="value">{{ detail.contactPerson || '\u2014' }}</div>
</div>
<div class="detail-item">
<div class="label">{{ $t('label.contactPosition') }}</div>
<div class="value">{{ detail.contactPosition || '\u2014' }}</div>
</div>
</div>
<div class="detail-row row-3">
<div class="detail-item">
<div class="label">{{ $t('label.contactPhone') }}</div>
<div class="value">{{ detail.contactPhone || '\u2014' }}</div>
</div>
<div class="detail-item">
<div class="label">{{ $t('table.signDate') }}</div>
<div class="value">{{ formatDate(detail.signDate) }}</div>
</div>
<div class="detail-item">
<div class="label">{{ $t('label.acceptDate') }}</div>
<div class="value">{{ formatDate(detail.acceptDate) }}</div>
</div>
</div>
<div class="detail-row row-3">
<div class="detail-item">
<div class="label">{{ $t('label.years') }}</div>
<div class="value">{{ detail.contractYears }}</div>
</div>
<div class="detail-item">
<div class="label">{{ $t('table.maintenanceEnd') }}</div>
<div class="value">{{ formatDate(detail.maintenanceEnd) }}</div>
</div>
<div class="detail-item">
<div class="label">{{ $t('table.manager') }}</div>
<div class="value">{{ detail.managerName || '\u2014' }}</div>
</div>
</div>
<div class="detail-row row-1">
<div class="detail-item">
<div class="label">{{ $t('common.remark') }}</div>
<div class="value">{{ detail.remark || '\u2014' }}</div>
</div>
</div>
</div>
</template>
<el-descriptions :column="3" border>
<el-descriptions-item :label="$t('label.contactDept')">
{{ detail.contactDept }}
</el-descriptions-item>
<el-descriptions-item :label="$t('label.contactPerson')">
{{ detail.contactPerson }}
</el-descriptions-item>
<el-descriptions-item :label="$t('label.contactPosition')">
{{ detail.contactPosition }}
</el-descriptions-item>
<el-descriptions-item :label="$t('label.contactPhone')">
{{ detail.contactPhone }}
</el-descriptions-item>
<el-descriptions-item :label="$t('table.signDate')">
{{ formatDate(detail.signDate) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('label.acceptDate')">
{{ formatDate(detail.acceptDate) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('label.years')">
{{ detail.contractYears }}
</el-descriptions-item>
<el-descriptions-item :label="$t('table.maintenanceEnd')">
{{ formatDate(detail.maintenanceEnd) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('table.manager')">
{{ detail.managerName }}
</el-descriptions-item>
</el-descriptions>
<div class="section">
<div class="section-title">{{ $t("common.remark") }}</div>
<div class="html-content">{{ detail.remark || "—" }}</div>
</div>
</el-card>
</div>
<div class="body-footer">
<el-button type="primary" style="margin-left:auto" plain @click="close">
取消
</el-button>
<el-button type="primary" @click="goEdit">
{{ $t('btn.edit') }}
</el-button>
</div>
</el-dialog>
</template>
@ -80,7 +86,7 @@ const emit = defineEmits(["update:visible", "close", "edit"]);
const loading = ref(false);
const detail = ref({});
const formatDate = (v) => (v ? dayjs(v).format("YYYY-MM-DD") : "");
const formatDate = (v) => (v ? dayjs(v).format("YYYY-MM-DD") : "\u2014");
const loadDetail = async (id) => {
if (!id) {
@ -110,43 +116,66 @@ const close = () => emit("update:visible", false);
const goEdit = () => emit("edit", props.hospitalId);
</script>
<style scoped lang="scss">
<style lang="scss">
.hospital-detail-dialog {
max-width: 90vw;
min-width: 70vw;
.body-wrap {
padding: 24px;
.body-header {
margin-bottom: 18px;
margin-bottom: 16px;
}
.detail-container {
.detail-row + .detail-row {
margin-top: 15px;
}
.hospital-detail {
.card-header {
display: flex;
align-items: center;
gap: 8px;
.detail-row {
display: grid;
gap: 15px;
.title {
font-size: 16px;
font-weight: 600;
margin-right: 8px;
&.row-1 {
grid-template-columns: 1fr;
}
&.row-3 {
grid-template-columns: repeat(3, 1fr);
}
.section {
margin-top: 20px;
.detail-item {
display: flex;
align-items: center;
gap: 10px;
.section-title {
.label {
font-weight: 400;
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 10px;
}
color: #1D2129;
width: 6em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
flex-grow: 0;
}
.html-content {
background: #fafafa;
padding: 12px;
border-radius: 4px;
min-height: 60px;
line-height: 1.6;
.value {
flex-shrink: 1;
flex-grow: 1;
font-weight: 400;
font-size: 14px;
color: #666666;
padding: 10px;
background: #F8F8F8;
border-radius: 8px 8px 8px 8px;
border: 1px solid #E0E0E0;
}
}
}
}
}
}
</style>

@ -1,5 +1,5 @@
<template>
<el-dialog :model-value="visible" class="hospital-edit-dialog" top="6vh" width="780px" destroy-on-close
<el-dialog :model-value="visible" class="hospital-edit-dialog" top="6vh" destroy-on-close
:close-on-click-modal="false" @update:model-value="(v) => !v && close()">
<div class="body-wrap">
<div class="body-header">
@ -91,12 +91,19 @@
<el-form-item :label="$t('label.acceptanceReport')" required>
<el-upload v-model:file-list="reportFileList" :auto-upload="false" :limit="1"
<el-upload class="upload-wrap" v-model:file-list="reportFileList" :auto-upload="false" :limit="1"
:on-exceed="handleReportExceed" accept=".pdf">
<el-button type="primary" plain>{{ $t("btn.upload") }}</el-button>
<template #tip>
<div class="el-upload__tip">
{{ $t("label.acceptanceReportTip") }}
<div class="upload-btn">
<div class="left">+ 添加文件</div>
<div class="right">不超过50M</div>
</div>
<template #file="{ file }">
<div class="file-item">
<img class="file-icon" src="@/assets/image/admin/orders/file.svg" alt="">
<div class="file-name">&nbsp;&nbsp;{{ file.name }}</div>
<div class="file-size">&nbsp;&nbsp;{{ ((file.size || 0) / 1024 / 1024).toFixed(2) }}MB</div>
<div class="file-delete" @click="handleReportDelete(file)"></div>
</div>
</template>
</el-upload>
@ -208,6 +215,14 @@ const handleReportExceed = (files) => {
form.attachmentPath = "";
};
// "" handleReportExceed
const handleReportDelete = (file) => {
reportFileList.value = (reportFileList.value || []).filter(
(f) => f.uid !== file?.uid,
);
form.attachmentPath = "";
};
// / \
const getAttachmentFileName = (path) => {
if (!path) return "";
@ -334,6 +349,7 @@ watch(
.hospital-edit {
max-height: 70vh;
overflow-y: auto;
overflow-x: hidden;
padding-right: 4px;
}
</style>
@ -347,5 +363,68 @@ watch(
.body-header {
margin-bottom: 18px;
}
// ============== admin ==============
.upload-wrap {
width: 100%;
.el-upload-list__item {
&:hover {
background: transparent;
}
.file-item {
display: flex;
align-items: center;
font-weight: 400;
font-size: 14px;
.file-icon {
width: 18px;
height: 18px;
}
.file-name,
.file-delete {
color: #1D2129;
&:hover {
color: #1890ff;
}
}
.file-delete {
margin-left: 14px;
cursor: pointer;
}
.file-size {
color: #BCBCBC;
}
}
}
}
.upload-btn {
display: flex;
align-items: center;
.left {
font-weight: 400;
font-size: 14px;
color: #2B324C;
}
.right {
font-weight: 400;
font-size: 14px;
color: #666666;
}
}
//
:deep(.el-form-item .el-form-item__content > .el-upload) {
width: 100%;
}
}
</style>

@ -0,0 +1,192 @@
<template>
<el-dialog :model-value="modelValue" class="maintenance-detail-dialog" top="6vh" :close-on-click-modal="false"
destroy-on-close @update:model-value="(v) => $emit('update:modelValue', v)" @open="onOpen">
<div class="body-wrap" v-loading="loading">
<div class="body-header">
<span class="body-title">维保服务详情 - {{ detail.hospitalName || "—" }}</span>
<img src="@/assets/image/close-btn.svg" alt="" class="close-btn" @click="closeDialog" />
</div>
<el-form v-if="detail.hospitalId" ref="formRef" :model="form" :rules="rules" label-width="100px"
label-position="right">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="客户分类">
<el-tag :type="categoryTagType(detail.customerCategory)" size="small">
{{ detail.customerCategory || "—" }}
</el-tag>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="维保截止日期">
<span class="readonly-val">{{ formatDate(detail.maintenanceEnd) }}</span>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="客户经理">
<span class="readonly-val">{{ detail.managerName || "—" }}</span>
</el-form-item>
</el-col>
</el-row>
<el-divider class="thin-divider" />
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="续保完成" prop="renewal">
<el-select v-model="form.renewal" placeholder="请选择" clearable style="width: 100%">
<el-option label="未完成" value="no" />
<el-option label="已完成" value="yes" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="合同签署" prop="contractSigned">
<el-select v-model="form.contractSigned" placeholder="请选择" clearable style="width: 100%">
<el-option label="未签署" value="no" />
<el-option label="已签署" value="yes" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="款项支付" prop="paymentPaid">
<el-select v-model="form.paymentPaid" placeholder="请选择" clearable style="width: 100%">
<el-option label="未支付" value="no" />
<el-option label="已支付" value="yes" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="回访状态" prop="visited">
<el-select v-model="form.visited" placeholder="请选择" clearable style="width: 100%">
<el-option label="未回访" value="no" />
<el-option label="已回访" value="yes" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="回访结果" prop="visitResult">
<el-input v-model="form.visitResult" placeholder="请输入回访结果" maxlength="500" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入备注" maxlength="1000"
show-word-limit />
</el-form-item>
</el-form>
</div>
<div class="body-footer">
<el-button @click="closeDialog"></el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit"></el-button>
</div>
</el-dialog>
</template>
<script setup>
import { reactive, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import dayjs from "dayjs";
import { getAdminMaintenanceDetail, updateAdminMaintenance } from "@/service/modular/admin";
const props = defineProps({
modelValue: { type: Boolean, default: false },
hospitalId: { type: [String, Number], default: "" },
});
const emit = defineEmits(["update:modelValue", "saved"]);
const loading = ref(false);
const submitting = ref(false);
const formRef = ref();
const detail = ref({});
const form = reactive({
renewal: "",
contractSigned: "",
paymentPaid: "",
visited: "",
visitResult: "",
remark: "",
});
const rules = {};
const formatDate = (v) => (v ? dayjs(v).format("YYYY-MM-DD") : "—");
const categoryTagType = (c) => ({ A: "", B: "success", C: "warning" })[c] || "info";
const loadDetail = async (id) => {
if (!id) {
detail.value = {};
return;
}
loading.value = true;
try {
const data = await getAdminMaintenanceDetail(id);
detail.value = data || {};
Object.assign(form, {
renewal: data?.renewal || "",
contractSigned: data?.contractSigned || "",
paymentPaid: data?.paymentPaid || "",
visited: data?.visited || "",
visitResult: data?.visitResult || "",
remark: data?.remark || "",
});
} finally {
loading.value = false;
}
};
const onOpen = () => {
loadDetail(props.hospitalId);
};
watch(
() => [props.modelValue, props.hospitalId],
([vis, id]) => {
if (vis) loadDetail(id);
},
);
const closeDialog = () => emit("update:modelValue", false);
const onSubmit = async () => {
submitting.value = true;
try {
const payload = {};
["renewal", "contractSigned", "paymentPaid", "visited", "visitResult", "remark"].forEach((k) => {
const v = form[k];
if (v !== "" && v !== null && v !== undefined) payload[k] = v;
});
await updateAdminMaintenance(props.hospitalId, payload);
ElMessage.success("保存成功");
emit("saved");
closeDialog();
} finally {
submitting.value = false;
}
};
</script>
<style lang="scss">
.maintenance-detail-dialog {
width: 700px;
.readonly-val {
font-size: 14px;
color: #303133;
}
.thin-divider {
margin: 8px 0 16px;
}
}
</style>

@ -0,0 +1,395 @@
<template>
<el-dialog :model-value="modelValue" class="monthly-view-dialog" top="5vh" :close-on-click-modal="false"
destroy-on-close @update:model-value="(v) => $emit('update:modelValue', v)" @open="onOpen">
<div class="body-wrap">
<div class="body-header">
<span class="body-title">客户服务月视图</span>
<img src="@/assets/image/close-btn.svg" alt="" class="close-btn" @click="closeDialog" />
</div>
<div class="month-wrap">
<div class="filter-bar">
<div class="filter-left">
<div class="year-select">
<span class="filter-label">选择年份</span>
<el-date-picker v-model="year" type="year" format="YYYY年" value-format="YYYY" style="width: 130px"
@change="onSearch" />
</div>
<el-button class="year-btn" :class="{ active: yearOffset === -1 }" plain
@click="jumpYear(-1)">去年</el-button>
<el-button class="year-btn" :class="{ active: yearOffset === 0 }" plain @click="jumpYear(0)"></el-button>
<el-button class="year-btn" :class="{ active: yearOffset === 1 }" plain @click="jumpYear(1)"></el-button>
</div>
</div>
<div v-loading="loading" class="month-grid">
<div v-for="m in 12" :key="m" class="month-card">
<div class="month-head">
<span class="month-name">{{ m }}</span>
<span
v-if="monthList[m - 1]?.hospitals.some(item => item.daysUntilExpiry >= 0 && item.daysUntilExpiry <= 30)"
class="urgent-tag">紧急</span>
<span class="month-stats">
<span class="num">{{ monthList[m - 1]?.expiringCount ?? 0 }}</span>家医院到期
<span class="sep">|</span>
已续费
<span class="num renewed">{{ monthList[m - 1]?.renewedCount ?? 0 }}</span>
<span class="sep">|</span>
未续费
<span class="num not-renewed">{{ monthList[m - 1]?.notRenewedCount ?? 0 }}</span>
</span>
</div>
<div class="month-body">
<template v-if="monthList[m - 1]?.hospitals?.length">
<div v-for="h in monthList[m - 1].hospitals" :key="h.hospitalId" class="hospital-card">
<div class="h-top">
<span class="h-name">{{ h.hospitalName }}</span>
<span class="cat-tag" :class="`cat-${h.customerCategory}`">
{{ h.customerCategory }}
</span>
<span v-if="h.renewal === 'yes'" class="renewal-name"></span>
</div>
<div class="h-row">
<span class="h-lbl">到期</span>
<span class="h-val">{{ formatDate(h.maintenanceEnd) }}</span>
<span class="days-tag" :class="daysTagClass(h.daysUntilExpiry)">
{{ formatDays(h.daysUntilExpiry) }}
</span>
</div>
<div class="h-row">
<span class="h-lbl">客户经理</span>
<span class="h-val">{{ h.managerName || "—" }}</span>
</div>
</div>
</template>
<div v-else class="empty-month">本月无到期医院</div>
</div>
</div>
</div>
</div>
</div>
</el-dialog>
</template>
<script setup>
import { computed, ref, watch } from "vue";
import dayjs from "dayjs";
import { getAdminMonthlyView } from "@/service/modular/admin";
const props = defineProps({
modelValue: { type: Boolean, default: false },
});
const emit = defineEmits(["update:modelValue"]);
const loading = ref(false);
const year = ref(String(dayjs().year()));
const monthList = ref([]);
const yearOffset = computed(() => Number(year.value) - dayjs().year());
const formatDate = (v) => (v ? dayjs(v).format("M/D") : "—");
const formatDays = (n) => {
const v = Number(n);
if (v === null || v === undefined || isNaN(v)) return "";
if (v <= 0) return `已过期${-v}`;
if (v < 100) return `还有${v}`;
return `${v}天后`;
};
const daysTagClass = (n) => {
const v = Number(n);
if (v <= 0) return "expired";
if (v < 100) return "soon";
return "far";
};
const loadData = async () => {
loading.value = true;
try {
const res = await getAdminMonthlyView({ year: Number(year.value) });
const list = Array.isArray(res) ? res : res?.list || [];
const arr = new Array(12).fill(null);
list.forEach((item) => {
const idx = Number(item.month) - 1;
if (idx >= 0 && idx < 12) arr[idx] = item;
});
//
monthList.value = arr.map((it) => {
if (!it) return { expiringCount: 0, renewedCount: 0, notRenewedCount: 0, hospitals: [] };
return {
...it,
hospitals: it.hospitals || [],
};
});
} finally {
loading.value = false;
}
};
const onSearch = () => loadData();
const jumpYear = (offset) => {
year.value = String(dayjs().year() + offset);
loadData();
};
const closeDialog = () => emit("update:modelValue", false);
const onOpen = () => {
year.value = String(dayjs().year());
loadData();
};
watch(
() => props.modelValue,
(v) => {
if (v) onOpen();
},
);
</script>
<style lang="scss">
@use "@/assets/style/admin-common.scss" as *;
.monthly-view-dialog {
width: 1522px;
max-width: 90vw;
padding: 0;
.month-wrap {
border-radius: 12px;
border: 1px solid #E0E0E0;
overflow: hidden;
.filter-bar {
display: flex;
align-items: center;
height: 70px;
background: #F9FAFF;
border-bottom: 1px solid #E0E0E0;
padding-left: 24px;
.filter-left {
display: flex;
align-items: center;
.year-select {
display: flex;
align-items: center;
gap: 8px;
.filter-label {
font-weight: 400;
font-size: 14px;
color: #1D2129;
}
}
.year-btn {
border: none !important;
background-color: #fff !important;
color: #666666;
margin-left: 24px;
&:hover,
&.active {
background: #3289FB !important;
color: #fff;
}
}
}
}
.month-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
padding: 14px 21px;
gap: 8px;
.month-card {
background: #fff;
border: 1px solid #E0E0E0;
border-radius: 12px;
overflow: hidden;
.month-head {
display: flex;
flex-wrap: nowrap;
align-items: center;
background: #F9FAFF;
border-bottom: 1px solid #E0E0E0;
white-space: nowrap;
padding: 0 10px;
height: 46px;
.month-name {
font-weight: normal;
font-size: 16px;
color: #1D2129;
margin-right: 6px;
}
.urgent-tag {
display: inline-block;
width: 39px;
height: 18px;
background: #FF383C;
border-radius: 8px 8px 8px 8px;
line-height: 18px;
text-align: center;
font-weight: 500;
font-size: 12px;
color: #FFFFFF;
}
.month-stats {
font-weight: normal;
font-size: 14px;
color: #666;
flex: 0;
margin-left: auto;
.sep {
margin: 0 2px;
color: #c0c4cc;
}
.num {
color: #3289FB;
}
}
}
.month-body {
flex: 1;
display: flex;
flex-direction: column;
padding: 14px 21px;
gap: 4px;
height: 200px;
overflow-y: auto;
.hospital-card {
background: #FBFBFB;
border-radius: 12px 12px 12px 12px;
border: 1px solid #EFEFEF;
padding: 10px;
.h-top {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.h-name {
font-weight: 600;
font-size: 14px;
color: #1D2129;
max-width: 14em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cat-tag {
width: 32px;
height: 18px;
border-radius: 4px 4px 4px 4px;
display: inline-block;
line-height: 18px;
text-align: center;
font-weight: 400;
font-size: 12px;
color: #FFFFFF;
&.cat-A {
background: #3289FB;
}
&.cat-B {
background: #34C759;
}
&.cat-C {
background: #FF8D28;
}
}
.renewal-name {
width: 60px;
height: 18px;
background: #00C3D0;
border-radius: 4px 4px 4px 4px;
line-height: 18px;
text-align: center;
font-weight: 400;
font-size: 12px;
color: #FFFFFF;
}
}
.h-row {
display: flex;
align-items: center;
line-height: 1.1;
margin-bottom: 4px;
font-weight: 400;
font-size: 14px;
color: #1D2129;
.h-lbl {
color: #909399;
margin-right: 2px;
}
.days-tag {
display: inline-block;
margin-left: auto;
padding: 2px;
background: #FFEBEC;
border-radius: 4px 4px 4px 4px;
font-weight: 400;
font-size: 12px;
color: #FF383C;
&.soon {
background: #FFEFE2;
color: #FF8D28;
}
&.far {
background: #E8FFEE;
color: #34c759;
}
&.expired {
background: #ffebec;
color: #ff383c;
}
}
}
.h-row+.h-row {
margin-bottom: 0;
}
}
.empty-month {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-weight: 400;
font-size: 14px;
color: #BCBCBC;
}
}
}
}
}
}
</style>

@ -0,0 +1,696 @@
<template>
<el-dialog :model-value="modelValue" class="service-record-dialog" top="6vh" :close-on-click-modal="false"
destroy-on-close @update:model-value="(v) => $emit('update:modelValue', v)" @open="onOpen">
<div class="body-wrap">
<div class="body-header">
<span class="body-title">{{ title }} - {{ hospitalName }}</span>
<img src="@/assets/image/close-btn.svg" alt="" class="close-btn" @click="closeDialog" />
</div>
<!-- 添加记录 -->
<div class="section">
<div class="border-title">
添加{{ typeLabel }}记录
</div>
<!-- 电话回访 -->
<template v-if="type === 'call'">
<el-form class="service-form" ref="formRef" :model="form" :rules="rules" label-width="84px"
label-position="right">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="回访时间" prop="callTime">
<el-date-picker v-model="form.callTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss"
placeholder="年/月/日/时/分/秒" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="回访人" prop="callPerson">
<el-input v-model="form.callPerson" placeholder="请输入" maxlength="50" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="回访内容" prop="content">
<el-input v-model="form.content" type="textarea" :rows="4" placeholder="请输入回访情况" />
</el-form-item>
</el-form>
</template>
<!-- 现场巡检 -->
<template v-else-if="type === 'inspection'">
<el-form class="service-form" ref="formRef" :model="form" :rules="rules" label-width="84px"
label-position="right">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="巡检时间" prop="inspectionTime">
<el-date-picker v-model="form.inspectionTime" type="date" value-format="YYYY-MM-DD"
placeholder="年/月/日" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="巡检人" prop="inspectionPerson">
<el-input v-model="form.inspectionPerson" placeholder="请输入" maxlength="50" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="巡检内容" prop="content">
<el-input v-model="form.content" type="textarea" :rows="4" placeholder="请输入巡检内容" maxlength="1000"
show-word-limit />
</el-form-item>
<el-form-item label="巡检报告">
<el-upload class="upload-wrap" v-model:file-list="reportFileList" :auto-upload="false" :limit="1"
:on-exceed="handleReportExceed" :on-change="handleReportChange" :on-remove="handleReportRemove">
<div class="upload-btn">
<div class="left">+ 添加文件</div>
<div class="right">不超过50M</div>
</div>
<template #file="{ file }">
<div class="file-item">
<img class="file-icon" src="@/assets/image/admin/orders/file.svg" alt="">
<div class="file-name">&nbsp;&nbsp;{{ file.name }}</div>
<div class="file-size">&nbsp;&nbsp;{{ ((file.size || 0) / 1024 / 1024).toFixed(2) }}MB</div>
<div class="file-delete" @click="handleReportDelete(file)"></div>
</div>
</template>
</el-upload>
</el-form-item>
</el-form>
</template>
<!-- 培训记录 -->
<template v-else-if="type === 'training'">
<el-form class="service-form" ref="formRef" :model="form" :rules="rules" label-width="84px"
label-position="right">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="培训日期" prop="trainingDate">
<el-date-picker v-model="form.trainingDate" type="date" value-format="YYYY-MM-DD" placeholder="年/月/日"
style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="参与人数" prop="participants">
<el-input v-model="form.participants" type="number" placeholder="请输入参与人数" maxlength="50" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="培训方式" prop="mode">
<el-radio-group v-model="form.trainingMethod">
<el-radio value="线上">线上</el-radio>
<el-radio value="线下">线下</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="培训人员" prop="trainer">
<el-input v-model="form.trainer" placeholder="请输入培训人员名称" maxlength="50" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="培训主题" prop="topic">
<el-input v-model="form.topic" placeholder="请输入" maxlength="200" />
</el-form-item>
</el-form>
</template>
<!-- 纪念品记录 -->
<template v-else-if="type === 'gift'">
<el-form class="service-form" ref="formRef" :model="form" :rules="rules" label-width="96px"
label-position="right">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="年份" prop="date">
<el-date-picker v-model="form.date" type="year" value-format="YYYY" placeholder=""
style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="数量" prop="quantity">
<el-input v-model="form.quantity" type="number" placeholder="请输入数量" maxlength="50" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="纪念品名称" prop="name">
<el-input v-model="form.name" placeholder="请输入纪念品名称" maxlength="200" />
</el-form-item>
<el-form-item label="快递单号" prop="trackingNo">
<el-input v-model="form.trackingNo" placeholder="请填写快递单号" maxlength="100" style="max-width: 360px" />
</el-form-item>
</el-form>
</template>
<div class="form-footer">
<el-button type="primary" :loading="submitting" @click="onSubmit"></el-button>
</div>
</div>
<!-- 历史记录 -->
<div class="section">
<div class="border-title">
历史记录
</div>
<div v-loading="loading" class="history-list">
<el-empty v-if="!loading && historyList.length === 0" description="暂无记录" :image-size="80" />
<div v-else class="history-items">
<div v-for="(item, idx) in historyList" :key="item.id || idx" class="history-item">
<div class="history-head">
<span class="rec-no">记录 {{ idx + 1 }}</span>
<span class="rec-time">{{ formatTime(item) }}</span>
</div>
<div class="history-body">
<template v-if="type === 'call'">
<div class="row"><span class="lbl">回访人</span>{{ item.callPerson }}</div>
<div class="row"><span class="lbl">回访情况</span>{{ item.content || "—" }}</div>
</template>
<template v-else-if="type === 'inspection'">
<div class="row"><span class="lbl">巡检人</span>{{ item.inspectionPerson }}</div>
<div class="row"><span class="lbl">巡检情况</span>{{ item.content || "—" }}</div>
<div class="row" v-if="getInspectionFilePath(item)">
<span class="lbl">巡检报告</span>
<img style="width: 18px;height: 18px;vertical-align: middle;margin-right: 4px;" src="@/assets/image/admin/orders/file.svg" alt="">
<span class="file" style="cursor:pointer;" @click="viewReport(getInspectionFilePath(item))">
{{ getInspectionFileName(item) }}
</span>
</div>
</template>
<template v-else-if="type === 'training'">
<div class="row"><span class="lbl">培训主题</span>{{ item.topic }}</div>
<div class="row"><span class="lbl">参与人员</span>{{ item.participants || "—" }}</div>
</template>
<template v-else-if="type === 'gift'">
<div class="row"><span class="lbl">纪念品名称</span>{{ item.name }}</div>
<div class="row"><span class="lbl">数量</span>{{ item.quantity || "—" }}</div>
<div class="row"><span class="lbl">记录时间</span>{{ dayjs(item.createdAt).format('YYYY-MM-DD HH:mm') ||
"—" }}</div>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 巡检报告预览 -->
<FilePreviewDialog :visible="previewVisible" :blob-url="previewUrl" :blob="previewBlob" :file-name="previewName"
:file-type="previewType" @update:visible="(v) => (previewVisible = v)" @close="cleanupPreview"
@download="onPreviewDownload" />
</el-dialog>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import dayjs from "dayjs";
import {
addAdminCall,
addAdminGift,
addAdminInspection,
addAdminTraining,
getAdminCalls,
getAdminGifts,
getAdminInspections,
getAdminTrainings,
getUploadFile,
} from "@/service/modular/admin";
import FilePreviewDialog from "@/components/FilePreviewDialog.vue";
const props = defineProps({
modelValue: { type: Boolean, default: false },
// call | inspection | training | gift
type: { type: String, default: "call" },
hospitalId: { type: [String, Number], default: "" },
hospitalName: { type: String, default: "" },
});
const emit = defineEmits(["update:modelValue", "saved"]);
const formRef = ref();
const submitting = ref(false);
const loading = ref(false);
const historyList = ref([]);
// 使
const reportFileList = ref([]);
const MAX_REPORT_FILE_SIZE = 50 * 1024 * 1024;
// limit
const handleReportExceed = (files) => {
reportFileList.value = files.slice(-1);
};
//
const handleReportChange = (file) => {
if (file?.size > MAX_REPORT_FILE_SIZE) {
ElMessage.warning("文件不能超过 50MB");
reportFileList.value = (reportFileList.value || []).filter(
(f) => f.uid !== file.uid,
);
}
};
// el-upload v-model:file-list
const handleReportRemove = () => { };
// "" uid reportFileList
const handleReportDelete = (file) => {
reportFileList.value = (reportFileList.value || []).filter(
(f) => f.uid !== file?.uid,
);
};
// FormData + files[]
const buildInspectionFormData = (data) => {
const fd = new FormData();
Object.entries(data || {}).forEach(([key, value]) => {
if (value === undefined || value === null || value === "") return;
fd.append(key, value);
});
// raw files
for (const f of reportFileList.value || []) {
if (f.raw) fd.append("files", f.raw, f.name);
}
return fd;
};
const titleMap = {
call: "电话回访记录",
inspection: "现场巡检记录",
training: "培训记录",
gift: "纪念品记录",
};
const title = computed(() => titleMap[props.type] || "");
const typeLabel = computed(() => title.value.replace("记录", ""));
const defaultForm = () => {
if (props.type === "call") {
return { callTime: dayjs().format("YYYY-MM-DD HH:mm:ss"), callPerson: "", content: "" };
}
if (props.type === "inspection") {
return { inspectionTime: dayjs().format("YYYY-MM-DD"), inspectionPerson: "", content: "" };
}
if (props.type === "training") {
return {
trainingDate: dayjs().format("YYYY-MM-DD"),
participants: "",
trainingMethod: "线上",
trainer: "",
topic: "",
};
}
// gift
return { date: String(dayjs().format('YYYY-MM-DD')), quantity: "", name: "", trackingNo: "" };
};
const form = reactive(defaultForm());
const buildRules = () => {
if (props.type === "call") {
return {
callTime: [{ required: true, message: "请选择回访时间", trigger: "change" }],
callPerson: [{ required: true, message: "请输入回访人", trigger: "blur" }],
content: [{ required: true, message: "请输入回访内容", trigger: "blur" }],
};
}
if (props.type === "inspection") {
return {
inspectionTime: [{ required: true, message: "请选择巡检时间", trigger: "change" }],
inspectionPerson: [{ required: true, message: "请输入巡检人", trigger: "blur" }],
content: [{ required: true, message: "请输入巡检内容", trigger: "blur" }],
};
}
if (props.type === "training") {
return {
trainingDate: [{ required: true, message: "请选择培训日期", trigger: "change" }],
participants: [{ required: true, message: "请输入参与人数", trigger: "blur" }],
topic: [{ required: true, message: "请输入培训主题", trigger: "blur" }],
};
}
return {
date: [{ required: true, message: "请选择日期", trigger: "change" }],
name: [{ required: true, message: "请输入纪念品名称", trigger: "blur" }],
};
};
const rules = buildRules();
const loadHistory = async () => {
if (!props.hospitalId) {
historyList.value = [];
return;
}
loading.value = true;
try {
let res;
if (props.type === "call") {
res = await getAdminCalls(props.hospitalId, { page: 1, pageSize: 50 });
} else if (props.type === "inspection") {
res = await getAdminInspections(props.hospitalId);
} else if (props.type === "training") {
res = await getAdminTrainings(props.hospitalId);
} else if (props.type === "gift") {
res = await getAdminGifts(props.hospitalId);
}
historyList.value = res?.list || res || [];
} finally {
loading.value = false;
}
};
const resetForm = () => {
Object.assign(form, defaultForm());
};
const closeDialog = () => emit("update:modelValue", false);
const onOpen = () => {
resetForm();
reportFileList.value = [];
loadHistory();
};
const formatTime = (item) => {
if (props.type === "gift") {
const t = item.date;
return (dayjs(t).isValid() ? dayjs(t).format("YYYY") : "—") + '年';
}
if (props.type === "inspection") {
const t = item.inspectionTime;
return (dayjs(t).isValid() ? dayjs(t).format("YYYY-MM-DD HH:mm") : "—");
}
if (props.type === "training") {
const t = item.trainingDate;
return (dayjs(t).isValid() ? dayjs(t).format("YYYY-MM-DD HH:mm") : "—");
}
const t = item.callTime;
if (!t) return "—";
return dayjs(t).isValid() ? dayjs(t).format("YYYY-MM-DD HH:mm") : "—";
};
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
if (!props.hospitalId) {
ElMessage.error("缺少医院参数");
return;
}
submitting.value = true;
try {
let payload;
if (props.type === "call") payload = { ...form };
else if (props.type === "inspection") payload = buildInspectionFormData({ ...form });
else if (props.type === "training") payload = { ...form };
else payload = { date: form.date, name: form.name, quantity: form.quantity };
if (props.type === "call") {
await addAdminCall(props.hospitalId, payload);
} else if (props.type === "inspection") {
await addAdminInspection(props.hospitalId, payload);
} else if (props.type === "training") {
await addAdminTraining(props.hospitalId, payload);
} else if (props.type === "gift") {
await addAdminGift(props.hospitalId, payload);
}
ElMessage.success("保存成功");
emit("saved");
loadHistory();
resetForm();
} finally {
submitting.value = false;
}
});
};
// ============== / ==============
const previewVisible = ref(false);
const previewUrl = ref("");
const previewBlob = ref(null);
const previewName = ref("");
const previewType = ref(""); // 'pdf' | 'image' | ''
const cleanupPreview = () => {
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value);
previewUrl.value = "";
}
previewBlob.value = null;
previewName.value = "";
previewType.value = "";
};
//
const getFileNameFromPath = (path) => {
if (!path) return "";
return String(path).replace(/\\/g, "/").split("/").filter(Boolean).pop() || "";
};
// / + blob mime
const getFileType = (fileName, blob) => {
const name = String(fileName || "");
const ext = name.split(".").pop()?.toLowerCase() || "";
const mime = blob?.type || "";
if (ext === "pdf" || mime === "application/pdf") return "pdf";
if (
["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(ext) ||
(mime && mime.startsWith("image/"))
) {
return "image";
}
return "other";
};
// blob
const downloadBlob = (blob, fileName) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName || "download";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 0);
};
// FilePreviewDialog
const onPreviewDownload = ({ blob, fileName }) => {
if (!blob) return;
downloadBlob(blob, fileName);
};
// filePaths JSON null
const parseFilePaths = (raw) => {
if (!raw) return null;
if (Array.isArray(raw)) return raw;
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : null;
} catch (_) {
return null;
}
};
// 1 ""
const getInspectionFilePath = (item) => {
const list = parseFilePaths(item?.filePaths);
return list && list.length ? list[0] : "";
};
//
const getInspectionFileName = (item) => {
const p = getInspectionFilePath(item);
return getFileNameFromPath(p) || p || "—";
};
// PDF /
const viewReport = async (filePath) => {
if (!filePath) return;
const path = String(filePath);
const downloadName = getFileNameFromPath(path) || path;
try {
const blob = await getUploadFile(path);
const type = getFileType(downloadName, blob);
if (type === "pdf" || type === "image") {
cleanupPreview();
previewBlob.value = blob;
previewUrl.value = URL.createObjectURL(blob);
previewName.value = downloadName;
previewType.value = type;
previewVisible.value = true;
} else {
downloadBlob(blob, downloadName);
}
} catch (e) {
ElMessage.error(e?.message || "文件加载失败");
}
};
</script>
<style lang="scss">
.service-record-dialog {
min-width: 800px;
.section {
.border-title {
margin-bottom: 16px;
}
}
.section+.section {
margin-top: 32px;
}
.service-form {
.el-form-item__label {
font-weight: 400 !important;
font-size: 14px !important;
color: #1D2129 !important;
}
.el-input__wrapper {
min-height: 48px;
border-radius: 8px 8px 8px 8px;
border: 1px solid #E0E0E0;
box-shadow: none;
}
.el-textarea__inner {
border-radius: 8px 8px 8px 8px;
border: 1px solid #E0E0E0;
box-shadow: none;
}
}
.form-footer {
margin-top: 4px;
display: flex;
justify-content: flex-end;
}
.history-list {
min-height: 80px;
max-height: 200px;
overflow-y: auto;
.history-items {
display: flex;
flex-direction: column;
gap: 16px;
}
.history-item {
width: 100%;
background: #F8F8F8;
border-radius: 12px 12px 12px 12px;
padding: 12px;
}
.history-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.rec-no {
font-weight: 600;
font-size: 16px;
color: #1D2129;
}
.rec-time {
font-weight: 400;
font-size: 14px;
color: #666666;
}
}
.history-body {
.row {
font-weight: 400;
font-size: 14px;
color: #1D2129;
line-height: 1.5;
.file {
&:hover {
color: #3289FB;
}
}
.lbl {
color: #666;
}
}
}
}
// admin
.upload-wrap {
width: 100%;
.el-upload-list__item {
&:hover {
background: transparent;
}
.file-item {
display: flex;
align-items: center;
font-weight: 400;
font-size: 14px;
.file-icon {
width: 18px;
height: 18px;
}
.file-name,
.file-delete {
color: #1D2129;
&:hover {
color: #1890ff;
}
}
.file-delete {
margin-left: 14px;
cursor: pointer;
}
.file-size {
color: #BCBCBC;
}
}
}
}
.upload-btn {
display: flex;
align-items: center;
.left {
font-weight: 400;
font-size: 14px;
color: #2B324C;
}
.right {
font-weight: 400;
font-size: 14px;
color: #666666;
}
}
//
:deep(.el-form-item .el-form-item__content > .el-upload) {
width: 100%;
}
}
</style>

@ -1,116 +1,79 @@
<template>
<div class="hospital-list">
<el-card shadow="never">
<div class="hospital-list admin-page-card">
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item :label="$t('label.hospitalSearch')">
<el-input
v-model="filters.keyword"
:placeholder="$t('placeholder.hospital')"
clearable
style="width: 220px"
/>
<el-select v-model="filters.keyword" placeholder="全部医院" clearable filterable style="width: 220px">
<el-option v-for="h in hospitals" :key="h.id" :label="h.name" :value="h.name" />
</el-select>
</el-form-item>
<el-form-item :label="$t('label.category')">
<el-select
v-model="filters.category"
:placeholder="$t('common.all')"
clearable
style="width: 140px"
>
<el-select v-model="filters.category" :placeholder="$t('common.all')" clearable style="width: 140px">
<el-option label="A类客户活跃" value="A" />
<el-option label="B类客户不活跃" value="B" />
<el-option label="C类客户待挽回" value="C" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSearch">{{ $t('btn.query') }}</el-button>
<el-button type="primary" plain class="blue-sign" @click="onSearch">{{ $t('btn.query') }}</el-button>
<el-button @click="onReset">{{ $t('btn.reset') }}</el-button>
<el-button type="success" @click="goAdd">{{ $t('btn.addHospital') }}</el-button>
</el-form-item>
<el-button type="primary" class="add-btn" @click="goAdd">{{ $t('btn.addHospital') }}</el-button>
</el-form>
<el-table :data="list" v-loading="loading" border stripe>
<el-table class="admin-table" :data="list" v-loading="loading" header-cell-class-name="admin-table-header-cell"
stripe>
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
<el-table-column
:label="$t('table.hospital')"
prop="name"
min-width="140"
show-overflow-tooltip
/>
<el-table-column :label="$t('label.category')" prop="customerCategory" width="auto" />
<el-table-column :label="$t('table.hospital')" prop="name" min-width="140" show-overflow-tooltip />
<el-table-column :label="$t('label.category')" width="auto">
<template #default="{ row }">
<div class="cat-tag" :class="`cat-${row.customerCategory}`">
{{ row.customerCategory || "—" }}
</div>
</template>
</el-table-column>
<el-table-column :label="$t('table.contact')" prop="contactPerson" width="auto" />
<el-table-column :label="$t('table.phone')" prop="contactPhone" :min-width="getPhoneMinWidth()" show-overflow-tooltip />
<el-table-column :label="$t('table.phone')" prop="contactPhone" :min-width="getPhoneMinWidth()"
show-overflow-tooltip />
<el-table-column :label="$t('table.manager')" prop="managerName" width="auto" />
<el-table-column :label="$t('table.usageYears')" width="auto">
<el-table-column :label="$t('table.signDate')" width="auto">
<template #default="{ row }">{{ formatDate(row.signDate) }}</template>
</el-table-column>
<el-table-column :label="'客户使用年限'" width="auto">
<template #default="{ row }">{{ formatUsageYears(row.signDate) }}</template>
</el-table-column>
<el-table-column :label="$t('table.maintenanceEnd')" width="auto">
<template #default="{ row }">{{ formatDate(row.maintenanceEnd) }}</template>
</el-table-column>
<el-table-column :label="$t('table.acceptanceReport')" width="auto">
<template #default="{ row }">
<el-button
v-if="row.attachmentPath"
link
type="primary"
@click="viewReport(row.attachmentPath)"
>
<el-button v-if="row.attachmentPath" plain class="cyan-sign" @click="viewReport(row.attachmentPath)">
{{ $t('btn.preview') }}
</el-button>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="$t('table.signDate')" width="auto">
<template #default="{ row }">{{ formatDate(row.signDate) }}</template>
</el-table-column>
<el-table-column :label="$t('table.maintenanceEnd')" width="auto">
<template #default="{ row }">{{ formatDate(row.maintenanceEnd) }}</template>
</el-table-column>
<el-table-column :label="$t('table.action')" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="goDetail(row.id)">{{ $t('btn.detail') }}</el-button>
<el-button link type="primary" @click="goEdit(row.id)">{{ $t('btn.edit') }}</el-button>
<el-button type="primary" plain class="purple-sign smaller" @click="goEdit(row.id)">{{ $t('btn.edit') }}</el-button>
<el-button type="primary" plain class="blue-sign smaller" @click="goDetail(row.id)">{{ $t('btn.detail') }}</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadList"
@current-change="loadList"
/>
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :page-sizes="[10, 20, 50]" :total="total"
layout="total, sizes, prev, pager, next, jumper" @size-change="loadList" @current-change="loadList" />
</div>
</el-card>
<FilePreviewDialog
:visible="previewVisible"
:blob-url="previewUrl"
:blob="previewBlob"
:file-name="previewName"
:file-type="previewType"
@update:visible="(v) => (previewVisible = v)"
@close="cleanupPreview"
@download="onPreviewDownload"
/>
<HospitalDetailDialog
v-model:visible="detailVisible"
:hospital-id="detailId"
@edit="onGoEdit"
/>
<HospitalEditDialog
v-model:visible="editVisible"
:hospital-id="editId"
@success="loadList"
/>
<FilePreviewDialog :visible="previewVisible" :blob-url="previewUrl" :blob="previewBlob" :file-name="previewName"
:file-type="previewType" @update:visible="(v) => (previewVisible = v)" @close="cleanupPreview"
@download="onPreviewDownload" />
<HospitalDetailDialog v-model:visible="detailVisible" :hospital-id="detailId" @edit="onGoEdit" />
<HospitalEditDialog v-model:visible="editVisible" :hospital-id="editId" @success="loadList" />
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import { onMounted, reactive, ref, computed } from "vue";
import { ElMessage } from "element-plus";
import dayjs from "dayjs";
import { useI18n } from "vue-i18n";
@ -119,6 +82,12 @@ import FilePreviewDialog from "@/components/FilePreviewDialog.vue";
import HospitalDetailDialog from "./components/HospitalDetailDialog.vue";
import HospitalEditDialog from "./components/HospitalEditDialog.vue";
import { useHospitalStore } from "@/stores/api/hospital";
// Pinia store
const hospitalStore = useHospitalStore();
const hospitals = computed(() => hospitalStore.list);
const { t: $t } = useI18n();
const list = ref([]);
const total = ref(0);
@ -276,18 +245,53 @@ const viewReport = async (attachmentPath) => {
}
};
onMounted(loadList);
onMounted(() => { loadList(), hospitalStore.ensureLoaded(); });
</script>
<style scoped lang="scss">
.hospital-list {
.filter-form {
display: flex;
margin-bottom: 12px;
.add-btn {
margin-left: auto;
}
}
.cat-tag {
width: 48px;
height: 30px;
border-radius: 8px 8px 8px 8px;
line-height: 30px;
text-align: center;
font-weight: 500;
font-size: 14px;
&.cat-A {
background: #EAF3FF;
color: #3289FB;
}
&.cat-B {
background: #E8FFEE;
color: #34C759;
}
&.cat-C {
background: #FFEFE2;
color: #FF8D28;
}
}
}
.pagination {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.preview-iframe {
width: 100%;
height: 80vh;
@ -295,9 +299,14 @@ onMounted(loadList);
}
:deep(.contact-phone) {
// width: 120em !important;
.cell {
white-space: nowrap;
}
}
</style>
<style lang="scss">
@use "@/assets/style/admin-common.scss" as *;
</style>

@ -1,120 +1,465 @@
<template>
<div class="hospital-service">
<el-card shadow="never">
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item :label="$t('label.type')">
<el-select v-model="filters.type" :placeholder="$t('common.all')" clearable style="width:160px">
<el-option label="巡检服务" value="巡检服务" />
<el-option label="技术支持" value="技术支持" />
<el-option label="回访服务" value="回访服务" />
<el-option label="培训服务" value="培训服务" />
<div class="admin-page-card">
<!-- 顶部筛选条 -->
<div class="filter-bar">
<div class="filter-left">
<span class="filter-label">医院名称</span>
<el-select v-model="filters.keyword" placeholder="全部医院" clearable filterable style="width: 220px">
<el-option v-for="h in hospitals" :key="h.id" :label="h.name" :value="h.name" />
</el-select>
</el-form-item>
<el-form-item :label="$t('table.manager')">
<el-input v-model="filters.owner" :placeholder="$t('table.manager')" clearable style="width:160px" />
</el-form-item>
<el-form-item :label="$t('label.status')">
<el-select v-model="filters.status" :placeholder="$t('common.all')" clearable style="width:140px">
<el-option label="待执行" value="待执行" />
<el-option label="进行中" value="进行中" />
<el-option label="已完成" value="已完成" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSearch">{{ $t('btn.query') }}</el-button>
<el-button @click="onReset">{{ $t('btn.reset') }}</el-button>
<el-button type="success" @click="onAdd">{{ $t('btn.add') }}</el-button>
</el-form-item>
</el-form>
<el-table :data="list" v-loading="loading" border stripe>
<el-table-column prop="serviceNo" label="No." width="160" />
<el-table-column :label="$t('table.hospital')" prop="hospitalName" min-width="160" show-overflow-tooltip />
<el-table-column :label="$t('label.type')" prop="type" width="120" />
<el-table-column :label="$t('table.manager')" prop="owner" width="120" />
<el-table-column :label="$t('common.time')" prop="planDate" width="120" />
<el-table-column :label="$t('label.status')" width="100">
<el-button class="blue-sign" type="primary" plain @click="onSearch"></el-button>
</div>
<div class="filter-right">
<div class="month-switcher">
<el-button class="switch-btn" plain @click="prevMonth">
<div class="inner">
< </div>
</el-button>
<span class="month-label">{{ currentMonthLabel }}</span>
<el-button class="switch-btn" plain @click="nextMonth">
<div class="inner">></div>
</el-button>
</div>
<el-button class="monthly-view-btn" type="primary" @click="monthlyVisible = true">
<el-icon>
<Calendar />
</el-icon>
<span>月视图</span>
</el-button>
</div>
</div>
<!-- 维保列表 -->
<el-table class="admin-table service-table" :data="list" v-loading="loading" stripe
header-cell-class-name="admin-table-header-cell">
<el-table-column prop="hospitalName" label="医院名称" min-width="140" show-overflow-tooltip />
<el-table-column label="客户分类" width="100">
<template #default="{ row }">
<div class="cat-tag" :class="`cat-${row.customerCategory}`">
{{ row.customerCategory || "—" }}
</div>
</template>
</el-table-column>
<el-table-column label="维保截止日期" width="140">
<template #default="{ row }">{{ formatDate(row.maintenanceEnd) }}</template>
</el-table-column>
<el-table-column prop="managerName" label="客户经理" width="110" show-overflow-tooltip />
<el-table-column label="续保完成" width="90" align="center">
<template #default="{ row }">
<img style="width:16px;height:16px;" v-if="row.renewal === 'no'" src="@/assets/image/admin/orders/no.svg"
alt="">
<img style="width:16px;height:16px;" v-if="row.renewal === 'yes'" src="@/assets/image/admin/orders/yes.svg"
alt="">
</template>
</el-table-column>
<el-table-column label="合同签署" width="90" align="center">
<template #default="{ row }">
<img style="width:16px;height:16px;" v-if="row.contractSigned === 'no'" src="@/assets/image/admin/orders/no.svg"
alt="">
<img style="width:16px;height:16px;" v-if="row.contractSigned === 'yes'" src="@/assets/image/admin/orders/yes.svg"
alt="">
</template>
</el-table-column>
<el-table-column label="款项支付" width="90" align="center">
<template #default="{ row }">
<img style="width:16px;height:16px;" v-if="row.paymentPaid === 'no'" src="@/assets/image/admin/orders/no.svg"
alt="">
<img style="width:16px;height:16px;" v-if="row.paymentPaid === 'yes'" src="@/assets/image/admin/orders/yes.svg"
alt="">
</template>
</el-table-column>
<el-table-column label="回访状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ row.status }}</el-tag>
<img style="width:16px;height:16px;" v-if="row.visited === 'no'" src="@/assets/image/admin/orders/no.svg"
alt="">
<img style="width:16px;height:16px;" v-if="row.visited === 'yes'" src="@/assets/image/admin/orders/yes.svg"
alt="">
</template>
</el-table-column>
<el-table-column :label="$t('common.remark')" prop="remark" min-width="200" show-overflow-tooltip />
<el-table-column :label="$t('table.action')" width="160" fixed="right">
<el-table-column label="服务操作" min-width="320" class-name="op-cell">
<template #default="{ row }">
<el-button link type="primary" @click="onView(row)">{{ $t('btn.view') }}</el-button>
<el-button link type="success" @click="onFinish(row)" v-if="row.status !== '已完成'">{{ $t('status.completed') }}</el-button>
<div class="op-buttons">
<el-button class="op-btn" plain @click="openRecord(row, 'call')">
<el-icon>
<img style="width:16px;height:16px;" src="@/assets/image/admin/orders/dhhf.svg" alt="">
</el-icon>访
</el-button>
<el-button class="op-btn" plain @click="openRecord(row, 'inspection')">
<el-icon>
<img style="width:16px;height:16px;" src="@/assets/image/admin/orders/xcxj.svg" alt="">
</el-icon>
</el-button>
<el-button class="op-btn" plain @click="openRecord(row, 'training')">
<el-icon>
<img style="width:16px;height:16px;" src="@/assets/image/admin/orders/pxjl.svg" alt="">
</el-icon>
</el-button>
<el-button class="op-btn" plain @click="openRecord(row, 'gift')">
<el-icon>
<img style="width:16px;height:16px;" src="@/assets/image/admin/orders/jnpjl.svg" alt="">
</el-icon>
</el-button>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button class="detail-btn cyan-sign smaller" type="primary" plain @click="openDetail(row)"></el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="pagination">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadList"
@current-change="loadList"
/>
<!-- 本月到期提醒 -->
<div class="expiring-section admin-page-card">
<div class="expiring-title border-title">
本月到期提醒
</div>
<div class="expiring-grid" v-loading="expiringLoading">
<el-empty v-if="!expiringLoading && expiringList.length === 0" description="暂无即将到期的维保" :image-size="80" />
<div v-for="(h, idx) in expiringList" :key="h.hospitalId || idx" class="expiring-card">
<div class="ex-head">
<span class="ex-name">{{ h.hospitalName }}</span>
<span v-if="Number(h.daysUntilExpiry) <= 30" class="ex-urgent-tag"></span>
</div>
<div class="ex-row">
<span class="ex-lbl">到期日期</span>
<span class="ex-val">{{ formatDate(h.maintenanceEnd) }}</span>
</div>
<div class="ex-row">
<span class="ex-lbl">剩余天数</span>
<span class="ex-val">{{ formatExpiringDays(h.daysUntilExpiry) }}</span>
</div>
<div class="ex-tags">
<div v-if="h.renewal !== 'yes'" class="warn-tag"></div>
<div v-if="h.paymentPaid !== 'yes'" class="warn-tag"></div>
</div>
</div>
</div>
</el-card>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import { useI18n } from "vue-i18n";
<!-- 维保详情/编辑弹窗 -->
<MaintenanceDetailDialog v-model="detailVisible" :hospital-id="currentHospitalId" @saved="onSearch" />
const { t: $t } = useI18n();
<!-- 服务记录弹窗电话回访/现场巡检/培训/纪念品 -->
<ServiceRecordDialog v-model="recordVisible" :type="recordType" :hospital-id="currentHospitalId"
:hospital-name="currentHospitalName" />
const MOCK_LIST = [
{ id: 1, serviceNo: "SV-2025-0001", hospitalName: "上海第一人民医院", type: "巡检服务", owner: "张三", planDate: "2025-05-10", status: "已完成", remark: "季度例行巡检" },
{ id: 2, serviceNo: "SV-2025-0002", hospitalName: "北京协和医院", type: "技术支持", owner: "李四", planDate: "2025-05-12", status: "进行中", remark: "系统升级支持" },
{ id: 3, serviceNo: "SV-2025-0003", hospitalName: "广州中山医院", type: "回访服务", owner: "王五", planDate: "2025-05-15", status: "待执行", remark: "客户满意度回访" },
{ id: 4, serviceNo: "SV-2025-0004", hospitalName: "深圳人民医院", type: "培训服务", owner: "赵六", planDate: "2025-05-18", status: "待执行", remark: "操作人员培训" },
];
<!-- 客户服务月视图弹窗 -->
<MonthlyViewDialog v-model="monthlyVisible" />
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from "vue";
import { Calendar, Phone, View, Reading, Present } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import { getAdminExpiring, getAdminMaintenance } from "@/service/modular/admin";
import { useHospitalStore } from "@/stores/api/hospital";
import MaintenanceDetailDialog from "./components/MaintenanceDetailDialog.vue";
import ServiceRecordDialog from "./components/ServiceRecordDialog.vue";
import MonthlyViewDialog from "./components/MonthlyViewDialog.vue";
const list = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const loading = ref(false);
const filters = reactive({ type: "", owner: "", status: "" });
const expiringLoading = ref(false);
const expiringList = ref([]);
const statusType = (s) => ({ 待执行: "info", 进行中: "warning", 已完成: "success" })[s] || "info";
// Pinia store
const hospitalStore = useHospitalStore();
const hospitals = computed(() => hospitalStore.list);
const filters = reactive({ keyword: undefined });
// 稿
const currentMonth = ref(dayjs());
const currentMonthLabel = computed(() => currentMonth.value.format("YYYY年M月"));
const prevMonth = () => {
currentMonth.value = currentMonth.value.subtract(1, "month");
loadList();
};
const nextMonth = () => {
currentMonth.value = currentMonth.value.add(1, "month");
loadList();
};
const formatDate = (v) => (v ? dayjs(v).format("YYYY-MM-DD") : "—");
const formatExpiringDays = (n) => {
const v = Number(n);
return `${v}`;
};
const loadList = async () => {
loading.value = true;
try {
await new Promise((r) => setTimeout(r, 200));
list.value = MOCK_LIST;
total.value = MOCK_LIST.length;
const res = await getAdminMaintenance({
page: 1,
pageSize: 200,
keyword: filters.keyword || undefined,
year: currentMonth.value.year(),
month: currentMonth.value.month() + 1,
});
list.value = res?.list || [];
} finally {
loading.value = false;
}
};
const onSearch = () => { page.value = 1; loadList(); };
const onReset = () => { filters.type = ""; filters.owner = ""; filters.status = ""; onSearch(); };
const onAdd = () => ElMessage.success($t('btn.add'));
const onView = (row) => ElMessage.info(`${$t('btn.view')}${row.serviceNo}`);
const onFinish = (row) => {
row.status = "已完成";
ElMessage.success($t('msg.editSuccess'));
const loadExpiring = async () => {
expiringLoading.value = true;
try {
const res = await getAdminExpiring();
expiringList.value = Array.isArray(res) ? res : res?.list || [];
} finally {
expiringLoading.value = false;
}
};
const onSearch = () => loadList();
const onReset = () => {
filters.hospitalId = undefined;
loadList();
};
//
const detailVisible = ref(false);
const currentHospitalId = ref("");
const openDetail = (row) => {
currentHospitalId.value = row.hospitalId;
detailVisible.value = true;
};
onMounted(loadList);
//
const recordVisible = ref(false);
const recordType = ref("call");
const currentHospitalName = ref("");
const openRecord = (row, type) => {
currentHospitalId.value = row.hospitalId;
currentHospitalName.value = row.hospitalName || "";
recordType.value = type;
recordVisible.value = true;
};
//
const monthlyVisible = ref(false);
onMounted(() => {
hospitalStore.ensureLoaded();
loadList();
loadExpiring();
});
</script>
<style scoped lang="scss">
.filter-form {
margin-bottom: 12px;
.hospital-service {
.filter-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 12px;
.filter-left {
display: flex;
align-items: center;
gap: 10px;
.filter-label {
font-size: 14px;
color: #1d2129;
}
}
.pagination {
margin-top: 16px;
.filter-right {
display: flex;
align-items: center;
gap: 12px;
}
.month-switcher {
display: inline-flex;
align-items: center;
overflow: hidden;
background: #FFFFFF;
border-radius: 8px 8px 8px 8px;
border: 1px solid #E0E0E0;
.switch-btn {
border: none;
border-radius: 0;
padding: 6px 8px !important;
min-height: 32px;
background: transparent;
color: #606266;
.inner {
width: 24px;
height: 24px;
background: #EDEDED;
border-radius: 8px 8px 8px 8px;
line-height: 24px;
text-align: center;
color: #BCBCBC;
}
&:hover {
background: #f5f7fa;
}
}
.month-label {
padding: 0 12px;
font-size: 14px;
color: #1d2129;
min-width: 100px;
text-align: center;
}
}
.monthly-view-btn {
display: inline-flex;
align-items: center;
gap: 4px;
}
}
}
.cat-tag {
width: 48px;
height: 30px;
border-radius: 8px 8px 8px 8px;
line-height: 30px;
text-align: center;
font-weight: 500;
font-size: 14px;
&.cat-A {
background: #EAF3FF;
color: #3289FB;
}
&.cat-B {
background: #E8FFEE;
color: #34C759;
}
&.cat-C {
background: #FFEFE2;
color: #FF8D28;
}
}
:deep(.op-cell) {
.op-buttons {
display: flex;
flex-wrap: nowrap;
gap: 10px;
}
.op-btn {
border-radius: 8px 8px 8px 8px;
border: 1px solid #E0E0E0;
font-weight: 400;
font-size: 14px;
color: #1D2129;
line-height: 18px;
background: #fff;
font-weight: 400;
padding: 5px 10px;
margin: 0;
.el-icon {
margin-right: 2px;
}
}
}
.expiring-section {
margin-top: 24px;
.expiring-title {
margin-bottom: 16px;
}
.expiring-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 24px;
}
.expiring-card {
background: #FFFFFF;
border-radius: 12px 12px 12px 12px;
border: 1px solid #E0E0E0;
padding: 16px;
.ex-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
.ex-name {
font-weight: 600;
font-size: 16px;
color: #1D2129;
}
.ex-urgent-tag {
background: #ff383c;
color: #fff;
padding: 2px 8px;
font-weight: 500;
font-size: 14px;
color: #FFFFFF;
line-height: 18px;
border-radius: 8px 8px 8px 8px;
}
}
.ex-row {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 4px;
color: #666666;
line-height: 18px;
font-weight: 400;
font-size: 14px;
margin-bottom: 6px;
.ex-lbl {
color: #999999;
}
}
.ex-tags {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #EFEFEF;
display: flex;
gap: 12px;
.warn-tag {
background: #FFEBEC;
border-radius: 8px 8px 8px 8px;
color: #ff383c;
font-weight: 500;
font-size: 14px;
color: #FF383C;
line-height: 18px;
padding: 2px 8px;
}
}
}
}
</style>
<style lang="scss">
@use "@/assets/style/admin-common.scss" as *;
</style>

@ -3,7 +3,7 @@
@update:model-value="(v) => $emit('update:modelValue', v)" @closed="onClosed">
<div class="body-wrap">
<div class="body-header">
<div class="body-title">{{ isEdit ? $t('modal.edit') : $t('modal.submit') }}</div>
<div class="body-title">{{ isEdit ? '编辑工单' : '快速提交工单' }}</div>
<img src="@/assets/image/close-btn.svg" alt="" class="close-btn" @click="closeDialog" />
</div>
@ -537,8 +537,7 @@ onBeforeUnmount(() => {
</script>
<style lang="scss">
.admin-order-form-dialog {}
.admin-order-form-dialog {
.body-wrap {
.body-header {
display: flex;
@ -566,6 +565,7 @@ onBeforeUnmount(() => {
margin-top: 24px;
}
.rich-editor {
width: 100%;
background: #fff;
@ -763,6 +763,7 @@ onBeforeUnmount(() => {
:deep(.fujian .el-form-item__content > div) {
width: 100% !important;
}
}
</style>
<style lang="scss">

@ -109,7 +109,7 @@
<el-empty style="padding:0;" v-if="!detail.attachments?.length"
:description="$t('orderDetail.noAttachment')" :image-size="80" />
<div v-else class="files">
<el-button v-for="(f, i) in detail.attachments" :key="i" link type="primary"
<el-button v-for="(f, i) in detail.attachments" :key="i" link
class="file-item" @click="viewReport(f)">
<div class="inner">
<img :src="iconFile" alt="" class="file-icon" />

File diff suppressed because it is too large Load Diff

@ -0,0 +1,163 @@
<template>
<el-dialog :model-value="modelValue" class="report-download-dialog" top="20vh" :close-on-click-modal="true"
@update:model-value="(v) => $emit('update:modelValue', v)" @closed="onClosed">
<div class="body-wrap">
<div class="body-header">
<div class="body-title">选择下载格式</div>
<img src="@/assets/image/close-btn.svg" alt="" class="close-btn" @click="closeDialog" />
</div>
<div class="format-grid">
<div v-for="fmt in formats" :key="fmt.key" class="format-card" :class="{ active: selected === fmt.key }"
@click="onPick(fmt)">
<div class="format-icon">
<img :src="fmt.icon" :alt="fmt.label" />
</div>
<div class="format-label">{{ fmt.label }}</div>
</div>
</div>
</div>
</el-dialog>
</template>
<script setup>
import { ref } from "vue";
import { ElMessage } from "element-plus";
import { downloadAdminReport } from "@/service/modular/admin";
import iconWord from "@/assets/image/admin/reports/word-icon.svg";
import iconPdf from "@/assets/image/admin/reports/pdf-icon.svg";
const props = defineProps({
modelValue: { type: Boolean, default: false },
reportId: { type: [String, Number], default: "" },
});
const emit = defineEmits(["update:modelValue", "downloaded", "exportPdf", "exportDocx"]);
const formats = [
{ key: "word", label: "下载WORD格式", icon: iconWord, ext: "doc" },
{ key: "pdf", label: "下载PDF格式", icon: iconPdf, ext: "pdf" },
];
const selected = ref("");
const downloading = ref(false);
const closeDialog = () => emit("update:modelValue", false);
const onClosed = () => {
selected.value = "";
};
const downloadBlob = (blob, fileName) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName || "download";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 0);
};
/**
* 选中某个格式:
* - pdf: 触发 exportPdf 事件,由父组件调用详情框(ref)执行前端截图导出
* - word: 走后端 blob 下载,保持原逻辑不变
*/
const onPick = (fmt) => {
selected.value = fmt.key;
if (fmt.key === "pdf") {
if (!props.reportId) {
ElMessage.warning("报告不存在");
return;
}
emit("exportPdf", { reportId: props.reportId });
closeDialog();
return;
}
onConfirm();
};
const onConfirm = async () => {
if (!props.reportId) {
ElMessage.warning("报告不存在");
return;
}
const fmt = formats.find((f) => f.key === selected.value);
downloading.value = true;
try {
// Word :(emit , ReportDetailDialog )
if (selected.value === "word") {
emit("exportDocx", { reportId: props.reportId });
closeDialog();
return;
}
const blob = await downloadAdminReport(props.reportId, selected.value);
const fileName = `report-${props.reportId}.${fmt?.ext || selected.value}`;
downloadBlob(blob, fileName);
ElMessage.success("下载已开始");
emit("downloaded", { format: selected.value });
closeDialog();
} catch (e) {
ElMessage.error(e?.message || "下载失败");
} finally {
downloading.value = false;
}
};
</script>
<style lang="scss">
.report-download-dialog {
overflow: hidden;
.format-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
}
.format-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
width: 358px;
height: 158px;
background: #F9FAFF;
border-radius: 16px 16px 16px 16px;
border: 1px solid #E0E0E0;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: #4f8cff;
background: #f0f6ff;
}
&.active {
border-color: #4f8cff;
background: #f0f6ff;
box-shadow: 0 4px 14px rgba(79, 140, 255, 0.18);
}
.format-icon {
width: 70px;
height: 70px;
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.format-label {
font-weight: 600;
font-size: 16px;
color: #1D2129;
}
}
}
</style>

@ -0,0 +1,198 @@
<template>
<el-dialog
:model-value="modelValue"
class="report-generate-dialog"
top="15vh"
:close-on-click-modal="false"
@update:model-value="(v) => $emit('update:modelValue', v)"
@open="onOpen"
>
<div class="body-wrap">
<div class="body-header">
<div class="body-title">生成报告</div>
<img src="@/assets/image/close-btn.svg" alt="" class="close-btn" @click="closeDialog" />
</div>
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="医院名称" prop="hospitalId">
<el-select
v-model="form.hospitalId"
placeholder="全部医院"
clearable
filterable
style="width: 100%"
>
<el-option
v-for="h in hospitals"
:key="h.id"
:label="h.name"
:value="h.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="报告类型" prop="type">
<el-select v-model="form.type" placeholder="季度报告" style="width: 100%">
<el-option label="季度报告" value="quarterly" />
<el-option label="年度报告" value="annual" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="年份" prop="year">
<el-date-picker
v-model="form.year"
type="year"
format="YYYY年"
value-format="YYYY"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col v-if="form.type !== 'annual'" :span="12">
<el-form-item label="报告周期" prop="quarter">
<el-select
v-model="form.quarter"
placeholder="Q1第一季度"
style="width: 100%"
>
<el-option label="Q1第一季度" :value="1" />
<el-option label="Q2第二季度" :value="2" />
<el-option label="Q3第三季度" :value="3" />
<el-option label="Q4第四季度" :value="4" />
</el-select>
</el-form-item>
</el-col>
</el-row>
</el-form>
</div>
<div class="body-footer">
<el-button @click="closeDialog"></el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit"></el-button>
</div>
</el-dialog>
</template>
<script setup>
import { onMounted, reactive, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import dayjs from "dayjs";
import { useI18n } from "vue-i18n";
import { generateAdminReport, getAdminHospitals } from "@/service/modular/admin";
const { t: $t } = useI18n();
const props = defineProps({
modelValue: { type: Boolean, default: false },
});
const emit = defineEmits(["update:modelValue", "generated"]);
const formRef = ref();
const submitting = ref(false);
const hospitals = ref([]);
const currentYear = new Date().getFullYear();
const yearOptions = Array.from({ length: 5 }, (_, i) => currentYear - i);
const form = reactive({
hospitalId: undefined,
type: "quarterly",
year: dayjs().format('YYYY'),
quarter: 1,
});
const rules = {
hospitalId: [{ required: true, message: "请选择医院名称", trigger: "change" }],
type: [{ required: true, message: "请选择报告类型", trigger: "change" }],
year: [{ required: true, message: "请选择年份", trigger: "change" }],
quarter: [
{
validator: (_, value, cb) => {
if (form.type === "quarterly" && (value === undefined || value === null || value === ""))
return cb(new Error("季度报告必须选择季度"));
cb();
},
trigger: "change",
},
],
};
watch(
() => form.type,
(v) => {
if (v === "annual") form.quarter = 0;
else if (form.quarter === 0 || form.quarter === undefined) form.quarter = 1;
},
);
const loadHospitals = async () => {
try {
const res = await getAdminHospitals({ page: 1, pageSize: 1000 });
hospitals.value = res?.list || [];
} catch (_) {
hospitals.value = [];
}
};
const reset = () => {
form.hospitalId = undefined;
form.type = "quarterly";
form.year = dayjs().format("YYYY");
form.quarter = 1;
};
const onOpen = () => {
reset();
if (hospitals.value.length === 0) loadHospitals();
};
watch(
() => props.modelValue,
(v) => {
if (v) onOpen();
},
);
onMounted(() => {
if (hospitals.value.length === 0) loadHospitals();
});
const closeDialog = () => emit("update:modelValue", false);
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
submitting.value = true;
try {
const payload = {
hospitalId: form.hospitalId,
type: form.type,
year: Number(form.year),
quarter: form.type === "annual" ? 0 : form.quarter,
};
await generateAdminReport(payload);
emit("generated");
closeDialog();
} finally {
submitting.value = false;
}
});
};
</script>
<style lang="scss">
.report-generate-dialog {
width: 800px;
.body-wrap {
padding: 24px;
}
}
</style>

@ -0,0 +1,124 @@
<template>
<el-dialog :model-value="modelValue" class="report-remark-dialog" top="15vh" :close-on-click-modal="false"
@update:model-value="(v) => $emit('update:modelValue', v)" @open="onOpen">
<div class="body-wrap">
<div class="body-header">
<div class="body-title">编辑备注</div>
<img src="@/assets/image/close-btn.svg" alt="" class="close-btn" @click="closeDialog" />
</div>
<el-form :model="form" class="remark-form">
<el-form-item label="报告信息">
<el-input :model-value="reportInfo" disabled readonly />
</el-form-item>
<el-form-item label="发送状态">
<el-select v-model="form.sent" placeholder="请选择" style="width: 100%">
<el-option label="已发客户" :value="true" />
<el-option label="待发客户" :value="false" />
</el-select>
</el-form-item>
<el-form-item label="备注内容">
<el-input v-model="form.remark" type="textarea" :rows="5" placeholder="请输入备注内容" maxlength="500"
show-word-limit />
</el-form-item>
</el-form>
</div>
<div class="body-footer">
<el-button @click="closeDialog"></el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit"></el-button>
</div>
</el-dialog>
</template>
<script setup>
import { reactive, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { useI18n } from "vue-i18n";
import { updateAdminReportRemark } from "@/service/modular/admin";
const { t: $t } = useI18n();
const props = defineProps({
modelValue: { type: Boolean, default: false },
reportRow: { type: Object, default: null },
});
const emit = defineEmits(["update:modelValue", "saved"]);
const form = reactive({ remark: "", sent: "" });
const submitting = ref(false);
const reportInfo = ref("");
const reset = () => {
form.remark = "";
form.sent = "";
reportInfo.value = "";
};
const buildReportInfo = (row) => {
const hospital = row?.hospital;
const period = row?.period || "";
const typeText = row?.type === "annual" ? "报告" : " 季度报告";
return `${hospital} - ${period}${typeText}`;
};
const onOpen = () => {
if (!props.reportRow) return;
reset();
const row = props.reportRow;
console.log('row',row);
form.remark = row?.remark ?? "";
form.sent = row?.sent ?? "";
reportInfo.value = buildReportInfo(row);
};
watch(
() => props.modelValue,
(v) => {
if (v) onOpen();
else reset();
},
);
const closeDialog = () => emit("update:modelValue", false);
const onSubmit = async () => {
const reportId = props.reportRow?.id || "";
if (!reportId) {
ElMessage.warning("报告不存在");
return;
}
submitting.value = true;
try {
await updateAdminReportRemark(reportId, {
remark: form.remark,
sent: form.sent,
});
ElMessage.success('备注更新成功');
emit("saved");
closeDialog();
} finally {
submitting.value = false;
}
};
</script>
<style lang="scss">
.report-remark-dialog {
width: 800px;
.el-form-item:last-child {
margin-bottom: 0;
}
.remark-form {
.el-form-item__label {
font-weight: 500;
color: #1d2129;
}
}
}
</style>

@ -1,5 +1,5 @@
<template>
<div class="order-list">
<div class="order-list admin-page-card">
<!-- 工单总览 -->
<div class="order-stats">
<div class="stat-card stat-total" :class="{ active: activeStat === 'total' }" @click="onStatClick('total')">
@ -41,23 +41,23 @@
</div>
</div>
<el-form :inline="true" :model="filters" class="filter-form" :style="formStyle">
<el-form :inline="true" :model="filters" class="admin-filter-form" :style="formStyle">
<el-form-item :label="''">
<el-input v-model="filters.keyword" :placeholder="'搜索问题名称/工单号'" clearable style="width: 200px" />
<el-input v-model="filters.keyword" :placeholder="'搜索问题名称/工单号'" clearable style="width: 160px" />
</el-form-item>
<el-form-item :label="''">
<el-select v-model="filters.status" :placeholder="'全部状态'" clearable style="width: 140px">
<el-select v-model="filters.status" :placeholder="'全部状态'" clearable style="width: 120px">
<el-option label="待处理" value="待处理" />
<el-option label="已完成" value="已完成" />
</el-select>
</el-form-item>
<el-form-item :label="''">
<el-select v-model="filters.hospitalId" :placeholder="'输入医院名称'" clearable filterable style="width: 200px">
<el-select v-model="filters.hospitalId" :placeholder="'输入医院名称'" clearable filterable style="width: 160px">
<el-option v-for="h in hospitals" :key="h.id" :label="h.name" :value="h.id" />
</el-select>
</el-form-item>
<el-form-item :label="''">
<el-select v-model="filters.type" :placeholder="'全部类型'" clearable style="width: 120px">
<el-select v-model="filters.type" :placeholder="'全部类型'" clearable style="width: 110px">
<el-option label="故障问题" value="故障问题" />
<el-option label="使用咨询" value="使用咨询" />
<el-option label="功能需求" value="功能需求" />
@ -126,19 +126,17 @@
</el-table-column>
<el-table-column :label="$t('table.status')" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{
row.status
}}</el-tag>
<div class="status-pill" :class="statusClass(row.status)">{{ row.status }}</div>
</template>
</el-table-column>
<el-table-column :label="$t('table.time')" prop="createdAt" width="170" />
<el-table-column :label="$t('table.action')" width="160" fixed="right">
<template #default="{ row }">
<el-button class="handle" plain :type="row.status === '已完成' ? 'warning' : 'success'"
<el-button class="handle smaller" plain :class="row.status === '已完成' ? 'purple-sign' : 'rosein-sign'"
@click="goProcess(row.id)">{{
row.status === "已完成" ? $t("btn.modify") : $t("btn.process")
}}</el-button>
<el-button type="primary" plain @click="goDetail(row.id)">{{
<el-button class="blue-sign smaller" type="primary" plain @click="goDetail(row.id)">{{
$t("btn.detail")
}}</el-button>
</template>
@ -173,10 +171,10 @@ import { ElMessage } from "element-plus";
import { useI18n } from "vue-i18n";
import {
createAdminOrder,
getAdminHospitals,
getAdminOrders,
} from "@/service/modular/admin";
import { useUserStore } from "@/stores/api/user";
import { useHospitalStore } from "@/stores/api/hospital";
import OrderFormDialog from "./components/OrderFormDialog.vue";
import OrderDetailDialog from "./components/OrderDetailDialog.vue";
import OrderProcessDialog from "./components/OrderProcessDialog.vue";
@ -188,8 +186,9 @@ import iconHigh from "@/assets/image/admin/orders/high.svg";
const { t: $t } = useI18n();
const userStore = useUserStore();
const hospitalStore = useHospitalStore();
const hospitals = computed(() => hospitalStore.list);
const list = ref([]);
const hospitals = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
@ -212,10 +211,8 @@ const loadHospitals = async () => {
hospitals.value = [];
}
};
const statusType = (s) =>
({ 待处理: "warning", 处理中: "primary", 已完成: "success", 已取消: "info" })[
s
] || "info";
const statusClass = (s) =>
s === "已完成" ? "done" : s === "待处理" ? "pending" : 'info';
//
const stats = reactive({
@ -372,12 +369,45 @@ const formStyle = computed(() => {
</script>
<style scoped lang="scss">
@use "@/assets/style/admin-common.scss" as *;
.order-list {
background: #ffffff;
border-radius: 16px 16px 16px 16px;
border: 1px solid #e0e0e0;
padding: 24px;
.admin-filter-form {
width: 1800px !important;
}
.status-pill {
width: 66px;
height: 30px;
text-align: center;
line-height: 30px;
border-radius: 8px 8px 8px 8px;
font-weight: 500;
font-size: 14px;
&.done {
background: #E8FFEE;
color: #34C759;
}
&.pending {
background: #FFEFE2;
color: #FF8D28;
}
&.info {
background: #999;
color: #666;
}
}
& .title-link {
color: var(--el-color-primary);
cursor: pointer;
@ -388,28 +418,6 @@ const formStyle = computed(() => {
color: var(--el-color-primary-light-3);
}
}
:deep(.el-button.is-plain) {
background-color: transparent;
// el-button hover / active
// plain hover active hover/active
--el-button-hover-bg-color: var(--el-button-bg-color);
--el-button-hover-text-color: var(--el-button-text-color);
--el-button-hover-border-color: var(--el-button-border-color);
--el-button-active-bg-color: var(--el-button-bg-color);
--el-button-active-text-color: var(--el-button-text-color);
--el-button-active-border-color: var(--el-button-border-color);
}
:deep(.handle.el-button--success.is-plain) {
--el-button-border-color: #cb30e0;
--el-button-text-color: #cb30e0;
}
:deep(.handle.el-button--warning.is-plain) {
--el-button-border-color: #6155F5;
--el-button-text-color: #6155F5;
}
}
.filter-form {

@ -1,103 +1,119 @@
<template>
<div class="order-report">
<el-card shadow="never">
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item :label="$t('report.period')">
<el-select v-model="filters.period" :placeholder="$t('common.pleaseSelect')" style="width:140px">
<el-option label="本月" value="month" />
<el-option label="本季度" value="quarter" />
<el-option label="本年" value="year" />
<div class="report-page" v-loading="exporting" :element-loading-text="exporting ? `正在生成 ${exportTypeLabel}…` : ''" element-loading-background="rgba(255,255,255,0.7)">
<div class="admin-page-card">
<div class="filter-row">
<el-form :inline="true" :model="filters" class="admin-filter-form">
<el-form-item label="医院名称">
<el-select v-model="filters.hospitalId" placeholder="全部医院" clearable filterable style="width: 200px">
<el-option v-for="h in hospitals" :key="h.id" :label="h.name" :value="h.id" />
</el-select>
</el-form-item>
<el-form-item :label="$t('label.hospitalName')">
<el-input v-model="filters.hospital" :placeholder="$t('placeholder.hospital')" clearable style="width:180px" />
</el-form-item>
<el-form-item :label="$t('table.time')">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="-"
:start-placeholder="$t('common.startDate')"
:end-placeholder="$t('common.endDate')"
value-format="YYYY-MM-DD"
:shortcuts="dateRangeShortcuts"
style="width:240px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSearch">{{ $t('btn.query') }}</el-button>
<el-button @click="onReset">{{ $t('btn.reset') }}</el-button>
<el-button type="success" @click="onExport">{{ $t('btn.export') }}</el-button>
<el-form-item label="报告类型">
<el-select v-model="filters.type" placeholder="全部类型" clearable style="width: 140px">
<el-option label="季度报告" value="quarterly" />
<el-option label="年度报告" value="annual" />
</el-select>
</el-form-item>
<el-button class="blue-sign" type="primary" plain @click="onSearch"></el-button>
<el-button @click="onReset"></el-button>
<el-button class="blue-sign" type="primary" @click="generateVisible = true">生成报告</el-button>
</el-form>
</div>
<el-table :data="list" v-loading="loading" border stripe>
<el-table-column prop="reportNo" label="No." width="160" />
<el-table-column :label="$t('table.hospital')" prop="hospitalName" min-width="160" show-overflow-tooltip />
<el-table-column :label="$t('report.period')" prop="period" width="120" />
<el-table-column :label="$t('stat.totalOrders')" prop="totalOrders" width="100" />
<el-table-column :label="$t('stat.completedOrders')" prop="completed" width="100" />
<el-table-column :label="$t('stat.pendingOrders')" prop="pending" width="100" />
<el-table-column :label="$t('stat.completed')" width="100">
<el-table class="admin-table" :data="list" v-loading="loading" stripe
header-cell-class-name="admin-table-header-cell">
<el-table-column label="序号" type="index" width="80" />
<el-table-column label="医院名称" prop="hospital" min-width="100" show-overflow-tooltip />
<el-table-column label="报告类型" width="110">
<template #default="{ row }">
<div class="type-tag" :class="row.type">{{ typeLabel(row.type) }}</div>
</template>
</el-table-column>
<el-table-column label="报告周期" prop="period" width="140" />
<el-table-column label="生成时间" prop="date" width="170" />
<el-table-column label="发送状态" width="120">
<template #default="{ row }">
<div class="status-dot" :class="row.sent ? 'success' : 'pending'">
{{ row.sent ? "已发客户" : "待发客户" }}
</div>
</template>
</el-table-column>
<el-table-column label="备注/内容" prop="remark" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
<el-rate v-model="row.satisfaction" disabled show-score :colors="['#99A9BF', '#F7BA2A', '#FF9900']" />
<span>{{ row.remark || "—" }}</span>
</template>
</el-table-column>
<el-table-column :label="$t('common.time')" prop="createdAt" width="170" />
<el-table-column :label="$t('table.action')" width="120" fixed="right">
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="onView(row)">{{ $t('btn.view') }}</el-button>
<el-button class="op-btn purple-sign smaller" plain type="primary" @click="openView(row)"></el-button>
<el-button class="op-btn rosein-sign smaller" plain type="warning" @click="openDownload(row)"></el-button>
<el-button class="op-btn blue-sign smaller" plain type="success" @click="openRemark(row)"></el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadList"
@current-change="loadList"
/>
<div class="admin-pagination">
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :page-sizes="[10, 20, 50]"
:total="total" layout="total, sizes, prev, pager, next, jumper" @size-change="loadList"
@current-change="loadList" />
</div>
</div>
</el-card>
<el-empty v-if="!loading && list.length === 0" :description="$t('report.list')" />
<!-- 下载格式选择 -->
<ReportDownloadDialog v-model="downloadVisible" :report-id="currentReportId" @downloaded="onDownloaded" @exportPdf="onExportPdf" @exportDocx="onExportDocx" />
<!-- 编辑备注 -->
<ReportRemarkDialog v-model="remarkVisible" :report-row="currentReportRow" @saved="loadList" />
<!-- 报告详情 (silent 模式下不可见,仅供 PDF 截图使用) -->
<ReportDetailDialog ref="detailDialogRef" v-model="detailVisible" :silent="silentExport" :report-row="currentReportRow" :report-id="currentReportId" @download="openDownloadById" @exportDocx="onExportDocx" />
<!-- 生成报告 -->
<ReportGenerateDialog v-model="generateVisible" @generated="onGenerated" />
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import { dateRangeShortcuts } from "@/utils/dateShortcuts";
import { computed, nextTick, onMounted, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import { QuestionFilled } from "@element-plus/icons-vue";
import { useI18n } from "vue-i18n";
import { getAdminReports, toggleAdminReportSendStatus } from "@/service/modular/admin";
import { useHospitalStore } from "@/stores/api/hospital";
import ReportDownloadDialog from "./components/ReportDownloadDialog.vue";
import ReportRemarkDialog from "./components/ReportRemarkDialog.vue";
import ReportDetailDialog from "./components/ReportDetailDialog.vue";
import ReportGenerateDialog from "./components/ReportGenerateDialog.vue";
const { t: $t } = useI18n();
const MOCK_LIST = [
{ id: 1, reportNo: "RP-2025-001", hospitalName: "上海第一人民医院", period: "2025-Q1", totalOrders: 28, completed: 25, pending: 3, satisfaction: 5, createdAt: "2025-04-05 10:30" },
{ id: 2, reportNo: "RP-2025-002", hospitalName: "北京协和医院", period: "2025-Q1", totalOrders: 35, completed: 33, pending: 2, satisfaction: 4, createdAt: "2025-04-06 14:12" },
{ id: 3, reportNo: "RP-2025-003", hospitalName: "广州中山医院", period: "2025-03", totalOrders: 12, completed: 12, pending: 0, satisfaction: 5, createdAt: "2025-04-01 09:00" },
{ id: 4, reportNo: "RP-2025-004", hospitalName: "深圳人民医院", period: "2025-03", totalOrders: 19, completed: 16, pending: 3, satisfaction: 3, createdAt: "2025-04-02 16:45" },
{ id: 5, reportNo: "RP-2025-005", hospitalName: "杭州邵逸夫医院", period: "2025-03", totalOrders: 22, completed: 20, pending: 2, satisfaction: 4, createdAt: "2025-04-03 11:20" },
];
const hospitalStore = useHospitalStore();
const hospitals = computed(() => hospitalStore.list);
const list = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const loading = ref(false);
const dateRange = ref([]);
const filters = reactive({ period: "", hospital: "" });
const filters = reactive({ type: "", hospitalId: undefined });
// type / status
const typeLabel = (t) => (t === "annual" ? "年度报告" : "季度报告");
const loadList = async () => {
loading.value = true;
try {
await new Promise((r) => setTimeout(r, 200));
list.value = MOCK_LIST;
total.value = MOCK_LIST.length;
const params = {
page: page.value,
pageSize: pageSize.value,
type: filters.type || undefined,
hospitalId: filters.hospitalId || undefined,
};
const res = await getAdminReports(params);
list.value = res?.list || [];
total.value = res?.total || list.value.length;
} catch (e) {
list.value = [];
total.value = 0;
} finally {
loading.value = false;
}
@ -107,25 +123,262 @@ const onSearch = () => {
page.value = 1;
loadList();
};
const onReset = () => {
filters.period = "";
filters.hospital = "";
dateRange.value = [];
filters.type = "";
filters.hospitalId = undefined;
onSearch();
};
const onExport = () => ElMessage.success($t('btn.export'));
const onView = (row) => ElMessage.info(`${$t('btn.view')}${row.reportNo}`);
onMounted(loadList);
const onGenerated = () => {
loadList();
};
//
const currentReportId = ref("");
const currentReportRow = ref(null);
const detailVisible = ref(false);
const downloadVisible = ref(false);
const remarkVisible = ref(false);
const generateVisible = ref(false);
// PDF :,DOM html2canvas
const detailDialogRef = ref(null);
const silentExport = ref(false);
const exporting = ref(false);
const exportTypeLabel = ref("PDF");
/**
* 处理 PDF 导出( ReportDownloadDialog 在选中 PDF 时触发)
* 流程:静默打开详情框 -> 等待数据加载 -> 截图导出 -> 仅关闭 silent 触发的打开
*/
const onExportPdf = async ({ reportId } = {}) => {
const targetId = reportId || currentReportId.value;
if (!targetId) {
ElMessage.warning("报告不存在");
return;
}
// ()
const row =
list.value.find((r) => r.id === targetId) ||
currentReportRow.value ||
null;
currentReportId.value = targetId;
currentReportRow.value = row;
// :,
const wasDetailVisible = detailVisible.value;
// silent (),
silentExport.value = !wasDetailVisible;
if (!wasDetailVisible) {
detailVisible.value = true;
}
// loading
exportTypeLabel.value = "PDF";
exporting.value = true;
await nextTick();
// ref loadDetail ( onOpen loadDetail )
for (let i = 0; i < 20 && !detailDialogRef.value?.handleExportPdf; i++) {
await new Promise((r) => setTimeout(r, 50));
}
try {
if (detailDialogRef.value?.handleExportPdf) {
await detailDialogRef.value.handleExportPdf(targetId, row);
} else {
ElMessage.error("报告组件未就绪,请重试");
}
} catch (e) {
console.error("[report] PDF 导出失败:", e);
} finally {
silentExport.value = false;
exporting.value = false;
// (silent )
if (!wasDetailVisible) {
detailVisible.value = false;
}
}
};
const openView = (row) => {
currentReportId.value = row.id;
currentReportRow.value = row;
detailVisible.value = true;
};
/**
* 处理 Word(docx)导出( ReportDownloadDialog 在选中 WORD 时触发)
* 流程与 onExportPdf 完全一致:静默打开详情框 -> 等待数据加载 -> 截图导出 -> 关闭
*/
const onExportDocx = async ({ reportId } = {}) => {
const targetId = reportId || currentReportId.value;
if (!targetId) {
ElMessage.warning("报告不存在");
return;
}
const row =
list.value.find((r) => r.id === targetId) ||
currentReportRow.value ||
null;
currentReportId.value = targetId;
currentReportRow.value = row;
const wasDetailVisible = detailVisible.value;
silentExport.value = !wasDetailVisible;
if (!wasDetailVisible) {
detailVisible.value = true;
}
exportTypeLabel.value = "Word";
exporting.value = true;
await nextTick();
for (let i = 0; i < 20 && !detailDialogRef.value?.handleExportDocx; i++) {
await new Promise((r) => setTimeout(r, 50));
}
try {
if (detailDialogRef.value?.handleExportDocx) {
await detailDialogRef.value.handleExportDocx(targetId, row);
} else {
ElMessage.error("报告组件未就绪,请重试");
}
} catch (e) {
console.error("[report] Word 导出失败:", e);
} finally {
silentExport.value = false;
exporting.value = false;
if (!wasDetailVisible) {
detailVisible.value = false;
}
}
};
const openDownload = (row) => {
currentReportId.value = row.id;
downloadVisible.value = true;
};
const openDownloadById = (row) => {
if (row?.id) {
currentReportId.value = row.id;
downloadVisible.value = true;
}
};
const openRemark = (row) => {
currentReportRow.value = row;
currentReportId.value = row?.id || "";
remarkVisible.value = true;
};
const onDownloaded = async () => {
// ""
try {
await toggleAdminReportSendStatus(currentReportId.value);
ElMessage.success("已切换为已发送");
loadList();
} catch (_) {
// 便
}
};
onMounted(() => {
hospitalStore.ensureLoaded();
loadList();
});
</script>
<style scoped lang="scss">
.filter-form {
.report-page {
padding: 0;
}
.page-title-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 16px;
.page-title {
font-size: 18px;
font-weight: 600;
color: #1d2129;
}
.title-tip {
color: #86909c;
font-size: 16px;
cursor: help;
}
}
.filter-row {
margin-bottom: 12px;
.filter-row-label {
font-size: 14px;
color: #1d2129;
margin-bottom: 8px;
font-weight: 500;
border-left: 3px solid #4f8cff;
padding-left: 8px;
}
}
.type-tag {
width: 80px;
height: 30px;
border-radius: 8px 8px 8px 8px;
font-weight: 500;
font-size: 14px;
line-height: 30px;
text-align: center;
&.quarterly {
background: #DEFEFF;
color: #00C8B3;
}
.pagination {
margin-top: 16px;
&.annual {
background: #EAF3FF;
color: #3289FB;
}
}
.status-dot {
display: flex;
justify-content: flex-end;
align-items: center;
justify-content: center;
gap: 6px;
width: 92px;
height: 30px;
border-radius: 8px 8px 8px 8px;
font-weight: 500;
font-size: 14px;
&::before {
content: "";
display: block;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--dot-bg);
}
&.success {
background: #E8FFEE;
color: #34C759;
--dot-bg: #34C759;
}
&.pending {
background: #FFEFE2;
color: #FF8D28;
--dot-bg: #FF8D28;
}
}
.op-btn {
margin-left: 12px;
&:first-child {
margin-left: 0;
}
}
</style>
<style lang="scss">
@use "@/assets/style/admin-common.scss" as *;
</style>

@ -1,114 +1,643 @@
<template>
<div class="performance-score">
<el-card shadow="never">
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item :label="$t('table.manager')">
<el-input v-model="filters.name" :placeholder="$t('placeholder.search')" clearable style="width:160px" />
</el-form-item>
<el-form-item :label="$t('common.time')">
<el-date-picker
v-model="filters.month"
type="month"
:placeholder="$t('common.pleaseSelect')"
value-format="YYYY-MM"
style="width:160px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSearch">{{ $t('btn.query') }}</el-button>
<el-button @click="onReset">{{ $t('btn.reset') }}</el-button>
</el-form-item>
</el-form>
<el-table :data="list" v-loading="loading" border stripe>
<el-table-column :label="$t('points.points')" prop="rank" width="80" />
<el-table-column :label="$t('table.manager')" prop="name" width="120" />
<el-table-column :label="$t('label.dept')" prop="department" min-width="140" show-overflow-tooltip />
<el-table-column :label="$t('points.title')" prop="totalScore" width="120">
<template #default="{ row }">
<span class="score-num">{{ row.totalScore }}</span>
</template>
</el-table-column>
<el-table-column :label="$t('points.detail')" prop="monthScore" width="120" />
<el-table-column :label="$t('stat.totalOrders')" prop="serviceCount" width="100" />
<el-table-column :label="$t('stat.completed')" prop="satisfaction" width="160">
<template #default="{ row }">
<el-rate v-model="row.satisfaction" disabled show-score :colors="['#99A9BF', '#F7BA2A', '#FF9900']" />
</template>
</el-table-column>
<el-table-column :label="$t('table.action')" width="120" fixed="right">
<!-- 顶部周期切换 -->
<div class="period-bar">
<div class="period-tabs">
<el-button v-for="t in periodTabs" :key="t.value"
:class="['period-tab', { active: period === t.value && !isCustom }]" plain @click="onPickPeriod(t.value)">
{{ t.label }}
</el-button>
<el-button :class="['period-tab', { active: isCustom }]" plain @click="onPickCustom"></el-button>
</div>
<div v-if="isCustom" class="custom-range">
<el-date-picker v-model="customRange" type="daterange" range-separator="-" start-placeholder=""
end-placeholder="结束日期" value-format="YYYY-MM-DD" style="width: 240px" />
<el-button type="primary" class="search-btn" plain @click="onSearch"></el-button>
</div>
</div>
<!-- 统计卡片 -->
<div class="stat-grid">
<div class="stat-card">
<img src="@/assets/image/admin/score/total.svg" class="stat-icon">
<div class="stat-body">
<div class="stat-label">本期总积分</div>
<div class="stat-value">{{ stats.totalPoints || 0 }}</div>
</div>
</div>
<div class="stat-card">
<img src="@/assets/image/admin/score/manager-count.svg" class="stat-icon">
<div class="stat-body">
<div class="stat-label">客户经理人数</div>
<div class="stat-value">{{ stats.managerCount || 0 }}</div>
</div>
</div>
<div class="stat-card">
<img src="@/assets/image/admin/score/success-count.svg" class="stat-icon">
<div class="stat-body">
<div class="stat-label">本期完成工单</div>
<div class="stat-value">{{ stats.completedOrders || 0 }}</div>
</div>
</div>
<div class="stat-card">
<img src="@/assets/image/admin/score/success-ratio.svg" class="stat-icon">
<div class="stat-body">
<div class="stat-label">本期完成率</div>
<div class="stat-value">{{ stats.completionRate || 0 }}%</div>
</div>
</div>
</div>
<!-- 双栏排行榜 + 规则 -->
<div class="content-grid">
<div class="leaderboard-card jifen-card">
<div class="border-title">
积分排行榜
</div>
<div v-loading="leaderboardLoading" class="leaderboard">
<el-empty v-if="!leaderboardLoading && leaderboard.length === 0" description="暂无排行数据" :image-size="80" />
<div v-for="(item, idx) in leaderboard" :key="item.managerId" class="lb-row">
<div class="lb-rank" :class="rankClass(item.rank || idx + 1)">
{{ item.rank || idx + 1 }}
</div>
<div class="lb-info">
<div class="lb-name">{{ item.managerName || "—" }}</div>
<div class="lb-meta">
负责 <span class="num">{{ item.hospitalCount || 0 }}</span> 家医院
<span class="sep">|</span>
本期完成 <span class="num">{{ item.completedOrders || 0 }}</span>
</div>
</div>
<div class="lb-points">{{ item.points || 0 }}</div>
</div>
</div>
</div>
<div class="rules-card jifen-card">
<div class="border-title">
积分规则
</div>
<div v-loading="rulesLoading" class="rules">
<div v-for="(r, idx) in rules" :key="r.event" class="rule-row">
<div class="rule-left">
<img class="rule-icon" :src="ruleIcon(r.event)" alt="">
<span class="rule-event">{{ r.event }}</span>
</div>
<div class="rule-points" :class="Number(r.points) >= 0 ? 'pos' : 'neg'">
{{ Number(r.points) >= 0 ? "+" : "" }}{{ r.points }}
</div>
</div>
</div>
</div>
</div>
<!-- 客户经理积分明细弹窗 -->
<el-dialog v-model="detailsVisible" class="manager-details-dialog" top="6vh" width="900px"
:close-on-click-modal="false" destroy-on-close @update:model-value="(v) => !v && (detailsVisible = false)"
@open="onDetailsOpen">
<div class="body-wrap">
<div class="body-header">
<span class="body-title">积分明细 - {{ currentManagerName }}</span>
<img src="@/assets/image/close-btn.svg" alt="" class="close-btn" @click="detailsVisible = false" />
</div>
<el-table class="details-table" :data="detailsList" v-loading="detailsLoading" stripe>
<el-table-column prop="createdAt" label="时间" width="170" />
<el-table-column prop="source" label="来源" width="140" />
<el-table-column label="积分变动" width="120">
<template #default="{ row }">
<el-button link type="primary" @click="onDetail(row)">{{ $t('points.detail') }}</el-button>
<span class="change-amount" :class="Number(row.changeAmount) >= 0 ? 'pos' : 'neg'">
{{ Number(row.changeAmount) >= 0 ? "+" : "" }}{{ row.changeAmount }}
</span>
</template>
</el-table-column>
<el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip />
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadList"
@current-change="loadList"
/>
<div class="admin-pagination">
<el-pagination v-model:current-page="detailsPage" v-model:page-size="detailsPageSize"
:page-sizes="[10, 20, 50]" :total="detailsTotal" layout="total, sizes, prev, pager, next, jumper"
@size-change="loadDetails" @current-change="loadDetails" />
</div>
</div>
</el-card>
</el-dialog>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import { useI18n } from "vue-i18n";
const { t: $t } = useI18n();
const MOCK_LIST = [
{ id: 1, rank: 1, name: "张三", department: "华东大区·上海组", totalScore: 2860, monthScore: 320, serviceCount: 28, satisfaction: 5 },
{ id: 2, rank: 2, name: "李四", department: "华北大区·北京组", totalScore: 2540, monthScore: 285, serviceCount: 33, satisfaction: 4 },
{ id: 3, rank: 3, name: "王五", department: "华南大区·广州组", totalScore: 2310, monthScore: 260, serviceCount: 25, satisfaction: 4 },
{ id: 4, rank: 4, name: "赵六", department: "华南大区·深圳组", totalScore: 2080, monthScore: 240, serviceCount: 22, satisfaction: 3 },
{ id: 5, rank: 5, name: "钱七", department: "华东大区·杭州组", totalScore: 1950, monthScore: 220, serviceCount: 19, satisfaction: 5 },
import { onMounted, ref } from "vue";
import { Coin, User, Document, CircleCheck } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import serviceIcon from "@/assets/image/admin/score/service.svg";
import inspectorIcon from "@/assets/image/admin/score/inspector.svg";
import trainIcon from "@/assets/image/admin/score/train.svg";
import huifangIcon from "@/assets/image/admin/score/huifang.svg";
import complaintIcon from "@/assets/image/admin/score/complaint.svg";
import {
getManagerPointsDetails,
getManagerPointsLeaderboard,
getManagerPointsRules,
getManagerPointsStats,
} from "@/service/modular/admin";
const periodTabs = [
{ label: "本周", value: "week" },
{ label: "本月", value: "month" },
{ label: "本季", value: "quarter" },
{ label: "本年", value: "year" },
];
const list = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const loading = ref(false);
const filters = reactive({ name: "", month: "" });
const period = ref("month"); // month | quarter | year | weekly
const isCustom = ref(false);
const customRange = ref([]);
const stats = ref({});
const leaderboard = ref([]);
const rules = ref([]);
const leaderboardLoading = ref(false);
const rulesLoading = ref(false);
const statsLoading = ref(false);
const rankClass = (rank) => {
if (rank === 1) return "rank-1";
if (rank === 2) return "rank-2";
if (rank === 3) return "rank-3";
return "rank-other";
};
const ruleIconMap = {
'服务': "service",
'巡检': "inspector",
'表扬': "train",
'回访': "huifang",
'投诉': "complaint",
};
const iconUrls = {
service: serviceIcon,
inspector: inspectorIcon,
train: trainIcon,
huifang: huifangIcon,
complaint: complaintIcon,
};
const ruleIcon = (event) => {
for (const [keyword, name] of Object.entries(ruleIconMap)) {
if (event?.includes(keyword)) {
return iconUrls[name];
}
}
return "";
};
const getStatsParams = () => {
if (isCustom.value && customRange.value?.length === 2) {
return { start: customRange.value[0], end: customRange.value[1] };
}
return { period: period.value };
};
const loadList = async () => {
loading.value = true;
const paramsMap = {
week: 'weekly',
month: 'monthly',
quarter: 'quarterly',
year: 'annual'
}
const getLeaderboardParams = () => {
if (isCustom.value && customRange.value?.length === 2) {
return { start: customRange.value[0], end: customRange.value[1] };
}
return { period: paramsMap[period.value] };
};
const loadStats = async () => {
statsLoading.value = true;
try {
stats.value = (await getManagerPointsStats(getStatsParams())) || {};
} finally {
statsLoading.value = false;
}
};
const loadLeaderboard = async () => {
leaderboardLoading.value = true;
try {
const res = await getManagerPointsLeaderboard(getLeaderboardParams());
leaderboard.value = Array.isArray(res) ? res : res?.list || [];
} finally {
leaderboardLoading.value = false;
}
};
const loadRules = async () => {
rulesLoading.value = true;
try {
const res = await getManagerPointsRules();
rules.value = Array.isArray(res) ? res : res?.list || [];
} finally {
rulesLoading.value = false;
}
};
const onPickPeriod = (val) => {
isCustom.value = false;
customRange.value = [];
period.value = val;
onSearch();
};
const onPickCustom = () => {
isCustom.value = true;
};
const onSearch = () => {
loadStats();
loadLeaderboard();
};
const onReset = () => {
period.value = "month";
isCustom.value = false;
customRange.value = [];
loadStats();
loadLeaderboard();
};
//
const detailsVisible = ref(false);
const detailsLoading = ref(false);
const detailsList = ref([]);
const detailsTotal = ref(0);
const detailsPage = ref(1);
const detailsPageSize = ref(10);
const currentManagerId = ref("");
const currentManagerName = ref("");
const loadDetails = async () => {
if (!currentManagerId.value) return;
detailsLoading.value = true;
try {
await new Promise((r) => setTimeout(r, 200));
list.value = MOCK_LIST;
total.value = MOCK_LIST.length;
const res = await getManagerPointsDetails(currentManagerId.value, {
page: detailsPage.value,
pageSize: detailsPageSize.value,
});
detailsList.value = (res?.list || []).map((item) => ({
...item,
createdAt: item.createdAt ? dayjs(item.createdAt).format("YYYY-MM-DD HH:mm:ss") : "—",
}));
detailsTotal.value = res?.total || detailsList.value.length;
} finally {
loading.value = false;
detailsLoading.value = false;
}
};
const onSearch = () => { page.value = 1; loadList(); };
const onReset = () => { filters.name = ""; filters.month = ""; onSearch(); };
const onDetail = (row) => ElMessage.info(`${row.name} - ${$t('points.detail')}`);
const onPickManager = (item) => {
currentManagerId.value = item.managerId;
currentManagerName.value = item.managerName || "—";
detailsPage.value = 1;
detailsVisible.value = true;
};
onMounted(loadList);
const onDetailsOpen = () => {
loadDetails();
};
onMounted(() => {
loadStats();
loadLeaderboard();
loadRules();
});
</script>
<style scoped lang="scss">
.filter-form {
margin-bottom: 12px;
<style lang="scss">
@use "@/assets/style/admin-common.scss" as *;
.performance-score {
padding: 0;
.period-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 24px;
.period-tabs {
display: flex;
gap: 10px;
}
.period-tab {
background: #FFFFFF !important;
border-radius: 8px 8px 8px 8px !important;
border: 1px solid #E0E0E0 !important;
font-weight: normal !important;
font-size: 16px !important;
color: #1D2129 !important;
line-height: 22px !important;
width: auto !important;
height: auto !important;
padding: 10px 24px !important;
&:hover,
&.active {
background: #3289FB !important;
border-color: #3289FB !important;
color: #fff !important;
}
}
.search-btn {
font-size: 16px !important;
color: #1D2129 !important;
line-height: 22px !important;
width: auto !important;
height: auto !important;
padding: 10px 24px !important;
color: #3289FB !important;
&:hover,
&.active {
background: #3289FB !important;
border-color: #3289FB !important;
color: #fff !important;
}
}
.custom-range {
display: flex;
align-items: center;
gap: 10px;
.el-date-editor {
background: #FFFFFF !important;
border-radius: 8px 8px 8px 8px !important;
border: 1px solid #E0E0E0 !important;
overflow: hidden;
height: 42px !important;
}
}
.pagination {
}
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
.stat-card {
background: #FFFFFF;
border-radius: 16px 16px 16px 16px;
border: 1px solid #E0E0E0;
padding: 18px 24px;
display: flex;
align-items: center;
gap: 18px;
}
.stat-icon {
width: 48px;
height: 48px;
object-fit: contain;
}
.stat-body {
.stat-label {
font-weight: normal;
font-size: 14px;
color: #666666;
line-height: 20px;
}
.stat-value {
font-weight: bold;
font-size: 24px;
color: #1D2129;
line-height: 32px;
}
}
}
.content-grid {
display: flex;
gap: 24px;
.jifen-card {
padding: 28px 24px 22px;
background: #FFFFFF;
border-radius: 16px 16px 16px 16px;
border: 1px solid #E0E0E0;
}
.leaderboard-card {
flex: 1;
.border-title {
margin-bottom: 26px;
}
}
.rules-card {
width: 560px;
.border-title {
margin-bottom: 16px;
}
}
.leaderboard {
display: flex;
flex-direction: column;
gap: 10px;
.lb-row {
width: 100%;
display: flex;
align-items: center;
gap: 14px;
justify-content: space-between;
padding: 12px 16px;
background: #F9FAFF;
border-radius: 12px 12px 12px 12px;
cursor: pointer;
transition: all 0.2s;
// &:hover {
// background: #f0f7ff;
// border-color: #c0d8ff;
// }
}
.lb-rank {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 14px;
color: #3289FB;
flex-shrink: 0;
&.rank-1 {
background: linear-gradient(180deg, #B57000 0%, #EEC685 100%);
color: #fff;
}
&.rank-2 {
background: linear-gradient(180deg, #6F818E 0%, #BED8EA 100%);
color: #fff;
}
&.rank-3 {
background: linear-gradient(180deg, #AC6C55 0%, #C79888 100%);
color: #fff;
}
&.rank-other {
background: #EDF5FF;
color: #606266;
}
}
.lb-info {
flex: 1;
min-width: 0;
.lb-name {
font-weight: 600;
font-size: 16px;
color: #1D2129;
}
.lb-meta {
font-weight: normal;
font-size: 14px;
color: #666666;
.num {
color: #3289FB;
}
.sep {
margin: 0 4px;
}
}
}
.lb-points {
flex-shrink: 0;
font-weight: bold;
font-size: 24px;
color: #6F818E;
}
}
.rules {
display: flex;
flex-direction: column;
gap: 12px;
.rule-row {
width: 100%;
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
background: #FFFFFF;
border-radius: 16px 16px 16px 16px;
border: 1px solid #E0E0E0;
}
.rule-left {
display: flex;
align-items: center;
gap: 1px;
.rule-icon {
width: 16px;
height: 16px;
}
.rule-event {
font-weight: 400;
font-size: 14px;
color: #1D2129;
line-height: 18px;
}
}
.rule-points {
font-weight: 400;
font-size: 14px;
&.pos {
color: #34c759;
}
&.neg {
color: #ff383c;
}
}
}
}
}
</style>
<style lang="scss">
.manager-details-dialog {
.body-wrap {
padding: 4px 24px 16px;
}
.body-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.body-title {
font-size: 16px;
font-weight: 600;
color: #1d2129;
}
.close-btn {
width: 16px;
height: 16px;
cursor: pointer;
}
}
.details-table {
border-radius: 8px;
overflow: hidden;
}
.change-amount {
font-weight: 600;
&.pos {
color: #34c759;
}
&.neg {
color: #ff383c;
}
}
.admin-pagination {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.score-num {
font-weight: 600;
color: #f56c6c;
}
</style>

@ -4,7 +4,7 @@
<div class="body-wrap">
<div class="body-header">
<div class="body-title">
{{ isEdit ? '编辑工单' : $t('modal.submit') }}
{{ isEdit() ? '编辑工单' : $t('modal.submit') }}
</div>
<img src="@/assets/image/close-btn.svg" alt="" class="close-btn" @click="onCancel">
</div>
@ -78,12 +78,19 @@
</div>
</el-form-item>
<el-form-item class="fujian" :label="$t('label.attachment')">
<el-upload v-model:file-list="form.files" :auto-upload="false" :limit="1" :on-exceed="handleAttachmentExceed"
:on-remove="handleAttachmentRemove" :on-change="handleAttachmentChange">
<el-button type="primary" plain>{{ $t('btn.upload') }}</el-button>
<template #tip>
<div class="el-upload__tip">
{{ $t('label.attachmentTip') }}
<el-upload class="upload-wrap" v-model:file-list="form.files" :auto-upload="false" :limit="1"
:on-exceed="handleAttachmentExceed" :on-remove="handleAttachmentRemove" :on-change="handleAttachmentChange">
<div class="upload-btn">
<div class="left">+ 添加文件</div>
<div class="right">{{ $t('label.attachmentTip') }}</div>
</div>
<template #file="{ file }">
<div class="file-item">
<img class="file-icon" src="@/assets/image/admin/orders/file.svg" alt="">
<div class="file-name">&nbsp;&nbsp;{{ file.name }}</div>
<div class="file-size">&nbsp;&nbsp;{{ ((file.size || 0) / 1024 / 1024).toFixed(2) }}MB</div>
<div class="file-delete" @click="handleAttachmentDelete(file)"></div>
</div>
</template>
</el-upload>
@ -316,6 +323,10 @@ const handleAttachmentExceed = () => {
const handleAttachmentRemove = () => {
// el-upload v-model:file-list
};
const handleAttachmentDelete = (file) => {
// "" uid form.files admin
form.files = (form.files || []).filter((f) => f.uid !== file?.uid);
};
const nowDatetime = () => dayjs().format("YYYY-MM-DD HH:mm:ss");
@ -683,6 +694,66 @@ onBeforeUnmount(() => {
width: 100% !important;
}
// ============== admin ==============
.fujian {
.upload-wrap {
width: 100%;
.el-upload-list__item {
&:hover {
background: transparent;
}
.file-item {
display: flex;
align-items: center;
font-weight: 400;
font-size: 14px;
.file-icon {
width: 18px;
height: 18px;
}
.file-name,
.file-delete {
color: #1D2129;
&:hover {
color: #1890ff;
}
}
.file-delete {
margin-left: 14px;
cursor: pointer;
}
.file-size {
color: #BCBCBC;
}
}
}
}
.upload-btn {
display: flex;
align-items: center;
.left {
font-weight: 400;
font-size: 14px;
color: #2B324C;
}
.right {
font-weight: 400;
font-size: 14px;
color: #666666;
}
}
}
// ============== ==============
:deep(.reward-dialog) {
.el-dialog__header {

@ -8,26 +8,18 @@
<el-radio-button label="year">{{ $t('common.thisYear') }}</el-radio-button>
<el-radio-button label="custom">{{ $t('common.customDate') }}</el-radio-button>
</el-radio-group>
<el-date-picker
v-if="period === 'custom'"
v-model="dateRange"
type="daterange"
range-separator="-"
:start-placeholder="$t('common.startDate')"
:end-placeholder="$t('common.endDate')"
value-format="YYYY-MM-DD"
:shortcuts="dateRangeShortcuts"
size="small"
style="margin-left: 12px"
@change="loadStats"
/>
<el-date-picker v-if="period === 'custom'" v-model="dateRange" type="daterange" range-separator="-"
:start-placeholder="$t('common.startDate')" :end-placeholder="$t('common.endDate')" value-format="YYYY-MM-DD"
:shortcuts="dateRangeShortcuts" size="small" style="margin-left: 12px" @change="loadStats" />
</div>
<!-- 统计卡片 -->
<div class="stat-cards">
<div class="stat-card" v-for="card in cards" :key="card.label" @click="goToOrders(card)">
<div class="stat-icon" :style="{ background: card.bg }">
<el-icon :size="22" :color="card.color"><component :is="card.icon" /></el-icon>
<el-icon :size="22" :color="card.color">
<component :is="card.icon" />
</el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ card.value }}</div>
@ -42,33 +34,23 @@
<div class="card-header">
<span class="card-title">{{ $t('chart.title') }}</span>
<div class="chart-switch">
<span
class="switch-item"
:class="{ active: chartType === 'bar' }"
@click="chartType = 'bar'"
>
<i class="switch-icon bar" />
{{ $t('chart.bar') }}
<span class="switch-item" :class="{ active: chartType === 'bar' }" @click="chartType = 'bar'">
<i class="switch-icon bar" /> {{ $t('chart.bar') }}
</span>
<span
class="switch-item"
:class="{ active: chartType === 'line' }"
@click="chartType = 'line'"
>
<i class="switch-icon line" />
{{ $t('chart.line') }}
<span class="switch-item" :class="{ active: chartType === 'line' }" @click="chartType = 'line'">
<i class="switch-icon line" /> {{ $t('chart.line') }}
</span>
</div>
</div>
</template>
<v-chart class="chart" :option="chartOption" autoresize />
<v-chart ref="chartRef" class="chart" :option="chartOption" autoresize />
</el-card>
</div>
</template>
<script setup>
import dayjs from "dayjs";
import { computed, onMounted, ref } from "vue";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { dateRangeShortcuts } from "@/utils/dateShortcuts";
import { useRouter } from "vue-router";
import { Tickets, Check, Bell, Warning } from "@element-plus/icons-vue";
@ -104,60 +86,36 @@ const stats = ref({
const router = useRouter();
const chartRef = ref(null);
const overlaySize = ref({ w: 0, h: 0 });
const stripeTargets = ref([]);
const cards = computed(() => [
{
key: "total",
label: $t('stat.total'),
value: stats.value.totalOrders,
icon: Tickets,
color: "#5B8FF9",
bg: "rgba(91,143,249,0.12)",
},
{
key: "completed",
label: $t('stat.completed'),
value: stats.value.completedOrders,
icon: Check,
color: "#5AD8A6",
bg: "rgba(90,216,166,0.12)",
},
{
key: "pending",
label: $t('stat.pending'),
value: stats.value.pendingOrders,
icon: Bell,
color: "#F6BD16",
bg: "rgba(246,189,22,0.12)",
},
{
key: "highPriority",
label: $t('stat.highPriority'),
value: stats.value.highPriorityOrders,
icon: Warning,
color: "#F4664A",
bg: "rgba(244,102,74,0.12)",
},
{ key: "total", label: $t('stat.total'), value: stats.value.totalOrders, icon: Tickets, color: "#5B8FF9", bg: "rgba(91,143,249,0.12)" },
{ key: "completed", label: $t('stat.completed'), value: stats.value.completedOrders, icon: Check, color: "#5AD8A6", bg: "rgba(90,216,166,0.12)" },
{ key: "pending", label: $t('stat.pending'), value: stats.value.pendingOrders, icon: Bell, color: "#F6BD16", bg: "rgba(246,189,22,0.12)" },
{ key: "highPriority", label: $t('stat.highPriority'), value: stats.value.highPriorityOrders, icon: Warning, color: "#F4664A", bg: "rgba(244,102,74,0.12)" },
]);
// startDate/endDate
const allStripeLines = computed(() => {
const result = [];
stripeTargets.value.forEach((t) => {
buildStripeLines(t).forEach((ln) => result.push(ln));
});
return result;
});
const resolveDateRange = () => {
if (period.value === "custom" && dateRange.value?.length === 2) {
return { startDate: dateRange.value[0], endDate: dateRange.value[1] };
}
const now = dayjs();
if (period.value === "month") {
return { startDate: now.startOf("month").format("YYYY-MM-DD"), endDate: now.endOf("month").format("YYYY-MM-DD") };
}
if (period.value === "quarter") {
return { startDate: now.startOf("quarter").format("YYYY-MM-DD"), endDate: now.endOf("quarter").format("YYYY-MM-DD") };
}
if (period.value === "year") {
return { startDate: now.startOf("year").format("YYYY-MM-DD"), endDate: now.endOf("year").format("YYYY-MM-DD") };
}
if (period.value === "month") return { startDate: now.startOf("month").format("YYYY-MM-DD"), endDate: now.endOf("month").format("YYYY-MM-DD") };
if (period.value === "quarter") return { startDate: now.startOf("quarter").format("YYYY-MM-DD"), endDate: now.endOf("quarter").format("YYYY-MM-DD") };
if (period.value === "year") return { startDate: now.startOf("year").format("YYYY-MM-DD"), endDate: now.endOf("year").format("YYYY-MM-DD") };
return {};
};
//
const goToOrders = (card) => {
const query = { ...resolveDateRange() };
if (card.key === "completed") query.status = "已完成";
@ -166,15 +124,88 @@ const goToOrders = (card) => {
router.push({ path: "/hospital/orders", query });
};
// ==================== ECharts ====================
const BAR_W = 14;
const CORNER_R = BAR_W / 8;
const WRAP_THICK = BAR_W / 10;
const WRAP_BORDER = "#eff2fe";
const buildLinearGradient = (top, bottom) => ({
type: "linear",
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: top },
{ offset: 1, color: bottom },
],
});
const chartOption = computed(() => {
const data = stats.value.trendData || [];
const dates = data.map((d) => d.date);
const submit = data.map((d) => d.submitCount);
const complete = data.map((d) => d.completeCount);
const isLine = chartType.value === "line";
const isBar = chartType.value === "bar";
const showSlider = dates.length > 20;
const maxValue = Math.max(0, ...submit, ...complete);
const yMax = maxValue > 0 ? Math.ceil(maxValue / 2) * 2 : 4;
const submitTopColor = "#3289FB";
const completeTopColor = "#34C759";
const submitWrapColor = "#B4CAE6";
const completeWrapColor = "#B0D2B9";
const lineSeries = [
{
name: $t('chart.submit'),
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 8,
data: submit,
lineStyle: { color: submitTopColor, width: 2 },
itemStyle: { color: submitTopColor },
areaStyle: { color: "rgba(50,137,251,0.08)" },
},
{
name: $t('chart.complete'),
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 8,
data: complete,
lineStyle: { color: completeTopColor, width: 2 },
itemStyle: { color: completeTopColor },
areaStyle: { color: "rgba(52,199,89,0.08)" },
},
];
const makeBarSeries = (dataArr, topColor, bottomColor) => ({
type: chartType.value,
barWidth: BAR_W,
barGap: isBar ? "0%" : "20%",
data: dataArr,
label: isBar ? {
show: true,
position: "top",
distance: 4,
color: topColor,
fontWeight: 400,
fontSize: 14,
formatter: (p) => p.value,
} : { show: false },
itemStyle: isBar ? {
borderRadius: CORNER_R,
borderColor: WRAP_BORDER,
borderWidth: WRAP_THICK,
color: buildLinearGradient(topColor, bottomColor),
} : { color: topColor, borderRadius: 0 },
showBackground: false,
});
return {
grid: { left: 30, right: 70, top: 30, bottom: showSlider ? 60 : 30 },
grid: { left: 30, right: 70, top: 50, bottom: showSlider ? 60 : 30 },
tooltip: { trigger: "axis" },
legend: {
orient: "vertical",
@ -189,52 +220,109 @@ const chartOption = computed(() => {
type: "category",
data: dates,
boundaryGap: !isLine,
...(isBar ? { barCategoryGap: "7%" } : {}),
axisLine: { lineStyle: { color: "#e4e7ed" } },
},
yAxis: {
type: "value",
min: 0,
minInterval: 1,
max: yMax,
interval: 2,
axisLine: { show: false },
splitLine: { lineStyle: { color: "#f0f2f5" } },
},
dataZoom: [
{ type: "inside", xAxisIndex: 0, zoomOnMouseWheel: true, moveOnMouseMove: true, moveOnMouseWheel: false },
...(showSlider
? [{ type: "slider", xAxisIndex: 0, bottom: 10, height: 20, start: 0, end: 100, showDetail: false }]
: []),
...(showSlider ? [{ type: "slider", xAxisIndex: 0, bottom: 10, height: 20, start: 0, end: 100, showDetail: false }] : []),
],
series: [
{
name: $t('chart.submit'),
type: chartType.value,
barWidth: 14,
barGap: "20%",
smooth: isLine,
symbol: "circle",
symbolSize: 8,
data: submit,
lineStyle: { color: "#5B8FF9", width: 2 },
itemStyle: { color: "#5B8FF9", borderRadius: isLine ? 0 : [3, 3, 0, 0] },
areaStyle: isLine ? { color: "rgba(91,143,249,0.08)" } : undefined,
},
{
name: $t('chart.complete'),
type: chartType.value,
barWidth: 14,
barGap: "20%",
smooth: isLine,
symbol: "circle",
symbolSize: 8,
data: complete,
lineStyle: { color: "#5AD8A6", width: 2 },
itemStyle: { color: "#5AD8A6", borderRadius: isLine ? 0 : [3, 3, 0, 0] },
areaStyle: isLine ? { color: "rgba(90,216,166,0.08)" } : undefined,
},
series: isLine ? lineSeries : [
{ ...makeBarSeries(submit, submitTopColor, submitWrapColor), name: $t('chart.submit') },
{ ...makeBarSeries(complete, completeTopColor, completeWrapColor), name: $t('chart.complete') },
],
};
});
// ==================== 线 ====================
const computeStripeTargets = () => {
if (chartType.value !== "bar") {
stripeTargets.value = [];
return;
}
const inst = chartRef.value?.getInstance ? chartRef.value.getInstance() : chartRef.value?.chart;
if (!inst) return;
const data = stats.value.trendData || [];
if (!data.length) return;
const dom = inst.getDom();
const rect = dom.getBoundingClientRect();
overlaySize.value = { w: rect.width, h: rect.height };
const targets = [];
[0, 1].forEach((sIdx) => {
data.forEach((d, i) => {
const v = sIdx === 0 ? d.submitCount : d.completeCount;
if (!v || v <= 0) return;
const top = inst.convertToPixel({ xAxisIndex: 0, yAxisIndex: 0, seriesIndex: sIdx }, [i, v]);
const base = inst.convertToPixel({ xAxisIndex: 0, yAxisIndex: 0, seriesIndex: sIdx }, [i, 0]);
if (!top || !base) return;
const x = top[0] - BAR_W / 2;
const y = top[1];
const h = base[1] - top[1];
targets.push({ x, y, w: BAR_W, h: Math.max(h, 1), r: CORNER_R });
});
});
stripeTargets.value = targets;
};
// 45° 线
const buildStripeLines = (t) => {
const lines = [];
const gap = 4;
const x0 = t.x + WRAP_THICK;
const y0 = t.y + WRAP_THICK;
const w = t.w - WRAP_THICK * 2;
const h = t.h - WRAP_THICK * 2;
if (w <= 0 || h <= 0) return lines;
// 45° 线 y = -x + cc [x0+y0+h, x0+w+y0]
// gap*sqrt(2) = gap
const stepC = gap * Math.SQRT2;
for (let c = x0 + y0 + h; c <= x0 + w + y0 + 0.001; c += stepC) {
// x=x0 y = -x0 + c x=x0+w y = -(x0+w) + c
lines.push({
x1: x0,
y1: c - x0,
x2: x0 + w,
y2: c - x0 - w,
});
}
return lines;
};
watch([() => stats.value.trendData, chartType], () => {
nextTick(() => setTimeout(computeStripeTargets, 50));
}, { deep: true });
const onResize = () => setTimeout(computeStripeTargets, 50);
window.addEventListener("resize", onResize);
onBeforeUnmount(() => window.removeEventListener("resize", onResize));
// TODO: mock
const buildMockTrendData = (startDate, endDate) => {
const start = startDate ? dayjs(startDate) : dayjs().startOf("month");
const end = endDate ? dayjs(endDate) : dayjs().endOf("month");
const days = [];
let cursor = start;
while (cursor.isBefore(end) || cursor.isSame(end, "day")) {
days.push(cursor.format("YYYY-MM-DD"));
cursor = cursor.add(1, "day");
}
if (!days.length) days.push(dayjs().format("YYYY-MM-DD"));
return days.map((date, i) => ({
date,
submitCount: 3 + ((i * 7) % 9),
completeCount: 2 + ((i * 5) % 7),
}));
};
const loadStats = async () => {
const params = { period: period.value };
if (period.value === "custom" && dateRange.value?.length === 2) {
@ -243,9 +331,18 @@ const loadStats = async () => {
}
try {
const data = await getWorkbench(params);
stats.value = data || stats.value;
// TODO: mock data
if (data && Array.isArray(data.trendData) && data.trendData.length) {
stats.value = data;
const { startDate, endDate } = resolveDateRange();
stats.value = { ...stats.value, trendData: buildMockTrendData(startDate, endDate) };
} else {
const { startDate, endDate } = resolveDateRange();
stats.value = { ...stats.value, trendData: buildMockTrendData(startDate, endDate) };
}
} catch (_) {
/* request 层已提示 */
const { startDate, endDate } = resolveDateRange();
stats.value = { ...stats.value, trendData: buildMockTrendData(startDate, endDate) };
}
};
@ -269,8 +366,9 @@ onMounted(loadStats);
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.stat-card {
.stat-cards .stat-card {
background: #fff;
border-radius: 6px;
padding: 20px;
@ -279,13 +377,14 @@ onMounted(loadStats);
gap: 16px;
cursor: pointer;
transition: box-shadow 0.2s, transform 0.2s;
}
&:hover {
.stat-cards .stat-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
.stat-icon {
.stat-cards .stat-icon {
width: 48px;
height: 48px;
border-radius: 8px;
@ -294,40 +393,42 @@ onMounted(loadStats);
justify-content: center;
}
.stat-info {
.stat-value {
.stat-cards .stat-info .stat-value {
font-size: 24px;
font-weight: 600;
color: #303133;
}
.stat-label {
.stat-cards .stat-info .stat-label {
font-size: 13px;
color: #909399;
margin-top: 4px;
}
}
}
}
.trend-card {
:deep(.el-card__header) {
.trend-card :deep(.el-card__header) {
padding: 12px 16px;
}
.card-header {
.trend-card .card-header {
display: flex;
align-items: center;
justify-content: space-between;
.card-title {
}
.trend-card .card-header .card-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.chart-switch {
.trend-card .card-header .chart-switch {
display: inline-flex;
background: #f5f7fa;
border-radius: 4px;
padding: 2px;
.switch-item {
}
.trend-card .card-header .chart-switch .switch-item {
display: inline-flex;
align-items: center;
gap: 4px;
@ -338,36 +439,52 @@ onMounted(loadStats);
border-radius: 3px;
transition: all 0.2s;
user-select: none;
.switch-icon {
}
.trend-card .card-header .chart-switch .switch-item .switch-icon {
width: 10px;
height: 10px;
background: #5AD8A6;
border-radius: 1px;
&.line {
background: #5B8FF9;
}
.trend-card .card-header .chart-switch .switch-item .switch-icon.line {
background: #5B8FF9;
}
&:hover {
.trend-card .card-header .chart-switch .switch-item:hover {
color: #409eff;
}
&.active {
.trend-card .card-header .chart-switch .switch-item.active {
background: #409eff;
color: #fff;
.switch-icon {
background: #fff;
&.line {
}
.trend-card .card-header .chart-switch .switch-item.active .switch-icon {
background: #fff;
}
.trend-card .card-header .chart-switch .switch-item.active .switch-icon.line {
background: #fff;
}
&:hover {
.trend-card .card-header .chart-switch .switch-item.active:hover {
color: #fff;
}
.trend-card .chart-wrap {
position: relative;
}
}
}
}
.chart {
.trend-card .chart {
height: 340px;
}
.trend-card .stripe-overlay {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
}
</style>
Loading…
Cancel
Save