进度保存

master
2358328281@qq.com 2 months ago
parent 6608720740
commit 9325c26061

62
package-lock.json generated

@ -19,14 +19,11 @@
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"qs": "^6.12.1",
"swiper": "^12.1.3",
"vue": "^3.5.24",
"vue-draggable-next": "^2.3.0",
"vue-echarts": "^8.0.1",
"vue-i18n": "^9.14.5",
"vue-pdf-embed": "^2.1.5",
"vue-router": "^4.6.3",
"wujie-vue3": "^1.0.29"
"vue-router": "^4.6.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
@ -6574,13 +6571,6 @@
"node": ">=0.10.0"
}
},
"node_modules/sortablejs": {
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
"integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==",
"license": "MIT",
"peer": true
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@ -7067,25 +7057,6 @@
"node": ">=10.13.0"
}
},
"node_modules/swiper": {
"version": "12.1.3",
"resolved": "https://registry.npmjs.org/swiper/-/swiper-12.1.3.tgz",
"integrity": "sha512-XcWlVmkHFICI4fuoJKgbp8PscDcS4i7pBH8nwJRBi3dpQvhCySwsWRYm4bOf/BzKVWkHOYaFw7qz9uBSrY3oug==",
"funding": [
{
"type": "patreon",
"url": "https://www.patreon.com/swiperjs"
},
{
"type": "open_collective",
"url": "http://opencollective.com/swiper"
}
],
"license": "MIT",
"engines": {
"node": ">= 4.7.0"
}
},
"node_modules/sync-child-process": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz",
@ -7642,16 +7613,6 @@
}
}
},
"node_modules/vue-draggable-next": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/vue-draggable-next/-/vue-draggable-next-2.3.0.tgz",
"integrity": "sha512-ymbY0UIwfSdg0iDN/iyNNwUrTqZ/6KbPryzsvTNXBLuDCuOBdNijSK8yynNtmiSj6RapTPQfjLGQdJrZkzBd2w==",
"license": "MIT",
"peerDependencies": {
"sortablejs": "^1.14.0",
"vue": "^3.5.17"
}
},
"node_modules/vue-echarts": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-8.0.1.tgz",
@ -7811,27 +7772,6 @@
"integrity": "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==",
"license": "MIT"
},
"node_modules/wujie": {
"version": "1.0.29",
"resolved": "https://registry.npmjs.org/wujie/-/wujie-1.0.29.tgz",
"integrity": "sha512-u7PmBT4l5ov7ciwmZNG8DLIRKrYrnXDa0LnAFgRQikJ/ZrVZYzzSNz6duzEJoj8/o46dnMy/g9Hh/PBYGbgFUw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.6"
}
},
"node_modules/wujie-vue3": {
"version": "1.0.29",
"resolved": "https://registry.npmjs.org/wujie-vue3/-/wujie-vue3-1.0.29.tgz",
"integrity": "sha512-OGt5Veyq/DtmFzHQCANmv8nZgu877u7ZjS5qjFpSrtKARKhLjID3x99D40a4pb3Kj5UmRatkV5Gn5hqOWpziZQ==",
"license": "MIT",
"dependencies": {
"wujie": "1.0.29"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",

@ -0,0 +1,5 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame">
<path id="Vector" d="M1.49723 6.45945H6.77905V1.17763C6.77905 1.03417 6.83604 0.896588 6.93748 0.795147C7.03892 0.693707 7.1765 0.636719 7.31996 0.636719C7.46342 0.636719 7.601 0.693707 7.70244 0.795147C7.80388 0.896588 7.86087 1.03417 7.86087 1.17763V6.45945H13.1427C13.2861 6.45945 13.4237 6.51643 13.5252 6.61788C13.6266 6.71932 13.6836 6.8569 13.6836 7.00036C13.6836 7.14381 13.6266 7.2814 13.5252 7.38284C13.4237 7.48428 13.2861 7.54126 13.1427 7.54126H7.86087V12.8231C7.86087 12.9665 7.80388 13.1041 7.70244 13.2056C7.601 13.307 7.46342 13.364 7.31996 13.364C7.1765 13.364 7.03892 13.307 6.93748 13.2056C6.83604 13.1041 6.77905 12.9665 6.77905 12.8231V7.54126H1.49723C1.35377 7.54126 1.21619 7.48428 1.11475 7.38284C1.01331 7.2814 0.95632 7.14381 0.95632 7.00036C0.95632 6.8569 1.01331 6.71932 1.11475 6.61788C1.21619 6.51643 1.35377 6.45945 1.49723 6.45945Z" fill="#1D2129"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1006 B

@ -0,0 +1,5 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame">
<path id="Vector" d="M15.8343 9.16616H6.52592L10.5926 5.09949C10.7487 4.94215 10.8363 4.72948 10.8363 4.50782C10.8363 4.28617 10.7487 4.0735 10.5926 3.91616C10.4365 3.76095 10.2252 3.67383 10.0051 3.67383C9.78493 3.67383 9.57372 3.76095 9.41759 3.91616L3.92592 9.40782C3.77071 9.56396 3.68359 9.77517 3.68359 9.99532C3.68359 10.2155 3.77071 10.4267 3.92592 10.5828L9.41759 16.0745C9.57701 16.211 9.78207 16.2823 9.9918 16.2742C10.2015 16.2661 10.4005 16.1792 10.5489 16.0308C10.6973 15.8824 10.7842 15.6834 10.7923 15.4737C10.8004 15.264 10.7291 15.0589 10.5926 14.8995L6.52592 10.8328H15.8343C16.0553 10.8328 16.2672 10.745 16.4235 10.5887C16.5798 10.4325 16.6676 10.2205 16.6676 9.99949C16.6676 9.77848 16.5798 9.56651 16.4235 9.41023C16.2672 9.25395 16.0553 9.16616 15.8343 9.16616Z" fill="#374151"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 927 B

@ -0,0 +1,9 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame">
<path id="Vector" d="M13.9135 3.57227C14.4463 3.57227 14.9573 3.78513 15.334 4.16403C15.7108 4.54293 15.9224 5.05683 15.9224 5.59267V17.2661C15.9224 17.802 15.7108 18.3159 15.334 18.6948C14.9573 19.0737 14.4463 19.2865 13.9135 19.2865H3.64565C3.11285 19.2865 2.60187 19.0737 2.22512 18.6948C1.84837 18.3159 1.63672 17.802 1.63672 17.2661V5.59267C1.63672 5.05683 1.84837 4.54293 2.22512 4.16403C2.60187 3.78513 3.11285 3.57227 3.64565 3.57227H13.9135ZM13.9135 4.9192H3.64565C3.55771 4.9192 3.47063 4.93662 3.38939 4.97047C3.30814 5.00431 3.23432 5.05392 3.17214 5.11646C3.10996 5.179 3.06063 5.25324 3.02698 5.33495C2.99333 5.41666 2.976 5.50423 2.976 5.59267V17.2661C2.976 17.4448 3.04656 17.6161 3.17214 17.7424C3.29772 17.8687 3.46805 17.9396 3.64565 17.9396H13.9135C14.0911 17.9396 14.2614 17.8687 14.387 17.7424C14.5126 17.6161 14.5831 17.4448 14.5831 17.2661V5.59267C14.5831 5.41406 14.5126 5.24276 14.387 5.11646C14.2614 4.99016 14.0911 4.9192 13.9135 4.9192Z" fill="white"/>
<path id="Vector_2" d="M6.55213 6.42969H12.4345C12.8546 6.42969 13.0647 6.66778 13.0647 7.14397C13.0647 7.62016 12.8546 7.85826 12.4345 7.85826H6.55213C6.13196 7.85826 5.92188 7.62016 5.92188 7.14397C5.92188 6.66778 6.13196 6.42969 6.55213 6.42969Z" fill="white"/>
<path id="Vector_3" d="M6.55213 9.28711H12.4345C12.8546 9.28711 13.0647 9.5252 13.0647 10.0014C13.0647 10.4776 12.8546 10.7157 12.4345 10.7157H6.55213C6.13196 10.7157 5.92188 10.4776 5.92188 10.0014C5.92188 9.5252 6.13196 9.28711 6.55213 9.28711Z" fill="white"/>
<path id="Vector_4" d="M6.50629 13.5723H9.62317C10.0128 13.5723 10.2076 13.8104 10.2076 14.2866C10.2076 14.7627 10.0128 15.0008 9.62317 15.0008H6.50629C6.11668 15.0008 5.92188 14.7627 5.92188 14.2866C5.92188 13.8104 6.11668 13.5723 6.50629 13.5723Z" fill="white"/>
<path id="Vector_5" d="M6.56473 2.01355H16.8504C17.0209 2.01355 17.1845 2.08196 17.305 2.20374C17.4256 2.32551 17.4933 2.49068 17.4933 2.6629V14.3512C17.4933 14.5234 17.561 14.6886 17.6816 14.8104C17.8022 14.9321 17.9657 15.0006 18.1362 15.0006C18.3067 15.0006 18.4702 14.9321 18.5907 14.8104C18.7113 14.6886 18.779 14.5234 18.779 14.3512V2.6629C18.779 2.14624 18.5758 1.65075 18.2142 1.28542C17.8525 0.920084 17.3619 0.714844 16.8504 0.714844H6.56473C6.39424 0.714844 6.23072 0.783257 6.11016 0.905034C5.9896 1.02681 5.92188 1.19198 5.92188 1.36419C5.92188 1.53641 5.9896 1.70158 6.11016 1.82335C6.23072 1.94513 6.39424 2.01355 6.56473 2.01355Z" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

@ -0,0 +1,10 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame" clip-path="url(#clip0_1533_2702)">
<path id="Vector" d="M15.5879 17.043C15.5879 17.7129 15.043 18.2578 14.375 18.2578H5.62695C4.95703 18.2559 4.41406 17.7129 4.41211 17.043V4.15625H15.5879V17.043ZM6.83984 1.97656C6.83984 1.83594 6.94922 1.72656 7.08984 1.72656H12.9258C13.0664 1.72656 13.1738 1.83594 13.1738 1.97656V2.70898H6.83984V1.97656ZM19.2285 2.70898H14.623V1.97656C14.623 1.04297 13.8613 0.279297 12.9258 0.279297H7.08984C6.1543 0.28125 5.39453 1.03906 5.39258 1.97656V2.70898H0.771484C0.365234 2.70898 0.0390625 3.03516 0.0390625 3.43945C0.0390625 3.84375 0.365234 4.17188 0.771484 4.17188H2.94922V17.043C2.94922 18.5215 4.14844 19.7207 5.62695 19.7207H14.373C15.8516 19.7207 17.0508 18.5215 17.0508 17.043V4.15625H19.2305C19.6348 4.15625 19.9629 3.83008 19.9629 3.42383C19.9629 3.01758 19.6348 2.70898 19.2285 2.70898ZM10 16.0625C10.4043 16.0625 10.7324 15.7363 10.7324 15.3301V7.54883C10.7324 7.14453 10.4062 6.81641 10 6.81641C9.5957 6.81641 9.26758 7.14258 9.26758 7.54883V15.3301C9.26758 15.7344 9.5957 16.0625 10 16.0625ZM6.5918 16.0625C6.99609 16.0625 7.32422 15.7363 7.32422 15.3301V7.54883C7.32422 7.14453 6.99805 6.81641 6.5918 6.81641C6.1875 6.81641 5.85938 7.14258 5.85938 7.54883V15.3301C5.875 15.7344 6.20312 16.0625 6.5918 16.0625ZM13.4082 16.0625C13.8125 16.0625 14.1406 15.7363 14.1406 15.3301V7.54883C14.1406 7.14453 13.8145 6.81641 13.4082 6.81641C13.0039 6.81641 12.6758 7.14258 12.6758 7.54883V15.3301C12.6777 15.7344 13.0039 16.0625 13.4082 16.0625Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_1533_2702">
<rect width="20" height="20" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@ -0,0 +1,11 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame" clip-path="url(#clip0_1533_2687)">
<path id="Vector" d="M19.0818 10.5849C18.8408 10.5849 18.6096 10.6807 18.4391 10.8512C18.2687 11.0216 18.1729 11.2528 18.1729 11.4939V17.5887C18.1729 17.9147 17.9034 18.1797 17.5725 18.1797H2.51747C2.48997 18.1734 2.4623 18.1679 2.43448 18.1632L2.37698 18.1552C2.05501 18.1232 1.83004 17.8862 1.83004 17.5792V2.48325C1.84354 2.43776 1.85603 2.39126 1.87303 2.31227L1.89103 2.20378L1.89453 2.17628L1.90453 2.15279C1.91703 2.12329 1.95702 2.05829 2.09051 1.9563C2.2095 1.86481 2.32099 1.81782 2.42048 1.81782H8.49637C8.73743 1.81782 8.96861 1.72206 9.13907 1.5516C9.30952 1.38115 9.40528 1.14997 9.40528 0.908909C9.40528 0.667852 9.30952 0.436667 9.13907 0.266213C8.96861 0.0957598 8.73743 3.59204e-09 8.49637 0L2.42048 0C1.91803 0 1.42008 0.177982 0.985122 0.512949C0.682652 0.743426 0.460174 0.995901 0.308189 1.28037L0.136706 1.46135V1.69933C0.115708 1.78532 0.10371 1.86231 0.0937106 1.92631L0.0837116 1.9763C0.0378606 2.11939 0.0135963 2.26852 0.0117188 2.41876V17.5797C0.0117188 18.7941 0.919628 19.806 2.13351 19.958C2.194 19.9735 2.30199 19.998 2.43998 19.998H17.5725C18.9058 19.998 19.9912 18.9176 19.9912 17.5892V11.4944C19.9911 11.2532 19.8952 11.022 19.7247 10.8514C19.5542 10.6809 19.323 10.5851 19.0818 10.5849Z" fill="white"/>
<path id="Vector_2" d="M9.61445 10.3036C9.79193 10.4806 10.0239 10.5691 10.2564 10.5691C10.4894 10.5691 10.7223 10.4801 10.8998 10.3021L19.466 1.71648C19.551 1.63215 19.6186 1.53183 19.6647 1.42131C19.7109 1.31078 19.7348 1.19222 19.7349 1.07245C19.7351 0.952667 19.7116 0.834033 19.6658 0.723363C19.62 0.612693 19.5528 0.51217 19.468 0.427573C19.3832 0.342976 19.2825 0.275973 19.1717 0.230417C19.061 0.18486 18.9423 0.161649 18.8225 0.162116C18.7027 0.162584 18.5842 0.186721 18.4738 0.233141C18.3634 0.279561 18.2632 0.347348 18.1791 0.432604L9.61295 9.01775C9.44279 9.18855 9.34738 9.4199 9.34766 9.66099C9.34794 9.90209 9.44389 10.1332 9.61445 10.3036Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_1533_2687">
<rect width="20" height="20" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@ -0,0 +1,10 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame" clip-path="url(#clip0_1533_2593)">
<path id="Vector" d="M13.1588 0.486795C14.8328 1.48355 15.4032 3.65704 14.4053 5.33104L9.89966 12.936C9.58324 13.4746 9.06583 13.8654 8.46128 14.0225C7.85672 14.1795 7.21452 14.09 6.67597 13.7736C6.13741 13.4572 5.74661 12.9398 5.58954 12.3352C5.43247 11.7307 5.52199 11.0885 5.83841 10.5499L9.43616 4.45805C9.5182 4.33177 9.64563 4.24183 9.79209 4.20683C9.93856 4.17183 10.0929 4.19443 10.2232 4.26997C10.3534 4.3455 10.4497 4.4682 10.4921 4.61271C10.5345 4.75721 10.5197 4.91248 10.4509 5.04642L6.85316 11.1372C6.51566 11.6884 6.71141 12.4197 7.26266 12.7572C7.81503 13.0969 8.54516 12.9 8.88378 12.3488L13.3905 4.74379C13.7071 4.20524 13.7968 3.56299 13.6398 2.95832C13.4828 2.35366 13.0921 1.83611 12.5535 1.51955C12.015 1.20298 11.3727 1.11331 10.7681 1.27028C10.1634 1.42725 9.64585 1.81799 9.32928 2.35654L3.93266 11.4938C2.93478 13.1678 3.50516 15.3413 5.17916 16.338C6.85316 17.3359 9.02665 16.7655 10.0234 15.0915L14.5302 7.48654C14.6127 7.36165 14.7398 7.27301 14.8856 7.23873C15.0313 7.20444 15.1846 7.22711 15.3142 7.3021C15.4438 7.37708 15.5398 7.49873 15.5827 7.64216C15.6256 7.7856 15.6121 7.93999 15.5449 8.0738L11.0393 15.6788C10.7245 16.2131 10.3073 16.6801 9.81167 17.0527C9.31599 17.4254 8.75159 17.6965 8.15085 17.8505C7.55012 18.0045 6.92489 18.0383 6.31105 17.95C5.69722 17.8617 5.10687 17.653 4.57391 17.3359C4.03947 17.0213 3.57244 16.6041 3.19965 16.1085C2.82686 15.6129 2.55563 15.0485 2.40155 14.4477C2.24748 13.847 2.21357 13.2217 2.3018 12.6079C2.39002 11.994 2.59863 11.4036 2.91566 10.8705L8.31566 1.7333C9.29328 0.059295 11.4657 -0.494205 13.1588 0.486795Z" fill="#1D2129"/>
</g>
<defs>
<clipPath id="clip0_1533_2593">
<rect width="18" height="18" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@ -0,0 +1,10 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame" clip-path="url(#clip0_1533_2682)">
<path id="Vector" d="M3.84615 0H16.1538C17.1739 0 18.1522 0.405219 18.8735 1.12651C19.5948 1.84781 20 2.82609 20 3.84615V16.1538C20 17.1739 19.5948 18.1522 18.8735 18.8735C18.1522 19.5948 17.1739 20 16.1538 20H3.84615C2.82609 20 1.84781 19.5948 1.12651 18.8735C0.405219 18.1522 0 17.1739 0 16.1538L0 3.84615C0 2.82609 0.405219 1.84781 1.12651 1.12651C1.84781 0.405219 2.82609 0 3.84615 0ZM3.84615 1.53846C3.23412 1.53846 2.64715 1.78159 2.21437 2.21437C1.78159 2.64715 1.53846 3.23412 1.53846 3.84615V16.1538C1.53846 16.4569 1.59815 16.757 1.71412 17.037C1.8301 17.3169 2.00008 17.5713 2.21437 17.7856C2.42866 17.9999 2.68306 18.1699 2.96304 18.2859C3.24302 18.4018 3.5431 18.4615 3.84615 18.4615H16.1538C16.4569 18.4615 16.757 18.4018 17.037 18.2859C17.3169 18.1699 17.5713 17.9999 17.7856 17.7856C17.9999 17.5713 18.1699 17.3169 18.2859 17.037C18.4018 16.757 18.4615 16.4569 18.4615 16.1538V3.84615C18.4615 3.5431 18.4018 3.24302 18.2859 2.96304C18.1699 2.68306 17.9999 2.42866 17.7856 2.21437C17.5713 2.00008 17.3169 1.8301 17.037 1.71412C16.757 1.59815 16.4569 1.53846 16.1538 1.53846H3.84615ZM15.27 6.58154C15.3379 6.64612 15.3923 6.72345 15.4303 6.80909C15.4683 6.89473 15.489 6.98702 15.4913 7.08067C15.4935 7.17432 15.4774 7.26751 15.4436 7.3549C15.4099 7.44229 15.3592 7.52218 15.2946 7.59L9.44538 13.7054C9.44308 13.7085 9.43769 13.71 9.43308 13.7146L9.42538 13.7254C9.38308 13.7669 9.33308 13.7885 9.28462 13.8177C9.25385 13.8346 9.23 13.86 9.19846 13.8731C9.11348 13.9087 9.0223 13.9272 8.93015 13.9274C8.83801 13.9277 8.74672 13.9097 8.66154 13.8746C8.63692 13.8646 8.61692 13.8431 8.59231 13.83C8.53846 13.8015 8.48308 13.7746 8.43538 13.73C8.43154 13.7269 8.43 13.7215 8.42538 13.7169C8.42154 13.7146 8.41769 13.7131 8.41462 13.71L5.46846 10.6515C5.40326 10.5842 5.35199 10.5047 5.31761 10.4175C5.28324 10.3303 5.26642 10.2372 5.26814 10.1435C5.26985 10.0498 5.29006 9.95732 5.32761 9.87145C5.36516 9.78557 5.4193 9.70796 5.48692 9.64308C5.55419 9.57778 5.63372 9.52643 5.72091 9.492C5.80811 9.45756 5.90126 9.44072 5.995 9.44243C6.08873 9.44415 6.1812 9.46439 6.26708 9.502C6.35296 9.5396 6.43055 9.59382 6.49538 9.66154L8.92615 12.1831L14.26 6.60462C14.3248 6.53672 14.4023 6.48226 14.4881 6.44435C14.5739 6.40644 14.6664 6.38583 14.7602 6.38368C14.854 6.38154 14.9473 6.39791 15.0348 6.43186C15.1223 6.46581 15.2022 6.51667 15.27 6.58154Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_1533_2682">
<rect width="20" height="20" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

@ -0,0 +1,3 @@
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M10.5 0C4.66734 0 0 4.4449 0 10C0 15.5551 4.66734 20 10.5 20C11.4349 20 12.25 19.2204 12.25 18.3333C12.25 17.8873 12.1339 17.5537 11.7852 17.2218C11.5492 16.8881 11.3169 16.5543 11.3169 16.1117C11.3169 15.2213 12.1339 14.445 13.067 14.445H15.1671C18.4348 14.445 21 12.0003 21 8.88837C21 3.99904 16.3344 0 10.5 0ZM5.71875 8.75C5.71875 9.70587 4.96322 10.4808 4.03125 10.4808C3.09928 10.4808 2.34375 9.70587 2.34375 8.75C2.34375 7.79413 3.09928 7.01923 4.03125 7.01923C4.96322 7.01923 5.71875 7.79413 5.71875 8.75ZM7.96875 6.05769C7.03678 6.05769 6.28125 5.28279 6.28125 4.32692C6.28125 3.37106 7.03678 2.59615 7.96875 2.59615C8.90072 2.59615 9.65625 3.37106 9.65625 4.32692C9.65625 5.28279 8.90072 6.05769 7.96875 6.05769ZM15 4.03846C15 4.99433 14.2445 5.76923 13.3125 5.76923C12.3805 5.76923 11.625 4.99433 11.625 4.03846C11.625 3.0826 12.3805 2.30769 13.3125 2.30769C14.2445 2.30769 15 3.0826 15 4.03846ZM17.0625 10.2885C16.1305 10.2885 15.375 9.51356 15.375 8.55769C15.375 7.60183 16.1305 6.82692 17.0625 6.82692C17.9945 6.82692 18.75 7.60183 18.75 8.55769C18.75 9.51356 17.9945 10.2885 17.0625 10.2885Z" fill="#DFDFDF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Before

Width:  |  Height:  |  Size: 770 B

After

Width:  |  Height:  |  Size: 770 B

Before

Width:  |  Height:  |  Size: 770 B

After

Width:  |  Height:  |  Size: 770 B

Before

Width:  |  Height:  |  Size: 781 B

After

Width:  |  Height:  |  Size: 781 B

Before

Width:  |  Height:  |  Size: 781 B

After

Width:  |  Height:  |  Size: 781 B

Before

Width:  |  Height:  |  Size: 906 B

After

Width:  |  Height:  |  Size: 906 B

Before

Width:  |  Height:  |  Size: 906 B

After

Width:  |  Height:  |  Size: 906 B

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

@ -0,0 +1,12 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="icon">
<g id="Group 1707486094">
<rect id="Rectangle 1430107565" x="3.6" y="3.6" width="12.8" height="13.8" rx="2.4" stroke="#3289FB" stroke-width="1.2"/>
<path id="Vector 1093" d="M7 2V5" stroke="#3289FB" stroke-width="1.2" stroke-linecap="round"/>
<path id="Vector 1094" d="M13 2V5" stroke="#3289FB" stroke-width="1.2" stroke-linecap="round"/>
<path id="Vector 1077" d="M7 8H13" stroke="#3289FB" stroke-width="1.2" stroke-linecap="round"/>
<path id="Vector 1078" d="M7 11H13" stroke="#3289FB" stroke-width="1.2" stroke-linecap="round"/>
<path id="Vector 1079" d="M7 14H11" stroke="#3289FB" stroke-width="1.2" stroke-linecap="round"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 756 B

@ -0,0 +1,12 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="icon">
<g id="Group 1707486094">
<rect id="Rectangle 1430107565" x="3.6" y="3.6" width="12.8" height="13.8" rx="2.4" stroke="#1D2129" stroke-width="1.2"/>
<path id="Vector 1093" d="M7 2V5" stroke="#1D2129" stroke-width="1.2" stroke-linecap="round"/>
<path id="Vector 1094" d="M13 2V5" stroke="#1D2129" stroke-width="1.2" stroke-linecap="round"/>
<path id="Vector 1077" d="M7 8H13" stroke="#666666" stroke-width="1.2" stroke-linecap="round"/>
<path id="Vector 1078" d="M7 11H13" stroke="#666666" stroke-width="1.2" stroke-linecap="round"/>
<path id="Vector 1079" d="M7 14H11" stroke="#666666" stroke-width="1.2" stroke-linecap="round"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 756 B

@ -0,0 +1,12 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="icon">
<g id="Group 1707486112">
<path id="Vector" d="M5.2 14C4.0799 14 3.51984 14 3.09202 13.782C2.71569 13.5903 2.40973 13.2843 2.21799 12.908C2 12.4802 2 11.9201 2 10.8V7C2 5.11438 2 4.17157 2.58579 3.58579C3.17157 3 4.11438 3 6 3H14C15.8856 3 16.8284 3 17.4142 3.58579C18 4.17157 18 5.11438 18 7V10.8C18 11.9201 18 12.4802 17.782 12.908C17.5903 13.2843 17.2843 13.5903 16.908 13.782C16.4802 14 15.9201 14 14.8 14H5.2Z" stroke="#3289FB" stroke-width="1.2" stroke-linejoin="round"/>
<path id="Vector_2" d="M7 9V11" stroke="#3289FB" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_3" d="M10 14V16" stroke="#3289FB" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_4" d="M10 8V11" stroke="#3289FB" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_5" d="M13 6V11" stroke="#3289FB" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_6" d="M5 17H15" stroke="#3289FB" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@ -0,0 +1,12 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="icon">
<g id="Group 1707486112">
<path id="Vector" d="M5.2 14C4.0799 14 3.51984 14 3.09202 13.782C2.71569 13.5903 2.40973 13.2843 2.21799 12.908C2 12.4802 2 11.9201 2 10.8V7C2 5.11438 2 4.17157 2.58579 3.58579C3.17157 3 4.11438 3 6 3H14C15.8856 3 16.8284 3 17.4142 3.58579C18 4.17157 18 5.11438 18 7V10.8C18 11.9201 18 12.4802 17.782 12.908C17.5903 13.2843 17.2843 13.5903 16.908 13.782C16.4802 14 15.9201 14 14.8 14H5.2Z" stroke="#1D2129" stroke-width="1.2" stroke-linejoin="round"/>
<path id="Vector_2" d="M7 9V11" stroke="#666666" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_3" d="M10 14V16" stroke="#1D2129" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_4" d="M10 8V11" stroke="#666666" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_5" d="M13 6V11" stroke="#666666" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_6" d="M5 17H15" stroke="#1D2129" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@ -0,0 +1,7 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="icon">
<ellipse id="Ellipse 2299" cx="10" cy="6" rx="7" ry="3" stroke="#3289FB" stroke-width="1.2"/>
<path id="Ellipse 2300" d="M4.17876 8C3.43421 8.51075 3 9.12495 3 9.78571C3 11.5609 6.13401 13 10 13C13.866 13 17 11.5609 17 9.78571C17 9.12495 16.5658 8.51075 15.8212 8" stroke="#3289FB" stroke-width="1.2"/>
<path id="Ellipse 2301" d="M4.17876 12C3.43421 12.5107 3 13.1249 3 13.7857C3 15.5609 6.13401 17 10 17C13.866 17 17 15.5609 17 13.7857C17 13.1249 16.5658 12.5107 15.8212 12" stroke="#3289FB" stroke-width="1.2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 636 B

@ -0,0 +1,7 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="icon">
<ellipse id="Ellipse 2299" cx="10" cy="6" rx="7" ry="3" stroke="#1D2129" stroke-width="1.2"/>
<path id="Ellipse 2300" d="M4.17876 8C3.43421 8.51075 3 9.12495 3 9.78571C3 11.5609 6.13401 13 10 13C13.866 13 17 11.5609 17 9.78571C17 9.12495 16.5658 8.51075 15.8212 8" stroke="#666666" stroke-width="1.2"/>
<path id="Ellipse 2301" d="M4.17876 12C3.43421 12.5107 3 13.1249 3 13.7857C3 15.5609 6.13401 17 10 17C13.866 17 17 15.5609 17 13.7857C17 13.1249 16.5658 12.5107 15.8212 12" stroke="#1D2129" stroke-width="1.2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 636 B

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

@ -1,60 +1,59 @@
<template>
<el-dialog
:model-value="visible"
:title="fileName || $t('btn.preview')"
width="80%"
top="5vh"
destroy-on-close
:before-close="handleClose"
@update:model-value="(v) => !v && handleClose()"
>
<template #header>
<el-dialog :model-value="visible" class="file-preview-dialog" top="5vh" destroy-on-close :before-close="handleClose"
@update:model-value="(v) => !v && handleClose()">
<!-- <template #header> -->
<!-- <div class="body-header">
<div class="body-title">{{ fileName || $t('btn.preview') }}</div>
<img src="@/assets/image/close-btn.svg" alt="" class="close-btn" @click="closeDialog" />
</div> -->
<!-- </template> -->
<div class="body-wrap">
<div class="file-preview-header">
<span class="file-preview-title" :title="fileName">{{ fileName || $t('btn.preview') }}</span>
<div class="file-preview-tools">
<template v-if="fileType === 'pdf'">
<el-button size="small" :disabled="pageScale <= 0.5" @click="scaleDown">
<el-icon><ZoomOut /></el-icon>
<el-icon>
<ZoomOut />
</el-icon>
</el-button>
<span class="scale-text">{{ Math.round(pageScale * 100) }}%</span>
<el-button size="small" :disabled="pageScale >= 2.5" @click="scaleUp">
<el-icon><ZoomIn /></el-icon>
<el-icon>
<ZoomIn />
</el-icon>
</el-button>
<el-button size="small" :disabled="currentPage <= 1" @click="prevPage">
<el-icon><ArrowLeft /></el-icon>
<el-icon>
<ArrowLeft />
</el-icon>
</el-button>
<span class="page-text">{{ currentPage }} / {{ totalPages }}</span>
<el-button size="small" :disabled="currentPage >= totalPages" @click="nextPage">
<el-icon><ArrowRight /></el-icon>
<el-icon>
<ArrowRight />
</el-icon>
</el-button>
</template>
<el-button type="primary" size="small" @click="handleDownload">
<el-icon><Download /></el-icon>
<el-icon>
<Download />
</el-icon>
<span style="margin-left: 4px">{{ $t('btn.download') || '下载' }}</span>
</el-button>
<img src="@/assets/image/close-btn.svg" alt="" class="close-btn" @click="handleClose"/>
</div>
</div>
</template>
<div v-loading="loading" class="file-preview-body">
<VuePdfEmbed
v-if="fileType === 'pdf' && blobUrl"
:source="blobUrl"
:page="currentPage"
:scale="pageScale"
class="pdf-viewer"
@loaded="onPdfLoaded"
@loading-failed="onPdfError"
/>
<img
v-else-if="fileType === 'image' && blobUrl"
:src="blobUrl"
class="image-viewer"
/>
<VuePdfEmbed v-if="fileType === 'pdf' && blobUrl" :source="blobUrl" :page="currentPage" :scale="pageScale"
class="pdf-viewer" @loaded="onPdfLoaded" @loading-failed="onPdfError" />
<img v-else-if="fileType === 'image' && blobUrl" :src="blobUrl" class="image-viewer" />
<div v-else class="file-preview-empty">
{{ $t('msg.noPreview') || '暂不支持预览该类型' }}
</div>
</div>
</div>
</el-dialog>
</template>
@ -140,6 +139,7 @@ watch(
gap: 12px;
width: 100%;
padding-right: 8px;
margin-bottom: 12px;
}
.file-preview-title {
@ -201,3 +201,15 @@ watch(
padding: 60px 0;
}
</style>
<style>
.file-preview-dialog {
width: 80% !important;
.close-btn {
width: 30px;
height: 30px;
margin-left: 30px;
cursor: pointer;
}
}
</style>

@ -13,15 +13,11 @@
<el-menu class="header-menu" mode="horizontal" :default-active="$route.path" :ellipsis="false" router>
<el-menu-item v-for="item in sideRoutes" :key="item.path" :index="item.path"
@mouseenter="hoveredPath = item.path" @mouseleave="hoveredPath = ''">
<!-- 管理端SVG 图标 -->
<!-- 管理端 / 医院端统一使用 SVG 图标正常/激活两种状态 -->
<img v-if="item.iconUrl" :src="isMenuHoveredOrActive(item.path)
? item.iconUrlActive || item.iconUrl
: item.iconUrl
" :alt="item.title" class="nav-icon" />
<!-- 医院端Element Plus 图标 -->
<el-icon v-else-if="item.iconName" class="nav-icon">
<component :is="resolveIcon(item.iconName)" />
</el-icon>
<template #title>
<span>{{ item.title }}</span>
</template>
@ -101,7 +97,7 @@
import { computed, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessageBox } from "element-plus";
import { ArrowDown, DataLine, Document, Coin } from "@element-plus/icons-vue";
import { ArrowDown } from "@element-plus/icons-vue";
import SvgIcon from "@/components/SvgIcon/index.vue";
import { useUserStore } from "@/stores/api/user";
import { useThemeStore } from "@/stores/theme";
@ -110,18 +106,26 @@ import OrderFormDialog from "@/views/hospital/orders/OrderFormDialog.vue";
import PasswordDialog from "@/components/PasswordDialog.vue";
// import SvgIcon fill
import fuwuGd from "@/assets/image/admin/router/fuwu-gd.svg";
import fuwuGdActive from "@/assets/image/admin/router/fuwu-gd-active.svg";
import fuwuBg from "@/assets/image/admin/router/fuwu-bg.svg";
import fuwuBgActive from "@/assets/image/admin/router/fuwu-bg-active.svg";
import yiyuanXx from "@/assets/image/admin/router/yiyuan-xx.svg";
import yiyuanXxActive from "@/assets/image/admin/router/yiyuan-xx-active.svg";
import kehuFw from "@/assets/image/admin/router/kehu-fw.svg";
import kehuFwActive from "@/assets/image/admin/router/kehu-fw-active.svg";
import kehuJljf from "@/assets/image/admin/router/kehu-jljf.svg";
import kehuJljfActive from "@/assets/image/admin/router/kehu-jljf-active.svg";
import zhanghaoGl from "@/assets/image/admin/router/zhanghao-gl.svg";
import zhanghaoGlActive from "@/assets/image/admin/router/zhanghao-gl-active.svg";
//
import fuwuGd from "@/assets/image/router/fuwu-gd.svg";
import fuwuGdActive from "@/assets/image/router/fuwu-gd-active.svg";
import fuwuBg from "@/assets/image/router/fuwu-bg.svg";
import fuwuBgActive from "@/assets/image/router/fuwu-bg-active.svg";
import yiyuanXx from "@/assets/image/router/yiyuan-xx.svg";
import yiyuanXxActive from "@/assets/image/router/yiyuan-xx-active.svg";
import kehuFw from "@/assets/image/router/kehu-fw.svg";
import kehuFwActive from "@/assets/image/router/kehu-fw-active.svg";
import kehuJljf from "@/assets/image/router/kehu-jljf.svg";
import kehuJljfActive from "@/assets/image/router/kehu-jljf-active.svg";
import zhanghaoGl from "@/assets/image/router/zhanghao-gl.svg";
import zhanghaoGlActive from "@/assets/image/router/zhanghao-gl-active.svg";
//
import yyGzt from "@/assets/image/router/yy-gzt.svg";
import yyGztActive from "@/assets/image/router/yy-gzt-active.svg";
import yyGd from "@/assets/image/router/yy-gd.svg";
import yyGdActive from "@/assets/image/router/yy-gd-active.svg";
import yyJf from "@/assets/image/router/yy-jf.svg";
import yyJfActive from "@/assets/image/router/yy-jf-active.svg";
// ()
import avatarMale from "@/assets/image/avatar-male.svg";
@ -184,6 +188,7 @@ const handlePresetTheme = (value) => {
};
// route meta.icon -> { normal, active }
// SVG
const ICON_MAP = {
"admin-router-fuwu-gd": { normal: fuwuGd, active: fuwuGdActive },
"admin-router-fuwu-bg": { normal: fuwuBg, active: fuwuBgActive },
@ -193,13 +198,12 @@ const ICON_MAP = {
"admin-router-zhanghao-gl": { normal: zhanghaoGl, active: zhanghaoGlActive },
};
// Element Plus route meta.icon -> icon component
const HOSPITAL_ICON_MAP = {
DataLine,
Document,
Coin,
// SVG
const HOSPITAL_SVG_ICON_MAP = {
"hospital-router-yy-gzt": { normal: yyGzt, active: yyGztActive },
"hospital-router-yy-gd": { normal: yyGd, active: yyGdActive },
"hospital-router-yy-jf": { normal: yyJf, active: yyJfActive },
};
const resolveIcon = (name) => HOSPITAL_ICON_MAP[name] || Document;
// hover path
const hoveredPath = ref("");
@ -223,16 +227,9 @@ const sideRoutes = computed(() => {
return groupRoute.children
.filter((c) => c.meta?.title && !c.meta?.hidden && !c.path.includes(":"))
.map((c) => {
if (isHospitalMode.value) {
// 使 Element Plus
return {
path: `${groupPath}/${c.path}`,
title: c.meta.title,
iconName: c.meta?.icon || "Document",
};
}
// 使 SVG
const icon = ICON_MAP[c.meta?.icon];
// / SVGnormal + active
const iconMap = isHospitalMode.value ? HOSPITAL_SVG_ICON_MAP : ICON_MAP;
const icon = iconMap[c.meta?.icon];
return {
path: `${groupPath}/${c.path}`,
title: c.meta.title,
@ -249,7 +246,7 @@ const currentTitle = computed(() => {
// sex === 1 ,
const avatarSrc = computed(() => {
return userStore.userInfo?.sex === 2 ? avatarFemale : avatarMale;
return userStore.userInfo?.gender === '女' ? avatarFemale : avatarMale;
});
const handleCommand = async (cmd) => {

@ -22,13 +22,13 @@ export const constantRoutes = [
path: "workbench",
name: "HospitalWorkbench",
component: () => import("@/views/hospital/workbench.vue"),
meta: { title: "我的工作台", icon: "DataLine" },
meta: { title: "我的工作台", icon: "hospital-router-yy-gzt" },
},
{
path: "orders",
name: "HospitalOrders",
component: () => import("@/views/hospital/orders/list.vue"),
meta: { title: "我的工单", icon: "Document" },
meta: { title: "我的工单", icon: "hospital-router-yy-gd" },
},
{
path: "orders/detail/:id",
@ -40,7 +40,7 @@ export const constantRoutes = [
path: "points",
name: "HospitalPoints",
component: () => import("@/views/hospital/points.vue"),
meta: { title: "我的积分", icon: "Coin" },
meta: { title: "我的积分", icon: "hospital-router-yy-jf" },
},
// {
// path: "password",
@ -65,25 +65,13 @@ export const constantRoutes = [
component: () => import("@/views/admin/orders/list.vue"),
meta: { title: "服务工单", icon: "admin-router-fuwu-gd" },
},
// 服务报告(必须在 orders/detail/:id 之前声明,避免被动态路由抢占)
// 服务报告
{
path: "orders/report",
name: "AdminOrderReport",
component: () => import("@/views/admin/orders/report.vue"),
meta: { title: "服务报告", icon: "admin-router-fuwu-bg" },
},
{
path: "orders/detail/:id",
name: "AdminOrderDetail",
component: () => import("@/views/admin/orders/detail.vue"),
meta: { title: "工单详情", hidden: true },
},
{
path: "orders/process/:id",
name: "AdminOrderProcess",
component: () => import("@/views/admin/orders/process.vue"),
meta: { title: "处理工单", hidden: true },
},
// 医院信息
{
path: "hospitals",

@ -21,6 +21,10 @@ export const processAdminOrder = (id, data) => {
export const updateAdminOrder = (id, formData) => {
return kcRequest.uploadFile(`/admin/orders/${id}`, formData, true, "PUT");
};
// 删除工单(软删除)
export const deleteAdminOrder = (id) => {
return kcRequest.request(`/admin/orders/${id}`, "delete");
};
// ============== 医院信息 ==============
export const getAdminHospitals = (params) => {

@ -0,0 +1,422 @@
<template>
<el-dialog :model-value="modelValue" class="admin-order-detail-dialog" width="900px" :close-on-click-modal="true"
top="5vh" @update:model-value="(v) => $emit('update:modelValue', v)" @open="onOpen" @closed="cleanupPreview">
<div class="body-wrap" v-loading="loading">
<div class="body-header">
<div class="body-title">{{ $t('modal.detail') }}</div>
<img src="@/assets/image/close-btn.svg" alt="" class="close-btn" @click="closeDialog" />
</div>
<div v-if="detail.id" class="detail-container">
<!-- 第一行4 -->
<div class="detail-row row-4">
<div class="detail-item">
<div class="label">工单号</div>
<div class="value">{{ detail.orderNo }}</div>
</div>
<div class="detail-item">
<div class="label">当前状态</div>
<div class="value">{{ detail.status }}</div>
</div>
<div class="detail-item">
<div class="label">优先级</div>
<div class="value">{{ detail.priority }}</div>
</div>
<div class="detail-item">
<div class="label">服务类型</div>
<div class="value">{{ detail.serviceType }}</div>
</div>
</div>
<!-- 第二行1 -->
<div class="detail-row row-1">
<div class="detail-item">
<div class="label">问题名称</div>
<div class="value">{{ detail.title }}</div>
</div>
</div>
<!-- 第三行1 -->
<div class="detail-row row-1">
<div class="detail-item">
<div class="label">问题描述</div>
<div class="value" style="max-height: 300px; overflow: auto;">
<div class="html-content" v-html="detail.description || '—'"></div>
</div>
</div>
</div>
<!-- 第四行1 -->
<div class="detail-row row-1">
<div class="detail-item">
<div class="label">附件</div>
<div class="value">
<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"
class="file-item" @click="viewReport(f.filePath, f.fileName)">
<el-icon>
<Document />
</el-icon>{{ f.fileName }}
</el-button>
</div>
</div>
</div>
</div>
<!-- 第五行4 -->
<div class="detail-row row-4">
<div class="detail-item">
<div class="label">提出医院</div>
<div class="value">{{ detail.hospitalName || '-' }}</div>
</div>
<div class="detail-item">
<div class="label">提出科室</div>
<div class="value">{{ detail.department || '-' }}</div>
</div>
<div class="detail-item">
<div class="label">提出人员</div>
<div class="value">{{ detail.submitter || '-' }}</div>
</div>
<div class="detail-item">
<div class="label">提交时间</div>
<div class="value">{{ detail.createdAt?.split('T').join(' ') || '-' }}</div>
</div>
</div>
</div>
<!-- <div class="section">
<div class="section-title">{{ $t('orderDetail.statusChange') }}</div>
<el-timeline>
<el-timeline-item v-for="(log, i) in detail.statusLogs || []" :key="i"
:timestamp="log.createdAt" placement="top">
<div class="log-title">{{ log.toStatus }} · {{ log.operator }}</div>
<div class="log-remark" v-html="log.remark"></div>
</el-timeline-item>
</el-timeline>
</div> -->
</div>
<div class="body-footer">
<el-button type="primary" plain style="margin-left:auto" @click="closeDialog">
取消
</el-button>
<el-button v-if="detail.status !== '已完成'" type="primary" @click="editVisible = true">
{{ $t('btn.edit') }}
</el-button>
</div>
<!-- 编辑工单弹窗复用公共 OrderFormDialogmode='edit' -->
<OrderFormDialog v-model="editVisible" mode="edit" :hospitals="hospitals" :order-data="detail"
:submitting="editSubmitting" @submit="onEditSubmit" />
<!-- 福建弹窗 -->
<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 { ref, watch } from "vue";
import { Document } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { useI18n } from "vue-i18n";
import {
getAdminOrderDetail,
getAdminHospitals,
getUploadFile,
updateAdminOrder,
} from "@/service/modular/admin";
import FilePreviewDialog from "@/components/FilePreviewDialog.vue";
import OrderFormDialog from "./OrderFormDialog.vue";
const { t: $t } = useI18n();
const props = defineProps({
modelValue: { type: Boolean, default: false },
orderId: { type: [String, Number], default: "" },
});
const emit = defineEmits(["update:modelValue", "updated"]);
const loading = ref(false);
const detail = ref({});
const priorityType = (p) => (p === "高" ? "danger" : p === "中" ? "warning" : "info");
const statusType = (s) =>
({ 待处理: "warning", 处理中: "primary", 已完成: "success", 已取消: "info" })[s] || "info";
const loadDetail = async () => {
if (!props.orderId) return;
loading.value = true;
try {
detail.value = await getAdminOrderDetail(props.orderId);
} finally {
loading.value = false;
}
};
const onOpen = () => {
loadDetail();
};
watch(
() => props.orderId,
() => {
if (props.modelValue) loadDetail();
},
);
const closeDialog = () => emit("update:modelValue", 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 = "";
};
// /+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);
};
const viewReport = async (attachmentPath, originalName) => {
if (!attachmentPath) return;
const path = String(attachmentPath);
const downloadName = originalName || path;
try {
const blob = await getUploadFile(path);
const type = getFileType(downloadName, blob);
// PDF / PDF vue-pdf-embed
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 || $t('msg.failed'));
}
};
// ============== ==============
const hospitals = ref([]);
const loadHospitals = async () => {
try {
const res = await getAdminHospitals({ page: 1, pageSize: 1000 });
hospitals.value = res?.list || [];
} catch (_) {
hospitals.value = [];
}
};
const editVisible = ref(false);
const editSubmitting = ref(false);
const onEditSubmit = async ({ formData }) => {
editSubmitting.value = true;
try {
await updateAdminOrder(props.orderId, formData);
ElMessage.success($t("msg.editSuccess"));
editVisible.value = false;
loadDetail();
emit("updated");
} finally {
editSubmitting.value = false;
}
};
//
watch(
() => props.modelValue,
(v) => {
if (v && hospitals.value.length === 0) {
loadHospitals();
}
},
);
</script>
<style lang="scss">
.admin-order-detail-dialog {
max-width: 90vw;
min-width: 70vw;
.body-wrap {
padding: 24px;
.body-header {
margin-bottom: 16px;
}
.detail-container {
.detail-row+.detail-row {
margin-top: 15px;
}
.detail-row {
display: grid;
gap: 15px;
&.row-1 {
grid-template-columns: 1fr;
}
&.row-4 {
grid-template-columns: repeat(4, 1fr);
}
.detail-item {
display: flex;
align-items: center;
gap: 10px;
.label {
font-weight: 400;
font-size: 14px;
color: #1D2129;
width: 4em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
flex-grow: 0;
}
.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;
.html-content {
background: #fafafa;
padding: 12px;
border-radius: 4px;
min-height: 60px;
line-height: 1.6;
max-width: 100%;
img {
max-width: 100% !important;
}
}
}
}
}
}
}
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
.title {
font-size: 16px;
font-weight: 600;
margin-right: 8px;
}
}
.section {
margin-top: 20px;
.section-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 10px;
}
}
.files {
display: flex;
flex-direction: column;
gap: 8px;
.file-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
}
.log-title {
font-weight: 600;
}
.log-remark {
color: #606266;
margin-top: 4px;
}
.preview-iframe {
width: 100%;
height: 80vh;
border: 0;
}
.preview-image {
display: block;
max-width: 100%;
max-height: 80vh;
margin: 0 auto;
object-fit: contain;
}
</style>

@ -8,29 +8,16 @@
</div>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" v-loading="formLoading">
<el-row :gutter="20">
<el-col :span="8">
<el-form-item :label="$t('label.hospitalName')" prop="hospitalId">
<el-select v-model="form.hospitalId" :placeholder="$t('msg.pleaseSelectHospital')" 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-form-item :label="$t('label.title')" prop="title">
<div class="title-row">
<el-input v-model="form.title" :placeholder="$t('placeholder.title')" maxlength="100" show-word-limit />
<div class="color-tag-picker" ref="colorPickerRef">
<div class="color-tag-preview" :style="{ background: form.colorTag || '#dcdfe6' }"
:class="{ 'is-empty': !form.colorTag }" @click.stop="toggleColorPicker"></div>
<div v-show="colorPickerVisible" class="color-tag-dropdown" @click.stop>
<div class="color-tag-title">{{ $t('label.colorTag') }}</div>
<div class="color-tag-options">
<div v-for="c in colorOptions" :key="c.value" class="color-tag-option"
:class="{ active: form.colorTag === c.value }" :style="{ background: c.value }" :title="c.label"
@click="selectColor(c.value)"></div>
</div>
</div>
</div>
</div>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item :label="$t('label.type')" prop="serviceType">
<el-select v-model="form.serviceType" :placeholder="$t('placeholder.type')" clearable>
<el-option label="故障问题" value="故障问题" />
@ -39,18 +26,10 @@
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label-width="0">
<div class="inline-row">
<el-form-item :label="$t('label.priority')" prop="priority" class="inline-item">
<el-radio-group v-model="form.priority">
<el-radio-button label="高"></el-radio-button>
<el-radio-button label="中"></el-radio-button>
<el-radio-button label="低"></el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item :label="$t('label.feedbackchannel')" prop="feedbackchannel" class="inline-item">
<el-select v-model="form.feedbackchannel" :placeholder="$t('placeholder.feedbackchannel')" clearable
style="width: 160px">
</el-col>
<el-col :span="8">
<el-form-item :label="$t('label.feedbackchannel')" prop="feedbackchannel">
<el-select v-model="form.feedbackchannel" :placeholder="$t('placeholder.feedbackchannel')" clearable>
<el-option label="微信群" value="微信群" />
<el-option label="微信" value="微信" />
<el-option label="电话" value="电话" />
@ -60,18 +39,75 @@
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item :label="$t('label.dept')" prop="department">
<el-input v-model="form.department" :placeholder="$t('placeholder.dept')" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item :label="$t('label.name')" prop="submitter">
<el-input v-model="form.submitter" :placeholder="$t('placeholder.name')" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item :label="$t('label.createat')" prop="createdat">
<el-date-picker v-model="form.createdat" type="datetime" :placeholder="$t('placeholder.createat')"
value-format="YYYY-MM-DD HH:mm:ss" format="YYYY-MM-DD HH:mm:ss" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="$t('label.title')" prop="title">
<div class="title-row">
<el-input v-model="form.title" :placeholder="$t('placeholder.title')" maxlength="300" class="title-input">
<template #suffix>
<span class="title-counter"><span :style="{ color: form.colorTag || '#dcdfe6' }">{{ form.title.length
}}</span>/300</span>
<div class="color-tag-picker">
<el-popover v-model:visible="colorPickerVisible" :width="180" placement="bottom-end" trigger="click"
:show-arrow="false" popper-class="color-tag-popper">
<template #reference>
<el-badge :color="form.colorTag || '#dcdfe6'" is-dot class="item">
<img src="@/assets/image/admin/orders/sepan.svg" alt="" class="title-sepan" />
</el-badge>
</template>
<div class="color-tag-title">{{ $t('label.colorTag') }}</div>
<div class="color-tag-options">
<div v-for="c in colorOptions" :key="c.value" class="color-tag-option"
:class="{ active: form.colorTag === c.value }" :style="{ background: c.value }" :title="c.label"
@click="selectColor(c.value)"></div>
</div>
</el-popover>
</div>
</template>
</el-input>
<div class="priority-wrap">
<div class="priority-label">{{ $t('label.priority') }}</div>
<el-select v-model="form.priority" class="priority-select" :teleported="false">
<template #label="{ label, value }">
<div class="priority-row high" v-if="value === '高'">
{{ value }}
</div>
<div class="priority-row medium" v-if="value === '中'">
{{ value }}
</div>
<div class="priority-row low" v-if="value === '低'">
{{ value }}
</div>
</template>
<el-option v-for="opt in priorityOptions" :key="opt.value" :value="opt.value">
<span class="priority-option">
<span class="priority-dot" :class="opt.cls"></span>
<span class="priority-option-label">{{ opt.label }}</span>
</span>
</el-option>
</el-select>
</div>
</div>
</el-form-item>
<el-form-item :label="$t('label.description')" prop="description">
<div class="rich-editor">
<Toolbar :editor="editorRef" :default-config="toolbarConfig" mode="default"
@ -107,7 +143,6 @@ import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
reactive,
ref,
shallowRef,
@ -147,22 +182,18 @@ const colorOptions = [
{ label: '红', value: '#ff4d4f' },
];
// ../list.vue priority-row
const priorityOptions = [
{ label: '高', value: '高', cls: 'high' },
{ label: '中', value: '中', cls: 'medium' },
{ label: '低', value: '低', cls: 'low' },
];
const colorPickerVisible = ref(false);
const colorPickerRef = ref();
const toggleColorPicker = () => {
colorPickerVisible.value = !colorPickerVisible.value;
};
const selectColor = (val) => {
form.colorTag = val;
colorPickerVisible.value = false;
};
const onDocClick = (e) => {
if (!colorPickerVisible.value) return;
const el = colorPickerRef.value;
if (el && !el.contains(e.target)) {
colorPickerVisible.value = false;
}
};
const formRef = ref();
// loading
@ -430,7 +461,8 @@ watch(
formLoading.value = true;
nextTick(() => {
resetForm();
if (isEdit.value) {
// ( / ) orderData
if (isEdit.value || props.orderData) {
fillFormFromOrder(props.orderData || {});
} else {
//
@ -469,10 +501,6 @@ const onSubmit = async () => {
});
};
onMounted(() => {
document.addEventListener('click', onDocClick);
});
const removePasteListener = () => {
const editor = editorRef.value;
if (!editor || !pasteHandlerRef) return;
@ -483,7 +511,6 @@ const removePasteListener = () => {
};
onBeforeUnmount(() => {
document.removeEventListener('click', onDocClick);
removePasteListener();
pasteHandlerRef = null;
const editor = editorRef.value;
@ -492,7 +519,9 @@ onBeforeUnmount(() => {
});
</script>
<style scoped lang="scss">
<style lang="scss">
.admin-order-form-dialog {}
.body-wrap {
.body-header {
display: flex;
@ -537,37 +566,76 @@ onBeforeUnmount(() => {
background: #fff;
}
// +
.priority-row {
&.high {
--current-color: #ff383c;
background: #ffebec;
}
&.medium {
--current-color: #ff8d28;
background: #ffefe2;
}
&.low {
--current-color: #3289fb;
background: #eaf3ff;
}
width: 50px;
height: 30px;
border-radius: 8px 8px 8px 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
&::before {
content: "";
display: block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--current-color);
}
color: var(--current-color);
}
// + +
.title-row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.title-row .el-input {
.title-row .title-input {
flex: 1;
.el-input__wrapper {
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
}
}
// +
.inline-row {
.title-row :deep(.el-input__suffix) {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 0 24px;
width: 100%;
gap: 8px;
}
.inline-row .inline-item {
flex: 1 1 0;
min-width: 0;
margin-bottom: 0 !important;
.title-counter {
font-size: 13px;
color: #909399;
margin-right: 10px;
}
.inline-row .inline-item :deep(.el-form-item__content) {
display: flex;
align-items: center;
flex-wrap: wrap;
.title-sepan {
width: 16px;
height: 16px;
display: block;
cursor: pointer;
}
.color-tag-picker {
@ -576,8 +644,8 @@ onBeforeUnmount(() => {
}
.color-tag-preview {
width: 28px;
height: 28px;
width: 24px;
height: 24px;
border-radius: 6px;
cursor: pointer;
border: 2px solid #fff;
@ -603,49 +671,76 @@ onBeforeUnmount(() => {
box-shadow: 0 0 0 1px #dcdfe6;
}
.color-tag-dropdown {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 10;
background: #fff;
border-radius: 10px;
padding: 12px 14px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
min-width: 180px;
//
.priority-wrap {
display: flex;
align-items: center;
flex-shrink: 0;
border-radius: 8px;
height: 40px;
}
.color-tag-title {
font-size: 13px;
font-weight: 600;
color: #333;
margin-bottom: 10px;
.priority-label {
height: 40px;
border-top: 1px solid #ebeef5;
border-bottom: 1px solid #ebeef5;
background: #F9FAFF;
color: #606266;
font-weight: 400;
font-size: 14px;
color: #BCBCBC;
white-space: nowrap;
padding: 0 14px;
line-height: 40px;
}
.priority-select {
width: 110px;
}
.color-tag-options {
.priority-select .el-select__wrapper {
background: transparent;
padding: 5px 13px;
display: flex;
justify-content: space-between;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
.priority-select :deep(.el-select__placeholder) {
color: #303133;
}
.priority-option {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.color-tag-option {
width: 24px;
height: 24px;
border-radius: 6px;
cursor: pointer;
border: 2px solid transparent;
transition:
transform 0.2s ease,
border-color 0.2s ease;
.priority-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
}
.color-tag-option:hover {
transform: scale(1.12);
.priority-dot.high {
background: #ff383c;
}
.color-tag-option.active {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
.priority-dot.medium {
background: #ff8d28;
}
.priority-dot.low {
background: #3289fb;
}
.priority-option-label {
font-size: 14px;
color: #303133;
}
:deep(.fujian .el-form-item__content > div) {
@ -661,5 +756,53 @@ onBeforeUnmount(() => {
.body-footer {
border-radius: 0 0 24px 24px;
}
--el-component-size: 40px;
.el-select__wrapper {
height: 40px;
}
// popover teleport body scoped
.color-tag-popper {
padding: 12px 14px !important;
border-radius: 10px !important;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12) !important;
min-width: 180px;
}
.color-tag-popper .color-tag-title {
font-size: 13px;
font-weight: 600;
color: #333;
margin-bottom: 10px;
}
.color-tag-popper .color-tag-options {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.color-tag-popper .color-tag-option {
width: 24px;
height: 24px;
border-radius: 6px;
cursor: pointer;
border: 2px solid transparent;
transition:
transform 0.2s ease,
border-color 0.2s ease;
}
.color-tag-popper .color-tag-option:hover {
transform: scale(1.12);
}
.color-tag-popper .color-tag-option.active {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
}
</style>

@ -0,0 +1,566 @@
<template>
<el-dialog :model-value="modelValue" class="admin-order-handle-dialog" width="900px" :close-on-click-modal="false"
top="5vh" @update:model-value="(v) => $emit('update:modelValue', v)" @open="onOpen" @closed="cleanupPreview">
<!-- 顶部返回按钮 -->
<div class="dialog-topbar">
<el-button link class="back-btn" @click="closeDialog">
<img src="@/assets/image/admin/orders/arrow-left.svg" alt="返回" class="icon" />
<span>返回</span>
</el-button>
</div>
<div class="body-wrap" v-loading="loading">
<div v-if="detail.id" class="handle-form">
<el-form>
<!-- 第一行所属医院 / 服务类型 / 提出科室 / 提出人员 -->
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="所属医院" required>
<el-input :model-value="detail.hospitalName" readonly />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="服务类型" required>
<el-input :model-value="detail.serviceType" readonly />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="提出科室" required>
<el-input :model-value="detail.department" readonly />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="提出人员" required>
<el-input :model-value="detail.submitter" readonly />
</el-form-item>
</el-col>
</el-row>
<!-- 第二行问题名称 + 优先级 -->
<el-form-item label="问题名称" required>
<div class="title-row title-row-priority">
<el-input class="title-input" :model-value="detail.title" readonly style="flex: 1" />
<div class="priority-block">
<div class="priority-label">优先级</div>
<div class="priority-container">
<div class="priority-row high" v-if="detail.priority === '高'">
{{ detail.priority }}
</div>
<div class="priority-row medium" v-if="detail.priority === '中'">
{{ detail.priority }}
</div>
<div class="priority-row low" v-if="detail.priority === '低'">
{{ detail.priority }}
</div>
</div>
<!-- <el-input class="priority-input" :model-value="detail.priority" readonly
style="width: 100px" /> -->
</div>
</div>
</el-form-item>
<!-- 第三行问题描述 富文本 -->
<el-form-item label="问题描述" required>
<div class="rich-editor readonly-editor">
<div class="editor-content"
v-html="detail.description || '<p style=\'color: #999;\'>暂无描述</p>'"></div>
</div>
</el-form-item>
<!-- 底部 4 个操作按钮 -->
<el-form-item label=" ">
<div class="action-bar">
<div class="action-btn" @click="onProcess">
<img :src="iconProcess" alt="" />
<span>{{
detail.status === "已完成" ? "修改" : "处理"
}}</span>
</div>
<div class="line"></div>
<div class="action-btn" @click="onEdit">
<img :src="iconEdit" alt="" />
<span>编辑工单</span>
</div>
<div class="line"></div>
<div class="action-btn" @click="onCopy">
<img :src="iconCopy" alt="" />
<span>复制工单</span>
</div>
<div class="line"></div>
<el-popconfirm
title="确定删除该工单吗?"
confirm-button-text="确定"
cancel-button-text="取消"
@confirm="onDelete"
>
<template #reference>
<div class="action-btn danger">
<img :src="iconDelete" alt="" />
<span>删除工单</span>
</div>
</template>
</el-popconfirm>
</div>
</el-form-item>
<!-- 附件 -->
<el-form-item label="附件">
<div class="attachment">
<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"
class="file-item" @click="viewReport(f)">
<div class="inner">
<img :src="iconFile" alt="" class="file-icon" />
<span>{{ f.fileName }}</span>
</div>
</el-button>
</div>
</div>
</el-form-item>
</el-form>
</div>
</div>
<!-- 处理说明弹窗复用 OrderProcessDialog -->
<OrderProcessDialog v-model="processVisible" :order-id="currentOrderId" @processed="onProcessed" />
<!-- 编辑工单弹窗复用公共 OrderFormDialogmode='edit' -->
<OrderFormDialog v-model="editVisible" mode="edit" :hospitals="hospitals" :order-data="detail"
:submitting="editSubmitting" @submit="onEditSubmit" />
<!-- 复制工单复用 OrderFormDialogmode='create'回填整条 detail剔除附件字段 -->
<OrderFormDialog v-model="copyVisible" mode="create" :hospitals="hospitals" :order-data="copyOrderData"
:submitting="copySubmitting" @submit="onCopySubmit" />
<!-- 文件预览弹窗 -->
<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, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { useI18n } from "vue-i18n";
import {
createAdminOrder,
deleteAdminOrder,
getAdminHospitals,
getAdminOrderDetail,
getUploadFile,
updateAdminOrder,
} from "@/service/modular/admin";
import FilePreviewDialog from "@/components/FilePreviewDialog.vue";
import OrderFormDialog from "./OrderFormDialog.vue";
import OrderProcessDialog from "./OrderProcessDialog.vue";
import iconProcess from "@/assets/image/admin/orders/process.svg";
import iconEdit from "@/assets/image/admin/orders/edit.svg";
import iconCopy from "@/assets/image/admin/orders/copy.svg";
import iconDelete from "@/assets/image/admin/orders/delete.svg";
import iconFile from "@/assets/image/admin/orders/file.svg";
const { t: $t } = useI18n();
const props = defineProps({
modelValue: { type: Boolean, default: false },
orderId: { type: [String, Number], default: "" },
});
const emit = defineEmits(["update:modelValue", "updated"]);
const loading = ref(false);
const detail = ref({});
const hospitals = ref([]);
const getAttachmentFileName = (path) => {
if (!path) return "";
return String(path).replace(/\\/g, "/").split("/").pop() || "";
};
const loadDetail = async () => {
if (!props.orderId) return;
loading.value = true;
try {
detail.value = await getAdminOrderDetail(props.orderId);
} finally {
loading.value = false;
}
};
const loadHospitals = async () => {
try {
const res = await getAdminHospitals({ page: 1, pageSize: 1000 });
hospitals.value = res?.list || [];
} catch (_) {
hospitals.value = [];
}
};
const onOpen = () => {
loadDetail();
loadHospitals();
};
watch(
() => props.orderId,
() => {
if (props.modelValue) loadDetail();
},
);
const closeDialog = () => emit("update:modelValue", false);
//
const copyOrderData = computed(() => {
const { attachments: _attachments, ...rest } = detail.value || {};
return rest;
});
// ============== ==============
const currentOrderId = computed(() => props.orderId);
const processVisible = ref(false);
const editVisible = ref(false);
const editSubmitting = ref(false);
const copyVisible = ref(false);
const copySubmitting = ref(false);
// /
const onProcess = () => {
if (!detail.value.id) return;
processVisible.value = true;
};
const onProcessed = () => {
loadDetail();
emit("updated");
};
// OrderFormDialogmode='edit'
const onEdit = () => {
if (!detail.value.id) return;
editVisible.value = true;
};
const onEditSubmit = async ({ formData }) => {
editSubmitting.value = true;
try {
await updateAdminOrder(props.orderId, formData);
ElMessage.success($t("msg.editSuccess"));
editVisible.value = false;
loadDetail();
emit("updated");
} finally {
editSubmitting.value = false;
}
};
// create detail orderData
const onCopy = () => {
if (!detail.value.id) return;
copyVisible.value = true;
};
const onCopySubmit = async ({ formData }) => {
copySubmitting.value = true;
try {
await createAdminOrder(formData);
ElMessage.success($t("msg.submitSuccess"));
copyVisible.value = false;
emit("updated");
} finally {
copySubmitting.value = false;
}
};
// DELETE /admin/orders/{id}
const onDelete = async () => {
if (!props.orderId) return;
try {
await deleteAdminOrder(props.orderId);
ElMessage.success("删除成功");
emit("updated");
closeDialog();
} catch (e) {
ElMessage.error(e?.message || "删除失败");
}
};
// ============== ==============
const previewVisible = ref(false);
const previewUrl = ref("");
const previewBlob = ref(null);
const previewName = ref("");
const previewType = ref("");
const cleanupPreview = () => {
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value);
previewUrl.value = "";
}
previewBlob.value = null;
previewName.value = "";
previewType.value = "";
};
// /
const viewReport = async (file) => {
if (!file) return;
const path = String(file.filePath || "");
if (!path) {
ElMessage.warning("附件路径为空");
return;
}
const downloadName = file.fileName || getAttachmentFileName(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 || $t("msg.failed"));
}
};
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";
};
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);
};
const onPreviewDownload = ({ blob, fileName }) => {
if (!blob) return;
downloadBlob(blob, fileName);
};
</script>
<style lang="scss">
.admin-order-handle-dialog {
max-width: 90vw;
min-width: 70vw;
border-radius: 8px !important;
overflow: hidden;
background: #FFFFFF;
box-shadow: 0px 25px 50px -12px rgba(0, 0, 0, 0.25);
--el-component-size: 40px;
.dialog-topbar {
padding: 16px 24px;
border-bottom: 1px solid #F3F4F6;
.back-btn {
font-weight: 500;
font-size: 16px;
color: #374151;
border: 1px solid #D1D5DB;
border-radius: 4px;
padding: 8px 16px;
&:hover {
border: 1px solid #D1D5DB;
}
.icon {
margin-right: 4px;
}
}
}
.body-wrap {
padding: 24px;
.handle-form {
.el-form-item {
margin-bottom: 16px;
}
.el-form-item__label {
font-weight: 500;
font-size: 14px;
color: #374151;
width: 7em;
text-align: left;
}
}
.title-row {
display: flex;
align-items: center;
width: 100%;
.title-input .el-input__wrapper {
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
}
.priority-block {
display: flex;
align-items: center;
flex-shrink: 0;
.priority-label {
background: #F9FAFF;
border-radius: 0px 0px 0px 0px;
border-top: 1px solid #F2F3F5;
border-bottom: 1px solid #F2F3F5;
font-weight: 400;
font-size: 14px;
color: #BCBCBC;
padding: 0 16px;
height: 40px;
line-height: 40px;
}
.priority-container {
border-radius: 4px;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
padding: 5px 16px;
box-shadow: 0px 0px 0px 1px #EBEEF5 inset;
.priority-row {
&.high {
--current-color: #ff383c;
background: #ffebec;
}
&.medium {
--current-color: #ff8d28;
background: #ffefe2;
}
&.low {
--current-color: #3289fb;
background: #eaf3ff;
}
width: 50px;
height: 30px;
border-radius: 8px 8px 8px 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
&::before {
content: "";
display: block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--current-color);
}
color: var(--current-color);
}
}
}
}
.rich-editor {
width: 100%;
background: #fff;
border-radius: 4px;
border: 1px solid #EBEEF5;
overflow: hidden;
min-height: 40px;
padding: 5px 11px;
max-height: 300px;
overflow-y: auto;
img {
max-width: 100%;
}
}
// 4
.action-bar {
width: 100%;
display: flex;
align-items: center;
// margin: 16px 0 16px 90px;
background: rgba(0, 0, 0, 0.56);
border-radius: 4px 4px 4px 4px;
overflow: hidden;
.action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 12px 0;
cursor: pointer;
font-weight: 500;
font-size: 16px;
color: #FFFFFF;
line-height: 28px;
transition: background 0.2s ease;
&:hover {
background: rgba(0, 0, 0, 0.1);
}
img {
width: 20px;
height: 20px;
}
}
.line {
width: 1px;
height: 12px;
background: rgba(255, 255, 255, 0.1);
}
}
.attachment {
.file-list {
margin-bottom: 8px;
}
.file-item {
.inner {
display: inline-flex;
align-items: center;
gap: 4px;
}
.file-icon {
width: 18px;
height: 18px;
}
}
}
}
}
</style>

@ -1,55 +1,51 @@
<template>
<div class="process" v-loading="loading">
<el-page-header
:icon="ArrowLeft"
:content="$t('common.back')"
@back="goBack"
class="page-header"
>
<template #content>
<span class="page-title">{{ $t("btn.process") }}</span>
</template>
</el-page-header>
<el-card shadow="never" v-if="detail.id" class="form-card">
<template #header>
<span class="card-title">{{ $t("btn.process") }}</span>
</template>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
style="max-width: 800px"
>
<el-dialog :model-value="modelValue" class="admin-order-process-dialog" width="800px" :close-on-click-modal="false"
top="5vh" @update:model-value="(v) => $emit('update:modelValue', v)" @open="onOpen">
<div class="body-wrap" v-loading="loading">
<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="100px" v-if="detail.id">
<!-- 工单号只读 -->
<el-form-item :label="$t('table.id')">
<el-input :model-value="detail.orderNo" readonly />
</el-form-item>
<!-- 问题描述只读富文本展示 -->
<el-form-item :label="$t('label.description')">
<div class="readonly-html" v-html="detail.description || ''" />
<!-- 科室 / 提交人可编辑回显原值 -->
<el-row :gutter="16">
<el-col :span="12">
<el-form-item :label="$t('table.dept')" prop="department">
<el-input v-model="form.department" :placeholder="'请输入科室'" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="$t('table.submitter')" prop="submitter">
<el-input v-model="form.submitter" :placeholder="'请输入提交人'" />
</el-form-item>
</el-col>
</el-row>
<!-- 问题描述可编辑富文本默认回显原值 -->
<el-form-item :label="$t('label.description')" prop="description">
<div class="rich-editor">
<Toolbar :editor="descEditorRef" :default-config="toolbarConfig" mode="default"
style="border-bottom: 1px solid #dcdfe6" />
<Editor v-model="form.description" :default-config="descEditorConfig" mode="default"
style="height: 200px; overflow-y: auto" @on-created="handleDescEditorCreated"
@custom-paste="handleEditorPasteImage" />
</div>
</el-form-item>
<!-- 处理说明wangEditor 富文本编辑器与提交工单页面一致 -->
<el-form-item :label="$t('common.remark')" prop="processRemark">
<div class="rich-editor">
<Toolbar
:editor="editorRef"
:default-config="toolbarConfig"
mode="default"
style="border-bottom: 1px solid #dcdfe6"
/>
<Editor
v-model="form.processRemark"
:default-config="editorConfig"
mode="default"
style="height: 200px; overflow-y: auto"
@on-created="handleEditorCreated"
@custom-paste="handleEditorPasteImage"
/>
<Toolbar :editor="editorRef" :default-config="toolbarConfig" mode="default"
style="border-bottom: 1px solid #dcdfe6" />
<Editor v-model="form.processRemark" :default-config="editorConfig" mode="default"
style="height: 200px; overflow-y: auto" @on-created="handleEditorCreated"
@custom-paste="handleEditorPasteImage" />
</div>
</el-form-item>
@ -69,11 +65,11 @@
<el-button type="primary" :loading="submitting" @click="submit">{{
$t("btn.confirm")
}}</el-button>
<el-button @click="goBack">{{ $t("common.cancel") }}</el-button>
<el-button @click="closeDialog">{{ $t("common.cancel") }}</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</el-dialog>
</template>
<script setup>
@ -81,14 +77,12 @@ import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
reactive,
ref,
shallowRef,
watch,
} from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { ArrowLeft } from "@element-plus/icons-vue";
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
import "@wangeditor/editor/dist/css/style.css";
import { useI18n } from "vue-i18n";
@ -99,10 +93,14 @@ import {
import { useUserStore } from "@/stores/api/user";
const { t: $t } = useI18n();
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const props = defineProps({
modelValue: { type: Boolean, default: false },
orderId: { type: [String, Number], default: "" },
});
const emit = defineEmits(["update:modelValue", "processed"]);
const loading = ref(false);
const submitting = ref(false);
const detail = ref({});
@ -121,6 +119,9 @@ const handlerName = computed(
const form = reactive({
status: "已完成",
processRemark: "",
description: "",
department: "",
submitter: "",
});
const rules = {
@ -147,6 +148,7 @@ const rules = {
// wangEditor
const editorRef = shallowRef();
const descEditorRef = shallowRef();
const toolbarConfig = {
excludeKeys: [
"uploadImage",
@ -160,6 +162,9 @@ const toolbarConfig = {
const editorConfig = {
placeholder: $t("placeholder.remark"),
};
const descEditorConfig = {
placeholder: $t("placeholder.description") || "请输入问题描述",
};
// base64 HTML
const MAX_PASTE_IMAGE_SIZE = 5 * 1024 * 1024;
@ -233,112 +238,119 @@ const handleEditorPasteImage = (editor, event) => {
return false; // wangEditor
};
let pasteHandlerRef = null;
const handleEditorCreated = (editor) => {
editorRef.value = editor;
// contentEditable capture customPaste
// paste capture customPaste
// handler setup let
const pasteHandlerRef = { current: null };
const descPasteHandlerRef = { current: null };
const attachPasteListener = (editor, refHolder) => {
nextTick(() => {
const root = typeof editor.$el === "function" ? editor.$el() : editor.$el;
if (!root) return;
const editable = root.querySelector?.('[contenteditable="true"]') || root;
if (pasteHandlerRef) {
editable.removeEventListener("paste", pasteHandlerRef, { capture: true });
if (refHolder.current) {
editable.removeEventListener("paste", refHolder.current, { capture: true });
}
pasteHandlerRef = (event) => handleEditorPasteImage(editor, event);
editable.addEventListener("paste", pasteHandlerRef, { capture: true });
refHolder.current = (event) => handleEditorPasteImage(editor, event);
editable.addEventListener("paste", refHolder.current, { capture: true });
});
};
const handleEditorCreated = (editor) => {
editorRef.value = editor;
attachPasteListener(editor, pasteHandlerRef);
};
const handleDescEditorCreated = (editor) => {
descEditorRef.value = editor;
attachPasteListener(editor, descPasteHandlerRef);
};
const loadDetail = async () => {
if (!props.orderId) return;
loading.value = true;
try {
detail.value = await getAdminOrderDetail(route.params.id);
detail.value = await getAdminOrderDetail(props.orderId);
form.processRemark = detail.value.processRemark || "";
form.description = detail.value.description || "";
form.department = detail.value.department || "";
form.submitter = detail.value.submitter || "";
} finally {
loading.value = false;
}
};
const onOpen = () => {
loadDetail();
};
watch(
() => props.orderId,
() => {
if (props.modelValue) loadDetail();
},
);
const submit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
submitting.value = true;
try {
await processAdminOrder(route.params.id, {
await processAdminOrder(props.orderId, {
status: form.status,
processRemark: form.processRemark,
description: form.description,
department: form.department,
submitter: form.submitter,
});
ElMessage.success($t("msg.editSuccess"));
router.replace("/admin/orders");
emit("update:modelValue", false);
emit("processed");
} finally {
submitting.value = false;
}
});
};
const goBack = () => router.back();
const closeDialog = () => emit("update:modelValue", false);
const removePasteListener = () => {
const editor = editorRef.value;
if (!editor || !pasteHandlerRef) return;
const removePasteListener = (editor, refHolder) => {
if (!editor || !refHolder?.current) return;
const root = typeof editor.$el === "function" ? editor.$el() : editor.$el;
if (!root) return;
const editable = root.querySelector?.('[contenteditable="true"]') || root;
editable.removeEventListener("paste", pasteHandlerRef, { capture: true });
editable.removeEventListener("paste", refHolder.current, { capture: true });
};
onMounted(loadDetail);
onBeforeUnmount(() => {
removePasteListener();
pasteHandlerRef = null;
const editor = editorRef.value;
if (editor == null) return;
editor.destroy();
removePasteListener(editorRef.value, pasteHandlerRef);
removePasteListener(descEditorRef.value, descPasteHandlerRef);
pasteHandlerRef.current = null;
descPasteHandlerRef.current = null;
if (editorRef.value) editorRef.value.destroy();
if (descEditorRef.value) descEditorRef.value.destroy();
});
</script>
<style scoped lang="scss">
.page-header {
margin-bottom: 12px;
.page-title {
font-size: 16px;
font-weight: 600;
}
}
.form-card {
.card-title {
font-size: 16px;
font-weight: 600;
}
}
<style lang="scss">
.admin-order-process-dialog {
max-width: 90vw;
min-width: 70vw;
//
.readonly-html {
width: 100%;
min-height: 60px;
padding: 10px 12px;
background: #fafafa;
border: 1px solid #ebeef5;
border-radius: 4px;
line-height: 1.6;
word-break: break-word;
:deep(img) {
max-width: 100%;
.body-header {
margin-bottom: 16px;
}
}
// wangEditor
.rich-editor {
// wangEditor
.rich-editor {
border: 1px solid #dcdfe6;
border-radius: 4px;
width: 100%;
overflow: hidden;
background: #fff;
}
.rich-editor :deep(.w-e-toolbar) {
}
.rich-editor :deep(.w-e-toolbar) {
background: #fafafa;
}
}
</style>

@ -1,306 +0,0 @@
<template>
<div class="order-detail" v-loading="loading">
<el-page-header :icon="ArrowLeft" :content="$t('common.back')" @back="goBack" class="page-header">
<template #content>
<span class="page-title">{{ $t('modal.detail') }}</span>
</template>
</el-page-header>
<el-card shadow="never" v-if="detail.id" class="detail-card">
<template #header>
<div class="card-header">
<span class="title">{{ detail.title }}</span>
<el-tag :type="priorityType(detail.priority)" size="small">{{ detail.priority }}</el-tag>
<el-tag :type="statusType(detail.status)" size="small">{{ detail.status }}</el-tag>
<el-button
v-if="detail.status !== '已完成'"
type="primary"
size="small"
style="margin-left:auto"
@click="editVisible = true"
>
{{ $t('btn.edit') }}
</el-button>
</div>
</template>
<el-descriptions :column="3" border>
<el-descriptions-item :label="$t('table.id')">{{ detail.orderNo }}</el-descriptions-item>
<el-descriptions-item :label="$t('table.hospital')">{{ detail.hospitalName }}</el-descriptions-item>
<el-descriptions-item :label="$t('table.type')">{{ detail.serviceType }}</el-descriptions-item>
<el-descriptions-item :label="$t('table.submitter')">{{ detail.submitter }}</el-descriptions-item>
<el-descriptions-item :label="$t('label.dept')">{{ detail.department }}</el-descriptions-item>
<el-descriptions-item :label="$t('table.time')">{{ detail.createdAt?.split('T').join(' ') }}</el-descriptions-item>
</el-descriptions>
<div class="section">
<div class="section-title">{{ $t('label.description') }}</div>
<div class="html-content" v-html="detail.description || ''" />
</div>
<div class="section">
<div class="section-title">{{ $t('label.attachment') }}</div>
<el-empty 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"
class="file-item"
@click="viewReport(f.filePath, f.fileName)"
>
<el-icon><Document /></el-icon>{{ f.fileName }}
</el-button>
</div>
</div>
<div class="section">
<div class="section-title">{{ $t('orderDetail.statusChange') }}</div>
<el-timeline>
<el-timeline-item
v-for="(log, i) in detail.statusLogs || []"
:key="i"
:timestamp="log.createdAt"
placement="top"
>
<div class="log-title">{{ log.toStatus }} · {{ log.operator }}</div>
<div class="log-remark" v-html="log.remark"></div>
</el-timeline-item>
</el-timeline>
</div>
</el-card>
<!-- 编辑工单弹窗复用公共 OrderFormDialogmode='edit' -->
<OrderFormDialog
v-model="editVisible"
mode="edit"
:hospitals="hospitals"
:order-data="detail"
:submitting="editSubmitting"
@submit="onEditSubmit"
/>
<FilePreviewDialog
:visible="previewVisible"
:blob-url="previewUrl"
:blob="previewBlob"
:file-name="previewName"
:file-type="previewType"
@update:visible="(v) => (previewVisible = v)"
@close="cleanupPreview"
@download="onPreviewDownload"
/>
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ArrowLeft, Document } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { useI18n } from "vue-i18n";
import {
getAdminOrderDetail,
getAdminHospitals,
getUploadFile,
updateAdminOrder,
} from "@/service/modular/admin";
import FilePreviewDialog from "@/components/FilePreviewDialog.vue";
import OrderFormDialog from "./components/OrderFormDialog.vue";
const { t: $t } = useI18n();
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const detail = ref({});
const priorityType = (p) => (p === "高" ? "danger" : p === "中" ? "warning" : "info");
const statusType = (s) =>
({ 待处理: "warning", 处理中: "primary", 已完成: "success", 已取消: "info" })[s] || "info";
const loadDetail = async () => {
loading.value = true;
try {
detail.value = await getAdminOrderDetail(route.params.id);
} finally {
loading.value = false;
}
};
const goBack = () => router.back();
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 = "";
};
// /+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);
};
const viewReport = async (attachmentPath, originalName) => {
if (!attachmentPath) return;
const path = String(attachmentPath);
const downloadName = originalName || path;
try {
const blob = await getUploadFile(path);
const type = getFileType(downloadName, blob);
// PDF / PDF vue-pdf-embed
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 || $t('msg.failed'));
}
};
// ============== ==============
const hospitals = ref([]);
const loadHospitals = async () => {
try {
const res = await getAdminHospitals({ page: 1, pageSize: 1000 });
hospitals.value = res?.list || [];
} catch (_) {
hospitals.value = [];
}
};
const editVisible = ref(false);
const editSubmitting = ref(false);
const onEditSubmit = async ({ formData }) => {
editSubmitting.value = true;
try {
await updateAdminOrder(route.params.id, formData);
ElMessage.success($t("msg.editSuccess"));
editVisible.value = false;
loadDetail();
} finally {
editSubmitting.value = false;
}
};
onMounted(() => {
loadDetail();
loadHospitals();
});
</script>
<style scoped lang="scss">
.page-header {
margin-bottom: 12px;
.page-title {
font-size: 16px;
font-weight: 600;
}
}
.detail-card {
.card-header {
display: flex;
align-items: center;
gap: 8px;
.title {
font-size: 16px;
font-weight: 600;
margin-right: 8px;
}
}
.section {
margin-top: 20px;
.section-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 10px;
}
}
.html-content {
background: #fafafa;
padding: 12px;
border-radius: 4px;
min-height: 60px;
line-height: 1.6;
:deep(img) {
max-width: 100% !important;
}
}
.files {
display: flex;
flex-direction: column;
gap: 8px;
.file-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
}
.log-title {
font-weight: 600;
}
.log-remark {
color: #606266;
margin-top: 4px;
}
}
.preview-iframe {
width: 100%;
height: 80vh;
border: 0;
}
.preview-image {
display: block;
max-width: 100%;
max-height: 80vh;
margin: 0 auto;
object-fit: contain;
}
</style>

@ -76,6 +76,10 @@
:start-placeholder="$t('common.startDate')" :end-placeholder="$t('common.endDate')" value-format="YYYY-MM-DD"
style="width: 240px" />
</el-form-item>
<el-form-item :label="'处理时间:'">
<el-date-picker v-model="processDateRange" type="datetimerange" range-separator="-" :start-placeholder="'开始时间'"
:end-placeholder="'结束时间'" value-format="YYYY-MM-DD HH:mm:ss" style="width: 400px" />
</el-form-item>
<el-button type="primary" plain @click="onSearch">{{
$t("btn.query")
}}</el-button>
@ -102,7 +106,12 @@
</div>
</template>
</el-table-column>
<el-table-column :label="$t('table.title')" prop="title" min-width="300" show-overflow-tooltip />
<el-table-column :label="$t('table.title')" min-width="300" show-overflow-tooltip>
<template #default="{ row }">
<el-button type="primary" link style="text-decoration: underline;" @click="goHandle(row.id)">{{ row.title
}}</el-button>
</template>
</el-table-column>
<el-table-column :label="$t('table.type')" prop="serviceType" width="120" />
<el-table-column :label="$t('table.dept')" prop="department" min-width="120" show-overflow-tooltip />
<el-table-column :label="$t('table.submitter')" prop="submitter" min-width="100" show-overflow-tooltip />
@ -142,20 +151,23 @@
</div>
<!-- 快速提交工单 弹窗复用公共 OrderFormDialogmode='create' -->
<OrderFormDialog
v-model="quickVisible"
mode="create"
:hospitals="hospitals"
:submitting="quickSubmitting"
@submit="onQuickSubmit"
/>
<OrderFormDialog v-model="quickVisible" mode="create" :hospitals="hospitals" :submitting="quickSubmitting"
@submit="onQuickSubmit" />
<!-- 工单详情 弹窗 -->
<OrderDetailDialog v-model="detailVisible" :order-id="currentOrderId" @updated="loadList" />
<!-- 处理工单 弹窗 -->
<OrderProcessDialog v-model="processVisible" :order-id="currentOrderId" @processed="loadList" />
<!-- 工单处理/详情一体化 弹窗点击标题进入 -->
<OrderHandleDialog v-model="handleVisible" :order-id="currentOrderId" @updated="loadList" />
</div>
</template>
<script setup>
import dayjs from "dayjs";
import { onMounted, reactive, ref } from "vue";
import { onBeforeRouteLeave, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useI18n } from "vue-i18n";
import {
@ -165,13 +177,15 @@ import {
} from "@/service/modular/admin";
import { useUserStore } from "@/stores/api/user";
import OrderFormDialog from "./components/OrderFormDialog.vue";
import OrderDetailDialog from "./components/OrderDetailDialog.vue";
import OrderProcessDialog from "./components/OrderProcessDialog.vue";
import OrderHandleDialog from "./components/OrderHandleDialog.vue";
import iconAll from "@/assets/image/admin/orders/all.svg";
import iconPending from "@/assets/image/admin/orders/pending.svg";
import iconComplete from "@/assets/image/admin/orders/complete.svg";
import iconHigh from "@/assets/image/admin/orders/high.svg";
const { t: $t } = useI18n();
const router = useRouter();
const userStore = useUserStore();
const list = ref([]);
const hospitals = ref([]);
@ -180,6 +194,7 @@ const page = ref(1);
const pageSize = ref(10);
const loading = ref(false);
const dateRange = ref([]);
const processDateRange = ref([]);
const filters = reactive({
hospitalId: undefined,
status: "",
@ -234,6 +249,10 @@ const loadList = async () => {
params.startDate = dateRange.value[0];
params.endDate = dateRange.value[1];
}
if (processDateRange.value?.length === 2) {
params.completedDateStart = processDateRange.value[0];
params.completedDateEnd = processDateRange.value[1];
}
const res = await getAdminOrders(params);
list.value = res?.list || [];
list.value.forEach((item) => {
@ -286,76 +305,30 @@ const onReset = () => {
keyword: "",
});
dateRange.value = [];
processDateRange.value = [];
activeStat.value = "total";
onSearch();
};
// sessionStorage key/
const LIST_STATE_KEY = "admin_orders_list_state";
// /
const detailVisible = ref(false);
const processVisible = ref(false);
const handleVisible = ref(false);
const currentOrderId = ref("");
const saveListState = () => {
try {
const state = {
filters: { ...filters },
dateRange: Array.isArray(dateRange.value) ? [...dateRange.value] : [],
page: page.value,
pageSize: pageSize.value,
activeStat: activeStat.value,
};
sessionStorage.setItem(LIST_STATE_KEY, JSON.stringify(state));
} catch (_) {
//
}
const goDetail = (id) => {
currentOrderId.value = id;
detailVisible.value = true;
};
const restoreListState = () => {
try {
const raw = sessionStorage.getItem(LIST_STATE_KEY);
if (!raw) return;
const state = JSON.parse(raw);
if (!state || typeof state !== "object") return;
if (state.filters && typeof state.filters === "object") {
Object.assign(filters, state.filters);
}
if (Array.isArray(state.dateRange)) {
dateRange.value = state.dateRange;
}
if (typeof state.page === "number" && state.page > 0) {
page.value = state.page;
}
if (typeof state.pageSize === "number" && state.pageSize > 0) {
pageSize.value = state.pageSize;
}
if (typeof state.activeStat === "string") {
activeStat.value = state.activeStat;
}
} catch (_) {
//
}
const goProcess = (id) => {
currentOrderId.value = id;
processVisible.value = true;
};
const clearListState = () => {
try {
sessionStorage.removeItem(LIST_STATE_KEY);
} catch (_) {
/* noop */
}
const goHandle = (id) => {
currentOrderId.value = id;
handleVisible.value = true;
};
const goDetail = (id) => router.push(`/admin/orders/detail/${id}`);
const goProcess = (id) => router.push(`/admin/orders/process/${id}`);
// /
onBeforeRouteLeave((to) => {
const path = to?.path || "";
if (
path.startsWith("/admin/orders/detail") ||
path.startsWith("/admin/orders/process")
) {
saveListState();
}
});
//
const quickVisible = ref(false);
const quickSubmitting = ref(false);
@ -376,9 +349,6 @@ const onQuickSubmit = async ({ formData }) => {
onMounted(() => {
loadHospitals();
// /
restoreListState();
clearListState();
loadList();
});
</script>

@ -83,15 +83,15 @@ const unwrap = (res) => res?.data ?? res ?? {};
// ============ ============
const gridItems = [
{ type: "points", points: 6 },
{ type: "points", points: 1 },
{ type: "points", points: 6 },
{ type: "points", points: 10 },
{ type: "points", points: 2 },
{ type: "points", points: 3 },
{ type: "points", points: 4 },
{ type: "draw" }, //
{ type: "points", points: 5 },
{ type: "points", points: 6 },
{ type: "points", points: 6 },
{ type: "points", points: 1 },
{ type: "points", points: 6 },
{ type: "points", points: 7 },
{ type: "points", points: 8 },
];
//
@ -112,28 +112,21 @@ const batchPercent = computed(() =>
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
// ============ ============
const findNearestIndex = (points) => {
let nearestIdx = drawableIndices[0];
let minDiff = Infinity;
for (const idx of drawableIndices) {
const diff = Math.abs(gridItems[idx].points - points);
if (diff < minDiff) {
minDiff = diff;
nearestIdx = idx;
}
}
return nearestIdx;
// ============ ============
const findIndexByPoints = (points) => {
return gridItems.findIndex(
(item) => item.type === "points" && item.points === points,
);
};
// ============ ============
const startDraw = async () => {
if (isDrawing.value) return;
// todo:
// if (!props.drawable) {
// ElMessage.info(",!");
// return;
// }
if (!props.drawable) {
ElMessage.info("今日已抽奖,明天再来吧!");
return;
}
//
isDrawing.value = true;
@ -180,23 +173,22 @@ const startDraw = async () => {
await wait(250);
}
// if (apiError || apiResult === null || apiResult === undefined) {
// isDrawing.value = false;
// ElMessage.error(apiError?.message || "");
// emit("failed", apiError);
// return;
// }
if (apiError || apiResult === null || apiResult === undefined) {
isDrawing.value = false;
ElMessage.error(apiError?.message || "抽奖失败");
emit("failed", apiError);
return;
}
//
// todo
// const points = Number(
// apiResult.points ?? apiResult.score ?? apiResult ?? 0,
// ) ;
// todo 6
const points = 6;
//
const targetIdx = findNearestIndex(points);
const points = Number(
apiResult.points ?? apiResult.score ?? apiResult ?? 0,
) ;
//
const targetIdx = findIndexByPoints(points);
// ========== ==========
let safety = 0;

@ -6,7 +6,9 @@
<div class="signin-header">
<h3 class="signin-title" :class="{ 'is-signed': signedIn }">
<template v-if="signedIn">
连续签到<span class="title-num">{{ streakDays }}</span> 排名<span class="title-num">{{ rank }}</span>
连续签到<span class="title-num">{{ streakDays }}</span>
<template v-if="rank > 0">排名第<span class="title-num">{{ rank }}</span></template>
<template v-else></template>
</template>
<template v-else>
每日签到 <span class="title-num">领积分</span>
@ -14,7 +16,9 @@
</h3>
<p class="signin-subtitle">
<template v-if="signedIn">
当前积分: <span class="title-num">{{ totalPoints }}</span> 您已击败全国{{ beatPercent }}%的医院
当前积分: <span class="title-num">{{ totalPoints }}</span>
<template v-if="beatPercent > 0">{{ beatPercent }}%</template>
<template v-else></template>
</template>
<template v-else>
坚持签到积分兑好礼
@ -32,7 +36,7 @@
}">
<div class="day-top">
<!-- 上方第N天 -->
<div class="day-label" v-if="!day.isToday">{{ idx + 1 }}</div>
<div class="day-label" v-if="!day.isToday">{{ day.dayIdx }}</div>
<div class="day-label" v-else></div>
<!-- 中间图标 -->
<div class="day-icon">
@ -63,7 +67,7 @@
</template>
<script setup>
import { ref } from "vue";
import { computed, ref, watch } from "vue";
import dayjs from "dayjs";
import { ElMessage } from "element-plus";
import { signIn } from "@/service/modular/hospital";
@ -75,43 +79,66 @@ const props = defineProps({
signedIn: { type: Boolean, default: false },
//
totalPoints: { type: Number, default: 0 },
// consecutiveSignInDays
consecutiveSignInDays: { type: Number, default: 0 },
// signInRank
signInRank: { type: Number, default: 0 },
// 0-1000 beatPercent
beatPercent: { type: Number, default: 0 },
});
const emit = defineEmits(["update:modelValue", "success"]);
const unwrap = (res) => res?.data ?? res ?? {};
// ============ ============
// 2 06-0906-10
const streakDays = 2;
const rank = 350;
const beatPercent = "95.68";
// ============ (~) 7 ============
// 1. (:)
// 2. 7
// 3. isToday=true
const buildWeekDays = () => {
// props 便
const streakDays = computed(() => Number(props.consecutiveSignInDays) || 0);
const rank = computed(() => Number(props.signInRank) || 0);
const beatPercent = computed(() => {
const n = Number(props.beatPercent) || 0;
// 0
return n > 0 ? n : 0;
});
// ============ 7 ============
//
// N = consecutiveSignInDays
// T =
// displayN = T ? N : N + 1 // ,"" N+1
// startIdx = max(1, displayN - 6) // displayN <= 7 1 ,> 7 7
// k (k = startIdx .. startIdx+6) k <= N
const buildSignInDays = (consecutiveDays, signedToday) => {
const today = dayjs().startOf("day");
// dayjs day(): 0=Sun, 1=Mon, ..., 6=Sat
const dow = today.day();
// :,(0)退 6
const offsetToMonday = dow === 0 ? 6 : dow - 1;
const monday = today.subtract(offsetToMonday, "day");
const N = Math.max(0, Number(consecutiveDays) || 0);
const displayN = signedToday ? N : N + 1;
const startIdx = Math.max(1, displayN - 6);
return Array.from({ length: 7 }, (_, i) => {
const d = monday.add(i, "day");
const isToday = d.isSame(today, "day");
const dayIdx = startIdx + i;
const offset = displayN - dayIdx; //
const d = today.subtract(offset, "day");
const isToday = dayIdx === displayN;
const signed = dayIdx <= N;
return {
date: d.format("MM-DD"),
// :("")
signed: !d.isAfter(today, "day"),
signed,
isToday,
dayIdx,
};
});
};
const days = ref(buildWeekDays());
const days = ref(
buildSignInDays(props.consecutiveSignInDays, props.signedIn),
);
// summary , 7
watch(
() => [props.consecutiveSignInDays, props.signedIn],
([n, signed]) => {
days.value = buildSignInDays(n, signed);
},
);
const submitting = ref(false);
@ -134,8 +161,8 @@ const handleSignIn = async () => {
try {
await signIn({ remark: "" });
ElMessage.success("签到成功! +1 积分 🎉");
// 7 ()
days.value = buildWeekDays();
// 7 ()
days.value = buildSignInDays(props.consecutiveSignInDays, props.signedIn);
emit("success");
emit("update:modelValue", false);
} catch (e) {
@ -149,7 +176,7 @@ const handleSignIn = async () => {
<style lang="scss">
/* ============ 所有样式都嵌套在 .signin-dialog 下 ============ */
.signin-dialog {
width: 502px;
min-width: 502px;
padding: 0;
background: transparent;
box-shadow: none;
@ -167,7 +194,7 @@ const handleSignIn = async () => {
// ===== =====
.signin-card {
width: 502px;
min-width: 502px;
height: 380px;
position: relative;
border-radius: 24px;

@ -1,63 +1,34 @@
<template>
<el-dialog
:model-value="modelValue"
:title="isEdit ? '编辑工单' : $t('modal.submit')"
width="720px"
:close-on-click-modal="false"
destroy-on-close
class="order-form-dialog"
@update:model-value="onDialogUpdate"
@closed="onClosed"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
>
<el-dialog :model-value="modelValue" width="720px" :close-on-click-modal="true" destroy-on-close
class="hospital-order-form-dialog" @update:model-value="onDialogUpdate" @closed="onClosed">
<div class="body-wrap">
<div class="body-header">
<div class="body-title">
{{ isEdit ? '编辑工单' : $t('modal.submit') }}
</div>
<img src="@/assets/image/close-btn.svg" alt="" class="close-btn" @click="onCancel">
</div>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item :label="$t('label.title')" prop="title">
<div class="title-row">
<el-input
v-model="form.title"
:placeholder="$t('placeholder.title')"
maxlength="100"
show-word-limit
/>
<el-input v-model="form.title" :placeholder="$t('placeholder.title')" maxlength="100" show-word-limit />
<div class="color-tag-picker" ref="colorPickerRef">
<div
class="color-tag-preview"
:style="{ background: form.colorTag || '#dcdfe6' }"
:class="{ 'is-empty': !form.colorTag }"
@click.stop="toggleColorPicker"
></div>
<div
v-show="colorPickerVisible"
class="color-tag-dropdown"
@click.stop
>
<div class="color-tag-preview" :style="{ background: form.colorTag || '#dcdfe6' }"
:class="{ 'is-empty': !form.colorTag }" @click.stop="toggleColorPicker"></div>
<div v-show="colorPickerVisible" class="color-tag-dropdown" @click.stop>
<div class="color-tag-title">{{ $t('label.colorTag') }}</div>
<div class="color-tag-options">
<div
v-for="c in colorOptions"
:key="c.value"
class="color-tag-option"
:class="{ active: form.colorTag === c.value }"
:style="{ background: c.value }"
:title="c.label"
@click="selectColor(c.value)"
></div>
<div v-for="c in colorOptions" :key="c.value" class="color-tag-option"
:class="{ active: form.colorTag === c.value }" :style="{ background: c.value }" :title="c.label"
@click="selectColor(c.value)"></div>
</div>
</div>
</div>
</div>
</el-form-item>
<el-form-item :label="$t('label.type')" prop="serviceType">
<el-select
v-model="form.serviceType"
:placeholder="$t('placeholder.type')"
clearable
style="width: 240px"
>
<el-select v-model="form.serviceType" :placeholder="$t('placeholder.type')" clearable style="width: 240px">
<el-option label="故障报修" value="故障报修" />
<el-option label="业务咨询" value="业务咨询" />
<el-option label="需求变更" value="需求变更" />
@ -74,12 +45,8 @@
</el-radio-group>
</el-form-item>
<el-form-item :label="$t('label.feedbackchannel')" prop="feedbackchannel" class="inline-item">
<el-select
v-model="form.feedbackchannel"
:placeholder="$t('placeholder.feedbackchannel')"
clearable
style="width: 200px"
>
<el-select v-model="form.feedbackchannel" :placeholder="$t('placeholder.feedbackchannel')" clearable
style="width: 200px">
<el-option label="微信群" value="微信群" />
<el-option label="微信" value="微信" />
<el-option label="电话" value="电话" />
@ -92,54 +59,27 @@
</div>
</el-form-item>
<el-form-item :label="$t('label.dept')" prop="department">
<el-input
v-model="form.department"
:placeholder="$t('placeholder.dept')"
/>
<el-input v-model="form.department" :placeholder="$t('placeholder.dept')" />
</el-form-item>
<el-form-item :label="$t('label.name')" prop="submitter">
<el-input
v-model="form.submitter"
:placeholder="$t('placeholder.name')"
/>
<el-input v-model="form.submitter" :placeholder="$t('placeholder.name')" />
</el-form-item>
<el-form-item :label="$t('label.createat')" prop="createdat">
<el-date-picker
v-model="form.createdat"
type="datetime"
:placeholder="$t('placeholder.createat')"
value-format="YYYY-MM-DD HH:mm:ss"
format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
<el-date-picker v-model="form.createdat" type="datetime" :placeholder="$t('placeholder.createat')"
value-format="YYYY-MM-DD HH:mm:ss" format="YYYY-MM-DD HH:mm:ss" style="width: 100%" />
</el-form-item>
<el-form-item :label="$t('label.description')" prop="description">
<div class="rich-editor">
<Toolbar
:editor="editorRef"
:default-config="toolbarConfig"
mode="default"
style="border-bottom: 1px solid #dcdfe6"
/>
<Editor
v-model="form.description"
:default-config="editorConfig"
mode="default"
style="height: 200px; overflow-y: auto"
@on-created="handleEditorCreated"
@custom-paste="handleEditorPasteImage"
/>
<Toolbar :editor="editorRef" :default-config="toolbarConfig" mode="default"
style="border-bottom: 1px solid #dcdfe6" />
<Editor v-model="form.description" :default-config="editorConfig" mode="default"
style="height: 200px; overflow-y: auto" @on-created="handleEditorCreated"
@custom-paste="handleEditorPasteImage" />
</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-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">
@ -149,17 +89,19 @@
</el-upload>
</el-form-item>
</el-form>
</div>
<div class="body-footer">
<el-button @click="onCancel">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="submitting" @click="submit">
{{ $t('common.submit') }}
</el-button>
</div>
<!-- 提交工单后 +1 积分奖励弹窗 -->
<el-dialog
v-model="rewardVisible"
width="360px"
:show-close="false"
:close-on-click-modal="false"
:close-on-press-escape="false"
append-to-body
class="reward-dialog"
>
<el-dialog v-model="rewardVisible" width="360px" :show-close="false" :close-on-click-modal="false"
:close-on-press-escape="false" append-to-body class="reward-dialog">
<div class="reward-content">
<div class="reward-badge"> 奖励已发放</div>
<div class="coin-wrapper">
@ -182,10 +124,7 @@
</span>
</div>
<div class="reward-bar-bg">
<div
class="reward-bar-fill"
:style="{ width: rewardProgress + '%' }"
></div>
<div class="reward-bar-fill" :style="{ width: rewardProgress + '%' }"></div>
</div>
<div class="reward-current">
当前 {{ rewardTotalPoints }}
@ -202,13 +141,6 @@
</div>
</div>
</el-dialog>
<template #footer>
<el-button @click="onCancel">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="submitting" @click="submit">
{{ $t('common.submit') }}
</el-button>
</template>
</el-dialog>
</template>
@ -616,48 +548,64 @@ onBeforeUnmount(() => {
});
</script>
<style scoped lang="scss">
.rich-editor {
<style lang="scss">
.hospital-order-form-dialog {
.body-wrap {
padding: 24px;
}
.body-header {
margin-bottom: 18px;
}
.rich-editor {
border: 1px solid #dcdfe6;
border-radius: 4px;
width: 100%;
overflow: hidden;
background: #fff;
}
.rich-editor :deep(.w-e-toolbar) {
}
.rich-editor :deep(.w-e-toolbar) {
background: #fafafa;
}
}
// +
.title-row {
// +
.title-row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.title-row .el-input {
}
.title-row .el-input {
flex: 1;
}
// +
.inline-row {
}
// +
.inline-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0 24px;
width: 100%;
}
.inline-row .inline-item {
}
.inline-row .inline-item {
margin-bottom: 0 !important;
}
.inline-row .inline-item :deep(.el-form-item__content) {
}
.inline-row .inline-item :deep(.el-form-item__content) {
display: flex;
align-items: center;
}
.color-tag-picker {
}
.color-tag-picker {
position: relative;
flex-shrink: 0;
}
.color-tag-preview {
}
.color-tag-preview {
width: 28px;
height: 28px;
border-radius: 6px;
@ -667,24 +615,25 @@ onBeforeUnmount(() => {
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.color-tag-preview:hover {
}
.color-tag-preview:hover {
transform: scale(1.08);
}
.color-tag-preview.is-empty {
background-image: linear-gradient(
135deg,
}
.color-tag-preview.is-empty {
background-image: linear-gradient(135deg,
#f5f5f5 25%,
transparent 25%,
transparent 50%,
#f5f5f5 50%,
#f5f5f5 75%,
transparent 75%
);
transparent 75%);
background-size: 8px 8px;
box-shadow: 0 0 0 1px #dcdfe6;
}
.color-tag-dropdown {
}
.color-tag-dropdown {
position: absolute;
top: calc(100% + 8px);
left: 0;
@ -694,19 +643,22 @@ onBeforeUnmount(() => {
padding: 12px 14px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
min-width: 180px;
}
.color-tag-title {
}
.color-tag-title {
font-size: 13px;
font-weight: 600;
color: #333;
margin-bottom: 10px;
}
.color-tag-options {
}
.color-tag-options {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.color-tag-option {
}
.color-tag-option {
width: 24px;
height: 24px;
border-radius: 6px;
@ -715,34 +667,39 @@ onBeforeUnmount(() => {
transition:
transform 0.2s ease,
border-color 0.2s ease;
}
.color-tag-option:hover {
}
.color-tag-option:hover {
transform: scale(1.12);
}
.color-tag-option.active {
}
.color-tag-option.active {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
}
//
:deep(.fujian .el-form-item__content > div) {
//
:deep(.fujian .el-form-item__content > div) {
width: 100% !important;
}
}
// ============== ==============
:deep(.reward-dialog) {
// ============== ==============
:deep(.reward-dialog) {
.el-dialog__header {
display: none;
}
.el-dialog__body {
padding: 0;
}
}
.reward-content {
}
.reward-content {
text-align: center;
padding: 24px 24px 22px;
}
.reward-badge {
}
.reward-badge {
display: inline-block;
background: #fef3e6;
color: #c47a2e;
@ -751,128 +708,151 @@ onBeforeUnmount(() => {
padding: 4px 16px;
border-radius: 40px;
margin-bottom: 8px;
}
.coin-wrapper {
}
.coin-wrapper {
height: 80px;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 4px;
}
.reward-coin {
}
.reward-coin {
font-size: 64px;
display: inline-block;
filter: drop-shadow(0 8px 16px rgba(255, 193, 7, 0.3));
}
.reward-coin.spin {
}
.reward-coin.spin {
animation: rewardCoinSpin 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
@keyframes rewardCoinSpin {
}
@keyframes rewardCoinSpin {
0% {
transform: rotateY(0deg) scale(0.2);
opacity: 0;
}
60% {
transform: rotateY(720deg) scale(1.2);
opacity: 1;
}
100% {
transform: rotateY(720deg) scale(1);
opacity: 1;
}
}
.points-number {
}
.points-number {
font-size: 48px;
font-weight: 800;
color: #f5a623;
line-height: 1.1;
margin-top: -4px;
}
.points-number small {
}
.points-number small {
font-size: 20px;
font-weight: 600;
color: #b8863a;
}
.reward-title {
}
.reward-title {
font-size: 22px;
font-weight: 700;
color: #1f140e;
margin: 4px 0 6px;
}
.reward-sub {
}
.reward-sub {
font-size: 14px;
color: #7a6a5c;
margin-bottom: 16px;
}
.reward-progress {
}
.reward-progress {
background: #f8f4f0;
border-radius: 16px;
padding: 14px 16px;
margin-bottom: 18px;
text-align: left;
}
.reward-progress-label {
}
.reward-progress-label {
display: flex;
justify-content: space-between;
font-size: 13px;
color: #4d3e32;
font-weight: 500;
gap: 8px;
}
.reward-distance {
}
.reward-distance {
color: #f5a623;
font-weight: 700;
flex-shrink: 0;
}
.reward-bar-bg {
}
.reward-bar-bg {
width: 100%;
height: 6px;
background: #e6ddd5;
border-radius: 10px;
margin-top: 8px;
overflow: hidden;
}
.reward-bar-fill {
}
.reward-bar-fill {
height: 100%;
background: linear-gradient(90deg, #f9b84a, #f5a623);
border-radius: 10px;
transition: width 0.6s ease;
}
.reward-current {
}
.reward-current {
margin-top: 8px;
font-size: 12px;
color: #8a7a6a;
text-align: right;
}
.reward-actions {
}
.reward-actions {
display: flex;
gap: 10px;
}
.reward-btn {
}
.reward-btn {
flex: 1;
padding: 12px 0 !important;
font-size: 14px !important;
font-weight: 600 !important;
border-radius: 60px !important;
}
.reward-btn-received {
}
.reward-btn-received {
background: #f0ebe6 !important;
border-color: #f0ebe6 !important;
color: #4d3e32 !important;
}
.reward-btn-received:hover {
}
.reward-btn-received:hover {
background: #e6ddd5 !important;
border-color: #e6ddd5 !important;
}
.reward-btn-assets {
}
.reward-btn-assets {
background: #f9b84a !important;
border-color: #f9b84a !important;
color: #1f140e !important;
box-shadow: 0 4px 14px rgba(249, 184, 74, 0.25);
}
.reward-btn-assets:hover {
}
.reward-btn-assets:hover {
background: #f5ac2e !important;
border-color: #f5ac2e !important;
transform: translateY(-1px);
}
}
</style>

@ -81,7 +81,7 @@
<div class="action-name">签到</div>
<div class="action-desc">
<template v-if="summary.isSignIn">
连续签到{{ signInStreakDays }}排名{{ signInRank }}您已击败全国{{ signInBeatPercent }}%的医院
连续签到{{ signInStreakDays }}排名{{ signInRank }}您已击败全国{{ signInBeatPercent }}%的医院
</template>
<template v-else>
每日签到可领积分连续签到奖励更多
@ -165,7 +165,7 @@
</el-card>
<!-- ========== 兑换表单弹窗 ========== -->
<el-dialog v-model="exchangeVisible" title="🎁 兑换礼物" width="520px" :close-on-click-modal="false" append-to-body>
<el-dialog v-model="exchangeVisible" style="padding: 40px;" title="🎁 兑换礼物" width="520px" :close-on-click-modal="false" append-to-body>
<div class="redeem-sub">
填写收件信息,即可兑换{{ REWARD_NAME }}
</div>
@ -208,6 +208,7 @@
<!-- ========== 签到弹窗 ========== -->
<SignInDialog v-model="signInVisible" :signed-in="!!summary.isSignIn" :total-points="totalPoints"
:consecutive-sign-in-days="consecutiveSignInDays" :sign-in-rank="signInRank" :beat-percent="beatPercent"
@success="onSignInSuccess" />
<!-- ========== 兑换记录弹窗 ========== -->
@ -297,13 +298,23 @@ import {
const router = useRouter();
// (500 = )
const EXCHANGE_GOAL = 10;
const EXCHANGE_GOAL = 500;
const REWARD_NAME = "康小虎 AI 吉祥物";
// (,)
const signInStreakDays = 2;
const signInRank = 350;
const signInBeatPercent = "95.68";
// ( summary)
const signInStreakDays = computed(
() => Number(summary.value.consecutiveSignInDays) || 0,
);
const signInRank = computed(() => Number(summary.value.signInRank) || 0);
const signInBeatPercent = computed(() => {
const n = Number(summary.value.beatPercent) || 0;
// 0 ,
return n;
});
// ()
const consecutiveSignInDays = signInStreakDays;
const beatPercent = signInBeatPercent;
// 7
const COIN_TOTAL = 7;
@ -420,10 +431,10 @@ const onSignInSuccess = async () => {
const lotteryVisible = ref(false);
const assetsVisible = ref(false);
const handleDraw = () => {
// if (summary.value.isDraw) {
// ElMessage.info(",!");
// return;
// }
if (summary.value.isDraw) {
ElMessage.info("今日已抽奖,明天再来吧!");
return;
}
lotteryVisible.value = true;
};
const onLotterySuccess = async () => {

@ -136,6 +136,7 @@ const submit = async () => {
hospitalName: data?.hospitalName,
role: data?.role,
name: data?.name,
gender: data?.gender,
};
userStore.setToken(data?.token || "");

Loading…
Cancel
Save