first commit
58
front/src/App.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { NConfigProvider, darkTheme } from 'naive-ui';
|
||||
import type { WatermarkProps } from 'naive-ui';
|
||||
import { useAppStore } from './store/modules/app';
|
||||
import { useThemeStore } from './store/modules/theme';
|
||||
import { naiveDateLocales, naiveLocales } from './locales/naive';
|
||||
|
||||
defineOptions({
|
||||
name: 'App'
|
||||
});
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const naiveDarkTheme = computed(() => (themeStore.darkMode ? darkTheme : undefined));
|
||||
|
||||
const naiveLocale = computed(() => {
|
||||
return naiveLocales[appStore.locale];
|
||||
});
|
||||
|
||||
const naiveDateLocale = computed(() => {
|
||||
return naiveDateLocales[appStore.locale];
|
||||
});
|
||||
|
||||
const watermarkProps = computed<WatermarkProps>(() => {
|
||||
return {
|
||||
content: themeStore.watermark?.text || import.meta.env.VITE_APP_TITLE || '倾云脚手架V3',
|
||||
cross: true,
|
||||
fullscreen: true,
|
||||
fontSize: 16,
|
||||
lineHeight: 16,
|
||||
width: 384,
|
||||
height: 384,
|
||||
xOffset: 12,
|
||||
yOffset: 60,
|
||||
rotate: -15,
|
||||
zIndex: 9999
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NConfigProvider
|
||||
:theme="naiveDarkTheme"
|
||||
:theme-overrides="themeStore.naiveTheme"
|
||||
:locale="naiveLocale"
|
||||
:date-locale="naiveDateLocale"
|
||||
class="h-full"
|
||||
>
|
||||
<AppProvider>
|
||||
<RouterView class="bg-layout" />
|
||||
<NWatermark v-if="themeStore.watermark?.visible" v-bind="watermarkProps" />
|
||||
</AppProvider>
|
||||
</NConfigProvider>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
BIN
front/src/assets/imgs/default-avatar.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
front/src/assets/imgs/soybean.jpg
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
front/src/assets/imgs/test-img.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
1
front/src/assets/svg-icon/activity.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
||||
|
After Width: | Height: | Size: 202 B |
1
front/src/assets/svg-icon/alova.svg
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
1
front/src/assets/svg-icon/at-sign.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="prefix__prefix__feather prefix__prefix__feather-at-sign"><circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 006 0v-1a10 10 0 10-3.92 7.94"/></svg>
|
||||
|
After Width: | Height: | Size: 315 B |
1
front/src/assets/svg-icon/avatar.svg
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
1
front/src/assets/svg-icon/banner.svg
Normal file
|
After Width: | Height: | Size: 19 KiB |
1
front/src/assets/svg-icon/cast.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="prefix__prefix__feather prefix__prefix__feather-cast"><path d="M2 16.1A5 5 0 015.9 20M2 12.05A9 9 0 019.95 20M2 8V6a2 2 0 012-2h16a2 2 0 012 2v12a2 2 0 01-2 2h-6M2 20h.01"/></svg>
|
||||
|
After Width: | Height: | Size: 345 B |
1
front/src/assets/svg-icon/chrome.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="4"/><path d="M21.17 8H12M3.95 6.06L8.54 14m2.34 7.94L15.46 14"/></svg>
|
||||
|
After Width: | Height: | Size: 288 B |
1
front/src/assets/svg-icon/copy.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||
|
After Width: | Height: | Size: 283 B |
1
front/src/assets/svg-icon/custom-icon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24"><path fill="currentColor" d="M19 10c0 1.38-2.12 2.5-3.5 2.5s-2.75-1.12-2.75-2.5h-1.5c0 1.38-1.37 2.5-2.75 2.5S5 11.38 5 10h-.75c-.16.64-.25 1.31-.25 2a8 8 0 008 8 8 8 0 008-8c0-.69-.09-1.36-.25-2H19m-7-6C9.04 4 6.45 5.61 5.07 8h13.86C17.55 5.61 14.96 4 12 4m10 8a10 10 0 01-10 10A10 10 0 012 12 10 10 0 0112 2a10 10 0 0110 10m-10 5.23c-1.75 0-3.29-.73-4.19-1.81L9.23 14c.45.72 1.52 1.23 2.77 1.23s2.32-.51 2.77-1.23l1.42 1.42c-.9 1.08-2.44 1.81-4.19 1.81z"/></svg>
|
||||
|
After Width: | Height: | Size: 544 B |
1
front/src/assets/svg-icon/empty-data.svg
Normal file
|
After Width: | Height: | Size: 77 KiB |
1
front/src/assets/svg-icon/expectation.svg
Normal file
|
After Width: | Height: | Size: 70 KiB |
1
front/src/assets/svg-icon/heart.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
|
||||
|
After Width: | Height: | Size: 309 B |
19
front/src/assets/svg-icon/login/ddEnterpriseEnvConfig.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg t="1731295493100" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2593"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200">
|
||||
<path d="M504.3712 513.1264m-450.816 0a450.816 450.816 0 1 0 901.632 0 450.816 450.816 0 1 0-901.632 0Z"
|
||||
p-id="2594"></path>
|
||||
<path
|
||||
d="M665.6512 688.2816h89.1392c-55.4496 77.5168-108.8 152.064-162.1504 226.6112l-3.84-1.3824c11.6224-48.9472 23.296-97.8944 35.4304-148.9408H554.496c7.936-36.1472 15.36-70.0928 23.5008-107.2128-19.8144 5.9904-37.12 9.4208-52.9408 16.2816-34.1504 14.8992-64.8704 8.5504-92.6208-13.4656-18.3296-14.592-36.3008-30.4128-51.2-48.3328-15.5648-18.7392-10.7008-29.3888 13.0048-33.4336 50.2272-8.448 100.608-15.9232 151.0912-25.2928-10.5472 0-21.0944 0.1024-31.6928 0-43.4176-0.4096-86.8864-1.1264-130.304-1.28-26.6752-0.1024-45.4656-13.9264-61.5424-33.2288-20.7872-24.9344-34.5088-53.4016-42.24-84.7872-4.9152-19.8656 0.512-25.344 21.0432-20.5824 51.8656 12.0832 103.68 24.576 155.5456 36.7104 26.5216 6.1952 53.1456 11.9808 80.4352 15.4112-31.488-10.3936-63.2832-19.9168-94.3616-31.4368-47.36-17.5616-94.1056-36.6592-141.312-54.6816-14.336-5.4784-23.7568-15.0016-29.7984-28.7744-18.432-42.0352-32.2048-85.2992-32.5632-131.7376-0.1024-16.9472 5.5296-21.0432 20.0704-14.0288 148.1216 71.6288 300.9024 131.9424 454.3488 190.8224 14.7456 5.632 28.7232 13.9776 41.9328 22.7328 25.4976 16.9472 33.792 38.3488 20.5824 65.1776-27.0848 55.1424-56.8832 108.9024-85.8112 163.1232-7.0656 13.4656-15.104 26.368-24.0128 41.728z"
|
||||
fill="#2595E8" p-id="2595"></path>
|
||||
<path
|
||||
d="M708.096 393.6256c-151.808-58.3168-302.9504-118.0672-449.4336-188.928-14.5408-7.0144-20.1728-2.9184-20.0704 14.0288 0.3584 46.4384 14.1312 89.6512 32.5632 131.7376 6.0416 13.7728 15.4624 23.296 29.7984 28.7744 47.2064 18.0224 93.952 37.1712 141.312 54.6816 31.0784 11.52 62.8736 21.0432 94.3616 31.4368-27.2896-3.4304-53.9136-9.216-80.4352-15.4112-51.8656-12.1344-103.68-24.6272-155.5456-36.7104-20.5312-4.7616-25.9584 0.7168-21.0432 20.5824 7.7824 31.3856 21.4528 59.8528 42.24 84.7872 16.0768 19.3024 34.8672 33.1264 61.5424 33.2288 43.4176 0.1536 86.8864 0.8704 130.304 1.28 10.5472 0.1024 21.0944 0 31.6928 0-50.4832 9.3696-100.864 16.7936-151.0912 25.2928-23.7568 3.9936-28.5696 14.6944-13.0048 33.4336 14.8992 17.92 32.8704 33.792 51.2 48.3328 27.6992 22.016 58.4192 28.3648 92.6208 13.4656 15.8208-6.8608 33.1264-10.3424 52.9408-16.2816l-11.776 53.6576c85.4528-79.4112 139.52-192.0512 141.824-317.3888z"
|
||||
fill="#3A9CED" p-id="2596"></path>
|
||||
<path
|
||||
d="M300.9536 379.1872c47.2064 18.0224 93.952 37.1712 141.312 54.6816 25.856 9.5744 52.2752 17.8176 78.5408 26.3168a442.70592 442.70592 0 0 0 51.2512-119.6544C466.2272 298.496 361.3184 254.3104 258.6624 204.6976c-14.5408-7.0144-20.1728-2.9184-20.0704 14.0288 0.3584 46.4384 14.1312 89.6512 32.5632 131.7376 6.0416 13.7216 15.4624 23.2448 29.7984 28.7232zM456.192 449.9456c-51.8656-12.1344-103.68-24.6272-155.5456-36.7104-20.5312-4.7616-25.9584 0.7168-21.0432 20.5824 7.7824 31.3856 21.4528 59.8528 42.24 84.7872 16.0768 19.3024 34.8672 33.1264 61.5424 33.2288 20.6336 0.0512 41.3184 0.256 61.952 0.512a448.11264 448.11264 0 0 0 73.8304-89.5488c-21.248-3.4304-42.1376-7.9872-62.976-12.8512zM394.2912 578.4576c-20.7872 3.4816-27.0848 12.1344-17.8688 26.7776a439.0912 439.0912 0 0 0 43.776-31.0784c-8.6528 1.4336-17.3056 2.816-25.9072 4.3008z"
|
||||
fill="#59ADF8" p-id="2597"></path>
|
||||
<path
|
||||
d="M300.9536 379.1872c11.6736 4.4544 23.296 8.96 34.8672 13.5168a446.39232 446.39232 0 0 0 86.7328-113.664c-55.1424-23.6032-109.824-48.2304-163.8912-74.3936-14.5408-7.0144-20.1728-2.9184-20.0704 14.0288 0.3584 46.4384 14.1312 89.6512 32.5632 131.7376 6.0416 13.7728 15.4624 23.296 29.7984 28.7744zM300.6464 413.2352c-20.5312-4.7616-25.9584 0.7168-21.0432 20.5824 0.3072 1.2288 0.6656 2.3552 0.9728 3.584 10.1376-6.912 19.9168-14.2336 29.44-21.9648-3.1232-0.7168-6.2464-1.4848-9.3696-2.2016z"
|
||||
fill="#6BC2FC" p-id="2598"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
15
front/src/assets/svg-icon/login/pswLogin.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg width="114.000000" height="114.000000" viewBox="0 0 114 114" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<desc>
|
||||
Created with Pixso.
|
||||
</desc>
|
||||
<circle id="椭圆 3" cx="57.000000" cy="57.000000" r="57.000000" />
|
||||
<g clip-path="url(#clip23_1)">
|
||||
<path id="path" d="M76.95 48.1312L40.9688 48.1312L40.9688 39.3438C40.9688 30.5563 48.2125 23.3125 57 23.3125C65.7875 23.3125 73.0312 30.5563 73.0312 39.3438C73.0312 40.2938 73.8625 41.125 74.8125 41.125C75.7625 41.125 76.5938 40.2938 76.5938 39.3438C76.7125 28.5375 67.8063 19.75 57 19.75C46.1938 19.75 37.4062 28.5375 37.4062 39.3438L37.4062 48.1312L37.05 48.1312C33.0125 48.1312 29.6875 51.4562 29.6875 55.4938L29.6875 78.7687C29.6875 82.8063 33.0125 86.1312 37.05 86.1312L76.95 86.1312C80.9875 86.1312 84.3125 82.8063 84.3125 78.7687L84.3125 55.4938C84.3125 51.4562 80.9875 48.1312 76.95 48.1312ZM80.75 78.7687C80.75 80.9062 79.0875 82.5687 76.95 82.5687L37.05 82.5687C34.9125 82.5687 33.25 80.9062 33.25 78.7687L33.25 55.4938C33.25 53.3563 34.9125 51.6937 37.05 51.6937L76.95 51.6937C79.0875 51.6937 80.75 53.3563 80.75 55.4938L80.75 78.7687Z" fill-rule="nonzero" fill="#1890FF"/>
|
||||
<path id="path" d="M57 57.5125C53.4375 57.5125 50.4688 60.4813 50.4688 64.0438C50.4688 67.1312 52.6062 69.7438 55.4562 70.3375L55.4562 75.9188C55.4562 76.75 56.05 77.3438 56.8812 77.3438L57.1187 77.3438C57.95 77.3438 58.5437 76.75 58.5437 75.9188L58.5437 70.3375C61.3937 69.625 63.5312 67.0125 63.5312 64.0438C63.5312 60.3625 60.5625 57.5125 57 57.5125ZM57 67.4875C55.1 67.4875 53.5562 65.9437 53.5562 64.0438C53.5562 62.1437 55.1 60.6 57 60.6C58.9 60.6 60.4437 62.1437 60.4437 64.0438C60.4437 65.9437 58.9 67.4875 57 67.4875Z" fill-rule="nonzero" fill="#1890FF"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip23_1">
|
||||
<rect id="pswLogin.svg" width="76.000000" height="76.000000" transform="translate(19.000000 15.000000)" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
12
front/src/assets/svg-icon/login/wechatEnterprise.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="38" height="38" viewBox="0 0 38 38">
|
||||
<g fill-rule="evenodd">
|
||||
<circle cx="19" cy="19" r="19" />
|
||||
<g fill-rule="nonzero" transform="translate(8 10)">
|
||||
<path fill="#148EF8" d="M20.7538545,14.2649729 C20.3431613,14.3468481 19.9515646,14.5053477 19.599544,14.7321824 C19.2545578,15.0942259 18.819004,15.3573937 18.3380456,15.4943974 C18.4021716,15.0638219 18.6156352,14.6708143 18.9381107,14.3803909 C19.2081463,13.9682271 19.4026535,13.5112834 19.5125299,13.0309446 C19.5110239,12.3481419 20.0631426,11.7933048 20.7459445,11.7914525 C21.4287463,11.7896094 21.9838597,12.3414503 21.9860558,13.0242511 C21.9882388,13.7070519 21.4366759,14.2624415 20.7538762,14.2649729 L20.7538545,14.2649729 Z"/>
|
||||
<path fill="#3FC914" d="M17.1461889,10.7223018 C16.6459644,10.7223018 16.1949953,10.4209739 16.0035676,9.9588267 C15.81214,9.49667952 15.9179523,8.96472511 16.2716644,8.61101297 C16.6253766,8.25730084 17.157331,8.15148853 17.6194782,8.34291616 C18.0816253,8.53434378 18.3829533,8.98531296 18.3829533,9.48553746 C18.4635489,9.89687644 18.6201373,10.2895811 18.8446906,10.6435179 C19.2044171,10.9912862 19.4649805,11.4286266 19.5995657,11.910532 C19.1719985,11.8423038 18.7814361,11.6275935 18.4947231,11.3031488 L18.4901629,11.3031488 C18.0808335,11.0311805 17.6254224,10.8344989 17.1461889,10.7223018 Z"/>
|
||||
<path fill="#148EF8" d="M16.4535939,6.94421281 C16.0807383,3.82115092 12.8468187,1.37418024 8.93407166,1.37418024 C4.76568947,1.37418024 1.37420195,4.14820847 1.37420195,7.55984799 C1.44094703,9.47545169 2.45243574,11.2335466 4.07494028,12.2540717 C4.33816322,12.4480401 4.61356868,12.6249077 4.89945711,12.7835831 L4.56416938,14.1220413 C4.68508143,14.1788491 4.80234528,14.2393051 4.9260152,14.2915309 L6.6190228,13.445038 C6.86636265,13.509164 7.12655809,13.55038 7.38488599,13.594354 C7.5497937,13.6236699 7.71470141,13.6538979 7.8841911,13.6740499 C8.93996958,13.8071565 10.0112022,13.745678 11.0448208,13.4926602 C11.0768046,13.9507277 11.1603883,14.4037216 11.2939848,14.8430402 C10.5203835,15.0243884 9.72862064,15.1169009 8.93404995,15.1187839 C8.22122289,15.1147616 7.51075549,15.0364668 6.81415852,14.8851683 L3.74234528,16.4187839 C3.50350339,16.5409546 3.21508419,16.5107905 3.00668838,16.3418458 C2.79747308,16.1742415 2.70484103,15.9002622 2.76942454,15.6400869 L3.31908795,13.4267318 C1.30482716,12.1457823 0.0604221336,9.94614794 -2.48689958e-14,7.55984799 C-2.48689958e-14,3.38416938 3.99978284,6.75015599e-14 8.93404995,6.75015599e-14 C13.6190879,6.75015599e-14 17.4558306,3.05435396 17.8295983,6.93689468 C17.6144149,6.90377733 17.3975919,6.8823705 17.1800869,6.87276873 C16.9373073,6.88193268 16.6945277,6.90482085 16.4535722,6.94330076 L16.4535939,6.94421281 Z"/>
|
||||
<path fill="#FDD50B" d="M13.5998697,11.8207383 C14.0112269,11.7382845 14.4024104,11.5807166 14.754202,11.3535288 C15.0996588,10.9920128 15.5355241,10.7294769 16.0166124,10.5931379 C15.9510464,11.0222479 15.7386453,11.4153225 15.4156352,11.7053203 C15.1453855,12.1175679 14.9511618,12.5756352 14.8412378,13.0556786 C14.8422399,13.7383609 14.2899014,14.2927401 13.6072201,14.2942586 C12.9245388,14.2957709 12.3697445,13.7438493 12.3677102,13.0611693 C12.3656871,12.3784893 12.9171914,11.8232803 13.5998697,11.8207383 L13.5998697,11.8207383 Z"/>
|
||||
<path fill="#FF6B06" d="M15.8278827,14.7963084 C16.2364821,15.070228 16.6908795,15.2681216 17.1690988,15.3826276 C17.6693233,15.3826276 18.1202925,15.6839555 18.3117201,16.1461027 C18.5031477,16.6082499 18.3973354,17.1402043 18.0436233,17.4939164 C17.6899112,17.8476286 17.1579567,17.9534409 16.6958096,17.7620133 C16.2336624,17.5705856 15.9323344,17.1196165 15.9323344,16.619392 C15.8535096,16.2075491 15.698134,15.8141456 15.4742671,15.4595874 C15.1158928,15.1102289 14.8571987,14.6715959 14.7248643,14.1889251 C15.1528284,14.2593493 15.5428692,14.4767229 15.8278827,14.8036482 L15.8278827,14.7963084 L15.8278827,14.7963084 Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
9
front/src/assets/svg-icon/login/wechatOpen.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="38" height="38" viewBox="0 0 38 38">
|
||||
<g fill-rule="evenodd">
|
||||
<circle cx="19" cy="19" r="19"/>
|
||||
<path fill="#07c160" fill-rule="nonzero"
|
||||
d="M23.7896365,15.2859589 C23.8638824,15.2859589 23.9257541,15.2859589 24,15.2859589 C23.2946636,12.2773973 20.0154679,10 16.0680588,10 C11.6133024,10 8,12.9006849 8,16.4726027 C8,18.5821918 9.24980665,20.4520548 11.1925754,21.6267123 L10.3882444,24 L13.2714617,22.5376712 C14.1376643,22.8013699 15.0904872,22.9332192 16.0680588,22.9332192 C16.2784223,22.9332192 16.4887858,22.9212329 16.6867749,22.9092466 C16.5135344,22.3938356 16.4145398,21.8424657 16.4145398,21.2910959 C16.4145398,17.9828767 19.7184841,15.2859589 23.7896365,15.2859589 Z M18.8275329,13.1763699 C19.4091261,13.1763699 19.8793503,13.6438356 19.8793503,14.2071918 C19.8793503,14.7825343 19.4091261,15.2380137 18.8275329,15.2380137 C18.2459397,15.2380137 17.7757154,14.7825342 17.7757154,14.2071918 C17.7757154,13.6438356 18.2459397,13.1763699 18.8275329,13.1763699 Z M13.2590874,15.2380137 C12.6774942,15.2380137 12.2072699,14.7705479 12.2072699,14.2071918 C12.2072699,13.6318493 12.6774942,13.1763698 13.2590874,13.1763698 C13.8406806,13.1763698 14.3109049,13.6438356 14.3109049,14.2071918 C14.3109049,14.7825342 13.8406806,15.2380137 13.2590874,15.2380137 Z"/>
|
||||
<path fill="#07c160" fill-rule="nonzero"
|
||||
d="M30,21.5375254 C30,18.4827586 27.093692,16 23.5,16 C19.9183673,16 17,18.4827586 17,21.5375254 C17,24.5922921 19.906308,27.0750507 23.5,27.0750507 C24.2597403,27.0750507 24.9953618,26.9655173 25.6827458,26.7586207 L27.8654917,28 L27.2745826,26.0527383 C28.9267161,25.0425964 30,23.3995943 30,21.5375254 Z M21.3896104,20.6125761 C20.919295,20.6125761 20.5333952,20.2596349 20.5333952,19.8336714 C20.5333952,19.4077079 20.919295,19.0547668 21.3896104,19.0547668 C21.8599258,19.0547668 22.2458256,19.4077079 22.2458256,19.8336714 C22.2458256,20.2718053 21.8599258,20.6125761 21.3896104,20.6125761 Z M25.6465677,20.6125761 C25.1762523,20.6125761 24.7903525,20.2596349 24.7903525,19.8336714 C24.7903525,19.4077079 25.1762523,19.0547668 25.6465677,19.0547668 C26.1168831,19.0547668 26.5027829,19.4077079 26.5027829,19.8336714 C26.5027829,20.2718053 26.1168831,20.6125761 25.6465677,20.6125761 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
1
front/src/assets/svg-icon/logo-copy.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 160 160" xmlns="http://www.w3.org/2000/svg"><path d="M81.28 55.9c-.1-11.67-2.93-22.55-9.37-32.38-1-1.5-2.14-2.86-2.5-4.71a8.1 8.1 0 014-8.61 7.89 7.89 0 019.3 1.23 35.999 35.999 0 015.9 8.83 75.18 75.18 0 018.44 28.58 83.211 83.211 0 01-5.23 36.74 102.983 102.983 0 01-3 7.28 1.2 1.2 0 000 1.41c9.58 13.3 21.76 23 37.85 27.24a54.37 54.37 0 0019.68 1.57 7.72 7.72 0 018.36 6.9 7.903 7.903 0 01-6.7 9 64.744 64.744 0 01-23-1.33 77.68 77.68 0 01-36.93-19.88 93.628 93.628 0 01-11.91-13.71 2.18 2.18 0 00-2.3-1.06 72.744 72.744 0 00-27.38 7.55c-11.6 6-20.67 14.58-26.4 26.45a10.134 10.134 0 01-3.7 4.7 8 8 0 01-9.19-.7 7.86 7.86 0 01-2.36-9.28 60.324 60.324 0 018.72-14.52c12.2-15.43 28.21-24.59 47.32-28.57A85.085 85.085 0 0173.07 87c.524.015 1-.307 1.18-.8a76.06 76.06 0 006.53-22.3c.351-2.652.518-5.325.5-8z" fill="currentColor"/><path d="M136.26 108.34a44.742 44.742 0 01-11.13-2.87 46.108 46.108 0 01-19.66-13.76 8 8 0 015.72-13.22 7.93 7.93 0 016.54 2.93 33.27 33.27 0 0018.87 10.75c1.546.155 3.058.553 4.48 1.18a8.08 8.08 0 013.84 9.21c-.92 3.52-4.13 5.81-8.66 5.78zm-80.6-75.02a7.61 7.61 0 016.64 5 49.139 49.139 0 013.64 17 46.33 46.33 0 01-2.46 17.28c-2 5.77-8.24 7.79-12.89 4.15a8.1 8.1 0 01-2.39-9 31.679 31.679 0 001.68-12.36 35.77 35.77 0 00-2.43-11c-2.1-5.45 1.75-11.07 8.21-11.07zm22.26 93.25a8 8 0 01-6.68 7.86 32.88 32.88 0 00-19.7 12.19 8.13 8.13 0 01-11.21 1.62 8 8 0 01-1.41-11.58A51.043 51.043 0 0154 123.81a45.842 45.842 0 0114-5.1c5.35-1.04 9.91 2.56 9.92 7.86z" fill="currentColor"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
12
front/src/assets/svg-icon/logo.svg
Normal file
|
After Width: | Height: | Size: 25 KiB |
1
front/src/assets/svg-icon/network-error.svg
Normal file
|
After Width: | Height: | Size: 19 KiB |
1
front/src/assets/svg-icon/no-icon.svg
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
1
front/src/assets/svg-icon/no-permission.svg
Normal file
|
After Width: | Height: | Size: 50 KiB |
1
front/src/assets/svg-icon/not-found.svg
Normal file
|
After Width: | Height: | Size: 33 KiB |
1
front/src/assets/svg-icon/service-error.svg
Normal file
|
After Width: | Height: | Size: 74 KiB |
1
front/src/assets/svg-icon/wind.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="feather feather-wind"><path d="M9.59 4.59A2 2 0 1 1 11 8H2m10.59 11.41A2 2 0 1 0 14 16H2m15.73-8.27A2.5 2.5 0 1 1 19.5 12H2"></path></svg>
|
||||
|
After Width: | Height: | Size: 327 B |
142
front/src/components/advanced/operate-model.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue';
|
||||
import type { LabelPlacement } from 'naive-ui/es/form/src/interface';
|
||||
import { $t } from '@/locales';
|
||||
import { type ModelCallback, useNaiveForm } from '@/hooks/common/form';
|
||||
|
||||
defineOptions({
|
||||
name: 'OperateModel'
|
||||
});
|
||||
|
||||
interface Props {
|
||||
operateType?: NaiveUI.TableOperateType;
|
||||
rowData?: object | null;
|
||||
rules?: Record<string, App.Global.FormRule | App.Global.FormRule[]>;
|
||||
title?: string;
|
||||
modelTitle?: string;
|
||||
classStyle?: string;
|
||||
scrollbarClass?: string;
|
||||
labelPlacement?: LabelPlacement;
|
||||
labelWidth?: string;
|
||||
}
|
||||
|
||||
type Model = Record<string, any>;
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
operateType: 'add',
|
||||
rowData: null,
|
||||
classStyle: 'w-800px',
|
||||
rules: () => ({}),
|
||||
title: '',
|
||||
modelTitle: '',
|
||||
scrollbarClass: 'pr-20px',
|
||||
labelPlacement: 'left',
|
||||
labelWidth: '100px'
|
||||
});
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false });
|
||||
const model = defineModel<Model | undefined>('model', { required: true });
|
||||
const initModel: Model | undefined = JSON.parse(JSON.stringify(model.value));
|
||||
|
||||
const { formRef, validate } = useNaiveForm();
|
||||
|
||||
const title = computed(() => {
|
||||
if (props.modelTitle) return props.modelTitle;
|
||||
const titles: Record<NaiveUI.TableOperateType, string> = {
|
||||
add: '添加',
|
||||
edit: '修改'
|
||||
};
|
||||
return titles[props.operateType] + props.title;
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
// 提交前的参数处理
|
||||
(e: 'preSubmit', callback: (options: { data?: Model; flag?: boolean }) => void): void;
|
||||
// 提交表单
|
||||
(e: 'submit', callback: (params: ModelCallback<Model>) => void): void;
|
||||
// 打开弹窗时,传递数据和类型,便于子组件操作
|
||||
(e: 'open', type: NaiveUI.TableOperateType, model: Model): void;
|
||||
}>();
|
||||
|
||||
watch(visible, val => {
|
||||
if (val) {
|
||||
// 在打开时
|
||||
if (props.operateType === 'add') {
|
||||
model.value = JSON.parse(JSON.stringify(initModel));
|
||||
emit('open', 'add', model.value as Model);
|
||||
} else if (props.operateType === 'edit') {
|
||||
// 如果外面不传递rowData,就直接是Model了
|
||||
// 请在数据回来时,再打开弹窗
|
||||
if (props.rowData) model.value = props.rowData;
|
||||
emit('open', 'edit', model.value as Model);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function closeDrawer() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
// 父组件调用的传递
|
||||
// async function handleSubmit(callback) {
|
||||
// callback({ add: addAbility, edit: editAbility, getData });
|
||||
// }
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
|
||||
let next = true;
|
||||
emit('preSubmit', ({ data, flag = true }) => {
|
||||
if (!flag || !data) {
|
||||
next = false;
|
||||
return;
|
||||
}
|
||||
model.value = data;
|
||||
});
|
||||
if (!next) return;
|
||||
|
||||
try {
|
||||
emit('submit', async ({ add, edit, getData, transformData = null }) => {
|
||||
const isAdd = props.operateType === 'add';
|
||||
const data = (
|
||||
transformData !== null ? transformData(JSON.parse(JSON.stringify(model.value))) : model.value
|
||||
) as Model;
|
||||
const { error } = isAdd ? add && (await add(data)) : edit && (await edit(data));
|
||||
if (!error) {
|
||||
if (!props.modelTitle) window.$message?.success($t(isAdd ? 'common.addSuccess' : 'common.updateSuccess'));
|
||||
else window.$message?.success(`${props.modelTitle}成功`);
|
||||
closeDrawer();
|
||||
await getData?.();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
window.$message?.error(`操作失败${e}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal v-model:show="visible" :title="title" :mask-closable="false" preset="card" :class="classStyle">
|
||||
<NScrollbar :class="scrollbarClass">
|
||||
<NForm
|
||||
ref="formRef"
|
||||
:model="model"
|
||||
:rules="rules"
|
||||
:label-placement="labelPlacement"
|
||||
require-mark-placement="left"
|
||||
:label-width="labelWidth"
|
||||
>
|
||||
<NGrid responsive="screen" item-responsive>
|
||||
<slot name="formItem" />
|
||||
</NGrid>
|
||||
</NForm>
|
||||
</NScrollbar>
|
||||
<template #footer>
|
||||
<NSpace justify="end" :size="16">
|
||||
<NButton @click="closeDrawer">{{ $t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" @click="handleSubmit">{{ $t('common.confirm') }}</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
61
front/src/components/advanced/search-form.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({
|
||||
name: 'SearchForm',
|
||||
inheritAttrs: false
|
||||
});
|
||||
|
||||
interface Emits {
|
||||
(e: 'reset'): void;
|
||||
(e: 'resetInterlayer'): void;
|
||||
(e: 'search'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const model = defineModel<any>('model');
|
||||
|
||||
// 重置后,再触发搜索
|
||||
function reset() {
|
||||
emit('reset'); // 触发重置事件:这个用于最外层的hook的resetSearchParams
|
||||
emit('resetInterlayer'); // 触发中间层的清除,用于中间层定义的数据清除
|
||||
emit('search');
|
||||
}
|
||||
|
||||
function search() {
|
||||
emit('search');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard :bordered="false" size="small" class="card-wrapper" v-bind="$attrs">
|
||||
<NCollapse :default-expanded-names="['search']">
|
||||
<NCollapseItem :title="$t('common.search')" name="search">
|
||||
<NForm :model="model" label-placement="left" :label-width="80">
|
||||
<NGrid responsive="screen" item-responsive>
|
||||
<slot name="formItem"></slot>
|
||||
<NFormItemGi span="24 s:12 m:4">
|
||||
<NSpace class="w-full" justify="start">
|
||||
<NButton @click="reset">
|
||||
<template #icon>
|
||||
<icon-ic-round-refresh class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.reset') }}
|
||||
</NButton>
|
||||
<NButton type="primary" ghost @click="search">
|
||||
<template #icon>
|
||||
<icon-ic-round-search class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.search') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NFormItemGi>
|
||||
</NGrid>
|
||||
</NForm>
|
||||
</NCollapseItem>
|
||||
</NCollapse>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
35
front/src/components/advanced/table-column-setting.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts" generic="T extends Record<string, unknown>, K = never">
|
||||
import { VueDraggable } from 'vue-draggable-plus';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({
|
||||
name: 'TableColumnSetting'
|
||||
});
|
||||
|
||||
const columns = defineModel<NaiveUI.TableColumnCheck[]>('columns', {
|
||||
required: true
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NPopover placement="bottom-end" trigger="click">
|
||||
<template #trigger>
|
||||
<NButton size="small">
|
||||
<template #icon>
|
||||
<icon-ant-design-setting-outlined class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.columnSetting') }}
|
||||
</NButton>
|
||||
</template>
|
||||
<VueDraggable v-model="columns" :animation="150" filter=".none_draggable">
|
||||
<div v-for="item in columns" :key="item.key" class="h-36px flex-y-center rd-4px hover:(bg-primary bg-opacity-20)">
|
||||
<icon-mdi-drag class="mr-8px h-full cursor-move text-icon" />
|
||||
<NCheckbox v-model:checked="item.checked" class="none_draggable flex-1">
|
||||
{{ item.title }}
|
||||
</NCheckbox>
|
||||
</div>
|
||||
</VueDraggable>
|
||||
</NPopover>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
89
front/src/components/advanced/table-header-operation.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { $t } from '@/locales';
|
||||
import { useAuth } from '@/hooks/business/auth';
|
||||
|
||||
defineOptions({
|
||||
name: 'TableHeaderOperation'
|
||||
});
|
||||
|
||||
interface Props {
|
||||
itemAlign?: NaiveUI.Align;
|
||||
disabledDelete?: boolean;
|
||||
loading?: boolean;
|
||||
showAddBtn?: boolean;
|
||||
showDeleteBtn?: boolean;
|
||||
hasPerm?: [string | null | undefined, string | null | undefined]; // 传递空字符串、null、undefined,则不检查权限,默认显示, 第一个新增,第二个删除
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'add'): void;
|
||||
(e: 'delete'): void;
|
||||
(e: 'refresh'): void;
|
||||
}
|
||||
|
||||
const { showAddBtn = true, showDeleteBtn = true, hasPerm } = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const { hasAuth } = useAuth();
|
||||
|
||||
const columns = defineModel<NaiveUI.TableColumnCheck[]>('columns', {
|
||||
default: () => []
|
||||
});
|
||||
|
||||
function getHasPerm(index: number) {
|
||||
if (!hasPerm) return true;
|
||||
if (hasPerm[index]) {
|
||||
return hasAuth(hasPerm[index]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function add() {
|
||||
emit('add');
|
||||
}
|
||||
|
||||
function batchDelete() {
|
||||
emit('delete');
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
emit('refresh');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpace :align="itemAlign" wrap justify="end" class="lt-sm:w-200px">
|
||||
<slot name="prefix"></slot>
|
||||
<slot name="default">
|
||||
<NButton v-if="showAddBtn && getHasPerm(0)" size="small" ghost type="primary" @click="add">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.add') }}
|
||||
</NButton>
|
||||
<NPopconfirm v-if="showDeleteBtn && getHasPerm(1)" @positive-click="batchDelete">
|
||||
<template #trigger>
|
||||
<NButton size="small" ghost type="error" :disabled="disabledDelete">
|
||||
<template #icon>
|
||||
<icon-ic-round-delete class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.batchDelete') }}
|
||||
</NButton>
|
||||
</template>
|
||||
{{ $t('common.confirmDelete') }}
|
||||
</NPopconfirm>
|
||||
</slot>
|
||||
<slot name="middle"></slot>
|
||||
<NButton size="small" @click="refresh">
|
||||
<template #icon>
|
||||
<icon-mdi-refresh class="text-icon" :class="{ 'animate-spin': loading }" />
|
||||
</template>
|
||||
{{ $t('common.refresh') }}
|
||||
</NButton>
|
||||
<TableColumnSetting v-model:columns="columns" />
|
||||
<slot name="suffix"></slot>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
39
front/src/components/common/app-provider.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { createTextVNode, defineComponent } from 'vue';
|
||||
import { useDialog, useLoadingBar, useMessage, useNotification } from 'naive-ui';
|
||||
|
||||
defineOptions({
|
||||
name: 'AppProvider'
|
||||
});
|
||||
|
||||
const ContextHolder = defineComponent({
|
||||
name: 'ContextHolder',
|
||||
setup() {
|
||||
function register() {
|
||||
window.$loadingBar = useLoadingBar();
|
||||
window.$dialog = useDialog();
|
||||
window.$message = useMessage();
|
||||
window.$notification = useNotification();
|
||||
}
|
||||
|
||||
register();
|
||||
|
||||
return () => createTextVNode();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NLoadingBarProvider>
|
||||
<NDialogProvider>
|
||||
<NNotificationProvider>
|
||||
<NMessageProvider>
|
||||
<ContextHolder />
|
||||
<slot></slot>
|
||||
</NMessageProvider>
|
||||
</NNotificationProvider>
|
||||
</NDialogProvider>
|
||||
</NLoadingBarProvider>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
17
front/src/components/common/dark-mode-container.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'DarkModeContainer' });
|
||||
|
||||
interface Props {
|
||||
inverted?: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-container text-base-text transition-300" :class="{ 'bg-inverted text-#1f1f1f': inverted }">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
43
front/src/components/common/exception-base.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { $t } from '@/locales';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
|
||||
defineOptions({ name: 'ExceptionBase' });
|
||||
|
||||
type ExceptionType = '403' | '404' | '500';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Exception type
|
||||
*
|
||||
* - 403: no permission
|
||||
* - 404: not found
|
||||
* - 500: service error
|
||||
*/
|
||||
type: ExceptionType;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
|
||||
const iconMap: Record<ExceptionType, string> = {
|
||||
'403': 'no-permission',
|
||||
'404': 'not-found',
|
||||
'500': 'service-error'
|
||||
};
|
||||
|
||||
const icon = computed(() => iconMap[props.type]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="size-full min-h-520px flex-col-center gap-24px overflow-hidden">
|
||||
<div class="flex text-400px text-primary">
|
||||
<SvgIcon :local-icon="icon" />
|
||||
</div>
|
||||
<NButton type="primary" @click="routerPushByKey('root')">{{ $t('common.backToHome') }}</NButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
47
front/src/components/common/export-button.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { downloadExcel } from '@/utils/download';
|
||||
|
||||
defineOptions({ name: 'ExportButton' });
|
||||
|
||||
interface Props {
|
||||
/** 导出接口地址 */
|
||||
exportApi: string;
|
||||
/** 导出参数 */
|
||||
exportParams?: Record<string, any>;
|
||||
/** 按钮文本 */
|
||||
text?: string;
|
||||
/** 默认文件名 */
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
exportParams: () => ({}),
|
||||
text: '导出',
|
||||
filename: '导出数据.xlsx'
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
async function handleExport() {
|
||||
if (loading.value) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
await downloadExcel(props.exportApi, props.exportParams, props.filename);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NButton size="small" ghost type="success" :loading="loading" @click="handleExport">
|
||||
<template #icon>
|
||||
<SvgIcon icon="si:file-download-fill" />
|
||||
</template>
|
||||
{{ props.text }}
|
||||
</NButton>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
222
front/src/components/common/file-upload.vue
Normal file
@@ -0,0 +1,222 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import type { UploadFileInfo, UploadInst } from 'naive-ui';
|
||||
import { isExternal } from '@/utils/ruoyi';
|
||||
import { getServiceBaseURL } from '@/utils/service';
|
||||
import { getAuthorization } from '@/service/request/shared';
|
||||
|
||||
defineOptions({
|
||||
name: 'FileUpload'
|
||||
});
|
||||
|
||||
// 上传服务器地址
|
||||
// const baseUrl = import.meta.env.VITE_BASE_URL;
|
||||
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
|
||||
const { baseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
|
||||
const uploadUrl = `${import.meta.env.VITE_SERVICE_BASE_URL}/${import.meta.env.VITE_SERVICE_UPLOAD}`;
|
||||
const upload = ref<UploadInst | null>(null);
|
||||
|
||||
const headers = {
|
||||
Authorization: getAuthorization()
|
||||
};
|
||||
|
||||
interface Props {
|
||||
maxCount?: number; // 最大上传数量
|
||||
maxSize?: number; // 最大文件大小(MB)
|
||||
accept?: string[]; // 允许的文件类型
|
||||
multiple?: boolean; // 是否支持多选
|
||||
draggable?: boolean; // 是否支持拖拽上传
|
||||
defaultUpload?: boolean; // 是否默认上传
|
||||
url?: string; // 上传地址
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
maxCount: 5,
|
||||
maxSize: 15,
|
||||
accept: () => ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'txt', 'pdf', 'png', 'jpg', 'jpeg'],
|
||||
multiple: true,
|
||||
draggable: false,
|
||||
defaultUpload: true,
|
||||
url: ''
|
||||
});
|
||||
|
||||
const modelValue = defineModel<string>('modelValue');
|
||||
|
||||
const isMultiple = computed(() => {
|
||||
if (props.maxCount === 1) return false;
|
||||
return props.multiple;
|
||||
});
|
||||
|
||||
// 文件列表
|
||||
const fileList = ref<UploadFileInfo[]>([]);
|
||||
|
||||
const action = computed(() => {
|
||||
if (props.url) {
|
||||
return `${import.meta.env.VITE_SERVICE_BASE_URL}/${props.url}`;
|
||||
}
|
||||
return uploadUrl;
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'success', value: { url: string; name: string }): void;
|
||||
(e: 'remove'): void;
|
||||
}>();
|
||||
|
||||
watch(
|
||||
() => modelValue.value,
|
||||
val => {
|
||||
if (val) {
|
||||
// 将值转换为数组
|
||||
let list: string[] = [];
|
||||
|
||||
// 确保 list 是数组,如果是字符串,则 split 成数组
|
||||
if (Array.isArray(val)) {
|
||||
list = val;
|
||||
} else if (typeof val === 'string') {
|
||||
list = val.split(',');
|
||||
}
|
||||
|
||||
// 转换为符合要求的对象数组
|
||||
fileList.value = list.map((item, index) => {
|
||||
let name = '';
|
||||
let url = '';
|
||||
|
||||
if (typeof item === 'string') {
|
||||
// 检查是否为外部链接或需要补全 baseURL
|
||||
if (!item.includes(baseURL) && !isExternal(item)) {
|
||||
url = `${baseURL}${item}`;
|
||||
name = item.split('/').pop() || '未知文件';
|
||||
} else {
|
||||
url = item;
|
||||
name = item.split('/').pop() || '未知文件';
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: String(index + 1),
|
||||
name,
|
||||
status: 'finished',
|
||||
url
|
||||
};
|
||||
});
|
||||
} else {
|
||||
fileList.value = [];
|
||||
}
|
||||
modelValue.value = listToString(fileList.value);
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
|
||||
// 上传前校验
|
||||
const beforeUpload = (data: { file: UploadFileInfo }) => {
|
||||
// 检查文件大小
|
||||
if (data.file.file) {
|
||||
const sizeInMB = data.file.file.size / 1024 / 1024;
|
||||
if (sizeInMB > props.maxSize) {
|
||||
window.$message?.error(`文件大小不能超过${props.maxSize}MB`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// 检查文件类型
|
||||
if (data.file.file) {
|
||||
const fileName = data.file.file.name;
|
||||
const ext = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase();
|
||||
if (!props.accept.includes(ext)) {
|
||||
window.$message?.error(`只支持${props.accept.join('、')}格式`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 上传完成
|
||||
const handleSuccess = ({ event }: { event: Event }) => {
|
||||
const target = event.target as XMLHttpRequest;
|
||||
const res = JSON.parse(target.response);
|
||||
if (res.code === 200) {
|
||||
fileList.value.push({
|
||||
id: String(fileList.value.length + 1),
|
||||
name: res.data.name,
|
||||
status: 'finished',
|
||||
url: res.data.url
|
||||
});
|
||||
modelValue.value = listToString(fileList.value);
|
||||
emit('success', {
|
||||
url: res.data.url,
|
||||
name: res.data.name
|
||||
});
|
||||
window.$message?.success('上传成功');
|
||||
} else {
|
||||
window.$message?.error(res.msg || '上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 删除文件
|
||||
const handleRemove = ({ index }: { index: number }) => {
|
||||
fileList.value.splice(index, 1);
|
||||
modelValue.value = listToString(fileList.value);
|
||||
emit('remove');
|
||||
};
|
||||
|
||||
// 对象转成指定字符串分隔
|
||||
function listToString(list: UploadFileInfo[], separator = ',') {
|
||||
return list
|
||||
.map(item => item.url)
|
||||
.filter(url => url && !url.startsWith('blob:'))
|
||||
.join(separator);
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
upload.value?.submit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<NUpload
|
||||
ref="upload"
|
||||
:action="action"
|
||||
:headers="headers"
|
||||
:max="maxCount"
|
||||
:default-file-list="fileList"
|
||||
list-type="image"
|
||||
:accept="accept.map(item => `.${item}`).join(',')"
|
||||
:multiple="isMultiple"
|
||||
:default-upload="defaultUpload"
|
||||
@before-upload="beforeUpload"
|
||||
@finish="handleSuccess"
|
||||
@remove="handleRemove"
|
||||
>
|
||||
<NUploadDragger v-if="draggable">
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="mb-16px text-24px">📤</div>
|
||||
<div class="mb-8px text-14px">
|
||||
拖拽文件至此,或者
|
||||
<span class="cursor-pointer text-primary">选择文件</span>
|
||||
</div>
|
||||
<div class="label-color text-12px">已支持{{ accept.join('、') }}文件,不超过 {{ maxSize }}MB。</div>
|
||||
</div>
|
||||
</div>
|
||||
</NUploadDragger>
|
||||
<NButtonGroup>
|
||||
<NUploadTrigger v-if="!draggable" #="{ handleClick }" abstract>
|
||||
<NButton type="primary">{{ !defaultUpload ? '上传文件' : '选择文件' }}</NButton>
|
||||
</NUploadTrigger>
|
||||
<NButton v-if="!defaultUpload" type="success" @click.stop="handleSubmit" :class="{ 'mt-15px': draggable }">
|
||||
上传服务器
|
||||
</NButton>
|
||||
</NButtonGroup>
|
||||
</NUpload>
|
||||
<template v-if="!draggable">
|
||||
<NP>
|
||||
请上传不超过
|
||||
<span class="mx-1px text-error">{{ maxSize }}</span>
|
||||
MB,格式为
|
||||
<span class="mx-1px text-error">{{ accept.join('、') }}</span>
|
||||
的文件!
|
||||
</NP>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
22
front/src/components/common/full-screen.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({
|
||||
name: 'FullScreen'
|
||||
});
|
||||
|
||||
interface Props {
|
||||
full?: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ButtonIcon :key="String(full)" :tooltip-content="full ? $t('icon.fullscreenExit') : $t('icon.fullscreen')">
|
||||
<icon-gridicons-fullscreen-exit v-if="full" />
|
||||
<icon-gridicons-fullscreen v-else />
|
||||
</ButtonIcon>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
176
front/src/components/common/image-upload.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import type { UploadFileInfo } from 'naive-ui';
|
||||
import { isExternal } from '@/utils/ruoyi';
|
||||
import { getAuthorization } from '@/service/request/shared';
|
||||
import { getServiceBaseURL } from '@/utils/service';
|
||||
|
||||
defineOptions({
|
||||
name: 'ImageUpload'
|
||||
});
|
||||
|
||||
// 上传服务器地址
|
||||
// const baseUrl = import.meta.env.VITE_BASE_URL;
|
||||
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
|
||||
const { baseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
|
||||
const uploadUrl = `${import.meta.env.VITE_SERVICE_BASE_URL}/${import.meta.env.VITE_SERVICE_UPLOAD}`;
|
||||
|
||||
const headers = {
|
||||
Authorization: getAuthorization()
|
||||
};
|
||||
|
||||
interface Props {
|
||||
maxCount?: number; // 最大上传数量
|
||||
maxSize?: number; // 最大文件大小(MB)
|
||||
accept?: string[]; // 允许的文件类型
|
||||
multiple?: boolean; // 是否支持多选
|
||||
defaultUpload?: boolean; // 是否默认上传
|
||||
url?: string; // 上传地址
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
maxCount: 1,
|
||||
maxSize: 15,
|
||||
accept: () => ['.jpg', '.jpeg', '.png', '.gif'],
|
||||
multiple: true,
|
||||
defaultUpload: true,
|
||||
url: ''
|
||||
});
|
||||
|
||||
const action = computed(() => {
|
||||
if (props.url) {
|
||||
return `${import.meta.env.VITE_SERVICE_BASE_URL}/${props.url}`;
|
||||
}
|
||||
return uploadUrl;
|
||||
});
|
||||
|
||||
// 拿到的是File对象
|
||||
const fileListObj = defineModel<File[]>('value', { default: () => [] });
|
||||
const modelValue = defineModel<string>('modelValue');
|
||||
|
||||
const isMultiple = computed(() => {
|
||||
if (props.maxCount === 1) return false;
|
||||
return props.multiple;
|
||||
});
|
||||
|
||||
// 文件列表
|
||||
const fileList = ref<UploadFileInfo[]>([]);
|
||||
|
||||
watch(
|
||||
() => modelValue.value,
|
||||
val => {
|
||||
if (val) {
|
||||
// 将值转换为数组
|
||||
let list: string[] = [];
|
||||
|
||||
// 确保 list 是数组,如果是字符串,则 split 成数组
|
||||
if (Array.isArray(val)) {
|
||||
list = val;
|
||||
} else if (typeof val === 'string') {
|
||||
list = val.split(',');
|
||||
}
|
||||
|
||||
// 转换为符合要求的对象数组
|
||||
fileList.value = list.map((item, index) => {
|
||||
let name = '';
|
||||
let url = '';
|
||||
|
||||
if (typeof item === 'string') {
|
||||
// 检查是否为外部链接或需要补全 baseURL
|
||||
if (!item.includes(baseURL) && !isExternal(item)) {
|
||||
url = `${baseURL}${item}`;
|
||||
name = item.split('/').pop() || '未知文件';
|
||||
} else {
|
||||
url = item;
|
||||
name = item.split('/').pop() || '未知文件';
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: String(index + 1),
|
||||
name,
|
||||
status: 'finished',
|
||||
url
|
||||
};
|
||||
});
|
||||
} else {
|
||||
fileList.value = [];
|
||||
}
|
||||
modelValue.value = listToString(fileList.value);
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
|
||||
// 上传前校验
|
||||
const beforeUpload = (data: { file: UploadFileInfo }) => {
|
||||
// 检查文件大小
|
||||
if (data.file.file) {
|
||||
const sizeInMB = data.file.file.size / 1024 / 1024;
|
||||
if (sizeInMB > props.maxSize) {
|
||||
window.$message?.error(`文件大小不能超过${props.maxSize}MB`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// 检查文件类型
|
||||
if (data.file.file) {
|
||||
const fileName = data.file.file.name;
|
||||
const ext = fileName.substring(fileName.lastIndexOf('.')).toLowerCase();
|
||||
if (!props.accept.includes(ext)) {
|
||||
window.$message?.error(`只支持${props.accept.join('、')}格式`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 上传完成
|
||||
const handleSuccess = ({ event }: { event: Event }) => {
|
||||
const target = event.target as XMLHttpRequest;
|
||||
const res = JSON.parse(target.response);
|
||||
fileList.value.push({
|
||||
id: String(fileList.value.length + 1),
|
||||
name: res.data.name,
|
||||
status: 'finished',
|
||||
url: res.data.url
|
||||
});
|
||||
modelValue.value = listToString(fileList.value);
|
||||
};
|
||||
|
||||
// 删除文件
|
||||
const handleRemove = ({ index }: { index: number }) => {
|
||||
fileList.value.splice(index, 1);
|
||||
modelValue.value = listToString(fileList.value);
|
||||
};
|
||||
|
||||
function handleChange(options: { fileList: UploadFileInfo[] }) {
|
||||
fileListObj.value = options.fileList.map(item => item.file) as File[];
|
||||
}
|
||||
|
||||
// 对象转成指定字符串分隔
|
||||
function listToString(list: UploadFileInfo[], separator = ',') {
|
||||
return list
|
||||
.map(item => item.url)
|
||||
.filter(url => url && !url.startsWith('blob:'))
|
||||
.join(separator);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NUpload
|
||||
:action="action"
|
||||
:headers="headers"
|
||||
:max="maxCount"
|
||||
:default-file-list="fileList"
|
||||
list-type="image-card"
|
||||
:accept="accept.map(item => `.${item}`).join(',')"
|
||||
:multiple="isMultiple"
|
||||
:default-upload="defaultUpload"
|
||||
@before-upload="beforeUpload"
|
||||
@finish="handleSuccess"
|
||||
@remove="handleRemove"
|
||||
@change="handleChange"
|
||||
>
|
||||
点击上传
|
||||
</NUpload>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
49
front/src/components/common/lang-switch.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({
|
||||
name: 'LangSwitch'
|
||||
});
|
||||
|
||||
interface Props {
|
||||
/** Current language */
|
||||
lang: App.I18n.LangType;
|
||||
/** Language options */
|
||||
langOptions: App.I18n.LangOption[];
|
||||
/** Show tooltip */
|
||||
showTooltip?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showTooltip: true
|
||||
});
|
||||
|
||||
type Emits = {
|
||||
(e: 'changeLang', lang: App.I18n.LangType): void;
|
||||
};
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const tooltipContent = computed(() => {
|
||||
if (!props.showTooltip) return '';
|
||||
|
||||
return $t('icon.lang');
|
||||
});
|
||||
|
||||
function changeLang(lang: App.I18n.LangType) {
|
||||
emit('changeLang', lang);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDropdown :value="lang" :options="langOptions" trigger="hover" @select="changeLang">
|
||||
<div>
|
||||
<ButtonIcon :tooltip-content="tooltipContent" tooltip-placement="left">
|
||||
<SvgIcon icon="heroicons:language" />
|
||||
</ButtonIcon>
|
||||
</div>
|
||||
</NDropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
238
front/src/components/common/map-gaode-select.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<!--可以标点和回显-->
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watchEffect } from 'vue';
|
||||
import { useScriptTag } from '@vueuse/core';
|
||||
import { AMAP_SDK_URL } from '@/constants/map-sdk';
|
||||
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
window._AMapSecurityConfig = {
|
||||
securityJsCode: '3625f15d14e8a930436da886419363ff'
|
||||
};
|
||||
|
||||
defineOptions({
|
||||
name: 'MapGaodeSelect'
|
||||
});
|
||||
|
||||
// 可得到地址
|
||||
const address = defineModel<string>('address', { default: '' });
|
||||
const logLat = defineModel<[number, number] | [string, string] | string | null>('value', { default: null });
|
||||
let originLogLat = logLat.value; // 初始化地址的经纬度
|
||||
|
||||
const { load } = useScriptTag(AMAP_SDK_URL);
|
||||
const domRef = ref<HTMLDivElement | null>(null);
|
||||
const openDomRef = ref<HTMLDivElement | null>(null);
|
||||
const searchValue = ref<string>('');
|
||||
const searchOptions = ref<any[]>([]);
|
||||
const showModal = ref(false);
|
||||
|
||||
// eslint-disable-next-line vue/return-in-computed-property
|
||||
const loglat = computed<[number, number]>(() => {
|
||||
// 转换成[number, number]格式
|
||||
if (Array.isArray(logLat.value)) {
|
||||
if (Object.prototype.toString.call(logLat.value[0]) === '[object String]')
|
||||
return logLat.value.map(item => Number(item));
|
||||
return logLat.value;
|
||||
} else if (Object.prototype.toString.call(logLat.value) === '[object String]') {
|
||||
return logLat.value!.split(',').map(item => Number(item));
|
||||
}
|
||||
});
|
||||
|
||||
const map = ref<AMap.Map | null>(null);
|
||||
const marker = ref<AMap.Marker | null>(null);
|
||||
const circle = ref<AMap.Circle | null>(null);
|
||||
const geocoder = ref<any>(null);
|
||||
const autoComplete = ref<any>(null);
|
||||
const placeSearch = ref<any>(null);
|
||||
|
||||
// 初始化渲染的
|
||||
async function renderMap(mapClick: boolean = false) {
|
||||
await load(true);
|
||||
const dom = mapClick ? openDomRef.value : domRef.value;
|
||||
if (dom) {
|
||||
map.value = new AMap.Map(dom, {
|
||||
zoom: 19,
|
||||
center: loglat.value || [113.3926, 22.51595],
|
||||
viewMode: '3D'
|
||||
});
|
||||
console.log('初始化地图成功', map.value);
|
||||
// 如果初始化点存在就绘制点
|
||||
if (logLat.value) {
|
||||
marker.value = new AMap.Marker({
|
||||
position: new AMap.LngLat(...(loglat.value as [number, number])),
|
||||
map: map.value
|
||||
});
|
||||
}
|
||||
|
||||
loadPlugin();
|
||||
|
||||
if (mapClick) {
|
||||
// 地图点击事件
|
||||
map.value.on('click', clickMapHandler);
|
||||
}
|
||||
|
||||
map.value.getCenter();
|
||||
}
|
||||
}
|
||||
|
||||
// 需要加载的插件有经纬度和电子转换,POI 搜索插件,输入提示插件
|
||||
function loadPlugin() {
|
||||
AMap.plugin(['AMap.Geocoder', 'AMap.PlaceSearch', 'AMap.AutoComplete'], () => {
|
||||
// 开发依赖有一个map的ts检测包,但是不包括插件相关的
|
||||
geocoder.value = new AMap.Geocoder();
|
||||
|
||||
if (showModal.value) {
|
||||
// 输入提示
|
||||
autoComplete.value = new AMap.AutoComplete();
|
||||
placeSearch.value = new AMap.PlaceSearch({
|
||||
map: map.value
|
||||
});
|
||||
// 构造地点查询类
|
||||
autoComplete.value.on('select', select);
|
||||
|
||||
// poi覆盖物点击事件
|
||||
placeSearch.value.on('markerClick', clickMarkerHandler);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function select(e: any) {
|
||||
console.log(e);
|
||||
placeSearch.value.setCity(e.poi.adcode);
|
||||
// 关键字查询查询
|
||||
placeSearch.value.search(e.poi.name);
|
||||
}
|
||||
|
||||
function openMap() {
|
||||
showModal.value = true;
|
||||
handleDestroy();
|
||||
renderMap(true);
|
||||
}
|
||||
|
||||
function closeMap() {
|
||||
showModal.value = false;
|
||||
logLat.value = originLogLat;
|
||||
handleDestroy();
|
||||
renderMap();
|
||||
}
|
||||
|
||||
// 地图点击事件
|
||||
function clickMapHandler(e: any) {
|
||||
logLat.value = [e.lnglat.getLng(), e.lnglat.getLat()];
|
||||
|
||||
if (marker.value) {
|
||||
marker.value.setMap(null);
|
||||
}
|
||||
marker.value = new AMap.Marker({
|
||||
position: new AMap.LngLat(e.lnglat.getLng(), e.lnglat.getLat()),
|
||||
map: map.value!
|
||||
});
|
||||
}
|
||||
|
||||
// poi点击事件
|
||||
function clickMarkerHandler(e: any) {
|
||||
logLat.value = [e.event.lnglat.getLng(), e.event.lnglat.getLat()];
|
||||
}
|
||||
|
||||
function handleSelect() {
|
||||
showModal.value = false;
|
||||
originLogLat = loglat.value;
|
||||
handleDestroy();
|
||||
renderMap();
|
||||
}
|
||||
|
||||
function handleDestroy() {
|
||||
map.value?.destroy();
|
||||
map.value = null;
|
||||
}
|
||||
|
||||
function handleSearch(val: string) {
|
||||
if (val.trim() === '') return;
|
||||
autoComplete.value.search(val, (status: string, result: any) => {
|
||||
if (status === 'complete' && result.tips) {
|
||||
searchOptions.value = result.tips;
|
||||
} else {
|
||||
searchOptions.value = [];
|
||||
}
|
||||
console.log('搜索结果:', status, result);
|
||||
});
|
||||
}
|
||||
|
||||
function handleSelectValue(value: string, option: any) {
|
||||
console.log(value, option);
|
||||
placeSearch.value.setCity(option.adcode);
|
||||
// 关键字查询查询
|
||||
placeSearch.value.search(option.name);
|
||||
|
||||
logLat.value = [option.location.getLng(), option.location.getLat()];
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (logLat.value && geocoder.value) {
|
||||
geocoder.value.getAddress(loglat.value, (status: string, result: any) => {
|
||||
if (status === 'complete' && result.regeocode) {
|
||||
address.value = result.regeocode.formattedAddress;
|
||||
console.log('地址解析成功:', result.regeocode.formattedAddress);
|
||||
} else {
|
||||
address.value = '';
|
||||
console.error('地址解析失败:', status, result); // 增加详细错误日志
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// 加载初始化的地图组件
|
||||
renderMap();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
handleDestroy();
|
||||
marker.value = null;
|
||||
circle.value = null;
|
||||
geocoder.value = null;
|
||||
autoComplete.value = null;
|
||||
placeSearch.value = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1">
|
||||
<NButton type="primary" @click="openMap">打开地图</NButton>
|
||||
|
||||
<!--默认展示的地图-->
|
||||
<div ref="domRef" class="mt-10px h-300px w-full"></div>
|
||||
|
||||
<NModal
|
||||
v-model:show="showModal"
|
||||
:mask-closable="false"
|
||||
title="选择位置"
|
||||
preset="card"
|
||||
class="w-700px"
|
||||
@after-leave="closeMap"
|
||||
>
|
||||
<NFormItem label="关键词搜索">
|
||||
<!--<NInput v-model:value="searchValue"></NInput>-->
|
||||
<NSelect
|
||||
v-model:value="searchValue"
|
||||
:options="searchOptions"
|
||||
filterable
|
||||
remote
|
||||
label-field="name"
|
||||
value-field="id"
|
||||
@search="handleSearch"
|
||||
@update:value="handleSelectValue"
|
||||
></NSelect>
|
||||
</NFormItem>
|
||||
<div ref="openDomRef" class="h-400px w-full"></div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex-y-center justify-end gap-x-10px">
|
||||
<NButton type="default" @click="closeMap">取消</NButton>
|
||||
<NButton type="primary" @click="handleSelect">确定</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
100
front/src/components/common/map-gaode.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watchEffect } from 'vue';
|
||||
import { useScriptTag } from '@vueuse/core';
|
||||
import { AMAP_SDK_URL } from '@/constants/map-sdk';
|
||||
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
window._AMapSecurityConfig = {
|
||||
securityJsCode: '3625f15d14e8a930436da886419363ff'
|
||||
};
|
||||
|
||||
defineOptions({
|
||||
name: 'MapGaode'
|
||||
});
|
||||
|
||||
const { value } = defineProps<{
|
||||
value: [number, number] | [string, string] | string | null;
|
||||
}>();
|
||||
|
||||
// 可得到地址信息
|
||||
const address = defineModel<string>('address', { default: '' });
|
||||
|
||||
const { load } = useScriptTag(AMAP_SDK_URL);
|
||||
|
||||
const domRef = ref<HTMLDivElement>();
|
||||
const map = ref<AMap.Map | null>(null);
|
||||
const marker = ref<AMap.Marker | null>(null);
|
||||
const circle = ref<AMap.Circle | null>(null);
|
||||
const geocoder = ref<any>(null);
|
||||
|
||||
// eslint-disable-next-line vue/return-in-computed-property
|
||||
const loglat = computed<[number, number]>(() => {
|
||||
// 转换成[number, number]格式
|
||||
if (Array.isArray(value)) {
|
||||
if (Object.prototype.toString.call(value[0]) === '[object String]') return value.map(item => Number(item));
|
||||
return value;
|
||||
} else if (Object.prototype.toString.call(value) === '[object String]') {
|
||||
return value!.split(',').map(item => Number(item));
|
||||
}
|
||||
});
|
||||
|
||||
async function renderMap() {
|
||||
await load(true);
|
||||
if (!domRef.value || !loglat.value) return;
|
||||
map.value = new AMap.Map(domRef.value, {
|
||||
zoom: 20,
|
||||
center: loglat.value,
|
||||
viewMode: '3D'
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-new
|
||||
marker.value = new AMap.Marker({
|
||||
position: new AMap.LngLat(...loglat.value)
|
||||
});
|
||||
map.value.add(marker.value);
|
||||
|
||||
loadPlugin();
|
||||
|
||||
map.value.getCenter();
|
||||
}
|
||||
|
||||
// 需要加载的插件有经纬度和电子转换,POI 搜索插件,输入提示插件
|
||||
function loadPlugin() {
|
||||
AMap.plugin(['AMap.Geocoder'], () => {
|
||||
// 开发依赖有一个map的ts检测包,但是不包括插件相关的
|
||||
geocoder.value = new AMap.Geocoder();
|
||||
});
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (value && geocoder.value) {
|
||||
geocoder.value.getAddress(loglat.value, (status: string, result: any) => {
|
||||
if (status === 'complete' && result.regeocode) {
|
||||
address.value = result.regeocode.formattedAddress;
|
||||
console.log('地址解析成功:', result.regeocode.formattedAddress);
|
||||
} else {
|
||||
address.value = '';
|
||||
console.error('地址解析失败:', status, result); // 增加详细错误日志
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
renderMap();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
map.value?.destroy();
|
||||
map.value = null;
|
||||
marker.value = null;
|
||||
circle.value = null;
|
||||
geocoder.value = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="domRef" class="h-full w-full"></div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
116
front/src/components/common/markdown.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import Vditor from 'vditor';
|
||||
import 'vditor/dist/index.css';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { localStg } from '@/utils/storage';
|
||||
import { getServiceBaseURL } from '@/utils/service';
|
||||
import { isExternal } from '@/utils/ruoyi';
|
||||
|
||||
defineOptions({
|
||||
// eslint-disable-next-line vue/multi-word-component-names
|
||||
name: 'Markdown'
|
||||
});
|
||||
|
||||
const { placeholder, height } = defineProps<{
|
||||
placeholder?: string;
|
||||
height?: number;
|
||||
}>();
|
||||
|
||||
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
|
||||
const { baseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
|
||||
|
||||
const value = defineModel<string>('value', { default: '' });
|
||||
|
||||
const theme = useThemeStore();
|
||||
|
||||
const vditor = ref<Vditor>();
|
||||
const domRef = ref<HTMLElement>();
|
||||
|
||||
function renderVditor() {
|
||||
if (!domRef.value) return;
|
||||
vditor.value = new Vditor(domRef.value, {
|
||||
height: height || 300,
|
||||
mode: 'wysiwyg',
|
||||
width: '100%',
|
||||
theme: theme.darkMode ? 'dark' : 'classic',
|
||||
placeholder: placeholder || '请输入内容...',
|
||||
cache: { enable: false },
|
||||
value: value.value, // 初始值
|
||||
blur: val => {
|
||||
value.value = val;
|
||||
},
|
||||
upload: {
|
||||
// 文件上传的接口地址
|
||||
url: `${import.meta.env.VITE_SERVICE_BASE_URL}/${import.meta.env.VITE_SERVICE_UPLOAD}`,
|
||||
// 图片链接转图片的接口地址
|
||||
linkToImgUrl: `${import.meta.env.VITE_SERVICE_BASE_URL}/${import.meta.env.VITE_SERVICE_UPLOAD}`,
|
||||
// 请求头,携带认证信息
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStg.get('token')}` as string
|
||||
},
|
||||
// 是否允许多文件上传
|
||||
multiple: false,
|
||||
// 文件上传后的格式处理函数
|
||||
format: (files: File[], responseText: string): string => {
|
||||
// 解析服务器返回的响应
|
||||
const res = JSON.parse(responseText);
|
||||
// 如果上传成功,返回格式化的数据
|
||||
if (res.code === 200) {
|
||||
let url = res.data.url;
|
||||
if (!url.includes(baseURL) && !isExternal(url)) {
|
||||
url = `${baseURL}${url}`;
|
||||
}
|
||||
return JSON.stringify({
|
||||
msg: res.msg,
|
||||
data: {
|
||||
succMap: {
|
||||
[url]: url // 成功上传的文件 URL
|
||||
},
|
||||
errFiles: [] // 失败的文件列表
|
||||
}
|
||||
});
|
||||
}
|
||||
// 如果上传失败,返回错误信息
|
||||
return JSON.stringify({
|
||||
msg: res.msg,
|
||||
data: {
|
||||
succMap: {},
|
||||
errFiles: files.map(file => file.name) // 所有文件标记为失败
|
||||
}
|
||||
});
|
||||
},
|
||||
// 文件上传的字段名
|
||||
fieldName: 'file',
|
||||
// 文件大小限制(单位:字节)
|
||||
max: 10 * 1024 * 1024 // 10MB
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const stopHandle = watch(
|
||||
() => theme.darkMode,
|
||||
newValue => {
|
||||
const themeMode = newValue ? 'dark' : 'classic';
|
||||
vditor.value?.setTheme(themeMode);
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
renderVditor();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopHandle();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
vditor
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="domRef"></div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
53
front/src/components/common/menu-toggler.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'MenuToggler' });
|
||||
|
||||
interface Props {
|
||||
/** Show collapsed icon */
|
||||
collapsed?: boolean;
|
||||
/** Arrow style icon */
|
||||
arrowIcon?: boolean;
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
arrowIcon: false,
|
||||
zIndex: 98
|
||||
});
|
||||
|
||||
type NumberBool = 0 | 1;
|
||||
|
||||
const icon = computed(() => {
|
||||
const icons: Record<NumberBool, Record<NumberBool, string>> = {
|
||||
0: {
|
||||
0: 'line-md:menu-fold-left',
|
||||
1: 'line-md:menu-fold-right'
|
||||
},
|
||||
1: {
|
||||
0: 'ph-caret-double-left-bold',
|
||||
1: 'ph-caret-double-right-bold'
|
||||
}
|
||||
};
|
||||
|
||||
const arrowIcon = Number(props.arrowIcon || false) as NumberBool;
|
||||
|
||||
const collapsed = Number(props.collapsed || false) as NumberBool;
|
||||
|
||||
return icons[arrowIcon][collapsed];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ButtonIcon
|
||||
:key="String(collapsed)"
|
||||
:tooltip-content="collapsed ? $t('icon.expand') : $t('icon.collapse')"
|
||||
tooltip-placement="bottom-start"
|
||||
:z-index="zIndex"
|
||||
>
|
||||
<SvgIcon :icon="icon" />
|
||||
</ButtonIcon>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
107
front/src/components/common/pdf-preview.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, shallowRef } from 'vue';
|
||||
import VuePdfEmbed from 'vue-pdf-embed';
|
||||
import { useLoading } from '~/packages/hooks';
|
||||
|
||||
defineOptions({
|
||||
name: 'PdfPreview'
|
||||
});
|
||||
|
||||
const source = defineModel('source', {
|
||||
default: 'https://xiaoxian521.github.io/hyperlink/pdf/Cookie%E5%92%8CSession%E5%8C%BA%E5%88%AB%E7%94%A8%E6%B3%95.pdf'
|
||||
});
|
||||
|
||||
withDefaults(defineProps<{ title?: string }>(), { title: 'PDF预览' });
|
||||
|
||||
const { loading, endLoading } = useLoading(true);
|
||||
const pdfRef = shallowRef<InstanceType<typeof VuePdfEmbed> | null>(null);
|
||||
|
||||
// 是否分页显示
|
||||
const showAllPages = ref(false);
|
||||
// 当前页码
|
||||
const currentPage = ref<undefined | number>(1);
|
||||
// 旋转角度
|
||||
const pageCount = ref(1);
|
||||
|
||||
// 旋转角度
|
||||
const rotations = [0, 90, 180, 270];
|
||||
// 当前角度的索引值拿的是rotations的索引值,所以这里用的是0
|
||||
const currentRotation = ref(0);
|
||||
|
||||
// 切换角度
|
||||
function handleRotate() {
|
||||
currentRotation.value = (currentRotation.value + 1) % 4;
|
||||
}
|
||||
|
||||
// 是否显示分页
|
||||
function showAllPagesChange() {
|
||||
currentPage.value = showAllPages.value ? undefined : 1;
|
||||
}
|
||||
|
||||
// 渲染完成
|
||||
function onPdfRendered() {
|
||||
endLoading();
|
||||
|
||||
if (pdfRef.value?.doc) {
|
||||
pageCount.value = pdfRef.value.doc.numPages;
|
||||
}
|
||||
}
|
||||
|
||||
// 打印PDF文件
|
||||
async function handlePrint() {
|
||||
// 随机名称
|
||||
await pdfRef.value?.print(undefined, getPdfName(), true);
|
||||
}
|
||||
|
||||
// 下载PDF文件
|
||||
async function handleDownload() {
|
||||
await pdfRef.value?.download(getPdfName());
|
||||
}
|
||||
|
||||
function getPdfName() {
|
||||
const name = source.value.split('/');
|
||||
return decodeURIComponent(name[name.length - 1]) || 'pdf文档.pdf';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard
|
||||
:title="title"
|
||||
:bordered="false"
|
||||
class="h-full card-wrapper"
|
||||
content-class="overflow-hidden flex-col-stretch box-border"
|
||||
>
|
||||
<template #header-extra>
|
||||
<div class="flex-y-center justify-end gap-12px">
|
||||
<NCheckbox v-model:checked="showAllPages" @update:checked="showAllPagesChange">是否分页显示</NCheckbox>
|
||||
<ButtonIcon tooltip-content="旋转90度" @click="handleRotate">
|
||||
<icon-material-symbols-light-rotate-90-degrees-ccw-outline-rounded />
|
||||
</ButtonIcon>
|
||||
<ButtonIcon tooltip-content="打印" @click="handlePrint">
|
||||
<icon-mdi-printer />
|
||||
</ButtonIcon>
|
||||
<ButtonIcon tooltip-content="下载" @click="handleDownload">
|
||||
<icon-charm-download />
|
||||
</ButtonIcon>
|
||||
</div>
|
||||
</template>
|
||||
<NScrollbar class="flex-1-hidden">
|
||||
<NSkeleton v-if="loading" size="small" class="mt-12px" text :repeat="12" />
|
||||
<VuePdfEmbed
|
||||
ref="pdfRef"
|
||||
class="overflow-auto container"
|
||||
:class="{ 'h-0': loading }"
|
||||
:rotation="rotations[currentRotation]"
|
||||
:page="currentPage"
|
||||
:source="source"
|
||||
@rendered="onPdfRendered"
|
||||
/>
|
||||
</NScrollbar>
|
||||
<div class="flex-center pt-20px">
|
||||
<div v-if="showAllPages" class="text-18px font-medium">共{{ pageCount }}页</div>
|
||||
<NPagination v-else v-model:page="currentPage" :page-count="pageCount" :page-size="1" />
|
||||
</div>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
26
front/src/components/common/pin-toggler.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'PinToggler' });
|
||||
|
||||
interface Props {
|
||||
pin?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const icon = computed(() => (props.pin ? 'mdi-pin-off' : 'mdi-pin'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ButtonIcon
|
||||
:tooltip-content="pin ? $t('icon.unpin') : $t('icon.pin')"
|
||||
tooltip-placement="bottom-start"
|
||||
:z-index="100"
|
||||
>
|
||||
<SvgIcon :icon="icon" />
|
||||
</ButtonIcon>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
21
front/src/components/common/reload-button.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({
|
||||
name: 'ReloadButton'
|
||||
});
|
||||
|
||||
interface Props {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ButtonIcon :tooltip-content="$t('icon.reload')">
|
||||
<icon-ant-design-reload-outlined :class="{ 'animate-spin animate-duration-750': loading }" />
|
||||
</ButtonIcon>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
9
front/src/components/common/system-logo.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
defineOptions({ name: 'SystemLogo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<icon-local-logo />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
56
front/src/components/common/theme-schema-switch.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { PopoverPlacement } from 'naive-ui';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'ThemeSchemaSwitch' });
|
||||
|
||||
interface Props {
|
||||
/** Theme schema */
|
||||
themeSchema: UnionKey.ThemeScheme;
|
||||
/** Show tooltip */
|
||||
showTooltip?: boolean;
|
||||
/** Tooltip placement */
|
||||
tooltipPlacement?: PopoverPlacement;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showTooltip: true,
|
||||
tooltipPlacement: 'bottom'
|
||||
});
|
||||
|
||||
interface Emits {
|
||||
(e: 'switch'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
function handleSwitch() {
|
||||
emit('switch');
|
||||
}
|
||||
|
||||
const icons: Record<UnionKey.ThemeScheme, string> = {
|
||||
light: 'material-symbols:sunny',
|
||||
dark: 'material-symbols:nightlight-rounded',
|
||||
auto: 'material-symbols:hdr-auto'
|
||||
};
|
||||
|
||||
const icon = computed(() => icons[props.themeSchema]);
|
||||
|
||||
const tooltipContent = computed(() => {
|
||||
if (!props.showTooltip) return '';
|
||||
|
||||
return $t('icon.themeSchema');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ButtonIcon
|
||||
:icon="icon"
|
||||
:tooltip-content="tooltipContent"
|
||||
:tooltip-placement="tooltipPlacement"
|
||||
@click="handleSwitch"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
32
front/src/components/common/tool-tip.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'ToolTip',
|
||||
inheritAttrs: false
|
||||
});
|
||||
|
||||
interface Props {
|
||||
type?: 'dark' | 'light';
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
type: 'light'
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NTooltip
|
||||
:class="{ 'text-#666F8D !bg-#ffffff': type === 'light' }"
|
||||
:arrow-class="`${type === 'light' ? '!bg-#ffffff' : ''}`"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<template #trigger>
|
||||
<div>
|
||||
<slot name="trigger"></slot>
|
||||
</div>
|
||||
</template>
|
||||
<slot></slot>
|
||||
</NTooltip>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
150
front/src/components/common/typewriter-effect.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
|
||||
const props = withDefaults(defineProps<{ speed?: number; strings?: string; lifeLike?: boolean; loop?: boolean }>(), {
|
||||
speed: 120,
|
||||
strings: '你们好!你嘿!嘿,你好!嘿,你们好!嘿,你们好!嘿,你们好!嘿,你们好!嘿,你们好!',
|
||||
lifeLike: true, // 使每次打字在延迟跳动,更像人类打字的样式
|
||||
loop: false
|
||||
});
|
||||
|
||||
// 用于loop: false
|
||||
const complete = defineModel<boolean>('complete', { default: false });
|
||||
|
||||
/** 光标跟随逻辑 */
|
||||
const contentRef = ref<HTMLElement | null>(null); // 包含text和光标的容器节点
|
||||
const textRef = ref<HTMLElement | null>(null); // text节点
|
||||
const cursorRef = ref<HTMLElement | null>(null); // 光标节点
|
||||
const isMounted = ref(true); // 循环中增加退出判断
|
||||
|
||||
const loopNum = 30;
|
||||
|
||||
function startDelay(delay = 0) {
|
||||
return new Promise(resolve => {
|
||||
if (delay) {
|
||||
setTimeout(resolve, delay);
|
||||
return;
|
||||
}
|
||||
if (props.lifeLike) {
|
||||
const randomDelay = Math.floor(Math.random() * (loopNum * 2 + 1)) - loopNum + props.speed; // 生成-10到10的随机数
|
||||
setTimeout(resolve, randomDelay);
|
||||
} else setTimeout(resolve, props.speed);
|
||||
});
|
||||
}
|
||||
|
||||
/** 模拟请求 */
|
||||
async function mockResponse() {
|
||||
cursorRef.value!.style.display = 'block';
|
||||
if (props.loop) {
|
||||
while (props.loop && isMounted.value) {
|
||||
for (let i = 0; i <= props.strings.length; i++) {
|
||||
if (textRef.value) {
|
||||
textRef.value.innerHTML = props.strings.slice(0, i);
|
||||
updateCursor();
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await startDelay();
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await startDelay(2000);
|
||||
for (let i = props.strings.length - 1; i >= 0; i--) {
|
||||
if (textRef.value) {
|
||||
textRef.value.innerHTML = props.strings.slice(0, i);
|
||||
updateCursor();
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await startDelay(40);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i <= props.strings.length; i++) {
|
||||
if (textRef.value) {
|
||||
textRef.value.innerHTML = props.strings.slice(0, i);
|
||||
updateCursor();
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await startDelay();
|
||||
}
|
||||
}
|
||||
|
||||
complete.value = true;
|
||||
}
|
||||
|
||||
if (cursorRef.value) cursorRef.value!.style.display = 'none';
|
||||
}
|
||||
|
||||
/** 获取最后一个文本节点 */
|
||||
function getLastTextNode(node: Node | null): Node | null {
|
||||
if (!node) return null;
|
||||
if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) {
|
||||
return node;
|
||||
}
|
||||
for (let i = node.childNodes.length - 1; i >= 0; i--) {
|
||||
const childNode = node.childNodes[i];
|
||||
const textNode = getLastTextNode(childNode);
|
||||
if (textNode) {
|
||||
return textNode;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function updateCursor() {
|
||||
// 1. 找到最后一个文本节点
|
||||
const lastTextNode = getLastTextNode(textRef.value);
|
||||
// 2. 创建一个临时文本节点
|
||||
const tempText = document.createTextNode('\u200B'); // 零宽字符
|
||||
// 3. 将临时文本节点放在最后一个文本节点之后
|
||||
if (lastTextNode && lastTextNode.parentNode) {
|
||||
lastTextNode.parentNode.appendChild(tempText);
|
||||
} else if (textRef.value) {
|
||||
textRef.value.appendChild(tempText);
|
||||
}
|
||||
// 4. 获取临时文本节点距离父节点的距离(x,y)
|
||||
const range = document.createRange(); // 设置范围
|
||||
range.setStart(tempText, 0);
|
||||
range.setEnd(tempText, 0);
|
||||
const rect = range.getBoundingClientRect(); // 获取距离信息
|
||||
// 5. 获取当前文本容器距离视图的距离(x,y)
|
||||
const textRect = contentRef.value && contentRef.value.getBoundingClientRect();
|
||||
// 6. 获取到当前文本节点的位置,并将光标的位置插入到相应位置
|
||||
if (textRect) {
|
||||
const x = rect.left - textRect.left;
|
||||
const y = rect.top - textRect.top;
|
||||
cursorRef.value!.style.transform = `translate(${x}px,${y}px)`;
|
||||
}
|
||||
// 7. 移除临时文本节点
|
||||
tempText.remove();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.strings.trim()) return;
|
||||
mockResponse();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
isMounted.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="contentRef" class="relative">
|
||||
<div ref="textRef" class="text"></div>
|
||||
<div ref="cursorRef" class="cursor absolute left-0 top--1px hidden">|</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.cursor {
|
||||
animation: cursorAnimate 0.8s infinite;
|
||||
}
|
||||
|
||||
@keyframes cursorAnimate {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
36
front/src/components/contract-compare/ContractHeader.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ContractHeader'
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="header">
|
||||
<h1>合同对比分析系统</h1>
|
||||
<p class="subtitle">智能识别合同差异,快速定位关键变更</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px 24px;
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
|
||||
h1 {
|
||||
margin: 0 0 8px 0;
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
889
front/src/components/contract-compare/ContractViewer.vue
Normal file
@@ -0,0 +1,889 @@
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue';
|
||||
import { defineComponent } from 'vue';
|
||||
import VuePdfEmbed from 'vue-pdf-embed';
|
||||
import uniqBy from 'lodash/uniqBy';
|
||||
import PaginationControl from './PaginationControl.vue';
|
||||
|
||||
interface CompareResult {
|
||||
baseBoxArea: string;
|
||||
baseDiffContent: string;
|
||||
baseDiffType: 'modify' | 'insert' | 'delete';
|
||||
basePageNum: number;
|
||||
compareBoxArea: string;
|
||||
compareDiffContent: string;
|
||||
compareDiffType: 'modify' | 'insert' | 'delete';
|
||||
comparePageNum: number;
|
||||
firstPageNum: number;
|
||||
secondPageNum: number;
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface CanvasMap {
|
||||
[key: number]: HTMLCanvasElement;
|
||||
}
|
||||
|
||||
interface ContextMap {
|
||||
[key: number]: CanvasRenderingContext2D | null;
|
||||
}
|
||||
|
||||
// 添加重试计数和强制设置页数功能
|
||||
export default defineComponent({
|
||||
name: 'ContractViewer',
|
||||
components: {
|
||||
PaginationControl,
|
||||
VuePdfEmbed
|
||||
},
|
||||
props: {
|
||||
type: {
|
||||
type: String as PropType<'original' | 'compare'>,
|
||||
required: true,
|
||||
validator: (value: string) => ['original', 'compare'].includes(value)
|
||||
},
|
||||
currentPage: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
totalPages: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
compareResults: {
|
||||
type: Array as PropType<CompareResult[]>,
|
||||
required: true
|
||||
},
|
||||
selectedDiff: {
|
||||
type: Object as PropType<CompareResult | null>,
|
||||
default: null
|
||||
},
|
||||
pdfUrl: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
loadError: {
|
||||
type: String as PropType<string | null>,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
canvases: {} as CanvasMap,
|
||||
ctxs: {} as ContextMap,
|
||||
resizeTimer: null as number | null,
|
||||
pdfDoc: null as any, // PDF 文档引用
|
||||
overlayCanvases: {} as CanvasMap, // 用于绘制边框的叠加层画布
|
||||
overlayCtxs: {} as ContextMap, // 叠加层画布上下文
|
||||
pdfLoaded: false, // 标记 PDF 是否已加载
|
||||
scrollTimer: null as number | null, // 滚动防抖计时器
|
||||
|
||||
scale: 1.5 // PDF 渲染缩放比例(用于实际渲染)
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
realTotalPages(): number {
|
||||
return this.totalPages || 1;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
selectedDiff() {
|
||||
// 只重绘下划线,不重绘 bbox
|
||||
this.clearOverlays(); // 只清除所有 overlay
|
||||
this.drawDiffBoxes(); // 重新绘制所有 bbox
|
||||
if (this.selectedDiff) {
|
||||
this.drawSelectedUnderline();
|
||||
}
|
||||
},
|
||||
currentPage() {
|
||||
// 当前页变化时,滚动到该页
|
||||
this.$nextTick().then(() => {
|
||||
this.scrollToPage(this.currentPage);
|
||||
|
||||
// 延迟执行其他操作
|
||||
setTimeout(() => {
|
||||
this.initOverlayCanvas();
|
||||
if (this.selectedDiff) {
|
||||
this.drawSelectedUnderline();
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
pdfUrl() {
|
||||
// 当 PDF URL 变化时,重置并重新初始化
|
||||
this.pdfLoaded = false;
|
||||
this.$nextTick().then(() => {
|
||||
// 重置状态
|
||||
this.changePage(1);
|
||||
this.overlayCanvases = {};
|
||||
this.overlayCtxs = {};
|
||||
});
|
||||
},
|
||||
compareResults: {
|
||||
handler() {
|
||||
// 当比较结果变化时,重绘叠加层
|
||||
this.clearOverlays();
|
||||
this.drawDiffBoxes();
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
},
|
||||
beforeUnmount() {
|
||||
// 移除事件监听器
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
},
|
||||
methods: {
|
||||
// 确保所有页面都显示(竖向排列)
|
||||
showAllPages(): void {
|
||||
this.$nextTick().then(() => {
|
||||
const pdfEmbed = document.querySelector(`.contract-section-${this.type} .vue-pdf-embed`) as HTMLElement;
|
||||
if (!pdfEmbed) {
|
||||
console.warn(`[${this.type}] 找不到 PDF 嵌入组件`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pdfPages = pdfEmbed.querySelectorAll('.vue-pdf-embed__page') as NodeListOf<HTMLElement>;
|
||||
if (pdfPages.length === 0) {
|
||||
console.warn(`[${this.type}] 找不到 PDF 页面元素`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保所有页面都显示
|
||||
pdfPages.forEach((page) => {
|
||||
page.style.display = 'block';
|
||||
});
|
||||
|
||||
console.log(`[${this.type}] 显示所有页面,共 ${pdfPages.length} 页`);
|
||||
});
|
||||
},
|
||||
|
||||
// 初始化叠加层画布用于绘制边框
|
||||
initOverlayCanvas(): void {
|
||||
this.$nextTick().then(() => {
|
||||
this.overlayCanvases = {};
|
||||
this.overlayCtxs = {};
|
||||
|
||||
// 查找 PDF 嵌入组件
|
||||
const pdfEmbed = document.querySelector(`.contract-section-${this.type} .vue-pdf-embed`) as HTMLElement;
|
||||
if (!pdfEmbed) {
|
||||
console.warn(`[${this.type}] 找不到 PDF 嵌入组件`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 查找所有 PDF 页面元素
|
||||
const pdfPages = pdfEmbed.querySelectorAll('.vue-pdf-embed__page') as NodeListOf<HTMLElement>;
|
||||
if (pdfPages.length === 0) {
|
||||
console.warn(`[${this.type}] 找不到 PDF 页面元素`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 为每个 PDF 页面创建叠加层
|
||||
pdfPages.forEach((pdfPage, idx) => {
|
||||
const pageNum = idx + 1;
|
||||
|
||||
// 移除现有的叠加层
|
||||
const existingOverlay = pdfPage.querySelector('.overlay-canvas');
|
||||
if (existingOverlay) {
|
||||
existingOverlay.remove();
|
||||
}
|
||||
|
||||
// 获取 PDF 页面的尺寸
|
||||
const rect = pdfPage.getBoundingClientRect();
|
||||
|
||||
// 创建叠加层画布
|
||||
const overlay = document.createElement('canvas');
|
||||
overlay.className = 'overlay-canvas';
|
||||
overlay.style.position = 'absolute';
|
||||
overlay.style.top = '0';
|
||||
overlay.style.left = '0';
|
||||
overlay.style.width = '100%';
|
||||
overlay.style.height = '100%';
|
||||
overlay.style.pointerEvents = 'none';
|
||||
overlay.style.zIndex = '100';
|
||||
|
||||
// 设置画布尺寸与 PDF 页面相匹配
|
||||
overlay.width = rect.width;
|
||||
overlay.height = rect.height;
|
||||
|
||||
// 存储画布和上下文引用
|
||||
this.overlayCanvases[pageNum] = overlay;
|
||||
this.overlayCtxs[pageNum] = overlay.getContext('2d');
|
||||
|
||||
// 将点击处理程序添加到 PDF 页面上
|
||||
pdfPage.addEventListener('click', (event: Event) => {
|
||||
if (event instanceof MouseEvent) {
|
||||
this.handleCanvasClick(event, pageNum);
|
||||
}
|
||||
});
|
||||
|
||||
// 确保 PDF 页面使用相对定位
|
||||
pdfPage.style.position = 'relative';
|
||||
pdfPage.appendChild(overlay);
|
||||
});
|
||||
|
||||
// 初始化后绘制差异框
|
||||
setTimeout(() => {
|
||||
this.drawDiffBoxes();
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
|
||||
// 自适应宽度:根据容器宽度自动调整 scale
|
||||
fitWidth(): void {
|
||||
this.$nextTick().then(() => {
|
||||
const container = this.$el.querySelector('.pages-container') as HTMLElement | null;
|
||||
const pdfPage = this.$el.querySelector('.vue-pdf-embed__page') as HTMLElement | null;
|
||||
if (!container || !pdfPage) return;
|
||||
|
||||
const containerWidth = container.clientWidth - 32; // 预留少量内边距
|
||||
const pageRect = pdfPage.getBoundingClientRect();
|
||||
if (!pageRect.width || containerWidth <= 0) return;
|
||||
|
||||
const currentDisplayedWidth = pageRect.width;
|
||||
const ratio = containerWidth / currentDisplayedWidth;
|
||||
const nextScale = Number((this.scale * ratio).toFixed(2));
|
||||
if (Math.abs(nextScale - this.scale) > 0.01) {
|
||||
this.scale = nextScale;
|
||||
// 缩放后重建叠加层,确保标注对齐
|
||||
setTimeout(() => {
|
||||
this.initOverlayCanvas();
|
||||
this.drawDiffBoxes();
|
||||
if (this.selectedDiff) this.drawSelectedUnderline();
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 处理窗口大小变化
|
||||
handleResize(): void {
|
||||
if (this.resizeTimer !== null) {
|
||||
clearTimeout(this.resizeTimer);
|
||||
}
|
||||
this.resizeTimer = window.setTimeout(() => {
|
||||
this.initOverlayCanvas();
|
||||
// 容器变化后适当重算叠加并可选地自适应宽度
|
||||
// 不强制调用 fitWidth,避免用户缩放被覆盖
|
||||
}, 200);
|
||||
},
|
||||
|
||||
// 清除所有叠加层
|
||||
clearOverlays(): void {
|
||||
for (const pageNum in this.overlayCtxs) {
|
||||
if (Object.hasOwn(this.overlayCtxs, pageNum)) {
|
||||
const ctx = this.overlayCtxs[pageNum];
|
||||
const canvas = this.overlayCanvases[pageNum];
|
||||
if (ctx && canvas) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 绘制所有页的所有差异框
|
||||
drawDiffBoxes(): void {
|
||||
this.clearOverlays(); // 保证每次都先清空
|
||||
|
||||
// 绘制所有页面的差异框
|
||||
// eslint-disable-next-line no-plusplus
|
||||
for (let pageNum = 1; pageNum <= this.totalPages; pageNum++) {
|
||||
const pageDiffs = this.compareResults.filter(item => {
|
||||
if (this.type === 'original') {
|
||||
return item.basePageNum === pageNum;
|
||||
}
|
||||
return item.comparePageNum === pageNum;
|
||||
});
|
||||
const uniqueDiffs = uniqBy(pageDiffs, diff =>
|
||||
this.type === 'original' ? diff.baseBoxArea : diff.compareBoxArea
|
||||
);
|
||||
uniqueDiffs.forEach(diff => {
|
||||
const boxArea = this.type === 'original' ? diff.baseBoxArea : diff.compareBoxArea;
|
||||
if (boxArea && boxArea !== '[0, 0, 0, 0]') {
|
||||
this.drawBox(pageNum, boxArea, this.type === 'original' ? diff.baseDiffType : diff.compareDiffType);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 解析 bbox 坐标
|
||||
parseBboxCoordinates(boxArea: string | number[]): number[] | null {
|
||||
if (!boxArea || boxArea === '[0, 0, 0, 0]') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 处理不同可能的格式
|
||||
let coords: number[];
|
||||
if (typeof boxArea === 'string') {
|
||||
// 移除括号和空格,然后按逗号分割
|
||||
const cleanStr = boxArea.replace(/^\[|\]$/g, '').replace(/\s+/g, '');
|
||||
coords = cleanStr.split(',').map(num => Number.parseFloat(num.trim()));
|
||||
} else if (Array.isArray(boxArea)) {
|
||||
coords = boxArea.map(num => Number.parseFloat(String(num)));
|
||||
} else {
|
||||
console.error('无效的bbox格式:', boxArea);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 验证4个有效数字
|
||||
if (coords.length !== 4 || coords.some(num => Number.isNaN(num))) {
|
||||
console.error('无效的bbox坐标:', coords);
|
||||
return null;
|
||||
}
|
||||
|
||||
return coords;
|
||||
} catch (error) {
|
||||
console.error('解析bbox坐标失败:', error, boxArea);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// 确保 bbox 具有最小尺寸
|
||||
ensureMinimumBboxSize(coords: number[] | null, minWidth = 5, minHeight = 5): number[] | null {
|
||||
if (!coords || !Array.isArray(coords) || coords.length !== 4) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [x, y, width, height] = coords;
|
||||
|
||||
// 设置最小宽度和高度
|
||||
const adjustedWidth = width <= 0 || width < minWidth ? minWidth : width;
|
||||
const adjustedHeight = height <= 0 || height < minHeight ? minHeight : height;
|
||||
|
||||
return [x, y, adjustedWidth, adjustedHeight];
|
||||
},
|
||||
|
||||
// 在指定页面上绘制一个 bbox
|
||||
drawBox(pageNum: number, boxArea: string, diffType: string): void {
|
||||
const ctx = this.overlayCtxs[pageNum];
|
||||
const canvas = this.overlayCanvases[pageNum];
|
||||
|
||||
if (!ctx || !canvas) return;
|
||||
|
||||
// 解析坐标
|
||||
const coords = this.parseBboxCoordinates(boxArea);
|
||||
if (!coords) return;
|
||||
|
||||
// 确保最小尺寸
|
||||
const adjustedCoords = this.ensureMinimumBboxSize(coords);
|
||||
if (!adjustedCoords) return;
|
||||
|
||||
// PDF 大小标准 (单位为pt)
|
||||
const PDF_WIDTH = 595; // A4宽度
|
||||
const PDF_HEIGHT = 842; // A4高度
|
||||
|
||||
// 计算缩放比例 (PDF 点到像素的转换)
|
||||
const scaleX = canvas.width / PDF_WIDTH;
|
||||
const scaleY = canvas.height / PDF_HEIGHT;
|
||||
|
||||
// 自动适配 boxArea 单位
|
||||
const [xInit, yInit, widthInit, heightInit] = adjustedCoords;
|
||||
let x = xInit;
|
||||
let y = yInit;
|
||||
let width = widthInit;
|
||||
let height = heightInit;
|
||||
// 如果 boxArea 是 px(150dpi),先转 pt
|
||||
const isPx = Math.max(x, y, width, height) > 900; // 粗略判断
|
||||
if (isPx) {
|
||||
const dpi = 150;
|
||||
const pxToPt = (px: number) => (px * 72) / dpi;
|
||||
x = pxToPt(x);
|
||||
y = pxToPt(y);
|
||||
width = pxToPt(width);
|
||||
height = pxToPt(height);
|
||||
}
|
||||
// 重要:PDF 坐标系原点在左下角,Y 轴向上
|
||||
// Canvas 坐标系原点在左上角,Y 轴向下
|
||||
// 因此需要转换 Y 坐标
|
||||
const scaledX = x * scaleX;
|
||||
const scaledWidth = width * scaleX;
|
||||
const scaledHeight = height * scaleY;
|
||||
const scaledY = y * scaleY - scaledHeight;
|
||||
|
||||
// 设置虚线样式
|
||||
ctx.setLineDash([5, 5]);
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
// 根据差异类型设置颜色
|
||||
switch (diffType) {
|
||||
case 'insert':
|
||||
ctx.strokeStyle = '#52c41a'; // 绿色
|
||||
break;
|
||||
case 'delete':
|
||||
ctx.strokeStyle = '#ff4d4f'; // 红色
|
||||
break;
|
||||
case 'modify':
|
||||
ctx.strokeStyle = '#faad14'; // 黄色
|
||||
break;
|
||||
default:
|
||||
ctx.strokeStyle = '#1890ff'; // 蓝色
|
||||
}
|
||||
|
||||
// 绘制矩形框
|
||||
ctx.strokeRect(scaledX, scaledY, scaledWidth, scaledHeight);
|
||||
|
||||
// 添加半透明填充效果
|
||||
ctx.fillStyle = `${ctx.strokeStyle}40`; // 25% 透明度
|
||||
ctx.fillRect(scaledX, scaledY, scaledWidth, scaledHeight);
|
||||
|
||||
// 重置虚线样式
|
||||
ctx.setLineDash([]);
|
||||
},
|
||||
|
||||
// 绘制选中的差异下划线
|
||||
drawSelectedUnderline(): void {
|
||||
if (!this.selectedDiff) return;
|
||||
|
||||
// 检查选中的差异是否在当前页
|
||||
const isCurrentPage =
|
||||
this.type === 'original'
|
||||
? this.selectedDiff.basePageNum === this.currentPage
|
||||
: this.selectedDiff.comparePageNum === this.currentPage;
|
||||
|
||||
if (!isCurrentPage) return;
|
||||
|
||||
const pageNum = this.currentPage;
|
||||
const ctx = this.overlayCtxs[pageNum];
|
||||
const canvas = this.overlayCanvases[pageNum];
|
||||
|
||||
if (!ctx || !canvas) return;
|
||||
|
||||
// 获取 bbox 区域
|
||||
const boxArea = this.type === 'original' ? this.selectedDiff.baseBoxArea : this.selectedDiff.compareBoxArea;
|
||||
|
||||
const coords = this.parseBboxCoordinates(boxArea);
|
||||
if (!coords) return;
|
||||
|
||||
const adjustedCoords = this.ensureMinimumBboxSize(coords);
|
||||
if (!adjustedCoords) return;
|
||||
|
||||
let [x, y, width, height] = adjustedCoords;
|
||||
|
||||
// PDF 大小标准 (单位为pt)
|
||||
const PDF_WIDTH = 595;
|
||||
const PDF_HEIGHT = 842;
|
||||
|
||||
// 计算缩放比例
|
||||
const scaleY = canvas.height / PDF_HEIGHT;
|
||||
|
||||
// 单位转换(如有必要)
|
||||
const isPx = Math.max(x, y, width, height) > 900;
|
||||
if (isPx) {
|
||||
const dpi = 150;
|
||||
const pxToPt = (px: number) => (px * 72) / dpi;
|
||||
x = pxToPt(x);
|
||||
y = pxToPt(y);
|
||||
width = pxToPt(width);
|
||||
height = pxToPt(height);
|
||||
}
|
||||
|
||||
// 重要:PDF 坐标系原点在左下角,Canvas 在左上角
|
||||
const scaledHeight = height * scaleY;
|
||||
const scaledY = y * scaleY - scaledHeight;
|
||||
|
||||
// 在 bbox 下方绘制下划线
|
||||
ctx.save();
|
||||
ctx.setLineDash([8, 4]);
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeStyle = '#1890ff';
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, scaledY + scaledHeight);
|
||||
ctx.lineTo(canvas.width, scaledY + scaledHeight);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
// 滚动到指定页
|
||||
scrollToPage(pageNum: number): void {
|
||||
this.$nextTick().then(() => {
|
||||
const pdfEmbed = document.querySelector(`.contract-section-${this.type} .vue-pdf-embed`) as HTMLElement;
|
||||
if (!pdfEmbed) {
|
||||
console.warn(`[${this.type}] 找不到 PDF 嵌入组件`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pdfPages = pdfEmbed.querySelectorAll('.vue-pdf-embed__page') as NodeListOf<HTMLElement>;
|
||||
if (pdfPages.length === 0 || pageNum > pdfPages.length) {
|
||||
console.warn(`[${this.type}] 页面 ${pageNum} 不存在`);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPage = pdfPages[pageNum - 1]; // 数组从0开始,页面从1开始
|
||||
if (targetPage) {
|
||||
targetPage.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
console.log(`[${this.type}] 滚动到页面 ${pageNum}`);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 处理画布点击
|
||||
handleCanvasClick(event: MouseEvent, pageNum: number): void {
|
||||
const canvas = this.overlayCanvases[pageNum];
|
||||
if (!canvas) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const clickX = event.clientX - rect.left;
|
||||
const clickY = event.clientY - rect.top;
|
||||
|
||||
// 查找点击的差异
|
||||
const clickedDiff = this.findClickedDiff(clickX, clickY, pageNum);
|
||||
if (clickedDiff) {
|
||||
this.$emit('select-diff', clickedDiff);
|
||||
}
|
||||
},
|
||||
|
||||
// 查找点击的是哪个差异
|
||||
findClickedDiff(x: number, y: number, pageNum: number): CompareResult | undefined {
|
||||
const pageDiffs = this.compareResults.filter(item => {
|
||||
if (this.type === 'original') {
|
||||
return item.basePageNum === pageNum;
|
||||
}
|
||||
return item.comparePageNum === pageNum;
|
||||
});
|
||||
|
||||
const canvas = this.overlayCanvases[pageNum];
|
||||
if (!canvas) return undefined;
|
||||
|
||||
// PDF 大小标准 (单位为pt)
|
||||
const PDF_WIDTH = 595;
|
||||
const PDF_HEIGHT = 842;
|
||||
|
||||
// 计算缩放比例
|
||||
const scaleX = canvas.width / PDF_WIDTH;
|
||||
const scaleY = canvas.height / PDF_HEIGHT;
|
||||
|
||||
return pageDiffs.find(diff => {
|
||||
const boxArea = this.type === 'original' ? diff.baseBoxArea : diff.compareBoxArea;
|
||||
if (boxArea && boxArea !== '[0, 0, 0, 0]') {
|
||||
const coords = this.parseBboxCoordinates(boxArea);
|
||||
if (!coords) return false;
|
||||
|
||||
const [boxX, boxY, width, height] = coords;
|
||||
|
||||
// 转换坐标
|
||||
const scaledBoxX = boxX * scaleX;
|
||||
const scaledBoxY = canvas.height - boxY * scaleY - height * scaleY;
|
||||
const scaledWidth = width * scaleX;
|
||||
const scaledHeight = height * scaleY;
|
||||
|
||||
return x >= scaledBoxX && x <= scaledBoxX + scaledWidth && y >= scaledBoxY && y <= scaledBoxY + scaledHeight;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
},
|
||||
|
||||
// 切换页
|
||||
changePage(pageNum: number): void {
|
||||
this.$emit('update:currentPage', pageNum);
|
||||
},
|
||||
|
||||
// 更新PDF加载事件处理
|
||||
onPdfLoaded(): void {
|
||||
this.pdfLoaded = true;
|
||||
console.log(`[${this.type}] PDF 文档加载完成`);
|
||||
},
|
||||
|
||||
// 更新Page Rendered处理
|
||||
onPageRendered(): void {
|
||||
// 使用延时确保PDF渲染完成后再绘制框框
|
||||
setTimeout(() => {
|
||||
// 确保所有页面都显示
|
||||
this.showAllPages();
|
||||
// 初始化叠加层
|
||||
this.initOverlayCanvas();
|
||||
// 绘制所有差异框
|
||||
this.drawDiffBoxes();
|
||||
if (this.selectedDiff) {
|
||||
this.drawSelectedUnderline();
|
||||
}
|
||||
console.log(`[${this.type}] 完成PDF渲染`);
|
||||
// 初始时如出现横向溢出,尝试适配宽度
|
||||
this.$nextTick(() => {
|
||||
const container = this.$el.querySelector('.pages-container') as HTMLElement | null;
|
||||
const content = this.$el.querySelector('.vue-pdf-embed__page') as HTMLElement | null;
|
||||
if (container && content && content.getBoundingClientRect().width > container.clientWidth) {
|
||||
this.fitWidth();
|
||||
}
|
||||
});
|
||||
}, 500);
|
||||
},
|
||||
|
||||
// 添加一个手动触发差异框绘制的方法
|
||||
redrawBoxes(): void {
|
||||
// 重绘框框
|
||||
this.clearOverlays();
|
||||
this.initOverlayCanvas();
|
||||
|
||||
setTimeout(() => {
|
||||
this.drawDiffBoxes();
|
||||
if (this.selectedDiff) {
|
||||
this.drawSelectedUnderline();
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="contract-section"
|
||||
:class="[type === 'original' ? 'contract-section-original' : 'contract-section-compare']"
|
||||
>
|
||||
<div class="contract-header">
|
||||
<div class="section-title">
|
||||
<div class="title-icon" :class="[type]">{{ type === 'original' ? '📄' : '📋' }}</div>
|
||||
<h3>{{ type === 'original' ? '原合同' : '对比合同' }}</h3>
|
||||
</div>
|
||||
<PaginationControl :current-page="currentPage" :total-pages="totalPages" @page-change="changePage" />
|
||||
</div>
|
||||
<div class="pages-container">
|
||||
<!-- 使用单个 PDF 组件加载整个文档 -->
|
||||
<div v-if="pdfUrl && !isLoading" class="pdf-viewer">
|
||||
<VuePdfEmbed
|
||||
:source="pdfUrl"
|
||||
class="pdf-document"
|
||||
:scale="scale"
|
||||
@rendered="onPageRendered"
|
||||
@loaded="onPdfLoaded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-else-if="isLoading" class="canvas-container">
|
||||
<div class="pdf-placeholder">
|
||||
<div class="loading-message">正在加载 PDF...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="loadError" class="canvas-container">
|
||||
<div class="pdf-placeholder">
|
||||
<div class="error-message">{{ loadError }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.contract-section {
|
||||
flex: 1;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e8e8e8;
|
||||
|
||||
.contract-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
.title-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
&.original {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
&.compare {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #262626;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.pages-container {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto; /* 超大屏或缩放导致的横向溢出时可横向滚动 */
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.page-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
background: #fafafa;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.pdf-viewer-container {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pdf-viewer {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: visible; /* 允许内容根据缩放溢出 */
|
||||
}
|
||||
|
||||
.page-display-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* PDF 文档样式 */
|
||||
:deep(.pdf-document) {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
:deep(.vue-pdf-embed) {
|
||||
width: 100% !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
:deep(.vue-pdf-embed__page) {
|
||||
position: relative !important;
|
||||
width: auto !important; /* 由 scale 控制显示宽度 */
|
||||
max-width: none; /* 取消固定 800px 限制,避免大屏放大时被限制 */
|
||||
height: auto !important;
|
||||
margin-bottom: 24px !important;
|
||||
display: block !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 叠加层样式 */
|
||||
:deep(.overlay-canvas) {
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pdf-placeholder {
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
background: #f0f0f0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.page-number {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 删除缩放工具条相关样式 */
|
||||
|
||||
/* Scrollbar styles */
|
||||
.pages-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.pages-container::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.pages-container::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.pages-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.contract-section {
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.contract-section {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
192
front/src/components/contract-compare/DiffItem.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue';
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
interface CompareResult {
|
||||
baseBoxArea: string;
|
||||
baseDiffContent: string;
|
||||
baseDiffType: 'modify' | 'insert' | 'delete';
|
||||
basePageNum: number;
|
||||
compareBoxArea: string;
|
||||
compareDiffContent: string;
|
||||
compareDiffType: 'modify' | 'insert' | 'delete';
|
||||
comparePageNum: number;
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface DiffTypeMap {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'DiffItem',
|
||||
props: {
|
||||
item: {
|
||||
type: Object as PropType<CompareResult>,
|
||||
required: true
|
||||
},
|
||||
isSelected: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
diffTypeLabel(): string {
|
||||
const typeMap: DiffTypeMap = {
|
||||
insert: '新增',
|
||||
delete: '删除',
|
||||
modify: '修改'
|
||||
};
|
||||
return typeMap[this.item.compareDiffType] || this.item.compareDiffType;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="result-item"
|
||||
:class="[`diff-${item.compareDiffType}`, { selected: isSelected }]"
|
||||
:data-id="item.id"
|
||||
@click="$emit('click', item)"
|
||||
>
|
||||
<div class="result-item-header">
|
||||
<span class="diff-type">{{ diffTypeLabel }}</span>
|
||||
<span class="page-info">第{{ item.basePageNum }}页</span>
|
||||
</div>
|
||||
<div class="result-content">
|
||||
<div class="original-content">
|
||||
<strong>原合同:</strong>
|
||||
<span>{{ item.baseDiffContent || '无内容' }}</span>
|
||||
</div>
|
||||
<div class="compare-content">
|
||||
<strong>对比合同:</strong>
|
||||
<span>{{ item.compareDiffContent || '无内容' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.result-item {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
background: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
position: relative;
|
||||
|
||||
&.diff-insert {
|
||||
border-left: 4px solid #52c41a;
|
||||
}
|
||||
&.diff-delete {
|
||||
border-left: 4px solid #ff4d4f;
|
||||
}
|
||||
&.diff-modify {
|
||||
border-left: 4px solid #faad14;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 4px 16px rgba(24, 144, 255, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: #e6f7ff;
|
||||
border: 2px solid #1890ff;
|
||||
box-shadow: 0 4px 16px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 添加选中状态标记 */
|
||||
&.selected::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #1890ff;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.result-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.diff-type {
|
||||
font-size: 12px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 16px;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.diff-insert & .diff-type {
|
||||
background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%);
|
||||
}
|
||||
.diff-delete & .diff-type {
|
||||
background: linear-gradient(135deg, #ff4d4f 0%, #ff7875 100%);
|
||||
}
|
||||
.diff-modify & .diff-type {
|
||||
background: linear-gradient(135deg, #faad14 0%, #ffc53d 100%);
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
font-weight: 500;
|
||||
background: #f5f5f5;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.result-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
|
||||
.original-content,
|
||||
.compare-content {
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid #e8e8e8;
|
||||
|
||||
strong {
|
||||
color: #262626;
|
||||
margin-right: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(0.95);
|
||||
opacity: 0.7;
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(0.95);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
351
front/src/components/contract-compare/DiffResultPanel.vue
Normal file
@@ -0,0 +1,351 @@
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue';
|
||||
import { defineComponent } from 'vue';
|
||||
import DiffItem from './DiffItem.vue';
|
||||
|
||||
interface CompareResult {
|
||||
baseBoxArea: string;
|
||||
baseDiffContent: string;
|
||||
baseDiffType: 'modify' | 'insert' | 'delete';
|
||||
basePageNum: number;
|
||||
compareBoxArea: string;
|
||||
compareDiffContent: string;
|
||||
compareDiffType: 'modify' | 'insert' | 'delete';
|
||||
comparePageNum: number;
|
||||
firstPageNum: number;
|
||||
secondPageNum: number;
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface Filter {
|
||||
type: 'all' | 'insert' | 'delete' | 'modify';
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
total: number;
|
||||
insert: number;
|
||||
delete: number;
|
||||
modify: number;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'DiffResultPanel',
|
||||
components: {
|
||||
DiffItem
|
||||
},
|
||||
props: {
|
||||
compareResults: {
|
||||
type: Array as PropType<CompareResult[]>,
|
||||
required: true
|
||||
},
|
||||
selectedDiff: {
|
||||
type: Object as PropType<CompareResult | null>,
|
||||
default: null
|
||||
},
|
||||
taskId: {
|
||||
type: [Number, String],
|
||||
required: true
|
||||
},
|
||||
isPolling: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
status: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentFilter: 'all' as 'all' | 'insert' | 'delete' | 'modify',
|
||||
filters: [
|
||||
{ type: 'all', label: '全部', color: '#666' },
|
||||
{ type: 'insert', label: '新增', color: '#52c41a' },
|
||||
{ type: 'delete', label: '删除', color: '#ff4d4f' },
|
||||
{ type: 'modify', label: '修改', color: '#faad14' }
|
||||
] as Filter[]
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
filteredResults(): CompareResult[] {
|
||||
if (this.currentFilter === 'all') {
|
||||
return this.compareResults;
|
||||
}
|
||||
return this.compareResults.filter(item => item.compareDiffType === this.currentFilter);
|
||||
},
|
||||
stats(): Stats {
|
||||
return {
|
||||
total: this.compareResults.length,
|
||||
insert: this.compareResults.filter(item => item.compareDiffType === 'insert').length,
|
||||
delete: this.compareResults.filter(item => item.compareDiffType === 'delete').length,
|
||||
modify: this.compareResults.filter(item => item.compareDiffType === 'modify').length
|
||||
};
|
||||
},
|
||||
emptyDescription(): string {
|
||||
// 根据任务状态显示不同的空数据提示
|
||||
if (this.status === 3) {
|
||||
// 已完成状态,没有数据表示两个文档完全一致
|
||||
return '两个文档完全一致';
|
||||
}
|
||||
// 其他状态显示暂无数据
|
||||
return '暂无数据';
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
setFilter(filterType: 'all' | 'insert' | 'delete' | 'modify'): void {
|
||||
this.currentFilter = filterType;
|
||||
},
|
||||
selectDiff(item: CompareResult): void {
|
||||
this.$emit('select-diff', item);
|
||||
},
|
||||
|
||||
handleRecognizeAgain() {
|
||||
this.$emit('recognizeAgain', this.taskId);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="result-section">
|
||||
<div class="result-header">
|
||||
<div class="section-title">
|
||||
<div class="title-icon result">🔍</div>
|
||||
<div class="flex-y-center flex-1 justify-between">
|
||||
<h3 class="!m-0">差异分析结果</h3>
|
||||
<NPopconfirm v-if="taskId" @positive-click="handleRecognizeAgain">
|
||||
确认重新执行差异识别吗?
|
||||
<template #trigger>
|
||||
<NButton :loading="isPolling" type="success">重新差异识别</NButton>
|
||||
</template>
|
||||
</NPopconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-info">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">总计</span>
|
||||
<span class="stat-value total">{{ stats.total }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">新增</span>
|
||||
<span class="stat-value insert">{{ stats.insert }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">修改</span>
|
||||
<span class="stat-value modify">{{ stats.modify }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">删除</span>
|
||||
<span class="stat-value delete">{{ stats.delete }}</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="filter-controls">
|
||||
<div class="filter-buttons">
|
||||
<button
|
||||
v-for="filter in filters"
|
||||
:key="filter.type"
|
||||
class="filter-btn"
|
||||
:class="[{ active: currentFilter === filter.type }]"
|
||||
@click="setFilter(filter.type)"
|
||||
>
|
||||
{{ filter.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-list">
|
||||
<NSpin :show="isPolling" description="重新识别差异中..." class="h-full" content-class="h-full">
|
||||
<DiffItem
|
||||
v-for="item in filteredResults"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
:is-selected="!!(selectedDiff && selectedDiff.id === item.id)"
|
||||
@click="selectDiff(item)"
|
||||
/>
|
||||
|
||||
<NEmpty v-if="filteredResults.length === 0" class="h-full flex-center" :description="emptyDescription" />
|
||||
</NSpin>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.result-section {
|
||||
width: 420px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e8e8e8;
|
||||
|
||||
.result-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
h3 {
|
||||
margin: 0 0 16px 0;
|
||||
color: #262626;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
.title-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
&.result {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
}
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #262626;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-info {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e8e8e8;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #262626;
|
||||
&.total {
|
||||
color: #1890ff;
|
||||
}
|
||||
&.insert {
|
||||
color: #52c41a;
|
||||
}
|
||||
&.delete {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
&.modify {
|
||||
color: #faad14;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
.filter-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.filter-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
.filter-btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
color: #1890ff;
|
||||
background: #f0f8ff;
|
||||
}
|
||||
&.active {
|
||||
background: #1890ff;
|
||||
border-color: #1890ff;
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.result-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.result-list::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.result-list::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.result-list::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.result-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1200px) {
|
||||
.result-section {
|
||||
width: 380px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.result-section {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
130
front/src/components/contract-compare/PaginationControl.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PaginationControl',
|
||||
props: {
|
||||
currentPage: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
totalPages: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
pageNum: this.currentPage
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
currentPage(newVal: number) {
|
||||
this.pageNum = newVal;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
goToPage(): void {
|
||||
// 验证页码范围
|
||||
if (this.pageNum < 1) this.pageNum = 1;
|
||||
if (this.pageNum > this.totalPages) this.pageNum = this.totalPages;
|
||||
this.$emit('page-change', this.pageNum);
|
||||
},
|
||||
prevPage(): void {
|
||||
if (this.currentPage > 1) {
|
||||
this.$emit('page-change', this.currentPage - 1);
|
||||
}
|
||||
},
|
||||
nextPage(): void {
|
||||
if (this.currentPage < this.totalPages) {
|
||||
this.$emit('page-change', this.currentPage + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pagination-control">
|
||||
<button class="page-btn" :disabled="currentPage <= 1" @click="prevPage">◀</button>
|
||||
<div class="page-input-container">
|
||||
<input v-model.number="pageNum" type="number" min="1" :max="totalPages" class="page-input" @change="goToPage" />
|
||||
<span class="page-separator">/</span>
|
||||
<span class="total-pages">{{ totalPages }}</span>
|
||||
</div>
|
||||
<button class="page-btn" :disabled="currentPage >= totalPages" @click="nextPage">▶</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.pagination-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #f8f9fa;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e8e8e8;
|
||||
|
||||
.page-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s;
|
||||
&:hover:not(:disabled) {
|
||||
border-color: #1890ff;
|
||||
color: #1890ff;
|
||||
background: #f0f8ff;
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
}
|
||||
|
||||
.page-input-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #d9d9d9;
|
||||
|
||||
.page-input {
|
||||
width: 30px;
|
||||
border: none;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
-moz-appearance: textfield;
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.page-separator {
|
||||
color: #d9d9d9;
|
||||
}
|
||||
|
||||
.total-pages {
|
||||
width: 30px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
191
front/src/components/contract-compare/contract-compare.html
Normal file
@@ -0,0 +1,191 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>PDF 坐标框选验证 Demo</title>
|
||||
<style>
|
||||
.container {
|
||||
max-width: 100%;
|
||||
margin: 20px auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.input-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
input {
|
||||
width: 100px;
|
||||
margin-right: 10px;
|
||||
padding: 5px;
|
||||
}
|
||||
button {
|
||||
padding: 8px 15px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
#pdf-container {
|
||||
position: relative;
|
||||
margin-top: 20px;
|
||||
}
|
||||
#pdf-canvas {
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
#overlay-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none; /* 允许点击下方 Canvas */
|
||||
}
|
||||
.error {
|
||||
color: red;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>PDF 坐标框选验证</h2>
|
||||
|
||||
<!-- 文件上传 -->
|
||||
<div class="input-group">
|
||||
<input type="file" id="pdf-file" accept=".pdf">
|
||||
<p>提示:上传后输入 PDF 页面中文字的坐标(x, y, w, h,单位:pt)</p >
|
||||
</div>
|
||||
|
||||
<!-- 坐标输入 -->
|
||||
<div class="input-group">
|
||||
<label>X (pt): </label><input type="number" id="x-input" step="1">
|
||||
<label>Y (pt): </label><input type="number" id="y-input" step="1">
|
||||
<label>宽度 (pt): </label><input type="number" id="w-input" step="1">
|
||||
<label>高度 (pt): </label><input type="number" id="h-input" step="1">
|
||||
<button onclick="highlightText()">绘制框选</button>
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div id="error-msg" class="error"></div>
|
||||
|
||||
<!-- PDF 渲染容器(含 Canvas 和覆盖层) -->
|
||||
<div id="pdf-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- 引入 PDF.js -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.10.377/pdf.min.js"></script>
|
||||
<script>
|
||||
let pdfDoc = null; // 存储 PDF 文档对象
|
||||
let currentPage = 1; // 当前渲染页(默认第一页)
|
||||
let currentPageRendered = false; // 标记当前页是否已渲染
|
||||
|
||||
// 监听文件上传
|
||||
document.getElementById('pdf-file').addEventListener('change', async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
// 清空错误提示和容器
|
||||
document.getElementById('error-msg').textContent = '';
|
||||
document.getElementById('pdf-container').innerHTML = '';
|
||||
|
||||
try {
|
||||
// 加载 PDF 文件
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
const pdfData = new Uint8Array(event.target.result);
|
||||
pdfDoc = await pdfjsLib.getDocument({ data: pdfData }).promise;
|
||||
|
||||
// 渲染第一页
|
||||
await renderPage(currentPage);
|
||||
currentPageRendered = true;
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
} catch (err) {
|
||||
document.getElementById('error-msg').textContent = `加载 PDF 失败:${err.message}`;
|
||||
}
|
||||
});
|
||||
|
||||
// 渲染 PDF 单页到 Canvas
|
||||
async function renderPage(pageNum) {
|
||||
if (!pdfDoc) return;
|
||||
|
||||
const page = await pdfDoc.getPage(pageNum);
|
||||
const viewport = page.getViewport({ scale: 1.0 }); // 初始缩放 1:1(1pt=1px)
|
||||
|
||||
// 创建 Canvas 容器
|
||||
const container = document.getElementById('pdf-container');
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.id = 'pdf-canvas';
|
||||
container.appendChild(canvas);
|
||||
|
||||
// 设置 Canvas 尺寸匹配 PDF 页面(pt → px,1:1 缩放)
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
// 渲染 PDF 内容到 Canvas
|
||||
await page.render({
|
||||
canvasContext: canvas.getContext('2d'),
|
||||
viewport: viewport
|
||||
}).promise;
|
||||
|
||||
// 创建覆盖 Canvas(用于绘制框选)
|
||||
const overlayCanvas = document.createElement('canvas');
|
||||
overlayCanvas.id = 'overlay-canvas';
|
||||
overlayCanvas.width = canvas.width;
|
||||
overlayCanvas.height = canvas.height;
|
||||
container.appendChild(overlayCanvas);
|
||||
}
|
||||
|
||||
// 绘制框选(关键修改)
|
||||
function highlightText() {
|
||||
const errorMsg = document.getElementById('error-msg');
|
||||
if (!pdfDoc || !currentPageRendered) {
|
||||
errorMsg.textContent = '请先上传 PDF 文件!';
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取输入的坐标
|
||||
const x = parseFloat(document.getElementById('x-input').value);
|
||||
const y = parseFloat(document.getElementById('y-input').value);
|
||||
const w = parseFloat(document.getElementById('w-input').value);
|
||||
const h = parseFloat(document.getElementById('h-input').value);
|
||||
|
||||
// 验证输入有效性
|
||||
if ([x, y, w, h].some(isNaN)) {
|
||||
errorMsg.textContent = '请输入有效的数字坐标!';
|
||||
return;
|
||||
}
|
||||
if (x < 0 || y < 0 || w <= 0 || h <= 0) {
|
||||
errorMsg.textContent = '坐标或尺寸不能为负数或零!';
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取覆盖 Canvas 和页面实际高度(已考虑旋转)
|
||||
const overlayCanvas = document.getElementById('overlay-canvas');
|
||||
const ctx = overlayCanvas.getContext('2d');
|
||||
|
||||
// 清空之前的绘制
|
||||
ctx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
|
||||
|
||||
// 设置荧光笔样式(黄色透明填充 + 边缘阴影)
|
||||
ctx.fillStyle = 'rgba(255, 255, 0, 0.3)'; // 黄色,30% 透明度
|
||||
ctx.strokeStyle = 'rgba(255, 200, 0, 0.8)'; // 深黄边框(可选)
|
||||
//ctx.lineWidth = 1; // 边框宽度(可选)
|
||||
ctx.shadowColor = 'rgba(255, 255, 0, 0.5)'; // 阴影颜色
|
||||
ctx.shadowBlur = 5; // 阴影模糊半径(可选)
|
||||
|
||||
// 计算 Canvas 中的 Y 坐标(修正偏移问题)
|
||||
const imgY = y - h;
|
||||
|
||||
// 绘制填充矩形(荧光笔主体)
|
||||
ctx.fillRect(x, imgY, w, h);
|
||||
|
||||
// 绘制边框(可选,增强边缘)
|
||||
// ctx.strokeRect(x, imgY, w, h);
|
||||
|
||||
errorMsg.textContent = '';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
53
front/src/components/custom/better-scroll.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
import BScroll from '@better-scroll/core';
|
||||
import type { Options } from '@better-scroll/core';
|
||||
|
||||
defineOptions({ name: 'BetterScroll' });
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* BetterScroll options
|
||||
*
|
||||
* @link https://better-scroll.github.io/docs/zh-CN/guide/base-scroll-options.html
|
||||
*/
|
||||
options: Options;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const bsWrapper = ref<HTMLElement>();
|
||||
const bsContent = ref<HTMLElement>();
|
||||
const { width: wrapWidth } = useElementSize(bsWrapper);
|
||||
const { width, height } = useElementSize(bsContent);
|
||||
|
||||
const instance = ref<BScroll>();
|
||||
const isScrollY = computed(() => Boolean(props.options.scrollY));
|
||||
|
||||
function initBetterScroll() {
|
||||
if (!bsWrapper.value) return;
|
||||
instance.value = new BScroll(bsWrapper.value, props.options);
|
||||
}
|
||||
|
||||
// refresh BS when scroll element size changed
|
||||
watch([() => wrapWidth.value, () => width.value, () => height.value], () => {
|
||||
instance.value?.refresh();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
initBetterScroll();
|
||||
});
|
||||
|
||||
defineExpose({ instance });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="bsWrapper" class="h-full text-left">
|
||||
<div ref="bsContent" class="inline-block" :class="{ 'h-full': !isScrollY }">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
48
front/src/components/custom/button-icon.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverPlacement } from 'naive-ui';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
defineOptions({
|
||||
name: 'ButtonIcon',
|
||||
inheritAttrs: false
|
||||
});
|
||||
|
||||
interface Props {
|
||||
/** Button class */
|
||||
class?: string;
|
||||
/** Iconify icon name */
|
||||
icon?: string;
|
||||
/** Tooltip content */
|
||||
tooltipContent?: string;
|
||||
/** Tooltip placement */
|
||||
tooltipPlacement?: PopoverPlacement;
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
class: '',
|
||||
icon: '',
|
||||
tooltipContent: '',
|
||||
tooltipPlacement: 'bottom',
|
||||
zIndex: 98
|
||||
});
|
||||
|
||||
const DEFAULT_CLASS = 'h-[36px] text-icon';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NTooltip :placement="tooltipPlacement" :z-index="zIndex" :disabled="!tooltipContent">
|
||||
<template #trigger>
|
||||
<NButton quaternary :class="twMerge(DEFAULT_CLASS, props.class)" v-bind="$attrs">
|
||||
<div class="flex-center gap-8px">
|
||||
<slot>
|
||||
<SvgIcon :icon="icon" />
|
||||
</slot>
|
||||
</div>
|
||||
</NButton>
|
||||
</template>
|
||||
{{ tooltipContent }}
|
||||
</NTooltip>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
88
front/src/components/custom/count-to.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { TransitionPresets, useTransition } from '@vueuse/core';
|
||||
|
||||
defineOptions({
|
||||
name: 'CountTo'
|
||||
});
|
||||
|
||||
interface Props {
|
||||
startValue?: number;
|
||||
endValue?: number;
|
||||
duration?: number;
|
||||
autoplay?: boolean;
|
||||
decimals?: number;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
separator?: string;
|
||||
decimal?: string;
|
||||
useEasing?: boolean;
|
||||
transition?: keyof typeof TransitionPresets;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
startValue: 0,
|
||||
endValue: 2021,
|
||||
duration: 1500,
|
||||
autoplay: true,
|
||||
decimals: 0,
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
separator: ',',
|
||||
decimal: '.',
|
||||
useEasing: true,
|
||||
transition: 'linear'
|
||||
});
|
||||
|
||||
const source = ref(props.startValue);
|
||||
|
||||
const transition = computed(() => (props.useEasing ? TransitionPresets[props.transition] : undefined));
|
||||
|
||||
const outputValue = useTransition(source, {
|
||||
disabled: false,
|
||||
duration: props.duration,
|
||||
transition: transition.value
|
||||
});
|
||||
|
||||
const value = computed(() => formatValue(outputValue.value));
|
||||
|
||||
function formatValue(num: number) {
|
||||
const { decimals, decimal, separator, suffix, prefix } = props;
|
||||
|
||||
let number = num.toFixed(decimals);
|
||||
number = String(number);
|
||||
|
||||
const x = number.split('.');
|
||||
let x1 = x[0];
|
||||
const x2 = x.length > 1 ? decimal + x[1] : '';
|
||||
const rgx = /(\d+)(\d{3})/;
|
||||
if (separator) {
|
||||
while (rgx.test(x1)) {
|
||||
x1 = x1.replace(rgx, `$1${separator}$2`);
|
||||
}
|
||||
}
|
||||
|
||||
return prefix + x1 + x2 + suffix;
|
||||
}
|
||||
|
||||
async function start() {
|
||||
await nextTick();
|
||||
source.value = props.endValue;
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => props.startValue, () => props.endValue],
|
||||
() => {
|
||||
if (props.autoplay) {
|
||||
start();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span>{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
78
front/src/components/custom/custom-icon-select.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
defineOptions({ name: 'CustomIconSelect' });
|
||||
|
||||
interface Props {
|
||||
/** Selected icon */
|
||||
value: string;
|
||||
/** List of icons */
|
||||
icons: string[];
|
||||
/** Icon for when nothing is selected */
|
||||
emptyIcon?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
emptyIcon: 'mdi:apps'
|
||||
});
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:value', val: string): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const modelValue = computed({
|
||||
get() {
|
||||
return props.value;
|
||||
},
|
||||
set(val: string) {
|
||||
emit('update:value', val);
|
||||
}
|
||||
});
|
||||
|
||||
const selectedIcon = computed(() => modelValue.value || props.emptyIcon);
|
||||
|
||||
const searchValue = ref('');
|
||||
|
||||
const iconsList = computed(() => props.icons.filter(v => v.includes(searchValue.value)));
|
||||
|
||||
function handleChange(iconItem: string) {
|
||||
modelValue.value = iconItem;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NPopover placement="bottom-end" trigger="click">
|
||||
<template #trigger>
|
||||
<NInput v-model:value="modelValue" readonly placeholder="点击选择图标">
|
||||
<template #suffix>
|
||||
<SvgIcon :icon="selectedIcon" class="p-5px text-30px" />
|
||||
</template>
|
||||
</NInput>
|
||||
</template>
|
||||
<template #header>
|
||||
<NInput v-model:value="searchValue" placeholder="搜索图标"></NInput>
|
||||
</template>
|
||||
<div v-if="iconsList.length > 0" class="grid grid-cols-9 h-auto overflow-auto">
|
||||
<span v-for="iconItem in iconsList" :key="iconItem" @click="handleChange(iconItem)">
|
||||
<SvgIcon
|
||||
:icon="iconItem"
|
||||
class="m-2px cursor-pointer border-1px border-#d9d9d9 p-5px text-30px"
|
||||
:class="{ 'border-primary': modelValue === iconItem }"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<NEmpty v-else class="w-306px" description="你什么也找不到" />
|
||||
</NPopover>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.n-input-wrapper) {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
:deep(.n-input__suffix) {
|
||||
border: 1px solid #d9d9d9;
|
||||
}
|
||||
</style>
|
||||
18
front/src/components/custom/github-link.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import WebSiteLink from './web-site-link.vue';
|
||||
|
||||
defineOptions({ name: 'GithubLink' });
|
||||
|
||||
interface Props {
|
||||
/** github link */
|
||||
link: string;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WebSiteLink label="github地址:" :link="link" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
20
front/src/components/custom/look-forward.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({
|
||||
name: 'LookForward'
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="size-full min-h-520px flex-col-center gap-24px overflow-hidden">
|
||||
<div class="flex text-400px text-primary">
|
||||
<SvgIcon local-icon="expectation" />
|
||||
</div>
|
||||
<slot>
|
||||
<h3 class="text-28px text-primary font-500">{{ $t('common.lookForward') }}</h3>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
13
front/src/components/custom/soybean-avatar.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'SoybeanAvatar'
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="size-72px overflow-hidden rd-1/2">
|
||||
<img src="@/assets/imgs/soybean.jpg" class="size-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
62
front/src/components/custom/svg-icon.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useAttrs } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
|
||||
defineOptions({ name: 'SvgIcon', inheritAttrs: false });
|
||||
|
||||
/**
|
||||
* Props
|
||||
*
|
||||
* - Support iconify and local svg icon
|
||||
* - If icon and localIcon are passed at the same time, localIcon will be rendered first
|
||||
*/
|
||||
interface Props {
|
||||
/** Iconify icon name */
|
||||
icon?: string;
|
||||
/** Local svg icon name */
|
||||
localIcon?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const attrs = useAttrs();
|
||||
|
||||
const bindAttrs = computed(() => {
|
||||
const baseAttrs = {
|
||||
class: (attrs.class as string) || '',
|
||||
style: (attrs.style as string) || ''
|
||||
};
|
||||
|
||||
if (attrs.onClick) {
|
||||
baseAttrs.onClick = attrs.onClick;
|
||||
}
|
||||
|
||||
return baseAttrs;
|
||||
});
|
||||
|
||||
const symbolId = computed(() => {
|
||||
const { VITE_ICON_LOCAL_PREFIX: prefix } = import.meta.env;
|
||||
|
||||
const defaultLocalIcon = 'no-icon';
|
||||
|
||||
const icon = props.localIcon || defaultLocalIcon;
|
||||
|
||||
return `#${prefix}-${icon}`;
|
||||
});
|
||||
|
||||
/** If localIcon is passed, render localIcon first */
|
||||
const renderLocalIcon = computed(() => props.localIcon || !props.icon);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="renderLocalIcon">
|
||||
<svg aria-hidden="true" width="1em" height="1em" v-bind="bindAttrs">
|
||||
<use :xlink:href="symbolId" fill="currentColor" />
|
||||
</svg>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Icon v-if="icon" :icon="icon" v-bind="bindAttrs" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
61
front/src/components/custom/wave-bg.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { getPaletteColorByNumber } from '@sa/color';
|
||||
|
||||
defineOptions({ name: 'WaveBg' });
|
||||
|
||||
interface Props {
|
||||
/** Theme color */
|
||||
themeColor: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const lightColor = computed(() => getPaletteColorByNumber(props.themeColor, 200));
|
||||
const darkColor = computed(() => getPaletteColorByNumber(props.themeColor, 500));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="absolute-lt z-1 size-full overflow-hidden">
|
||||
<div class="absolute -right-300px -top-900px lt-sm:(-right-100px -top-1170px)">
|
||||
<svg height="1337" width="1337">
|
||||
<defs>
|
||||
<path
|
||||
id="path-1"
|
||||
opacity="1"
|
||||
fill-rule="evenodd"
|
||||
d="M1337,668.5 C1337,1037.455193874239 1037.455193874239,1337 668.5,1337 C523.6725684305388,1337 337,1236 370.50000000000006,1094 C434.03835568300906,824.6732385973953 6.906089672974592e-14,892.6277623047779 0,668.5000000000001 C0,299.5448061257611 299.5448061257609,1.1368683772161603e-13 668.4999999999999,0 C1037.455193874239,0 1337,299.544806125761 1337,668.5Z"
|
||||
/>
|
||||
<linearGradient id="linearGradient-2" x1="0.79" y1="0.62" x2="0.21" y2="0.86">
|
||||
<stop offset="0" :stop-color="lightColor" stop-opacity="1" />
|
||||
<stop offset="1" :stop-color="darkColor" stop-opacity="1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g opacity="1">
|
||||
<use xlink:href="#path-1" fill="url(#linearGradient-2)" fill-opacity="1" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="absolute -bottom-400px -left-200px lt-sm:(-bottom-760px -left-100px)">
|
||||
<svg height="896" width="967.8852157128662">
|
||||
<defs>
|
||||
<path
|
||||
id="path-2"
|
||||
opacity="1"
|
||||
fill-rule="evenodd"
|
||||
d="M896,448 C1142.6325445712241,465.5747656464056 695.2579309733121,896 448,896 C200.74206902668806,896 5.684341886080802e-14,695.2579309733121 0,448.0000000000001 C0,200.74206902668806 200.74206902668791,5.684341886080802e-14 447.99999999999994,0 C695.2579309733121,0 475,418 896,448Z"
|
||||
/>
|
||||
<linearGradient id="linearGradient-3" x1="0.5" y1="0" x2="0.5" y2="1">
|
||||
<stop offset="0" :stop-color="darkColor" stop-opacity="1" />
|
||||
<stop offset="1" :stop-color="lightColor" stop-opacity="1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g opacity="1">
|
||||
<use xlink:href="#path-2" fill="url(#linearGradient-3)" fill-opacity="1" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
23
front/src/components/custom/web-site-link.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'WebSiteLink' });
|
||||
|
||||
interface Props {
|
||||
/** Web site name */
|
||||
label: string;
|
||||
/** Web site link */
|
||||
link: string;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p>
|
||||
<span>{{ label }}</span>
|
||||
<a class="text-blue-500" :href="link" target="#">
|
||||
{{ link }}
|
||||
</a>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
451
front/src/components/verifition/Verify.vue
Normal file
27
front/src/components/verifition/api/index.js
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 此处可直接引用自己项目封装好的 axios 配合后端联调
|
||||
*/
|
||||
|
||||
|
||||
import request from "./../utils/axios" //组件内部封装的axios
|
||||
// import request from "@/api/axios.js" //调用项目封装的axios
|
||||
|
||||
//获取验证图片 以及token
|
||||
export function reqGet(data) {
|
||||
return request({
|
||||
url: '/captcha/get',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
//滑动或者点选验证
|
||||
export function reqCheck(data) {
|
||||
return request({
|
||||
url: '/captcha/check',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
260
front/src/components/verifition/modules/VerifyPoints.vue
Normal file
@@ -0,0 +1,260 @@
|
||||
<template>
|
||||
<div style="position: relative"
|
||||
>
|
||||
<div class="verify-img-out">
|
||||
<div class="verify-img-panel" :style="{'width': setSize.imgWidth,
|
||||
'height': setSize.imgHeight,
|
||||
'background-size' : setSize.imgWidth + ' '+ setSize.imgHeight,
|
||||
'margin-bottom': vSpace + 'px'}"
|
||||
>
|
||||
<div class="verify-refresh" style="z-index:3" @click="refresh" v-show="showRefresh">
|
||||
<i class="iconfont icon-refresh"></i>
|
||||
</div>
|
||||
<img :src="'data:image/png;base64,'+pointBackImgBase"
|
||||
ref="canvas"
|
||||
alt="" style="width:100%;height:100%;display:block"
|
||||
@click="bindingClick?canvasClick($event):undefined">
|
||||
|
||||
<div v-for="(tempPoint, index) in tempPoints" :key="index" class="point-area"
|
||||
:style="{
|
||||
'background-color':'#1abd6c',
|
||||
color:'#fff',
|
||||
'z-index':9999,
|
||||
width:'20px',
|
||||
height:'20px',
|
||||
'text-align':'center',
|
||||
'line-height':'20px',
|
||||
'border-radius': '50%',
|
||||
position:'absolute',
|
||||
top:parseInt(tempPoint.y-10) + 'px',
|
||||
left:parseInt(tempPoint.x-10) + 'px'
|
||||
}">
|
||||
{{index + 1}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 'height': this.barSize.height, -->
|
||||
<div class="verify-bar-area"
|
||||
:style="{'width': setSize.imgWidth,
|
||||
'color': this.barAreaColor,
|
||||
'border-color': this.barAreaBorderColor,
|
||||
'line-height':this.barSize.height}">
|
||||
<span class="verify-msg">{{text}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script type="text/babel">
|
||||
/**
|
||||
* VerifyPoints
|
||||
* @description 点选
|
||||
* */
|
||||
import {resetSize, _code_chars, _code_color1, _code_color2} from '../utils/util.js'
|
||||
import {aesEncrypt} from "../utils/ase.js"
|
||||
import {reqGet,reqCheck} from "../api/index.js"
|
||||
import { computed, onMounted, reactive, ref,watch,nextTick,toRefs, watchEffect,getCurrentInstance} from 'vue';
|
||||
export default {
|
||||
name: 'VerifyPoints',
|
||||
props: {
|
||||
//弹出式pop,固定fixed
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'fixed'
|
||||
},
|
||||
captchaType:{
|
||||
type:String,
|
||||
},
|
||||
//间隔
|
||||
vSpace: {
|
||||
type: Number,
|
||||
default: 5
|
||||
},
|
||||
imgSize: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {
|
||||
width: '310px',
|
||||
height: '155px'
|
||||
}
|
||||
}
|
||||
},
|
||||
barSize: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {
|
||||
width: '310px',
|
||||
height: '40px'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
setup(props,context){
|
||||
const {mode,captchaType,vSpace,imgSize,barSize} = toRefs(props)
|
||||
const { proxy } = getCurrentInstance();
|
||||
let secretKey = ref(''), //后端返回的ase加密秘钥
|
||||
checkNum = ref(3), //默认需要点击的字数
|
||||
fontPos = reactive([]), //选中的坐标信息
|
||||
checkPosArr = reactive([]), //用户点击的坐标
|
||||
num = ref(1), //点击的记数
|
||||
pointBackImgBase = ref(''), //后端获取到的背景图片
|
||||
poinTextList = reactive([]), //后端返回的点击字体顺序
|
||||
backToken = ref(''), //后端返回的token值
|
||||
setSize = reactive({
|
||||
imgHeight: 0,
|
||||
imgWidth: 0,
|
||||
barHeight: 0,
|
||||
barWidth: 0
|
||||
}),
|
||||
tempPoints = reactive([]),
|
||||
text = ref(''),
|
||||
barAreaColor = ref(undefined),
|
||||
barAreaBorderColor = ref(undefined),
|
||||
showRefresh = ref(true),
|
||||
bindingClick = ref(true)
|
||||
|
||||
|
||||
|
||||
|
||||
const init = ()=>{
|
||||
//加载页面
|
||||
fontPos.splice(0, fontPos.length)
|
||||
checkPosArr.splice(0, checkPosArr.length)
|
||||
num.value = 1
|
||||
getPictrue();
|
||||
nextTick(() => {
|
||||
let {imgHeight,imgWidth,barHeight,barWidth} = resetSize(proxy)
|
||||
setSize.imgHeight = imgHeight
|
||||
setSize.imgWidth = imgWidth
|
||||
setSize.barHeight = barHeight
|
||||
setSize.barWidth = barWidth
|
||||
proxy.$parent.$emit('ready', proxy)
|
||||
})
|
||||
}
|
||||
onMounted(()=>{
|
||||
// 禁止拖拽
|
||||
init()
|
||||
proxy.$el.onselectstart = function () {
|
||||
return false
|
||||
}
|
||||
})
|
||||
const canvas = ref(null)
|
||||
const canvasClick = (e)=>{
|
||||
checkPosArr.push(getMousePos(canvas, e));
|
||||
if (num.value == checkNum.value) {
|
||||
num.value = createPoint(getMousePos(canvas, e));
|
||||
//按比例转换坐标值
|
||||
let arr = pointTransfrom(checkPosArr,setSize)
|
||||
checkPosArr.length = 0
|
||||
checkPosArr.push(...arr);
|
||||
//等创建坐标执行完
|
||||
setTimeout(() => {
|
||||
// var flag = this.comparePos(this.fontPos, this.checkPosArr);
|
||||
//发送后端请求
|
||||
var captchaVerification = secretKey.value? aesEncrypt(backToken.value+'---'+JSON.stringify(checkPosArr),secretKey.value):backToken.value+'---'+JSON.stringify(checkPosArr)
|
||||
let data = {
|
||||
captchaType:captchaType.value,
|
||||
"pointJson":secretKey.value? aesEncrypt(JSON.stringify(checkPosArr),secretKey.value):JSON.stringify(checkPosArr),
|
||||
"token":backToken.value
|
||||
}
|
||||
reqCheck(data).then(res=>{
|
||||
if (res.repCode == "0000") {
|
||||
barAreaColor.value = '#4cae4c'
|
||||
barAreaBorderColor.value = '#5cb85c'
|
||||
text.value = '验证成功'
|
||||
bindingClick.value = false
|
||||
if (mode.value=='pop') {
|
||||
setTimeout(()=>{
|
||||
proxy.$parent.clickShow = false;
|
||||
refresh();
|
||||
},1500)
|
||||
}
|
||||
proxy.$parent.$emit('success', {captchaVerification})
|
||||
}else{
|
||||
proxy.$parent.$emit('error', proxy)
|
||||
barAreaColor.value = '#d9534f'
|
||||
barAreaBorderColor.value = '#d9534f'
|
||||
text.value = '验证失败'
|
||||
setTimeout(() => {
|
||||
refresh();
|
||||
}, 700);
|
||||
}
|
||||
})
|
||||
}, 400);
|
||||
}
|
||||
if (num.value < checkNum.value) {
|
||||
num.value = createPoint(getMousePos(canvas, e));
|
||||
}
|
||||
}
|
||||
//获取坐标
|
||||
const getMousePos = function (obj, e) {
|
||||
var x = e.offsetX
|
||||
var y = e.offsetY
|
||||
return {x, y}
|
||||
}
|
||||
//创建坐标点
|
||||
const createPoint = function (pos) {
|
||||
tempPoints.push(Object.assign({}, pos))
|
||||
return num.value+1;
|
||||
}
|
||||
const refresh = function () {
|
||||
tempPoints.splice(0, tempPoints.length)
|
||||
barAreaColor.value = '#000'
|
||||
barAreaBorderColor.value = '#ddd'
|
||||
bindingClick.value = true
|
||||
fontPos.splice(0, fontPos.length)
|
||||
checkPosArr.splice(0, checkPosArr.length)
|
||||
num.value = 1
|
||||
getPictrue();
|
||||
text.value = '验证失败'
|
||||
showRefresh.value = true
|
||||
}
|
||||
|
||||
// 请求背景图片和验证图片
|
||||
function getPictrue() {
|
||||
let data = {
|
||||
captchaType:captchaType.value
|
||||
}
|
||||
reqGet(data).then(res=>{
|
||||
if (res.repCode == "0000") {
|
||||
pointBackImgBase.value = res.repData.originalImageBase64
|
||||
backToken.value = res.repData.token
|
||||
secretKey.value = res.repData.secretKey
|
||||
poinTextList.value = res.repData.wordList
|
||||
text.value = '请依次点击【' + poinTextList.value.join(",") + '】'
|
||||
}else{
|
||||
text.value = res.repMsg;
|
||||
}
|
||||
})
|
||||
}
|
||||
//坐标转换函数
|
||||
const pointTransfrom = function(pointArr,imgSize){
|
||||
var newPointArr = pointArr.map(p=>{
|
||||
let x = Math.round(310 * p.x/parseInt(imgSize.imgWidth))
|
||||
let y =Math.round(155 * p.y/parseInt(imgSize.imgHeight))
|
||||
return {x,y}
|
||||
})
|
||||
return newPointArr
|
||||
}
|
||||
return {
|
||||
secretKey,
|
||||
checkNum,
|
||||
fontPos,
|
||||
checkPosArr,
|
||||
num,
|
||||
pointBackImgBase,
|
||||
poinTextList,
|
||||
backToken,
|
||||
setSize,
|
||||
tempPoints,
|
||||
text,
|
||||
barAreaColor,
|
||||
barAreaBorderColor,
|
||||
showRefresh,
|
||||
bindingClick,
|
||||
init,
|
||||
canvas,
|
||||
canvasClick,
|
||||
getMousePos,createPoint,refresh,getPictrue,pointTransfrom
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
399
front/src/components/verifition/modules/verify-slide.vue
Normal file
@@ -0,0 +1,399 @@
|
||||
<script setup>
|
||||
import { computed, getCurrentInstance, inject, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useLoading } from '@sa/hooks';
|
||||
import { aesEncrypt } from './../utils/ase';
|
||||
import { resetSize } from './../utils/util';
|
||||
import { reqCheck, reqGet } from './../api/index';
|
||||
// 定义 emit
|
||||
const emit = defineEmits(['ready', 'success', 'error']);
|
||||
|
||||
// 注入父组件提供的方法和状态
|
||||
const closeBox = inject('closeBox');
|
||||
const clickShow = inject('clickShow', ref(false));
|
||||
|
||||
const props = defineProps({
|
||||
captchaType: {
|
||||
type: String
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: '1'
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'fixed'
|
||||
},
|
||||
vSpace: {
|
||||
type: Number,
|
||||
default: 5
|
||||
},
|
||||
explain: {
|
||||
type: String,
|
||||
default: '向右滑动完成验证'
|
||||
},
|
||||
imgSize: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
width: '310px',
|
||||
height: '155px'
|
||||
})
|
||||
},
|
||||
blockSize: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
width: '50px',
|
||||
height: '50px'
|
||||
})
|
||||
},
|
||||
barSize: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
width: '310px',
|
||||
height: '40px'
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
const {proxy} = getCurrentInstance();
|
||||
const secretKey = ref(''); // 后端返回的ase加密秘钥
|
||||
const passFlag = ref(''); // 是否通过的标识
|
||||
const backImgBase = ref(''); // 验证码背景图片
|
||||
const blockBackImgBase = ref(''); // 验证滑块的背景图片
|
||||
const backToken = ref(''); // 后端返回的唯一token值
|
||||
const startMoveTime = ref('');
|
||||
const endMovetime = ref('');
|
||||
const tipWords = ref('');
|
||||
const text = ref(props.explain);
|
||||
const finishText = ref('');
|
||||
|
||||
const setSize = reactive({
|
||||
imgHeight: 0,
|
||||
imgWidth: 0,
|
||||
barHeight: 0,
|
||||
barWidth: 0
|
||||
});
|
||||
|
||||
const top = ref(0);
|
||||
const left = ref(0);
|
||||
const moveBlockLeft = ref(undefined);
|
||||
const leftBarWidth = ref(undefined);
|
||||
// 移动中样式
|
||||
const moveBlockBackgroundColor = ref(undefined);
|
||||
const leftBarBorderColor = ref('#ddd');
|
||||
const iconColor = ref(undefined);
|
||||
const iconClass = ref('icon-right');
|
||||
const status = ref(false); // 鼠标状态
|
||||
const isEnd = ref(false); // 是够验证完成
|
||||
const showRefresh = ref(true);
|
||||
const transitionLeft = ref('');
|
||||
const transitionWidth = ref('');
|
||||
const startLeft = ref(0);
|
||||
|
||||
const barArea = computed(() => {
|
||||
return proxy.$el.querySelector('.verify-bar-area');
|
||||
});
|
||||
|
||||
// 初始化函数
|
||||
function init() {
|
||||
text.value = props.explain;
|
||||
getPictrue();
|
||||
nextTick().then(() => {
|
||||
const {imgHeight, imgWidth, barHeight, barWidth} = resetSize(proxy);
|
||||
setSize.imgHeight = imgHeight;
|
||||
setSize.imgWidth = imgWidth;
|
||||
setSize.barHeight = barHeight;
|
||||
setSize.barWidth = barWidth;
|
||||
// 修改为使用 emit
|
||||
emit('ready', proxy);
|
||||
});
|
||||
|
||||
// 事件监听器改写为箭头函数
|
||||
window.removeEventListener('touchmove', e => move(e));
|
||||
window.removeEventListener('mousemove', e => move(e));
|
||||
window.removeEventListener('touchend', () => end());
|
||||
window.removeEventListener('mouseup', () => end());
|
||||
|
||||
window.addEventListener('touchmove', e => move(e));
|
||||
window.addEventListener('mousemove', e => move(e));
|
||||
window.addEventListener('touchend', () => end());
|
||||
window.addEventListener('mouseup', () => end());
|
||||
}
|
||||
|
||||
// 监听 type 变化
|
||||
watch(
|
||||
() => props.type,
|
||||
() => {
|
||||
init();
|
||||
}
|
||||
);
|
||||
|
||||
// 组件挂载时
|
||||
onMounted(() => {
|
||||
init();
|
||||
proxy.$el.onselectstart = () => false;
|
||||
});
|
||||
|
||||
// start, move, end 函数基本保持不变,但需要修改一些语法
|
||||
function start(e) {
|
||||
const event = e || window.event;
|
||||
const xPos = event.touches ? event.touches[0].pageX : event.clientX;
|
||||
|
||||
startLeft.value = Math.floor(xPos - barArea.value.getBoundingClientRect().left);
|
||||
startMoveTime.value = Number(new Date());
|
||||
|
||||
if (isEnd.value === false) {
|
||||
text.value = '';
|
||||
moveBlockBackgroundColor.value = '#337ab7';
|
||||
leftBarBorderColor.value = '#337AB7';
|
||||
iconColor.value = '#fff';
|
||||
event.stopPropagation();
|
||||
status.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 鼠标移动
|
||||
function move(e) {
|
||||
e ||= window.event;
|
||||
if (status.value && isEnd.value == false) {
|
||||
if (!e.touches) {
|
||||
// 兼容PC端
|
||||
var x = e.clientX;
|
||||
} else {
|
||||
// 兼容移动端
|
||||
var x = e.touches[0].pageX;
|
||||
}
|
||||
const bar_area_left = barArea.value.getBoundingClientRect().left;
|
||||
let move_block_left = x - bar_area_left; // 小方块相对于父元素的left值
|
||||
if (
|
||||
move_block_left >=
|
||||
barArea.value.offsetWidth - Number.parseInt(Number.parseInt(props.blockSize.width) / 2) - 2
|
||||
) {
|
||||
move_block_left = barArea.value.offsetWidth - Number.parseInt(Number.parseInt(props.blockSize.width) / 2) - 2;
|
||||
}
|
||||
if (move_block_left <= 0) {
|
||||
move_block_left = Number.parseInt(Number.parseInt(props.blockSize.width, 10) / 2, 10);
|
||||
}
|
||||
// 拖动后小方块的left值
|
||||
moveBlockLeft.value = `${move_block_left - startLeft.value}px`;
|
||||
leftBarWidth.value = `${move_block_left - startLeft.value}px`;
|
||||
}
|
||||
}
|
||||
|
||||
// 鼠标松开
|
||||
function end() {
|
||||
endMovetime.value = Number(new Date());
|
||||
if (status.value && isEnd.value === false) {
|
||||
let moveLeftDistance = Number.parseInt((moveBlockLeft.value || '').replace('px', ''));
|
||||
moveLeftDistance = (moveLeftDistance * 310) / Number.parseInt(setSize.imgWidth);
|
||||
const data = {
|
||||
captchaType: props.captchaType,
|
||||
pointJson: secretKey.value
|
||||
? aesEncrypt(JSON.stringify({x: moveLeftDistance, y: 5.0}), secretKey.value)
|
||||
: JSON.stringify({x: moveLeftDistance, y: 5.0}),
|
||||
token: backToken.value
|
||||
};
|
||||
|
||||
reqCheck(data).then(res => {
|
||||
if (res.repCode === '0000') {
|
||||
moveBlockBackgroundColor.value = '#5cb85c';
|
||||
leftBarBorderColor.value = '#5cb85c';
|
||||
iconColor.value = '#fff';
|
||||
iconClass.value = 'icon-check';
|
||||
showRefresh.value = false;
|
||||
isEnd.value = true;
|
||||
|
||||
if (props.mode === 'pop') {
|
||||
setTimeout(() => {
|
||||
clickShow.value = false;
|
||||
refresh();
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
passFlag.value = true;
|
||||
tipWords.value = `${((endMovetime.value - startMoveTime.value) / 1000).toFixed(2)}s验证成功`;
|
||||
|
||||
const captchaVerification = secretKey.value
|
||||
? aesEncrypt(`${backToken.value}---${JSON.stringify({x: moveLeftDistance, y: 5.0})}`, secretKey.value)
|
||||
: `${backToken.value}---${JSON.stringify({x: moveLeftDistance, y: 5.0})}`;
|
||||
|
||||
setTimeout(() => {
|
||||
tipWords.value = '';
|
||||
closeBox?.();
|
||||
emit('success', {captchaVerification});
|
||||
}, 1000);
|
||||
} else {
|
||||
moveBlockBackgroundColor.value = '#d9534f';
|
||||
leftBarBorderColor.value = '#d9534f';
|
||||
iconColor.value = '#fff';
|
||||
iconClass.value = 'icon-close';
|
||||
passFlag.value = false;
|
||||
|
||||
setTimeout(() => {
|
||||
refresh();
|
||||
}, 1000);
|
||||
|
||||
emit('error', proxy);
|
||||
tipWords.value = '验证失败';
|
||||
setTimeout(() => {
|
||||
tipWords.value = '';
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
status.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新函数
|
||||
const refresh = () => {
|
||||
showRefresh.value = true;
|
||||
finishText.value = '';
|
||||
transitionLeft.value = 'left .3s';
|
||||
moveBlockLeft.value = 0;
|
||||
leftBarWidth.value = undefined;
|
||||
transitionWidth.value = 'width .3s';
|
||||
leftBarBorderColor.value = '#ddd';
|
||||
moveBlockBackgroundColor.value = '#fff';
|
||||
iconColor.value = '#000';
|
||||
iconClass.value = 'icon-right';
|
||||
isEnd.value = false;
|
||||
|
||||
getPictrue();
|
||||
setTimeout(() => {
|
||||
transitionWidth.value = '';
|
||||
transitionLeft.value = '';
|
||||
text.value = props.explain;
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const showPic = ref(true);
|
||||
const {loading, startLoading, endLoading} = useLoading();
|
||||
|
||||
// 获取图片函数
|
||||
async function getPictrue() {
|
||||
startLoading();
|
||||
const data = {
|
||||
captchaType: props.captchaType
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await reqGet(data);
|
||||
if (res.repCode === '0000') {
|
||||
showPic.value = true;
|
||||
backImgBase.value = res.repData.originalImageBase64;
|
||||
blockBackImgBase.value = res.repData.jigsawImageBase64;
|
||||
backToken.value = res.repData.token;
|
||||
secretKey.value = res.repData.secretKey;
|
||||
} else {
|
||||
tipWords.value = res.repMsg;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取验证码失败:', error);
|
||||
showPic.value = false;
|
||||
} finally {
|
||||
endLoading();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="position: relative">
|
||||
<NSpin :show="loading">
|
||||
<template v-if="showPic && !loading">
|
||||
<div
|
||||
v-if="type === '2'"
|
||||
class="verify-img-out"
|
||||
:style="{ height: parseInt(setSize.imgHeight) + vSpace + 'px' }"
|
||||
>
|
||||
<div class="verify-img-panel" :style="{ width: setSize.imgWidth, height: setSize.imgHeight }">
|
||||
<img
|
||||
:src="'data:image/png;base64,' + backImgBase"
|
||||
alt=""
|
||||
style="width: 100%; height: 100%; display: block"
|
||||
/>
|
||||
<div v-show="showRefresh" class="verify-refresh rounded-full hover:bg-gray-100" @click="refresh">
|
||||
<i class="iconfont icon-refresh"></i>
|
||||
</div>
|
||||
<Transition name="tips">
|
||||
<span v-if="tipWords" class="verify-tips" :class="passFlag ? 'suc-bg' : 'err-bg'">{{ tipWords }}</span>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="verify-bar-area"
|
||||
:style="{ width: setSize.imgWidth, height: barSize.height, 'line-height': barSize.height }"
|
||||
>
|
||||
<span class="verify-msg" v-text="text"></span>
|
||||
<div
|
||||
class="verify-left-bar"
|
||||
:style="{
|
||||
width: leftBarWidth !== undefined ? leftBarWidth : barSize.height,
|
||||
height: barSize.height,
|
||||
'border-color': leftBarBorderColor,
|
||||
transaction: transitionWidth
|
||||
}"
|
||||
>
|
||||
<span class="verify-msg" v-text="finishText"></span>
|
||||
<div
|
||||
class="verify-move-block"
|
||||
:style="{
|
||||
width: barSize.height,
|
||||
height: barSize.height,
|
||||
'background-color': moveBlockBackgroundColor,
|
||||
left: moveBlockLeft,
|
||||
transition: transitionLeft
|
||||
}"
|
||||
@touchstart="start"
|
||||
@mousedown="start"
|
||||
>
|
||||
<i class="iconfont verify-icon" :class="[iconClass]" :style="{ color: iconColor }"></i>
|
||||
<div
|
||||
v-if="type === '2'"
|
||||
class="verify-sub-block"
|
||||
:style="{
|
||||
width: Math.floor((parseInt(setSize.imgWidth) * 47) / 310) + 'px',
|
||||
height: setSize.imgHeight,
|
||||
top: '-' + (parseInt(setSize.imgHeight) + vSpace) + 'px',
|
||||
'background-size': setSize.imgWidth + ' ' + setSize.imgHeight
|
||||
}"
|
||||
>
|
||||
<img
|
||||
:src="'data:image/png;base64,' + blockBackImgBase"
|
||||
alt=""
|
||||
style="width: 100%; height: 100%; display: block"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>
|
||||
<div v-show="showRefresh" class="verify-refresh rounded-full hover:bg-gray-100" @click="refresh">
|
||||
<i class="iconfont icon-refresh"></i>
|
||||
</div>
|
||||
|
||||
<NResult
|
||||
size="small"
|
||||
:status="loading ? '404' : '500'"
|
||||
:title="loading ? '正在获取验证码...' : '哎呀,网络出错了!'"
|
||||
:description="loading ? '' : '重新刷新试试?'"
|
||||
></NResult>
|
||||
</div>
|
||||
</template>
|
||||
</NSpin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.verify-refresh {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
text-align: center;
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
</style>
|
||||
11
front/src/components/verifition/utils/ase.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import CryptoJS from 'crypto-js'
|
||||
/**
|
||||
* @word 要加密的内容
|
||||
* @keyWord String 服务器随机返回的关键字
|
||||
* */
|
||||
export function aesEncrypt(word,keyWord="XwKsGlMcdPMEhR1B"){
|
||||
var key = CryptoJS.enc.Utf8.parse(keyWord);
|
||||
var srcs = CryptoJS.enc.Utf8.parse(word);
|
||||
var encrypted = CryptoJS.AES.encrypt(srcs, key, {mode:CryptoJS.mode.ECB,padding: CryptoJS.pad.Pkcs7});
|
||||
return encrypted.toString();
|
||||
}
|
||||
34
front/src/components/verifition/utils/axios.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import axios from 'axios';
|
||||
import { getServiceBaseURL } from '@/utils/service';
|
||||
|
||||
// axios.defaults.baseURL = 'https://captcha.anji-plus.com/captcha-api';
|
||||
// axios.defaults.baseURL = 'http://192.168.1.13:28888';
|
||||
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
|
||||
const { baseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
|
||||
axios.defaults.baseURL = baseURL;
|
||||
|
||||
const service = axios.create({
|
||||
timeout: 40000,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Content-Type': 'application/json; charset=UTF-8'
|
||||
}
|
||||
});
|
||||
service.interceptors.request.use(
|
||||
config => {
|
||||
return config;
|
||||
},
|
||||
error => {
|
||||
Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// response interceptor
|
||||
service.interceptors.response.use(
|
||||
response => {
|
||||
const res = response.data;
|
||||
return res;
|
||||
},
|
||||
error => {}
|
||||
);
|
||||
export default service;
|
||||
103
front/src/components/verifition/utils/util.js
Normal file
@@ -0,0 +1,103 @@
|
||||
export function resetSize(vm) {
|
||||
let bar_height;
|
||||
let bar_width;
|
||||
let img_height;
|
||||
let img_width; // 图片的宽度、高度,移动条的宽度、高度
|
||||
|
||||
const parentWidth = vm.$el.parentNode.offsetWidth || window.offsetWidth;
|
||||
const parentHeight = vm.$el.parentNode.offsetHeight || window.offsetHeight;
|
||||
if (vm.imgSize.width.includes('%')) {
|
||||
img_width = `${(Number.parseInt(vm.imgSize.width, 10) / 100) * parentWidth}px`;
|
||||
} else {
|
||||
img_width = vm.imgSize.width;
|
||||
}
|
||||
|
||||
if (vm.imgSize.height.includes('%')) {
|
||||
img_height = `${(Number.parseInt(vm.imgSize.height, 10) / 100) * parentHeight}px`;
|
||||
} else {
|
||||
img_height = vm.imgSize.height;
|
||||
}
|
||||
|
||||
if (vm.barSize.width.includes('%')) {
|
||||
bar_width = `${(Number.parseInt(vm.barSize.width, 10) / 100) * parentWidth}px`;
|
||||
} else {
|
||||
bar_width = vm.barSize.width;
|
||||
}
|
||||
|
||||
if (vm.barSize.height.includes('%')) {
|
||||
bar_height = `${(Number.parseInt(vm.barSize.height, 10) / 100) * parentHeight}px`;
|
||||
} else {
|
||||
bar_height = vm.barSize.height;
|
||||
}
|
||||
|
||||
return { imgWidth: img_width, imgHeight: img_height, barWidth: bar_width, barHeight: bar_height };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
export const _code_chars = [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
'd',
|
||||
'e',
|
||||
'f',
|
||||
'g',
|
||||
'h',
|
||||
'i',
|
||||
'j',
|
||||
'k',
|
||||
'l',
|
||||
'm',
|
||||
'n',
|
||||
'o',
|
||||
'p',
|
||||
'q',
|
||||
'r',
|
||||
's',
|
||||
't',
|
||||
'u',
|
||||
'v',
|
||||
'w',
|
||||
'x',
|
||||
'y',
|
||||
'z',
|
||||
'A',
|
||||
'B',
|
||||
'C',
|
||||
'D',
|
||||
'E',
|
||||
'F',
|
||||
'G',
|
||||
'H',
|
||||
'I',
|
||||
'J',
|
||||
'K',
|
||||
'L',
|
||||
'M',
|
||||
'N',
|
||||
'O',
|
||||
'P',
|
||||
'Q',
|
||||
'R',
|
||||
'S',
|
||||
'T',
|
||||
'U',
|
||||
'V',
|
||||
'W',
|
||||
'X',
|
||||
'Y',
|
||||
'Z'
|
||||
];
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
export const _code_color1 = ['#fffff0', '#f0ffff', '#f0fff0', '#fff0f0'];
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
export const _code_color2 = ['#FF0033', '#006699', '#993366', '#FF9900', '#66CC66', '#FF33CC'];
|
||||
63
front/src/constants/app.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { transformRecordToOption } from '@/utils/common';
|
||||
|
||||
export const GLOBAL_HEADER_MENU_ID = '__GLOBAL_HEADER_MENU__';
|
||||
|
||||
export const GLOBAL_SIDER_MENU_ID = '__GLOBAL_SIDER_MENU__';
|
||||
|
||||
export const themeSchemaRecord: Record<UnionKey.ThemeScheme, App.I18n.I18nKey> = {
|
||||
light: 'theme.themeSchema.light',
|
||||
dark: 'theme.themeSchema.dark',
|
||||
auto: 'theme.themeSchema.auto'
|
||||
};
|
||||
|
||||
export const themeSchemaOptions = transformRecordToOption(themeSchemaRecord);
|
||||
|
||||
export const loginModuleRecord: Record<UnionKey.LoginModule, App.I18n.I18nKey> = {
|
||||
'pwd-login': 'page.login.pwdLogin.title',
|
||||
'code-login': 'page.login.codeLogin.title',
|
||||
register: 'page.login.register.title',
|
||||
'reset-pwd': 'page.login.resetPwd.title',
|
||||
'bind-wechat': 'page.login.bindWeChat.title'
|
||||
};
|
||||
|
||||
export const themeLayoutModeRecord: Record<UnionKey.ThemeLayoutMode, App.I18n.I18nKey> = {
|
||||
vertical: 'theme.layoutMode.vertical',
|
||||
'vertical-mix': 'theme.layoutMode.vertical-mix',
|
||||
horizontal: 'theme.layoutMode.horizontal',
|
||||
'horizontal-mix': 'theme.layoutMode.horizontal-mix'
|
||||
};
|
||||
|
||||
export const themeLayoutModeOptions = transformRecordToOption(themeLayoutModeRecord);
|
||||
|
||||
export const themeScrollModeRecord: Record<UnionKey.ThemeScrollMode, App.I18n.I18nKey> = {
|
||||
wrapper: 'theme.scrollMode.wrapper',
|
||||
content: 'theme.scrollMode.content'
|
||||
};
|
||||
|
||||
export const themeScrollModeOptions = transformRecordToOption(themeScrollModeRecord);
|
||||
|
||||
export const themeTabModeRecord: Record<UnionKey.ThemeTabMode, App.I18n.I18nKey> = {
|
||||
chrome: 'theme.tab.mode.chrome',
|
||||
button: 'theme.tab.mode.button'
|
||||
};
|
||||
|
||||
export const themeTabModeOptions = transformRecordToOption(themeTabModeRecord);
|
||||
|
||||
export const themePageAnimationModeRecord: Record<UnionKey.ThemePageAnimateMode, App.I18n.I18nKey> = {
|
||||
'fade-slide': 'theme.page.mode.fade-slide',
|
||||
fade: 'theme.page.mode.fade',
|
||||
'fade-bottom': 'theme.page.mode.fade-bottom',
|
||||
'fade-scale': 'theme.page.mode.fade-scale',
|
||||
'zoom-fade': 'theme.page.mode.zoom-fade',
|
||||
'zoom-out': 'theme.page.mode.zoom-out',
|
||||
none: 'theme.page.mode.none'
|
||||
};
|
||||
|
||||
export const themePageAnimationModeOptions = transformRecordToOption(themePageAnimationModeRecord);
|
||||
|
||||
export const resetCacheStrategyRecord: Record<UnionKey.ResetCacheStrategy, App.I18n.I18nKey> = {
|
||||
close: 'theme.resetCacheStrategy.close',
|
||||
refresh: 'theme.resetCacheStrategy.refresh'
|
||||
};
|
||||
|
||||
export const resetCacheStrategyOptions = transformRecordToOption(resetCacheStrategyRecord);
|
||||
38
front/src/constants/business.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { transformRecordToOption } from '@/utils/common';
|
||||
|
||||
export const enableStatusRecord: Record<Api.Common.EnableStatus, App.I18n.I18nKey> = {
|
||||
'1': 'page.manage.common.status.enable',
|
||||
'2': 'page.manage.common.status.disable'
|
||||
};
|
||||
|
||||
export const specialEnableStatusRecord: Record<Api.Common.SpecialEnableStatus, App.I18n.I18nKey> = {
|
||||
'0': 'page.manage.common.status.enable',
|
||||
'1': 'page.manage.common.status.disable'
|
||||
};
|
||||
|
||||
export const enableStatusOptions = transformRecordToOption(enableStatusRecord);
|
||||
|
||||
export const specialEnableStatusOptions = transformRecordToOption(specialEnableStatusRecord);
|
||||
|
||||
export const userGenderRecord: Record<Api.SystemManage.UserGender, App.I18n.I18nKey> = {
|
||||
'0': 'page.manage.user.gender.male',
|
||||
'1': 'page.manage.user.gender.female',
|
||||
'2': 'page.manage.user.gender.secret'
|
||||
};
|
||||
|
||||
export const userGenderOptions = transformRecordToOption(userGenderRecord);
|
||||
|
||||
export const menuTypeRecord: Record<Api.SystemManage.MenuType, App.I18n.I18nKey> = {
|
||||
'M': 'page.manage.menu.type.directory',
|
||||
'C': 'page.manage.menu.type.menu',
|
||||
'F': 'page.manage.menu.type.menu'
|
||||
};
|
||||
|
||||
export const menuTypeOptions = transformRecordToOption(menuTypeRecord);
|
||||
|
||||
export const menuIconTypeRecord: Record<Api.SystemManage.IconType, App.I18n.I18nKey> = {
|
||||
'1': 'page.manage.menu.iconType.iconify',
|
||||
'2': 'page.manage.menu.iconType.local'
|
||||
};
|
||||
|
||||
export const menuIconTypeOptions = transformRecordToOption(menuIconTypeRecord);
|
||||
8
front/src/constants/common.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { transformRecordToOption } from '@/utils/common';
|
||||
|
||||
export const yesOrNoRecord: Record<CommonType.YesOrNo, App.I18n.I18nKey> = {
|
||||
Y: 'common.yesOrNo.yes',
|
||||
N: 'common.yesOrNo.no'
|
||||
};
|
||||
|
||||
export const yesOrNoOptions = transformRecordToOption(yesOrNoRecord);
|
||||
8
front/src/constants/map-sdk.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/** baidu map sdk url */
|
||||
export const BAIDU_MAP_SDK_URL = `https://api.map.baidu.com/getscript?v=3.0&ak=KSezYymXPth1DIGILRX3oYN9PxbOQQmU&services=&t=20210201100830&s=1`;
|
||||
|
||||
/** Amap sdk url */
|
||||
export const AMAP_SDK_URL = 'https://webapi.amap.com/maps?v=2.0&key=6081c3d006ee87ac80ebb164ecfcc7e5';
|
||||
|
||||
/** tencent sdk url */
|
||||
export const TENCENT_MAP_SDK_URL = 'https://map.qq.com/api/gljs?v=1.exp&key=A6DBZ-KXPLW-JKSRY-ONZF4-CPHY3-K6BL7';
|
||||
26
front/src/constants/reg.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export const REG_USER_NAME = /^[\u4E00-\u9FA5a-zA-Z0-9_-]{4,16}$/;
|
||||
|
||||
/** Phone reg */
|
||||
export const REG_PHONE =
|
||||
/^[1](([3][0-9])|([4][01456789])|([5][012356789])|([6][2567])|([7][0-8])|([8][0-9])|([9][012356789]))[0-9]{8}$/;
|
||||
|
||||
/**
|
||||
* Password reg
|
||||
*
|
||||
* 6-18 characters, including letters, numbers, and underscores
|
||||
*/
|
||||
export const REG_PWD = /^[\w@!#$%^&*]{6,18}$/;
|
||||
|
||||
|
||||
/** Email reg */
|
||||
export const REG_EMAIL = /^\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$/;
|
||||
|
||||
/** Six digit code reg */
|
||||
export const REG_CODE_SIX = /^\d{6}$/;
|
||||
|
||||
/** Four digit code reg */
|
||||
export const REG_CODE_FOUR = /^\d{4}$/;
|
||||
|
||||
/** Url reg */
|
||||
export const REG_URL =
|
||||
/(((^https?:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+(?::\d+)?|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[\w]*))?)$/;
|
||||
8
front/src/directive/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { App } from 'vue';
|
||||
import hasRole from './permission/hasRole';
|
||||
import hasPermission from './permission/hasPermission';
|
||||
|
||||
export function setupDirective(app: App) {
|
||||
app.directive('hasRole', hasRole);
|
||||
app.directive('hasPermission', hasPermission);
|
||||
}
|
||||
35
front/src/directive/permission/hasPermission.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/** v-hasPermission 操作权限处理 */
|
||||
import type { Directive } from 'vue';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
|
||||
const hasPermission: Directive<HTMLElement, string | string[]> = {
|
||||
mounted(el, { value }) {
|
||||
const ALL_PERMISSION = '*:*:*';
|
||||
const authStore = useAuthStore();
|
||||
const permissions = authStore.userInfo.permissions;
|
||||
if (value) {
|
||||
let permissionFlag: string[] = [];
|
||||
// 如果直接在jsx的语法中写v-hasPermission=['code'],接收到的是字符串,建议使用withDirectives
|
||||
if (typeof value === 'string') {
|
||||
const matched = value.match(/\[(.*)]/);
|
||||
if (matched && matched[1]) {
|
||||
permissionFlag = matched[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, ''));
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
permissionFlag = value;
|
||||
}
|
||||
|
||||
const hasPermissions = permissions.some(permission => {
|
||||
return ALL_PERMISSION === permission || permissionFlag.includes(permission);
|
||||
});
|
||||
|
||||
if (!hasPermissions) {
|
||||
el.parentNode?.removeChild(el);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`请设置操作权限标签值`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default hasPermission;
|
||||
36
front/src/directive/permission/hasRole.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/** v-hasRole 角色权限处理 */
|
||||
import type { Directive } from 'vue';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
|
||||
const hasRole: Directive<HTMLElement, string[] | string> = {
|
||||
mounted(el, { value }) {
|
||||
const SUPER_ADMIN = 'superadmin';
|
||||
const authStore = useAuthStore();
|
||||
const roles = authStore.userInfo.roles;
|
||||
|
||||
if (value) {
|
||||
let roleFlag: string[] = [];
|
||||
if (typeof value === 'string') {
|
||||
// 定义在jsx的语法中,接收到的是字符串
|
||||
const matched = value.match(/\[(.*)]/);
|
||||
if (matched && matched[1]) {
|
||||
roleFlag = matched[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, ''));
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
roleFlag = value;
|
||||
}
|
||||
|
||||
const hasRoles = roles.some(role => {
|
||||
return SUPER_ADMIN === role || roleFlag.includes(role);
|
||||
});
|
||||
|
||||
if (!hasRoles) {
|
||||
el.parentNode?.removeChild(el);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`请设置角色权限标签值`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default hasRole;
|
||||
7
front/src/enum/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export enum SetupStoreId {
|
||||
App = 'app-store',
|
||||
Theme = 'theme-store',
|
||||
Auth = 'auth-store',
|
||||
Route = 'route-store',
|
||||
Tab = 'tab-store'
|
||||
}
|
||||
45
front/src/hooks/business/auth.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
|
||||
export function useAuth() {
|
||||
const authStore = useAuthStore();
|
||||
|
||||
function hasAuth(codes: string | string[]) {
|
||||
const ALL_PERMISSION = '*:*:*';
|
||||
|
||||
if (!authStore.isLogin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (authStore.userInfo.permissions.includes(ALL_PERMISSION)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof codes === 'string') {
|
||||
return authStore.userInfo.permissions.includes(codes);
|
||||
}
|
||||
|
||||
return codes.some(code => authStore.userInfo.permissions.includes(code));
|
||||
}
|
||||
|
||||
/** 是否有指定角色 */
|
||||
function hasRole(roles: string | string[]) {
|
||||
const SUPER_ADMIN = 'superadmin';
|
||||
if (!authStore.isLogin) {
|
||||
return false;
|
||||
}
|
||||
if (authStore.userInfo.roles.includes(SUPER_ADMIN)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof roles === 'string') {
|
||||
return authStore.userInfo.roles.includes(roles);
|
||||
}
|
||||
|
||||
return roles.some(role => authStore.userInfo.roles.includes(role));
|
||||
}
|
||||
|
||||
return {
|
||||
hasAuth,
|
||||
hasRole
|
||||
};
|
||||
}
|
||||
71
front/src/hooks/business/captcha.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { computed } from 'vue';
|
||||
import { useCountDown, useLoading } from '@sa/hooks';
|
||||
import { $t } from '@/locales';
|
||||
import { REG_PHONE } from '@/constants/reg';
|
||||
|
||||
export function useCaptcha() {
|
||||
const { loading, startLoading, endLoading } = useLoading();
|
||||
const { count, start, stop, isCounting } = useCountDown(10);
|
||||
|
||||
const label = computed(() => {
|
||||
let text = $t('page.login.codeLogin.getCode');
|
||||
|
||||
const countingLabel = $t('page.login.codeLogin.reGetCode', { time: count.value });
|
||||
|
||||
if (loading.value) {
|
||||
text = '';
|
||||
}
|
||||
|
||||
if (isCounting.value) {
|
||||
text = countingLabel;
|
||||
}
|
||||
|
||||
return text;
|
||||
});
|
||||
|
||||
function isPhoneValid(phone: string) {
|
||||
if (phone.trim() === '') {
|
||||
window.$message?.error?.($t('form.phone.required'));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!REG_PHONE.test(phone)) {
|
||||
window.$message?.error?.($t('form.phone.invalid'));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function getCaptcha(phone: string) {
|
||||
const valid = isPhoneValid(phone);
|
||||
|
||||
if (!valid || loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
startLoading();
|
||||
|
||||
// request
|
||||
await new Promise(resolve => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
|
||||
window.$message?.success?.($t('page.login.codeLogin.sendCodeSuccess'));
|
||||
|
||||
start();
|
||||
|
||||
endLoading();
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
start,
|
||||
stop,
|
||||
isCounting,
|
||||
loading,
|
||||
getCaptcha
|
||||
};
|
||||
}
|
||||
249
front/src/hooks/common/download.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { onBeforeUnmount } from 'vue';
|
||||
import axios from 'axios';
|
||||
import type { AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
import type { MessageReactive } from 'naive-ui';
|
||||
import type { MessageApiInjection } from 'naive-ui/es/message/src/MessageProvider';
|
||||
import { useLoading } from '@sa/hooks';
|
||||
import { getAuthorization } from '@/service/request/shared';
|
||||
|
||||
interface MimeMap {
|
||||
xlsx: string;
|
||||
zip: string;
|
||||
oss: string;
|
||||
}
|
||||
|
||||
const mimeMap: MimeMap = {
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
zip: 'application/zip',
|
||||
oss: 'application/octet-stream'
|
||||
};
|
||||
|
||||
const baseUrl = import.meta.env.VITE_SERVICE_BASE_URL;
|
||||
|
||||
export function useDownLoadFile() {
|
||||
let messageReactive: MessageReactive | undefined | null;
|
||||
const { loading, startLoading, endLoading } = useLoading();
|
||||
|
||||
function paramsGetToUrl(url: string, params: Record<string, any> | null): string {
|
||||
let urlparams = url;
|
||||
if (params) {
|
||||
urlparams = `${url}?`;
|
||||
for (const propName of Object.keys(params)) {
|
||||
const value = params[propName];
|
||||
const part = `${encodeURIComponent(propName)}=`;
|
||||
if (value !== null && typeof value !== 'undefined') {
|
||||
if (typeof value === 'object') {
|
||||
// eslint-disable-next-line max-depth
|
||||
for (const key of Object.keys(value)) {
|
||||
// eslint-disable-next-line max-depth
|
||||
if (value[key] !== null && typeof value[key] !== 'undefined') {
|
||||
const par = `${propName}[${key}]`;
|
||||
const subPart = `${encodeURIComponent(par)}=`;
|
||||
urlparams += `${subPart + encodeURIComponent(value[key])}&`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
urlparams += `${part + encodeURIComponent(value)}&`;
|
||||
}
|
||||
}
|
||||
}
|
||||
urlparams = urlparams.slice(0, -1);
|
||||
}
|
||||
return baseUrl + urlparams;
|
||||
}
|
||||
|
||||
function getPostParams(params: Record<string, any> | null) {
|
||||
const dataParams: Record<string, any> = {}; // 用于存放请求体数据
|
||||
// get请求映射params参数
|
||||
if (params) {
|
||||
for (const propName of Object.keys(params)) {
|
||||
const value = params[propName];
|
||||
if (value !== null && typeof value !== 'undefined') {
|
||||
if (typeof value === 'object') {
|
||||
// eslint-disable-next-line max-depth
|
||||
for (const key of Object.keys(value)) {
|
||||
// eslint-disable-next-line max-depth
|
||||
if (value[key] !== null && typeof value[key] !== 'undefined') {
|
||||
const nestedKey = `${propName}[${key}]`;
|
||||
dataParams[nestedKey] = value[key];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dataParams[propName] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return dataParams;
|
||||
}
|
||||
|
||||
function handleRequest(config: AxiosRequestConfig, mine: keyof MimeMap, filename: string) {
|
||||
createMessage('warning', '正在导出中,请稍后...');
|
||||
|
||||
axios(config)
|
||||
.then(res => {
|
||||
resolveBlob(res, mine, filename);
|
||||
})
|
||||
.catch(err => {
|
||||
createMessage('error', err.message);
|
||||
endLoading();
|
||||
});
|
||||
}
|
||||
|
||||
function downLoadOss(ossId: string | number = '', filename: string = '') {
|
||||
handleRequest(
|
||||
{
|
||||
method: 'get',
|
||||
url: `${baseUrl}/system/oss/download/${ossId}`,
|
||||
responseType: 'blob',
|
||||
headers: { Authorization: getAuthorization() }
|
||||
},
|
||||
'oss',
|
||||
filename
|
||||
);
|
||||
}
|
||||
|
||||
function downLoadZip(url: string, filename: string = '') {
|
||||
handleRequest(
|
||||
{
|
||||
method: 'get',
|
||||
url: baseUrl + url,
|
||||
responseType: 'blob',
|
||||
headers: { Authorization: getAuthorization() }
|
||||
},
|
||||
'zip',
|
||||
filename
|
||||
);
|
||||
}
|
||||
|
||||
function downLoadZipPost(url: string, params: Record<string, any> | null = null, filename: string = '') {
|
||||
handleRequest(
|
||||
{
|
||||
method: 'post',
|
||||
url: baseUrl + url,
|
||||
data: getPostParams(params),
|
||||
responseType: 'blob',
|
||||
headers: {
|
||||
Authorization: getAuthorization(),
|
||||
'Content-Type': 'application/json;charset=utf-8' // 设置请求体类型
|
||||
}
|
||||
},
|
||||
'zip',
|
||||
filename
|
||||
);
|
||||
}
|
||||
|
||||
function downLoadExcel(url: string, params: Record<string, any> | null = null, filename: string = '') {
|
||||
handleRequest(
|
||||
{
|
||||
method: 'get',
|
||||
url: paramsGetToUrl(url, params),
|
||||
responseType: 'blob',
|
||||
headers: {
|
||||
Authorization: getAuthorization()
|
||||
}
|
||||
},
|
||||
'xlsx',
|
||||
filename
|
||||
);
|
||||
}
|
||||
|
||||
function downLoadExcelPost(url: string, params: Record<string, any> | null = null, filename: string = '') {
|
||||
handleRequest(
|
||||
{
|
||||
method: 'post',
|
||||
url: baseUrl + url,
|
||||
data: getPostParams(params),
|
||||
responseType: 'blob',
|
||||
headers: {
|
||||
Authorization: getAuthorization(),
|
||||
'Content-Type': 'application/json;charset=utf-8' // 设置请求体类型
|
||||
}
|
||||
},
|
||||
'xlsx',
|
||||
filename
|
||||
);
|
||||
}
|
||||
|
||||
function resolveBlob(res: AxiosResponse, mime: keyof MimeMap, filename: string) {
|
||||
const mimeType = mimeMap[mime];
|
||||
if (res.headers['content-type'].includes('application/json')) {
|
||||
const fileReader = new FileReader();
|
||||
fileReader.readAsText(new Blob([res.data], { type: 'application/octet-stream' }), 'utf-8');
|
||||
fileReader.onload = () => {
|
||||
try {
|
||||
const result = JSON.parse(fileReader.result as string);
|
||||
if (result.code === 500) {
|
||||
removeMessage();
|
||||
messageReactive = window.$message?.error(`导出失败:${result.msg}`, { duration: 0 });
|
||||
}
|
||||
} catch {
|
||||
removeMessage();
|
||||
messageReactive = window.$message?.error(`系统错误,请联系管理员`, { duration: 0 });
|
||||
}
|
||||
};
|
||||
endLoading();
|
||||
setTimeout(() => {
|
||||
removeMessage();
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
const aLink = document.createElement('a');
|
||||
const blob = new Blob([res.data], { type: mimeType });
|
||||
// //从response的headers中获取filename, 后端response.setHeader("Content-disposition", "attachment; filename=xxxx.docx") 设置的文件名;
|
||||
const patt = /filename=([^;]+\.[^.;]+);*/;
|
||||
const contentDisposition = decodeURI(res.headers['content-disposition']);
|
||||
const result = patt.exec(contentDisposition);
|
||||
// var fileName = result[1]
|
||||
// fileName = fileName.replace(/\"/g, '')
|
||||
let fileName: string | undefined = '';
|
||||
if (filename) {
|
||||
fileName = filename;
|
||||
} else {
|
||||
fileName = result?.[1];
|
||||
fileName = fileName?.replace(/"/g, '');
|
||||
}
|
||||
aLink.style.display = 'none';
|
||||
aLink.href = URL.createObjectURL(blob);
|
||||
aLink.setAttribute('download', decodeURI(typeof fileName === 'string' ? fileName : '')); // 设置下载文件名称
|
||||
document.body.appendChild(aLink);
|
||||
aLink.click();
|
||||
URL.revokeObjectURL(aLink.href); // 清除引用
|
||||
document.body.removeChild(aLink);
|
||||
|
||||
createMessage('success', '导出成功');
|
||||
endLoading();
|
||||
setTimeout(() => {
|
||||
removeMessage();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// 创建消息框
|
||||
function createMessage(type: keyof MessageApiInjection, message: string) {
|
||||
startLoading();
|
||||
removeMessage();
|
||||
messageReactive = window.$message?.[type](message, { duration: 0 }) as MessageReactive;
|
||||
}
|
||||
|
||||
// 移出消息框
|
||||
function removeMessage() {
|
||||
if (messageReactive) {
|
||||
messageReactive.destroy();
|
||||
messageReactive = null;
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
removeMessage();
|
||||
});
|
||||
|
||||
return {
|
||||
loading,
|
||||
downLoadOss,
|
||||
downLoadZip,
|
||||
downLoadZipPost,
|
||||
downLoadExcel,
|
||||
downLoadExcelPost
|
||||
};
|
||||
}
|
||||
235
front/src/hooks/common/echarts.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { computed, effectScope, nextTick, onScopeDispose, ref, watch } from 'vue';
|
||||
import * as echarts from 'echarts/core';
|
||||
import { BarChart, GaugeChart, LineChart, PictorialBarChart, PieChart, RadarChart, ScatterChart } from 'echarts/charts';
|
||||
import type {
|
||||
BarSeriesOption,
|
||||
GaugeSeriesOption,
|
||||
LineSeriesOption,
|
||||
PictorialBarSeriesOption,
|
||||
PieSeriesOption,
|
||||
RadarSeriesOption,
|
||||
ScatterSeriesOption
|
||||
} from 'echarts/charts';
|
||||
import {
|
||||
DatasetComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TitleComponent,
|
||||
ToolboxComponent,
|
||||
TooltipComponent,
|
||||
TransformComponent
|
||||
} from 'echarts/components';
|
||||
import type {
|
||||
DatasetComponentOption,
|
||||
GridComponentOption,
|
||||
LegendComponentOption,
|
||||
TitleComponentOption,
|
||||
ToolboxComponentOption,
|
||||
TooltipComponentOption
|
||||
} from 'echarts/components';
|
||||
import { LabelLayout, UniversalTransition } from 'echarts/features';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
|
||||
export type ECOption = echarts.ComposeOption<
|
||||
| BarSeriesOption
|
||||
| LineSeriesOption
|
||||
| PieSeriesOption
|
||||
| ScatterSeriesOption
|
||||
| PictorialBarSeriesOption
|
||||
| RadarSeriesOption
|
||||
| GaugeSeriesOption
|
||||
| TitleComponentOption
|
||||
| LegendComponentOption
|
||||
| TooltipComponentOption
|
||||
| GridComponentOption
|
||||
| ToolboxComponentOption
|
||||
| DatasetComponentOption
|
||||
>;
|
||||
|
||||
echarts.use([
|
||||
TitleComponent,
|
||||
LegendComponent,
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
DatasetComponent,
|
||||
TransformComponent,
|
||||
ToolboxComponent,
|
||||
BarChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
ScatterChart,
|
||||
PictorialBarChart,
|
||||
RadarChart,
|
||||
GaugeChart,
|
||||
LabelLayout,
|
||||
UniversalTransition,
|
||||
CanvasRenderer
|
||||
]);
|
||||
|
||||
interface ChartHooks {
|
||||
onRender?: (chart: echarts.ECharts) => void | Promise<void>;
|
||||
onUpdated?: (chart: echarts.ECharts) => void | Promise<void>;
|
||||
onDestroy?: (chart: echarts.ECharts) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* use echarts
|
||||
*
|
||||
* @param optionsFactory echarts options factory function
|
||||
* @param darkMode dark mode
|
||||
*/
|
||||
export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: ChartHooks = {}) {
|
||||
const scope = effectScope();
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const darkMode = computed(() => themeStore.darkMode);
|
||||
|
||||
const domRef = ref<HTMLElement | null>(null);
|
||||
const initialSize = { width: 0, height: 0 };
|
||||
const { width, height } = useElementSize(domRef, initialSize);
|
||||
|
||||
let chart: echarts.ECharts | null = null;
|
||||
const chartOptions: T = optionsFactory();
|
||||
|
||||
const {
|
||||
onRender = instance => {
|
||||
const textColor = darkMode.value ? 'rgb(224, 224, 224)' : 'rgb(31, 31, 31)';
|
||||
const maskColor = darkMode.value ? 'rgba(0, 0, 0, 0.4)' : 'rgba(255, 255, 255, 0.8)';
|
||||
|
||||
instance.showLoading({
|
||||
color: themeStore.themeColor,
|
||||
textColor,
|
||||
fontSize: 14,
|
||||
maskColor
|
||||
});
|
||||
},
|
||||
onUpdated = instance => {
|
||||
instance.hideLoading();
|
||||
},
|
||||
onDestroy
|
||||
} = hooks;
|
||||
|
||||
/**
|
||||
* whether can render chart
|
||||
*
|
||||
* when domRef is ready and initialSize is valid
|
||||
*/
|
||||
function canRender() {
|
||||
return domRef.value && initialSize.width > 0 && initialSize.height > 0;
|
||||
}
|
||||
|
||||
/** is chart rendered */
|
||||
function isRendered() {
|
||||
return Boolean(domRef.value && chart);
|
||||
}
|
||||
|
||||
/**
|
||||
* update chart options
|
||||
*
|
||||
* @param callback callback function
|
||||
*/
|
||||
async function updateOptions(callback: (opts: T, optsFactory: () => T) => ECOption = () => chartOptions) {
|
||||
if (!isRendered()) return;
|
||||
|
||||
const updatedOpts = callback(chartOptions, optionsFactory);
|
||||
|
||||
Object.assign(chartOptions, updatedOpts);
|
||||
|
||||
if (isRendered()) {
|
||||
chart?.clear();
|
||||
}
|
||||
|
||||
chart?.setOption({ ...updatedOpts, backgroundColor: 'transparent' });
|
||||
|
||||
await onUpdated?.(chart!);
|
||||
}
|
||||
|
||||
function setOptions(options: T) {
|
||||
chart?.setOption(options);
|
||||
}
|
||||
|
||||
/** render chart */
|
||||
async function render() {
|
||||
if (!isRendered()) {
|
||||
const chartTheme = darkMode.value ? 'dark' : 'light';
|
||||
|
||||
await nextTick();
|
||||
|
||||
chart = echarts.init(domRef.value, chartTheme);
|
||||
|
||||
chart.setOption({ ...chartOptions, backgroundColor: 'transparent' });
|
||||
|
||||
await onRender?.(chart);
|
||||
}
|
||||
}
|
||||
|
||||
/** resize chart */
|
||||
function resize() {
|
||||
chart?.resize();
|
||||
}
|
||||
|
||||
/** destroy chart */
|
||||
async function destroy() {
|
||||
if (!chart) return;
|
||||
|
||||
await onDestroy?.(chart);
|
||||
chart?.dispose();
|
||||
chart = null;
|
||||
}
|
||||
|
||||
/** change chart theme */
|
||||
async function changeTheme() {
|
||||
await destroy();
|
||||
await render();
|
||||
await onUpdated?.(chart!);
|
||||
}
|
||||
|
||||
/**
|
||||
* render chart by size
|
||||
*
|
||||
* @param w width
|
||||
* @param h height
|
||||
*/
|
||||
async function renderChartBySize(w: number, h: number) {
|
||||
initialSize.width = w;
|
||||
initialSize.height = h;
|
||||
|
||||
// size is abnormal, destroy chart
|
||||
if (!canRender()) {
|
||||
await destroy();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// resize chart
|
||||
if (isRendered()) {
|
||||
resize();
|
||||
}
|
||||
|
||||
// render chart
|
||||
await render();
|
||||
}
|
||||
|
||||
scope.run(() => {
|
||||
watch([width, height], ([newWidth, newHeight]) => {
|
||||
renderChartBySize(newWidth, newHeight);
|
||||
});
|
||||
|
||||
watch(darkMode, () => {
|
||||
changeTheme();
|
||||
});
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
destroy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
return {
|
||||
domRef,
|
||||
updateOptions,
|
||||
setOptions
|
||||
};
|
||||
}
|
||||
104
front/src/hooks/common/form.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { ref, toValue } from 'vue';
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
import type { FormInst } from 'naive-ui';
|
||||
import { REG_CODE_SIX, REG_EMAIL, REG_PHONE, REG_PWD, REG_USER_NAME } from '@/constants/reg';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
export type ModelCallback<T> = {
|
||||
add?: (data: T) => Promise<any>;
|
||||
edit?: (data: T) => Promise<any>;
|
||||
getData?: () => Promise<void>;
|
||||
transformData?: (data: T) => Record<string, any> | null;
|
||||
};
|
||||
|
||||
export function useFormRules() {
|
||||
const patternRules = {
|
||||
userName: {
|
||||
pattern: REG_USER_NAME,
|
||||
message: $t('form.userName.invalid'),
|
||||
trigger: 'change'
|
||||
},
|
||||
phone: {
|
||||
pattern: REG_PHONE,
|
||||
message: $t('form.phone.invalid'),
|
||||
trigger: 'change'
|
||||
},
|
||||
pwd: {
|
||||
pattern: REG_PWD,
|
||||
message: $t('form.pwd.invalid'),
|
||||
trigger: 'change'
|
||||
},
|
||||
code: {
|
||||
pattern: REG_CODE_SIX,
|
||||
message: $t('form.code.invalid'),
|
||||
trigger: 'change'
|
||||
},
|
||||
email: {
|
||||
pattern: REG_EMAIL,
|
||||
message: $t('form.email.invalid'),
|
||||
trigger: 'change'
|
||||
}
|
||||
} satisfies Record<string, App.Global.FormRule>;
|
||||
|
||||
const formRules = {
|
||||
userName: [createRequiredRule($t('form.userName.required')), patternRules.userName],
|
||||
phone: [createRequiredRule($t('form.phone.required')), patternRules.phone],
|
||||
pwd: [createRequiredRule($t('form.pwd.required')), patternRules.pwd],
|
||||
code: [createRequiredRule($t('form.code.required')), patternRules.code],
|
||||
email: [createRequiredRule($t('form.email.required')), patternRules.email]
|
||||
} satisfies Record<string, App.Global.FormRule[]>;
|
||||
|
||||
/** the default required rule */
|
||||
const defaultRequiredRule = createRequiredRule($t('form.required'));
|
||||
|
||||
function createRequiredRule(message: string): App.Global.FormRule {
|
||||
return {
|
||||
required: true,
|
||||
message
|
||||
};
|
||||
}
|
||||
|
||||
/** create a rule for confirming the password */
|
||||
function createConfirmPwdRule(pwd: string | Ref<string> | ComputedRef<string>) {
|
||||
const confirmPwdRule: App.Global.FormRule[] = [
|
||||
{ required: true, message: $t('form.confirmPwd.required') },
|
||||
{
|
||||
asyncValidator: (rule, value) => {
|
||||
if (value.trim() !== '' && value !== toValue(pwd)) {
|
||||
return Promise.reject(rule.message);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
message: $t('form.confirmPwd.invalid'),
|
||||
trigger: 'input'
|
||||
}
|
||||
];
|
||||
return confirmPwdRule;
|
||||
}
|
||||
|
||||
return {
|
||||
patternRules,
|
||||
formRules,
|
||||
defaultRequiredRule,
|
||||
createRequiredRule,
|
||||
createConfirmPwdRule
|
||||
};
|
||||
}
|
||||
|
||||
export function useNaiveForm() {
|
||||
const formRef = ref<FormInst | null>(null);
|
||||
|
||||
async function validate() {
|
||||
await formRef.value?.validate();
|
||||
}
|
||||
|
||||
async function restoreValidation() {
|
||||
formRef.value?.restoreValidation();
|
||||
}
|
||||
|
||||
return {
|
||||
formRef,
|
||||
validate,
|
||||
restoreValidation
|
||||
};
|
||||
}
|
||||
10
front/src/hooks/common/icon.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useSvgIconRender } from '@sa/hooks';
|
||||
import SvgIcon from '@/components/custom/svg-icon.vue';
|
||||
|
||||
export function useSvgIcon() {
|
||||
const { SvgIconVNode } = useSvgIconRender(SvgIcon);
|
||||
|
||||
return {
|
||||
SvgIconVNode
|
||||
};
|
||||
}
|
||||
149
front/src/hooks/common/pagination.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import type { PaginationProps } from 'naive-ui';
|
||||
import { useLoading } from '@sa/hooks';
|
||||
import { $t } from '@/locales';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { jsonClone } from '~/packages/utils/src/klona';
|
||||
|
||||
// itemCount 数据回来,赋值一下,例如
|
||||
// async function getData() {
|
||||
// warningList<Api.AbilityWarn.Warn[]>().then(res => {
|
||||
// alarmData.value = res.data as Api.AbilityWarn.Warn[];
|
||||
// updatePagination({ itemCount: res.response.data.total });
|
||||
// });
|
||||
// }
|
||||
|
||||
export function usePagination(config?: {
|
||||
queryParams?: Record<string, any>;
|
||||
getData?: () => Promise<void>;
|
||||
showTotal?: boolean; // 是否显示总数
|
||||
}) {
|
||||
const appStore = useAppStore();
|
||||
|
||||
const { loading, startLoading, endLoading } = useLoading();
|
||||
|
||||
const isMobile = computed(() => appStore.isMobile);
|
||||
|
||||
const showTotal = config?.showTotal ?? true;
|
||||
|
||||
const searchParams: Record<string, any> = ref({});
|
||||
|
||||
const pagination: PaginationProps = reactive({
|
||||
page: 1,
|
||||
pageSize: config?.queryParams?.pageSize || 10,
|
||||
showSizePicker: true,
|
||||
pageSizes: [1, 2, 3, 4, 5].map(item => (config?.queryParams?.pageSize || 10) * item),
|
||||
onUpdatePage: async (page: number) => {
|
||||
pagination.page = page;
|
||||
|
||||
updateSearchParams({
|
||||
pageNum: page,
|
||||
pageSize: pagination.pageSize!
|
||||
});
|
||||
|
||||
config?.getData?.();
|
||||
},
|
||||
onUpdatePageSize: async (pageSize: number) => {
|
||||
pagination.pageSize = pageSize;
|
||||
pagination.page = 1;
|
||||
|
||||
updateSearchParams({
|
||||
pageNum: pagination.page,
|
||||
pageSize
|
||||
});
|
||||
|
||||
config?.getData?.();
|
||||
},
|
||||
...(showTotal
|
||||
? {
|
||||
prefix: page => $t('datatable.itemCount', { total: page.itemCount })
|
||||
}
|
||||
: {})
|
||||
});
|
||||
|
||||
if (config?.queryParams) {
|
||||
searchParams.value = jsonClone(config.queryParams);
|
||||
config.queryParams.pageNum ||= pagination.page;
|
||||
config.queryParams.pageSize ||= pagination.pageSize;
|
||||
initSearchParams();
|
||||
}
|
||||
|
||||
// 更新搜索参数中的分页参数
|
||||
function updateSearchParams(params: Record<string, any>) {
|
||||
if (!config || !config.queryParams) return;
|
||||
Object.assign(searchParams.value, params);
|
||||
}
|
||||
|
||||
function initSearchParams() {
|
||||
if (!config || !config.queryParams) return;
|
||||
updateSearchParams({
|
||||
pageNum: 1,
|
||||
pageSize: pagination.pageSize!
|
||||
});
|
||||
}
|
||||
|
||||
// 移动端看判断是否显示总数,页码个数也变更
|
||||
const mobilePagination = computed(() => {
|
||||
const p: PaginationProps = {
|
||||
...pagination,
|
||||
pageSlot: isMobile.value ? 3 : 9,
|
||||
prefix: !isMobile.value && showTotal ? pagination.prefix : undefined
|
||||
};
|
||||
|
||||
return p;
|
||||
});
|
||||
|
||||
function updatePagination(update: Partial<PaginationProps>) {
|
||||
Object.assign(pagination, update);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按页码获取数据
|
||||
*
|
||||
* @param pageNum the page number. default is 1
|
||||
*/
|
||||
async function getDataByPage(pageNum: number = 1) {
|
||||
updatePagination({
|
||||
page: pageNum
|
||||
});
|
||||
|
||||
updateSearchParams({
|
||||
pageNum,
|
||||
pageSize: pagination.pageSize!
|
||||
});
|
||||
|
||||
await config?.getData?.();
|
||||
}
|
||||
|
||||
// 重置分页
|
||||
function resetPagination() {
|
||||
updatePagination({
|
||||
page: 1
|
||||
});
|
||||
|
||||
updateSearchParams({
|
||||
pageNum: 1
|
||||
});
|
||||
}
|
||||
|
||||
function resetSearchParams() {
|
||||
if (!config || !config.queryParams) return;
|
||||
searchParams.value = jsonClone(config?.queryParams);
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
startLoading,
|
||||
endLoading,
|
||||
isMobile,
|
||||
pagination,
|
||||
mobilePagination,
|
||||
searchParams,
|
||||
updatePagination,
|
||||
getDataByPage,
|
||||
resetPagination,
|
||||
initSearchParams,
|
||||
updateSearchParams,
|
||||
resetSearchParams
|
||||
};
|
||||
}
|
||||
120
front/src/hooks/common/router.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useRouter } from 'vue-router';
|
||||
import type { RouteLocationRaw } from 'vue-router';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { router as globalRouter } from '@/router';
|
||||
|
||||
/**
|
||||
* Router push
|
||||
*
|
||||
* Jump to the specified route, it can replace function router.push
|
||||
*
|
||||
* @param inSetup Whether is in vue script setup
|
||||
*/
|
||||
export function useRouterPush(inSetup = true) {
|
||||
const router = inSetup ? useRouter() : globalRouter;
|
||||
const route = globalRouter.currentRoute;
|
||||
|
||||
const routerPush = router.push;
|
||||
|
||||
const routerBack = router.back;
|
||||
|
||||
interface RouterPushOptions {
|
||||
query?: Record<string, string>;
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
async function routerPushByKey(key: RouteKey, options?: RouterPushOptions) {
|
||||
const { query, params } = options || {};
|
||||
|
||||
const routeLocation: RouteLocationRaw = {
|
||||
name: key
|
||||
};
|
||||
|
||||
if (Object.keys(query || {}).length) {
|
||||
routeLocation.query = query;
|
||||
}
|
||||
|
||||
if (Object.keys(params || {}).length) {
|
||||
routeLocation.params = params;
|
||||
}
|
||||
|
||||
return routerPush(routeLocation);
|
||||
}
|
||||
|
||||
function routerPushByKeyWithMetaQuery(key: RouteKey) {
|
||||
const allRoutes = router.getRoutes();
|
||||
const meta = allRoutes.find(item => item.name === key)?.meta || null;
|
||||
|
||||
const query: Record<string, string> = {};
|
||||
|
||||
meta?.query?.forEach(item => {
|
||||
query[item.key] = item.value;
|
||||
});
|
||||
|
||||
return routerPushByKey(key, { query });
|
||||
}
|
||||
|
||||
async function toHome() {
|
||||
return routerPushByKey('root');
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to login page
|
||||
*
|
||||
* @param loginModule The login module
|
||||
* @param redirectUrl The redirect url, if not specified, it will be the current route fullPath
|
||||
*/
|
||||
async function toLogin(loginModule?: UnionKey.LoginModule, redirectUrl?: string) {
|
||||
const module = loginModule || 'pwd-login';
|
||||
|
||||
const options: RouterPushOptions = {
|
||||
params: {
|
||||
module
|
||||
}
|
||||
};
|
||||
|
||||
const redirect = redirectUrl || route.value.fullPath;
|
||||
|
||||
options.query = {
|
||||
redirect
|
||||
};
|
||||
|
||||
return routerPushByKey('login', options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle login module
|
||||
*
|
||||
* @param module
|
||||
*/
|
||||
async function toggleLoginModule(module: UnionKey.LoginModule) {
|
||||
const query = route.value.query as Record<string, string>;
|
||||
|
||||
return routerPushByKey('login', { query, params: { module } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect from login
|
||||
*
|
||||
* @param [needRedirect=true] Whether to redirect after login. Default is `true`
|
||||
*/
|
||||
async function redirectFromLogin(needRedirect = true) {
|
||||
const redirect = route.value.query?.redirect as string;
|
||||
|
||||
if (needRedirect && redirect) {
|
||||
await routerPush(redirect);
|
||||
} else {
|
||||
await toHome();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
routerPush,
|
||||
routerBack,
|
||||
routerPushByKey,
|
||||
routerPushByKeyWithMetaQuery,
|
||||
toLogin,
|
||||
toggleLoginModule,
|
||||
redirectFromLogin
|
||||
};
|
||||
}
|
||||
294
front/src/hooks/common/table.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { computed, effectScope, onScopeDispose, reactive, ref, watch } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import type { PaginationProps } from 'naive-ui';
|
||||
import { jsonClone } from '@sa/utils';
|
||||
import { useBoolean, useHookTable } from '@sa/hooks';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
type TableData = NaiveUI.TableData;
|
||||
type GetTableData<A extends NaiveUI.TableApiFn> = NaiveUI.GetTableData<A>;
|
||||
type TableColumn<T> = NaiveUI.TableColumn<T>;
|
||||
|
||||
export function useTable<A extends NaiveUI.TableApiFn>(config: NaiveUI.NaiveTableConfig<A>) {
|
||||
const scope = effectScope();
|
||||
const appStore = useAppStore();
|
||||
|
||||
const isMobile = computed(() => appStore.isMobile);
|
||||
|
||||
const { apiFn, apiParams, immediate, showTotal } = config;
|
||||
const SELECTION_KEY = '__selection__';
|
||||
|
||||
const EXPAND_KEY = '__expand__';
|
||||
const {
|
||||
loading,
|
||||
empty,
|
||||
data,
|
||||
columns,
|
||||
columnChecks,
|
||||
reloadColumns,
|
||||
getData,
|
||||
searchParams,
|
||||
updateSearchParams,
|
||||
resetSearchParams
|
||||
} = useHookTable<A, GetTableData<A>, TableColumn<NaiveUI.TableDataWithIndex<GetTableData<A>>>>({
|
||||
apiFn,
|
||||
apiParams,
|
||||
columns: config.columns,
|
||||
transformer: res => {
|
||||
// const { records = [], current = 1, size = 10, total = 0 } = res.data || {};
|
||||
// 如果res.data本身是数组,说明查的是全部,没有分页
|
||||
let total: number = 0;
|
||||
let records: GetTableData<A>[] = [];
|
||||
if (Array.isArray(res.data)) {
|
||||
records = res.data || [];
|
||||
total = records.length;
|
||||
} else {
|
||||
// 如果 data 是对象,解构获取内部的 rows 和 total
|
||||
const { data: rows = [], total: totalCount = 0 } = res.data || {};
|
||||
records = rows;
|
||||
total = totalCount;
|
||||
}
|
||||
const current: number = searchParams?.pageNum || 1;
|
||||
const size: number = searchParams?.pageSize || 10;
|
||||
// Ensure that the size is greater than 0, If it is less than 0, it will cause paging calculation errors.
|
||||
const pageSize = size <= 0 ? 10 : size;
|
||||
|
||||
const recordsWithIndex = records.map((item: GetTableData<A>, index: number) => {
|
||||
return {
|
||||
...item,
|
||||
index: (current - 1) * pageSize + index + 1
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
data: recordsWithIndex,
|
||||
pageNum: current,
|
||||
pageSize,
|
||||
total
|
||||
};
|
||||
},
|
||||
getColumnChecks: cols => {
|
||||
const checks: NaiveUI.TableColumnCheck[] = [];
|
||||
|
||||
cols.forEach(column => {
|
||||
if (isTableColumnHasKey(column)) {
|
||||
checks.push({
|
||||
key: column.key as string,
|
||||
title: column.title as string,
|
||||
checked: true
|
||||
});
|
||||
} else if (column.type === 'selection') {
|
||||
checks.push({
|
||||
key: SELECTION_KEY,
|
||||
title: $t('common.check'),
|
||||
checked: true
|
||||
});
|
||||
} else if (column.type === 'expand') {
|
||||
checks.push({
|
||||
key: EXPAND_KEY,
|
||||
title: $t('common.expandColumn'),
|
||||
checked: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return checks;
|
||||
},
|
||||
getColumns: (cols, checks) => {
|
||||
const columnMap = new Map<string, TableColumn<GetTableData<A>>>();
|
||||
|
||||
cols.forEach(column => {
|
||||
if (isTableColumnHasKey(column)) {
|
||||
columnMap.set(column.key as string, column);
|
||||
} else if (column.type === 'selection') {
|
||||
columnMap.set(SELECTION_KEY, column);
|
||||
} else if (column.type === 'expand') {
|
||||
columnMap.set(EXPAND_KEY, column);
|
||||
}
|
||||
});
|
||||
|
||||
const filteredColumns = checks
|
||||
.filter(item => item.checked)
|
||||
.map(check => columnMap.get(check.key) as TableColumn<GetTableData<A>>);
|
||||
|
||||
return filteredColumns;
|
||||
},
|
||||
onFetched: async transformed => {
|
||||
const { pageNum, pageSize, total } = transformed;
|
||||
|
||||
updatePagination({
|
||||
page: pageNum,
|
||||
pageSize,
|
||||
itemCount: total
|
||||
});
|
||||
},
|
||||
immediate
|
||||
});
|
||||
|
||||
const pagination: PaginationProps = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
showSizePicker: true,
|
||||
itemCount: 0,
|
||||
pageSizes: [10, 15, 20, 25, 30],
|
||||
onUpdatePage: async (page: number) => {
|
||||
pagination.page = page;
|
||||
updateSearchParams({
|
||||
pageNum: page,
|
||||
pageSize: pagination.pageSize!
|
||||
});
|
||||
|
||||
getData();
|
||||
},
|
||||
onUpdatePageSize: async (pageSize: number) => {
|
||||
pagination.pageSize = pageSize;
|
||||
pagination.page = 1;
|
||||
|
||||
updateSearchParams({
|
||||
pageNum: pagination.page,
|
||||
pageSize
|
||||
});
|
||||
|
||||
getData();
|
||||
},
|
||||
...(showTotal
|
||||
? {
|
||||
prefix: page => $t('datatable.itemCount', { total: page.itemCount })
|
||||
}
|
||||
: {})
|
||||
});
|
||||
|
||||
// this is for mobile, if the system does not support mobile, you can use `pagination` directly
|
||||
const mobilePagination = computed(() => {
|
||||
const p: PaginationProps = {
|
||||
...pagination,
|
||||
pageSlot: isMobile.value ? 3 : 9,
|
||||
prefix: !isMobile.value && showTotal ? pagination.prefix : undefined
|
||||
};
|
||||
|
||||
return p;
|
||||
});
|
||||
|
||||
function updatePagination(update: Partial<PaginationProps>) {
|
||||
Object.assign(pagination, update);
|
||||
}
|
||||
|
||||
/**
|
||||
* get data by page number
|
||||
*
|
||||
* @param pageNum the page number. default is 1
|
||||
*/
|
||||
async function getDataByPage(pageNum: number = 1) {
|
||||
updatePagination({
|
||||
page: pageNum
|
||||
});
|
||||
|
||||
updateSearchParams({
|
||||
pageNum,
|
||||
pageSize: pagination.pageSize!
|
||||
});
|
||||
|
||||
await getData();
|
||||
}
|
||||
|
||||
scope.run(() => {
|
||||
watch(
|
||||
() => appStore.locale,
|
||||
() => {
|
||||
reloadColumns();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
return {
|
||||
loading,
|
||||
empty,
|
||||
data,
|
||||
columns,
|
||||
columnChecks,
|
||||
reloadColumns,
|
||||
pagination,
|
||||
mobilePagination,
|
||||
updatePagination,
|
||||
getData,
|
||||
getDataByPage,
|
||||
searchParams,
|
||||
updateSearchParams,
|
||||
resetSearchParams
|
||||
};
|
||||
}
|
||||
|
||||
export function useTableOperate<T extends TableData = TableData>(
|
||||
data: Ref<T[]>,
|
||||
getData: () => Promise<void>,
|
||||
idField?: string
|
||||
) {
|
||||
const { bool: drawerVisible, setTrue: openDrawer, setFalse: closeDrawer } = useBoolean();
|
||||
|
||||
const operateType = ref<NaiveUI.TableOperateType>('add');
|
||||
|
||||
function handleAdd() {
|
||||
operateType.value = 'add';
|
||||
openDrawer();
|
||||
}
|
||||
|
||||
/** the editing row data */
|
||||
const editingData: Ref<T | null> = ref(null);
|
||||
|
||||
/** 简单的编辑,适用于编辑时不需要去查询详情接口或者只为了拿到当前表格行的数据的场景,使用这个hook时传入【idField】即可 */
|
||||
function handleSimpleEdit(id: string | number) {
|
||||
const field = idField || 'id'; // 使用默认字段 'id'
|
||||
operateType.value = 'edit';
|
||||
const findItem = data.value.find(item => item[field] === id) || null;
|
||||
editingData.value = jsonClone(findItem);
|
||||
openDrawer();
|
||||
}
|
||||
|
||||
/** 使用这个方式时,需要写查详情的逻辑回填数据到【editingData】 */
|
||||
function handleEdit() {
|
||||
operateType.value = 'edit';
|
||||
openDrawer();
|
||||
}
|
||||
|
||||
/** the checked row keys of table */
|
||||
const checkedRowKeys = ref<string[] | number[]>([]);
|
||||
|
||||
/** the hook after the batch delete operation is completed */
|
||||
async function onBatchDeleted() {
|
||||
window.$message?.success($t('common.deleteSuccess'));
|
||||
|
||||
checkedRowKeys.value = [];
|
||||
|
||||
await getData();
|
||||
}
|
||||
|
||||
/** the hook after the delete operation is completed */
|
||||
async function onDeleted() {
|
||||
window.$message?.success($t('common.deleteSuccess'));
|
||||
|
||||
await getData();
|
||||
}
|
||||
|
||||
return {
|
||||
drawerVisible,
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
operateType,
|
||||
handleAdd,
|
||||
editingData,
|
||||
handleEdit,
|
||||
handleSimpleEdit,
|
||||
checkedRowKeys,
|
||||
onBatchDeleted,
|
||||
onDeleted
|
||||
};
|
||||
}
|
||||
|
||||
function isTableColumnHasKey<T>(column: TableColumn<T>): column is NaiveUI.TableColumnWithKey<T> {
|
||||
return Boolean((column as NaiveUI.TableColumnWithKey<T>).key);
|
||||
}
|
||||
148
front/src/layouts/base-layout/index.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent } from 'vue';
|
||||
import { AdminLayout, LAYOUT_SCROLL_EL_ID } from '@sa/materials';
|
||||
import type { LayoutMode } from '@sa/materials';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import GlobalHeader from '../modules/global-header/index.vue';
|
||||
import GlobalSider from '../modules/global-sider/index.vue';
|
||||
import GlobalTab from '../modules/global-tab/index.vue';
|
||||
import GlobalContent from '../modules/global-content/index.vue';
|
||||
import GlobalFooter from '../modules/global-footer/index.vue';
|
||||
import ThemeDrawer from '../modules/theme-drawer/index.vue';
|
||||
import { setupMixMenuContext } from '../context';
|
||||
|
||||
defineOptions({
|
||||
name: 'BaseLayout'
|
||||
});
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const { childLevelMenus, isActiveFirstLevelMenuHasChildren } = setupMixMenuContext();
|
||||
|
||||
const GlobalMenu = defineAsyncComponent(() => import('../modules/global-menu/index.vue'));
|
||||
|
||||
const layoutMode = computed(() => {
|
||||
const vertical: LayoutMode = 'vertical';
|
||||
const horizontal: LayoutMode = 'horizontal';
|
||||
return themeStore.layout.mode.includes(vertical) ? vertical : horizontal;
|
||||
});
|
||||
|
||||
const headerProps = computed(() => {
|
||||
const { mode, reverseHorizontalMix } = themeStore.layout;
|
||||
|
||||
const headerPropsConfig: Record<UnionKey.ThemeLayoutMode, App.Global.HeaderProps> = {
|
||||
vertical: {
|
||||
showLogo: false,
|
||||
showMenu: false,
|
||||
showMenuToggler: true
|
||||
},
|
||||
'vertical-mix': {
|
||||
showLogo: false,
|
||||
showMenu: false,
|
||||
showMenuToggler: false
|
||||
},
|
||||
horizontal: {
|
||||
showLogo: true,
|
||||
showMenu: true,
|
||||
showMenuToggler: false
|
||||
},
|
||||
'horizontal-mix': {
|
||||
showLogo: true,
|
||||
showMenu: true,
|
||||
showMenuToggler: reverseHorizontalMix && isActiveFirstLevelMenuHasChildren.value
|
||||
}
|
||||
};
|
||||
|
||||
return headerPropsConfig[mode];
|
||||
});
|
||||
|
||||
const siderVisible = computed(() => themeStore.layout.mode !== 'horizontal');
|
||||
|
||||
const isVerticalMix = computed(() => themeStore.layout.mode === 'vertical-mix');
|
||||
|
||||
const isHorizontalMix = computed(() => themeStore.layout.mode === 'horizontal-mix');
|
||||
|
||||
const siderWidth = computed(() => getSiderWidth());
|
||||
|
||||
const siderCollapsedWidth = computed(() => getSiderCollapsedWidth());
|
||||
|
||||
function getSiderWidth() {
|
||||
const { reverseHorizontalMix } = themeStore.layout;
|
||||
const { width, mixWidth, mixChildMenuWidth } = themeStore.sider;
|
||||
|
||||
if (isHorizontalMix.value && reverseHorizontalMix) {
|
||||
return isActiveFirstLevelMenuHasChildren.value ? width : 0;
|
||||
}
|
||||
|
||||
let w = isVerticalMix.value || isHorizontalMix.value ? mixWidth : width;
|
||||
|
||||
if (isVerticalMix.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
|
||||
w += mixChildMenuWidth;
|
||||
}
|
||||
|
||||
return w;
|
||||
}
|
||||
|
||||
function getSiderCollapsedWidth() {
|
||||
const { reverseHorizontalMix } = themeStore.layout;
|
||||
const { collapsedWidth, mixCollapsedWidth, mixChildMenuWidth } = themeStore.sider;
|
||||
|
||||
if (isHorizontalMix.value && reverseHorizontalMix) {
|
||||
return isActiveFirstLevelMenuHasChildren.value ? collapsedWidth : 0;
|
||||
}
|
||||
|
||||
let w = isVerticalMix.value || isHorizontalMix.value ? mixCollapsedWidth : collapsedWidth;
|
||||
|
||||
if (isVerticalMix.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
|
||||
w += mixChildMenuWidth;
|
||||
}
|
||||
|
||||
return w;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminLayout
|
||||
v-model:sider-collapse="appStore.siderCollapse"
|
||||
:mode="layoutMode"
|
||||
:scroll-el-id="LAYOUT_SCROLL_EL_ID"
|
||||
:scroll-mode="themeStore.layout.scrollMode"
|
||||
:is-mobile="appStore.isMobile"
|
||||
:full-content="appStore.fullContent"
|
||||
:fixed-top="themeStore.fixedHeaderAndTab"
|
||||
:header-height="themeStore.header.height"
|
||||
:tab-visible="themeStore.tab.visible"
|
||||
:tab-height="themeStore.tab.height"
|
||||
:content-class="appStore.contentXScrollable ? 'overflow-x-hidden' : ''"
|
||||
:sider-visible="siderVisible"
|
||||
:sider-width="siderWidth"
|
||||
:sider-collapsed-width="siderCollapsedWidth"
|
||||
:footer-visible="themeStore.footer.visible"
|
||||
:footer-height="themeStore.footer.height"
|
||||
:fixed-footer="themeStore.footer.fixed"
|
||||
:right-footer="themeStore.footer.right"
|
||||
>
|
||||
<template #header>
|
||||
<GlobalHeader v-bind="headerProps" />
|
||||
</template>
|
||||
<template #tab>
|
||||
<GlobalTab />
|
||||
</template>
|
||||
<template #sider>
|
||||
<GlobalSider />
|
||||
</template>
|
||||
<GlobalMenu />
|
||||
<GlobalContent />
|
||||
<ThemeDrawer />
|
||||
<template #footer>
|
||||
<GlobalFooter />
|
||||
</template>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
#__SCROLL_EL_ID__ {
|
||||
@include scrollbar();
|
||||
}
|
||||
</style>
|
||||
13
front/src/layouts/blank-layout/index.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import GlobalContent from '../modules/global-content/index.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'BlankLayout'
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<GlobalContent :show-padding="false" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
83
front/src/layouts/context/index.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useContext } from '@sa/hooks';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
|
||||
export const { setupStore: setupMixMenuContext, useStore: useMixMenuContext } = useContext('mix-menu', useMixMenu);
|
||||
|
||||
function useMixMenu() {
|
||||
const route = useRoute();
|
||||
const routeStore = useRouteStore();
|
||||
const { selectedKey } = useMenu();
|
||||
|
||||
const activeFirstLevelMenuKey = ref('');
|
||||
|
||||
function setActiveFirstLevelMenuKey(key: string) {
|
||||
activeFirstLevelMenuKey.value = key;
|
||||
}
|
||||
|
||||
function getActiveFirstLevelMenuKey() {
|
||||
const [firstLevelRouteName] = selectedKey.value.split('_');
|
||||
|
||||
setActiveFirstLevelMenuKey(firstLevelRouteName);
|
||||
}
|
||||
|
||||
const allMenus = computed<App.Global.Menu[]>(() => routeStore.menus);
|
||||
|
||||
const firstLevelMenus = computed<App.Global.Menu[]>(() =>
|
||||
routeStore.menus.map(menu => {
|
||||
const { children: _, ...rest } = menu;
|
||||
|
||||
return rest;
|
||||
})
|
||||
);
|
||||
|
||||
const childLevelMenus = computed<App.Global.Menu[]>(
|
||||
() => routeStore.menus.find(menu => menu.key === activeFirstLevelMenuKey.value)?.children || []
|
||||
);
|
||||
|
||||
const isActiveFirstLevelMenuHasChildren = computed(() => {
|
||||
if (!activeFirstLevelMenuKey.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const findItem = allMenus.value.find(item => item.key === activeFirstLevelMenuKey.value);
|
||||
|
||||
return Boolean(findItem?.children?.length);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.name,
|
||||
() => {
|
||||
getActiveFirstLevelMenuKey();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return {
|
||||
allMenus,
|
||||
firstLevelMenus,
|
||||
childLevelMenus,
|
||||
isActiveFirstLevelMenuHasChildren,
|
||||
activeFirstLevelMenuKey,
|
||||
setActiveFirstLevelMenuKey,
|
||||
getActiveFirstLevelMenuKey
|
||||
};
|
||||
}
|
||||
|
||||
export function useMenu() {
|
||||
const route = useRoute();
|
||||
|
||||
const selectedKey = computed(() => {
|
||||
const { hideInMenu, activeMenu } = route.meta;
|
||||
const name = route.name as string;
|
||||
|
||||
const routeName = (hideInMenu ? activeMenu : name) || name;
|
||||
|
||||
return routeName;
|
||||
});
|
||||
|
||||
return {
|
||||
selectedKey
|
||||
};
|
||||
}
|
||||
44
front/src/layouts/foreground-layout/index.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<!--这个是前台的框架,由头部和内容组成-->
|
||||
<script setup lang="ts">
|
||||
import { AdminLayout, LAYOUT_SCROLL_EL_ID } from '@sa/materials';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import ForegroundHeader from '@/layouts/modules/global-foreground/foreground-header.vue';
|
||||
import ForegroundContent from '@/layouts/modules/global-foreground/foreground-content.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'ForegroundLayout'
|
||||
});
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminLayout
|
||||
v-model:sider-collapse="appStore.siderCollapse"
|
||||
mode="vertical"
|
||||
:scroll-el-id="LAYOUT_SCROLL_EL_ID"
|
||||
:scroll-mode="themeStore.layout.scrollMode"
|
||||
:is-mobile="appStore.isMobile"
|
||||
:full-content="appStore.fullContent"
|
||||
:fixed-top="themeStore.fixedHeaderAndTab"
|
||||
:header-height="80"
|
||||
:content-class="appStore.contentXScrollable ? 'overflow-x-hidden' : ''"
|
||||
:sider-visible="false"
|
||||
:footer-visible="themeStore.footer.visible"
|
||||
:footer-height="themeStore.footer.height"
|
||||
:fixed-footer="themeStore.footer.fixed"
|
||||
:right-footer="themeStore.footer.right"
|
||||
>
|
||||
<template #header>
|
||||
<ForegroundHeader />
|
||||
</template>
|
||||
<ForegroundContent />
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
#__SCROLL_EL_ID__ {
|
||||
@include scrollbar();
|
||||
}
|
||||
</style>
|
||||
47
front/src/layouts/modules/global-breadcrumb/index.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { createReusableTemplate } from '@vueuse/core';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
|
||||
defineOptions({
|
||||
name: 'GlobalBreadcrumb'
|
||||
});
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const routeStore = useRouteStore();
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
|
||||
interface BreadcrumbContentProps {
|
||||
breadcrumb: App.Global.Menu;
|
||||
}
|
||||
|
||||
const [DefineBreadcrumbContent, BreadcrumbContent] = createReusableTemplate<BreadcrumbContentProps>();
|
||||
|
||||
function handleClickMenu(key: RouteKey) {
|
||||
routerPushByKey(key);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NBreadcrumb v-if="themeStore.header.breadcrumb.visible">
|
||||
<!-- define component start: BreadcrumbContent -->
|
||||
<DefineBreadcrumbContent v-slot="{ breadcrumb }">
|
||||
<div class="i-flex-y-center align-middle">
|
||||
<component :is="breadcrumb.icon" v-if="themeStore.header.breadcrumb.showIcon" class="mr-4px text-icon" />
|
||||
{{ breadcrumb.label }}
|
||||
</div>
|
||||
</DefineBreadcrumbContent>
|
||||
<!-- define component end: BreadcrumbContent -->
|
||||
|
||||
<NBreadcrumbItem v-for="item in routeStore.breadcrumbs" :key="item.key">
|
||||
<NDropdown v-if="item.options?.length" :options="item.options" @select="handleClickMenu">
|
||||
<BreadcrumbContent :breadcrumb="item" />
|
||||
</NDropdown>
|
||||
<BreadcrumbContent v-else :breadcrumb="item" />
|
||||
</NBreadcrumbItem>
|
||||
</NBreadcrumb>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
58
front/src/layouts/modules/global-content/index.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { LAYOUT_SCROLL_EL_ID } from '@sa/materials';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useTabStore } from '@/store/modules/tab';
|
||||
|
||||
defineOptions({
|
||||
name: 'GlobalContent'
|
||||
});
|
||||
|
||||
interface Props {
|
||||
/** Show padding for content */
|
||||
showPadding?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
showPadding: true
|
||||
});
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const routeStore = useRouteStore();
|
||||
const tabStore = useTabStore();
|
||||
|
||||
const transitionName = computed(() => (themeStore.page.animate ? themeStore.page.animateMode : ''));
|
||||
|
||||
function resetScroll() {
|
||||
const el = document.querySelector(`#${LAYOUT_SCROLL_EL_ID}`);
|
||||
|
||||
el?.scrollTo({ left: 0, top: 0 });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<Transition
|
||||
:name="transitionName"
|
||||
mode="out-in"
|
||||
@before-leave="appStore.setContentXScrollable(true)"
|
||||
@after-leave="resetScroll"
|
||||
@after-enter="appStore.setContentXScrollable(false)"
|
||||
>
|
||||
<KeepAlive :include="routeStore.cacheRoutes" :exclude="routeStore.excludeCacheRoutes">
|
||||
<component
|
||||
:is="Component"
|
||||
v-if="appStore.reloadFlag"
|
||||
:key="tabStore.getTabIdByRoute(route)"
|
||||
:class="{ 'p-16px': showPadding }"
|
||||
class="flex-grow bg-layout transition-300"
|
||||
/>
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</template>
|
||||
|
||||
<style></style>
|
||||
15
front/src/layouts/modules/global-footer/index.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'GlobalFooter'
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DarkModeContainer class="h-full flex-center">
|
||||
<a href="https://github.com/soybeanjs/soybean-admin/blob/main/LICENSE" target="_blank" rel="noopener noreferrer">
|
||||
Copyright MIT © 2021 Soybean
|
||||
</a>
|
||||
</DarkModeContainer>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { LAYOUT_SCROLL_EL_ID } from '~/packages/materials';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useTabStore } from '@/store/modules/tab';
|
||||
|
||||
defineOptions({
|
||||
name: 'ForegroundContent'
|
||||
});
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const appStore = useAppStore();
|
||||
const tabStore = useTabStore();
|
||||
|
||||
const transitionName = computed(() => (themeStore.page.animate ? themeStore.page.animateMode : ''));
|
||||
|
||||
function resetScroll() {
|
||||
const el = document.querySelector(`#${LAYOUT_SCROLL_EL_ID}`);
|
||||
|
||||
el?.scrollTo({ left: 0, top: 0 });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<Transition
|
||||
:name="transitionName"
|
||||
mode="out-in"
|
||||
@before-leave="appStore.setContentXScrollable(true)"
|
||||
@after-leave="resetScroll"
|
||||
@after-enter="appStore.setContentXScrollable(false)"
|
||||
>
|
||||
<component
|
||||
:is="Component"
|
||||
v-if="appStore.reloadFlag"
|
||||
:key="tabStore.getTabIdByRoute(route)"
|
||||
class="flex-grow bg-layout transition-300"
|
||||
/>
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</template>
|
||||