mirror of
https://github.com/m-xlsea/ruoyi-plus-soybean.git
synced 2025-09-24 07:49:47 +08:00
feat(projects): 1.0 beta
This commit is contained in:
@ -1,97 +0,0 @@
|
||||
<template>
|
||||
<div v-if="reloadFlag" class="relative">
|
||||
<slot></slot>
|
||||
<div v-show="showPlaceholder" class="absolute-lt w-full h-full" :class="placeholderClass">
|
||||
<div v-show="loading" class="absolute-center">
|
||||
<n-spin :show="true" :size="loadingSize" />
|
||||
</div>
|
||||
<div v-show="isEmpty" class="absolute-center">
|
||||
<div class="relative">
|
||||
<icon-local-empty-data :class="iconClass" />
|
||||
<p class="absolute-lb w-full text-center" :class="descClass">{{ emptyDesc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="!network" class="absolute-center">
|
||||
<div class="relative" :class="{ 'cursor-pointer': showNetworkReload }" @click="handleReload">
|
||||
<icon-local-network-error :class="iconClass" />
|
||||
<p class="absolute-lb w-full text-center" :class="descClass">{{ networkErrorDesc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onUnmounted, watch } from 'vue';
|
||||
import { NETWORK_ERROR_MSG } from '@/config';
|
||||
import { useBoolean } from '@/hooks';
|
||||
|
||||
defineOptions({ name: 'LoadingEmptyWrapper' });
|
||||
|
||||
interface Props {
|
||||
/** 是否加载 */
|
||||
loading: boolean;
|
||||
/** 是否为空 */
|
||||
empty?: boolean;
|
||||
/** 加载图标的大小 */
|
||||
loadingSize?: 'small' | 'medium' | 'large';
|
||||
/** 中间占位符的class */
|
||||
placeholderClass?: string;
|
||||
/** 空数据描述文本 */
|
||||
emptyDesc?: string;
|
||||
/** 图标的class */
|
||||
iconClass?: string;
|
||||
/** 描述文本的class */
|
||||
descClass?: string;
|
||||
/** 显示网络异常的重试点击按钮 */
|
||||
showNetworkReload?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
empty: false,
|
||||
loadingSize: 'medium',
|
||||
placeholderClass: 'bg-white dark:bg-dark transition-background-color duration-300 ease-in-out',
|
||||
emptyDesc: '暂无数据',
|
||||
iconClass: 'text-320px text-primary',
|
||||
descClass: 'text-16px text-#666',
|
||||
showNetworkReload: false
|
||||
});
|
||||
|
||||
// 网络状态
|
||||
const { bool: network, setBool: setNetwork } = useBoolean(window.navigator.onLine);
|
||||
const { bool: reloadFlag, setBool: setReload } = useBoolean(true);
|
||||
|
||||
// 数据是否为空
|
||||
const isEmpty = computed(() => props.empty && !props.loading && network.value);
|
||||
|
||||
const showPlaceholder = computed(() => props.loading || isEmpty.value || !network.value);
|
||||
|
||||
const networkErrorDesc = computed(() =>
|
||||
props.showNetworkReload ? `${NETWORK_ERROR_MSG}, 点击重试` : NETWORK_ERROR_MSG
|
||||
);
|
||||
|
||||
function handleReload() {
|
||||
if (!props.showNetworkReload) return;
|
||||
setReload(false);
|
||||
nextTick(() => {
|
||||
setReload(true);
|
||||
});
|
||||
}
|
||||
|
||||
const stopHandle = watch(
|
||||
() => props.loading,
|
||||
newValue => {
|
||||
// 结束加载判断一下网络状态
|
||||
if (!newValue) {
|
||||
setNetwork(window.navigator.onLine);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
stopHandle();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
@ -1,50 +0,0 @@
|
||||
<template>
|
||||
<div class="w-full text-14px">
|
||||
<n-checkbox v-model:checked="checked">我已经仔细阅读并接受</n-checkbox>
|
||||
<n-button :text="true" type="primary" @click="handleClickProtocol">《用户协议》</n-button>
|
||||
<n-button :text="true" type="primary" @click="handleClickPolicy">《隐私权政策》</n-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
defineOptions({ name: 'LoginAgreement' });
|
||||
|
||||
interface Props {
|
||||
/** 是否勾选 */
|
||||
value?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
value: true
|
||||
});
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:value', value: boolean): void;
|
||||
/** 点击协议 */
|
||||
(e: 'click-protocol'): void;
|
||||
/** 点击隐私政策 */
|
||||
(e: 'click-policy'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const checked = computed({
|
||||
get() {
|
||||
return props.value;
|
||||
},
|
||||
set(newValue: boolean) {
|
||||
emit('update:value', newValue);
|
||||
}
|
||||
});
|
||||
|
||||
function handleClickProtocol() {
|
||||
emit('click-protocol');
|
||||
}
|
||||
function handleClickPolicy() {
|
||||
emit('click-policy');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
@ -1,6 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { getRgbOfColor } from '@sa/utils';
|
||||
import { $t } from '@/locales';
|
||||
import { localStg } from '@/utils/storage';
|
||||
|
||||
const loadingClasses = [
|
||||
'left-0 top-0',
|
||||
'left-0 bottom-0 animate-delay-500',
|
||||
'right-0 top-0 animate-delay-1000',
|
||||
'right-0 bottom-0 animate-delay-1500'
|
||||
];
|
||||
|
||||
function addThemeColorCssVars() {
|
||||
const themeColor = localStg.get('themeColor') || '#646cff';
|
||||
|
||||
const { r, g, b } = getRgbOfColor(themeColor);
|
||||
|
||||
const cssVars = `--primary-color: ${r} ${g} ${b}`;
|
||||
document.documentElement.style.cssText = cssVars;
|
||||
}
|
||||
|
||||
addThemeColorCssVars();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed-center flex-col">
|
||||
<system-logo class="text-128px text-primary" />
|
||||
<SystemLogo class="text-128px text-primary" />
|
||||
<div class="w-56px h-56px my-36px">
|
||||
<div class="relative h-full animate-spin">
|
||||
<div
|
||||
@ -15,29 +39,4 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { sessionStg, getRgbOfColor } from '@/utils';
|
||||
import { $t } from '@/locales';
|
||||
import themeSettings from '@/settings/theme.json';
|
||||
|
||||
const loadingClasses = [
|
||||
'left-0 top-0',
|
||||
'left-0 bottom-0 animate-delay-500',
|
||||
'right-0 top-0 animate-delay-1000',
|
||||
'right-0 bottom-0 animate-delay-1500'
|
||||
];
|
||||
|
||||
function addThemeColorCssVars() {
|
||||
const defaultColor = themeSettings.themeColor;
|
||||
const themeColor = sessionStg.get('themeColor') || defaultColor;
|
||||
|
||||
const { r, g, b } = getRgbOfColor(themeColor);
|
||||
|
||||
const cssVars = `--primary-color: ${r},${g},${b}`;
|
||||
document.documentElement.style.cssText = cssVars;
|
||||
}
|
||||
|
||||
addThemeColorCssVars();
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
40
src/components/common/app-provider.vue
Normal file
40
src/components/common/app-provider.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { defineComponent, createTextVNode } 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();
|
||||
},
|
||||
render() {
|
||||
return createTextVNode();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NLoadingBarProvider>
|
||||
<NDialogProvider>
|
||||
<NNotificationProvider>
|
||||
<NMessageProvider>
|
||||
<ContextHolder />
|
||||
<slot></slot>
|
||||
</NMessageProvider>
|
||||
</NNotificationProvider>
|
||||
</NDialogProvider>
|
||||
</NLoadingBarProvider>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
@ -1,12 +1,3 @@
|
||||
<template>
|
||||
<div
|
||||
class="dark:bg-dark dark:text-white dark:text-opacity-82 transition-all"
|
||||
:class="inverted ? 'bg-#001428 text-white' : 'bg-white text-#333639'"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'DarkModeContainer' });
|
||||
|
||||
@ -14,9 +5,13 @@ interface Props {
|
||||
inverted?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
inverted: false
|
||||
});
|
||||
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>
|
||||
|
@ -1,89 +0,0 @@
|
||||
<template>
|
||||
<div class="flex-center text-18px cursor-pointer" @click="handleSwitch">
|
||||
<icon-mdi-moon-waning-crescent v-if="darkMode" />
|
||||
<icon-mdi-white-balance-sunny v-else />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
defineOptions({ name: 'DarkModeSwitch' });
|
||||
|
||||
interface Props {
|
||||
/** 暗黑模式 */
|
||||
dark?: boolean;
|
||||
/** 自定义暗黑模式动画过渡 */
|
||||
customizeTransition?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
dark: false
|
||||
});
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:dark', darkMode: boolean): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const darkMode = computed({
|
||||
get() {
|
||||
return props.dark;
|
||||
},
|
||||
set(newValue: boolean) {
|
||||
emit('update:dark', newValue);
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSwitch(event: MouseEvent) {
|
||||
const x = event.clientX;
|
||||
const y = event.clientY;
|
||||
|
||||
if (!props.customizeTransition || !document.startViewTransition) {
|
||||
darkMode.value = !darkMode.value;
|
||||
return;
|
||||
}
|
||||
|
||||
const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
|
||||
|
||||
const transition = document.startViewTransition(() => {
|
||||
darkMode.value = !darkMode.value;
|
||||
});
|
||||
|
||||
await transition.ready;
|
||||
|
||||
const clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`];
|
||||
|
||||
document.documentElement.animate(
|
||||
{
|
||||
clipPath: darkMode.value ? clipPath : [...clipPath].reverse()
|
||||
},
|
||||
{
|
||||
duration: 300,
|
||||
easing: 'ease-in',
|
||||
pseudoElement: darkMode.value ? '::view-transition-new(root)' : '::view-transition-old(root)'
|
||||
}
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
::view-transition-old(root) {
|
||||
z-index: 9999;
|
||||
}
|
||||
::view-transition-new(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
.dark::view-transition-old(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
.dark::view-transition-new(root) {
|
||||
z-index: 9999;
|
||||
}
|
||||
</style>
|
@ -1,31 +1,41 @@
|
||||
<template>
|
||||
<div class="flex-col-center gap-24px min-h-520px wh-full overflow-hidden">
|
||||
<div class="flex text-400px text-primary">
|
||||
<icon-local-no-permission v-if="type === '403'" />
|
||||
<icon-local-not-found v-if="type === '404'" />
|
||||
<icon-local-service-error v-if="type === '500'" />
|
||||
</div>
|
||||
<router-link :to="{ name: routeHomePath }">
|
||||
<n-button type="primary">回到首页</n-button>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { routeName } from '@/router';
|
||||
import { computed } from 'vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'ExceptionBase' });
|
||||
|
||||
type ExceptionType = '403' | '404' | '500';
|
||||
|
||||
interface Props {
|
||||
/** 异常类型 403 404 500 */
|
||||
/**
|
||||
* exception type
|
||||
* - 403: no permission
|
||||
* - 404: not found
|
||||
* - 500: service error
|
||||
*/
|
||||
type: ExceptionType;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const routeHomePath = routeName('root');
|
||||
const iconMap: Record<ExceptionType, string> = {
|
||||
'403': 'no-permission',
|
||||
'404': 'not-found',
|
||||
'500': 'service-error'
|
||||
};
|
||||
|
||||
const icon = computed(() => iconMap[props.type]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-vertical-center gap-24px min-h-520px wh-full overflow-hidden">
|
||||
<div class="flex text-400px text-primary">
|
||||
<SvgIcon :local-icon="icon" />
|
||||
</div>
|
||||
<RouterLink to="/">
|
||||
<NButton type="primary">{{ $t('common.backToHome') }}</NButton>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
22
src/components/common/full-screen.vue
Normal file
22
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>
|
@ -1,48 +0,0 @@
|
||||
<template>
|
||||
<div v-if="showTooltip">
|
||||
<n-tooltip :placement="placement" trigger="hover">
|
||||
<template #trigger>
|
||||
<div class="flex-center h-full cursor-pointer dark:hover:bg-#333" :class="contentClassName">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
{{ tooltipContent }}
|
||||
</n-tooltip>
|
||||
</div>
|
||||
<div v-else class="flex-center cursor-pointer dark:hover:bg-#333" :class="contentClassName">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import type { PopoverPlacement } from 'naive-ui';
|
||||
|
||||
defineOptions({ name: 'HoverContainer' });
|
||||
|
||||
interface Props {
|
||||
/** tooltip显示文本 */
|
||||
tooltipContent?: string;
|
||||
/** tooltip的位置 */
|
||||
placement?: PopoverPlacement;
|
||||
/** class类 */
|
||||
contentClass?: string;
|
||||
/** 反转模式下 */
|
||||
inverted?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
tooltipContent: '',
|
||||
placement: 'bottom',
|
||||
contentClass: '',
|
||||
inverted: false
|
||||
});
|
||||
|
||||
const showTooltip = computed(() => Boolean(props.tooltipContent));
|
||||
|
||||
const contentClassName = computed(
|
||||
() => `${props.contentClass} ${props.inverted ? 'hover:bg-primary' : 'hover:bg-#f6f6f6'}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
55
src/components/common/lang-switch.vue
Normal file
55
src/components/common/lang-switch.vue
Normal file
@ -0,0 +1,55 @@
|
||||
<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 emits = defineEmits<Emits>();
|
||||
|
||||
const tooltipContent = computed(() => {
|
||||
if (!props.showTooltip) return '';
|
||||
|
||||
return $t('icon.lang');
|
||||
});
|
||||
|
||||
function changeLang(lang: App.I18n.LangType) {
|
||||
emits('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>
|
48
src/components/common/menu-toggler.vue
Normal file
48
src/components/common/menu-toggler.vue
Normal file
@ -0,0 +1,48 @@
|
||||
<script lang="ts" setup>
|
||||
import { $t } from '@/locales';
|
||||
import { computed } from 'vue';
|
||||
|
||||
defineOptions({ name: 'MenuToggler' });
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* show collapsed icon
|
||||
*/
|
||||
collapsed?: boolean;
|
||||
/**
|
||||
* arrow style icon
|
||||
*/
|
||||
arrowIcon?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
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 :tooltip-content="collapsed ? $t('icon.expand') : $t('icon.collapse')" tooltip-placement="bottom-start">
|
||||
<SvgIcon :icon="icon" />
|
||||
</ButtonIcon>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
@ -1,38 +0,0 @@
|
||||
<template>
|
||||
<n-loading-bar-provider>
|
||||
<n-dialog-provider>
|
||||
<n-notification-provider>
|
||||
<n-message-provider>
|
||||
<slot></slot>
|
||||
<naive-provider-content />
|
||||
</n-message-provider>
|
||||
</n-notification-provider>
|
||||
</n-dialog-provider>
|
||||
</n-loading-bar-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { useDialog, useLoadingBar, useMessage, useNotification } from 'naive-ui';
|
||||
|
||||
defineOptions({ name: 'NaiveProvider' });
|
||||
|
||||
// 挂载naive组件的方法至window, 以便在路由钩子函数和请求函数里面调用
|
||||
function registerNaiveTools() {
|
||||
window.$loadingBar = useLoadingBar();
|
||||
window.$dialog = useDialog();
|
||||
window.$message = useMessage();
|
||||
window.$notification = useNotification();
|
||||
}
|
||||
|
||||
const NaiveProviderContent = defineComponent({
|
||||
name: 'NaiveProviderContent',
|
||||
setup() {
|
||||
registerNaiveTools();
|
||||
},
|
||||
render() {
|
||||
return h('div');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<style scoped></style>
|
26
src/components/common/pin-toggler.vue
Normal file
26
src/components/common/pin-toggler.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<script lang="ts" setup>
|
||||
import { $t } from '@/locales';
|
||||
import { computed } from 'vue';
|
||||
|
||||
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.pin') : $t('icon.unpin')"
|
||||
tooltip-placement="bottom-start"
|
||||
trigger-parent
|
||||
>
|
||||
<SvgIcon :icon="icon" />
|
||||
</ButtonIcon>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
21
src/components/common/reload-button.vue
Normal file
21
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>
|
@ -1,19 +1,9 @@
|
||||
<template>
|
||||
<icon-local-logo-fill v-if="fill" />
|
||||
<icon-local-logo v-else />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineOptions({ name: 'SystemLogo' });
|
||||
|
||||
interface Props {
|
||||
/** logo是否填充 */
|
||||
fill?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
fill: false
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<icon-local-logo />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
62
src/components/common/theme-schema-switch.vue
Normal file
62
src/components/common/theme-schema-switch.vue
Normal file
@ -0,0 +1,62 @@
|
||||
<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>
|
@ -1,11 +1,3 @@
|
||||
<template>
|
||||
<div ref="bsWrap" class="h-full text-left">
|
||||
<div ref="bsContent" class="inline-block" :class="{ 'h-full': !isScrollY }">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
@ -15,15 +7,21 @@ import type { Options } from '@better-scroll/core';
|
||||
defineOptions({ name: 'BetterScroll' });
|
||||
|
||||
interface Props {
|
||||
/** better-scroll的配置: https://better-scroll.github.io/docs/zh-CN/guide/base-scroll-options.html */
|
||||
/**
|
||||
* BetterScroll options
|
||||
* @link https://better-scroll.github.io/docs/zh-CN/guide/base-scroll-options.html
|
||||
*/
|
||||
options: Options;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const bsWrap = ref<HTMLElement>();
|
||||
const instance = ref<BScroll>();
|
||||
const bsContent = ref<HTMLElement>();
|
||||
const { width: wrapWidth } = useElementSize(bsWrap);
|
||||
const { width, height } = useElementSize(bsContent);
|
||||
|
||||
const instance = ref<BScroll>();
|
||||
const isScrollY = computed(() => Boolean(props.options.scrollY));
|
||||
|
||||
function initBetterScroll() {
|
||||
@ -31,13 +29,9 @@ function initBetterScroll() {
|
||||
instance.value = new BScroll(bsWrap.value, props.options);
|
||||
}
|
||||
|
||||
// 滚动元素发生变化,刷新BS
|
||||
const { width: wrapWidth } = useElementSize(bsWrap);
|
||||
const { width, height } = useElementSize(bsContent);
|
||||
// refresh BS when scroll element size changed
|
||||
watch([() => wrapWidth.value, () => width.value, () => height.value], () => {
|
||||
if (instance.value) {
|
||||
instance.value.refresh();
|
||||
}
|
||||
instance.value?.refresh();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
@ -47,4 +41,12 @@ onMounted(() => {
|
||||
defineExpose({ instance });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="bsWrap" class="h-full text-left">
|
||||
<div ref="bsContent" class="inline-block" :class="{ 'h-full': !isScrollY }">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
86
src/components/custom/button-icon.vue
Normal file
86
src/components/custom/button-icon.vue
Normal file
@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { createReusableTemplate } from '@vueuse/core';
|
||||
import type { PopoverPlacement } from 'naive-ui';
|
||||
|
||||
defineOptions({
|
||||
name: 'ButtonIcon',
|
||||
inheritAttrs: false
|
||||
});
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* button class
|
||||
*/
|
||||
class?: string;
|
||||
/**
|
||||
* iconify icon name
|
||||
*/
|
||||
icon?: string;
|
||||
/**
|
||||
* tooltip content
|
||||
*/
|
||||
tooltipContent?: string;
|
||||
/**
|
||||
* tooltip placement
|
||||
*/
|
||||
tooltipPlacement?: PopoverPlacement;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
class: 'h-36px text-icon',
|
||||
icon: '',
|
||||
tooltipContent: '',
|
||||
tooltipPlacement: 'bottom'
|
||||
});
|
||||
|
||||
interface ButtonProps {
|
||||
className: string;
|
||||
}
|
||||
|
||||
const [DefineButton, Button] = createReusableTemplate<ButtonProps>();
|
||||
|
||||
const cls = computed(() => {
|
||||
let clsStr = props.class;
|
||||
|
||||
if (!clsStr.includes('h-')) {
|
||||
clsStr += ' h-36px';
|
||||
}
|
||||
|
||||
if (!clsStr.includes('text-')) {
|
||||
clsStr += ' text-icon';
|
||||
}
|
||||
|
||||
return clsStr;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- define component: Button -->
|
||||
<DefineButton v-slot="{ $slots, className }">
|
||||
<NButton quaternary :class="className">
|
||||
<div class="flex-center gap-8px">
|
||||
<component :is="$slots.default" />
|
||||
</div>
|
||||
</NButton>
|
||||
</DefineButton>
|
||||
|
||||
<!-- template -->
|
||||
<NTooltip v-if="tooltipContent" :placement="tooltipPlacement">
|
||||
<template #trigger>
|
||||
<Button :class-name="cls" v-bind="$attrs">
|
||||
<slot>
|
||||
<SvgIcon :icon="icon" />
|
||||
</slot>
|
||||
</Button>
|
||||
</template>
|
||||
{{ tooltipContent }}
|
||||
</NTooltip>
|
||||
<Button v-else :class-name="cls" v-bind="$attrs">
|
||||
<slot>
|
||||
<SvgIcon :icon="icon" />
|
||||
</slot>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
@ -1,116 +0,0 @@
|
||||
<template>
|
||||
<span>{{ value }}</span>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref, watch, watchEffect } from 'vue';
|
||||
import { TransitionPresets, useTransition } from '@vueuse/core';
|
||||
import { isNumber } from '@/utils';
|
||||
|
||||
defineOptions({ name: 'CountTo' });
|
||||
|
||||
type TransitionKey = keyof typeof TransitionPresets;
|
||||
|
||||
interface Props {
|
||||
/** 初始值 */
|
||||
startValue?: number;
|
||||
/** 结束值 */
|
||||
endValue?: number;
|
||||
/** 动画时长 */
|
||||
duration?: number;
|
||||
/** 自动动画 */
|
||||
autoplay?: boolean;
|
||||
/** 进制 */
|
||||
decimals?: number;
|
||||
/** 前缀 */
|
||||
prefix?: string;
|
||||
/** 后缀 */
|
||||
suffix?: string;
|
||||
/** 分割符号 */
|
||||
separator?: string;
|
||||
/** 小数点 */
|
||||
decimal?: string;
|
||||
/** 使用缓冲动画函数 */
|
||||
useEasing?: boolean;
|
||||
/** 缓冲动画函数类型 */
|
||||
transition?: TransitionKey;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
startValue: 0,
|
||||
endValue: 2021,
|
||||
duration: 1500,
|
||||
autoplay: true,
|
||||
decimals: 0,
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
separator: ',',
|
||||
decimal: '.',
|
||||
useEasing: true,
|
||||
transition: 'linear'
|
||||
});
|
||||
|
||||
interface Emits {
|
||||
(e: 'on-started'): void;
|
||||
(e: 'on-finished'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const source = ref(props.startValue);
|
||||
let outputValue = useTransition(source);
|
||||
const value = computed(() => formatNumber(outputValue.value));
|
||||
const disabled = ref(false);
|
||||
|
||||
function run() {
|
||||
outputValue = useTransition(source, {
|
||||
disabled,
|
||||
duration: props.duration,
|
||||
onStarted: () => emit('on-started'),
|
||||
onFinished: () => emit('on-finished'),
|
||||
...(props.useEasing ? { transition: TransitionPresets[props.transition] } : {})
|
||||
});
|
||||
}
|
||||
|
||||
function start() {
|
||||
run();
|
||||
source.value = props.endValue;
|
||||
}
|
||||
|
||||
function formatNumber(num: number | string) {
|
||||
if (num !== 0 && !num) {
|
||||
return '';
|
||||
}
|
||||
const { decimals, decimal, separator, suffix, prefix } = props;
|
||||
let number = 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 && !isNumber(separator)) {
|
||||
while (rgx.test(x1)) {
|
||||
x1 = x1.replace(rgx, `$1${separator}$2`);
|
||||
}
|
||||
}
|
||||
return prefix + x1 + x2 + suffix;
|
||||
}
|
||||
|
||||
watch([() => props.startValue, () => props.endValue], () => {
|
||||
if (props.autoplay) {
|
||||
start();
|
||||
}
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
source.value = props.startValue;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autoplay) {
|
||||
start();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
@ -1,17 +0,0 @@
|
||||
<template>
|
||||
<web-site-link label="github地址:" :link="link" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import WebSiteLink from './web-site-link.vue';
|
||||
|
||||
defineOptions({ name: 'GithubLink' });
|
||||
|
||||
interface Props {
|
||||
/** github链接 */
|
||||
link: string;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
<style scoped></style>
|
@ -1,77 +0,0 @@
|
||||
<template>
|
||||
<n-popover placement="bottom-end" trigger="click">
|
||||
<template #trigger>
|
||||
<n-input v-model:value="modelValue" readonly placeholder="点击选择图标">
|
||||
<template #suffix>
|
||||
<svg-icon :icon="selectedIcon" class="text-30px p-5px" />
|
||||
</template>
|
||||
</n-input>
|
||||
</template>
|
||||
<template #header>
|
||||
<n-input v-model:value="searchValue" placeholder="搜索图标"></n-input>
|
||||
</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)">
|
||||
<svg-icon
|
||||
:icon="iconItem"
|
||||
class="border-1px border-#d9d9d9 text-30px m-2px p-5px cursor-pointer"
|
||||
:class="{ 'border-primary': modelValue === iconItem }"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<n-empty v-else class="w-306px" description="你什么也找不到" />
|
||||
</n-popover>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
defineOptions({ name: 'IconSelect' });
|
||||
|
||||
interface Props {
|
||||
/** 选中的图标 */
|
||||
value: string;
|
||||
/** 图标列表 */
|
||||
icons: string[];
|
||||
/** 未选中图标 */
|
||||
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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.n-input-wrapper) {
|
||||
padding-right: 0;
|
||||
}
|
||||
:deep(.n-input__suffix) {
|
||||
border: 1px solid #d9d9d9;
|
||||
}
|
||||
</style>
|
@ -1,42 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<canvas ref="domRef" width="152" height="40" class="cursor-pointer" @click="getImgCode"></canvas>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue';
|
||||
import { useImageVerify } from '@/hooks';
|
||||
|
||||
defineOptions({ name: 'ImageVerify' });
|
||||
|
||||
interface Props {
|
||||
code?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
code: ''
|
||||
});
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:code', code: string): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const { domRef, imgCode, setImgCode, getImgCode } = useImageVerify();
|
||||
|
||||
watch(
|
||||
() => props.code,
|
||||
newValue => {
|
||||
setImgCode(newValue);
|
||||
}
|
||||
);
|
||||
watch(imgCode, newValue => {
|
||||
emit('update:code', newValue);
|
||||
});
|
||||
|
||||
defineExpose({ getImgCode });
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
18
src/components/custom/look-forward.vue
Normal file
18
src/components/custom/look-forward.vue
Normal file
@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({
|
||||
name: 'LookForward'
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-vertical-center gap-24px min-h-520px wh-full overflow-hidden">
|
||||
<div class="flex text-400px text-primary">
|
||||
<SvgIcon local-icon="expectation" />
|
||||
</div>
|
||||
<h3 class="text-28px font-500 text-primary">{{ $t('common.lookForward') }}</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
@ -1,14 +1,3 @@
|
||||
<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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useAttrs } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
@ -16,14 +5,18 @@ import { Icon } from '@iconify/vue';
|
||||
defineOptions({ name: 'SvgIcon' });
|
||||
|
||||
/**
|
||||
* 图标组件
|
||||
* - 支持iconify和本地svg图标
|
||||
* - 同时传递了icon和localIcon,localIcon会优先渲染
|
||||
* 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;
|
||||
/** 本地svg的文件名 */
|
||||
/**
|
||||
* local svg icon name
|
||||
*/
|
||||
localIcon?: string;
|
||||
}
|
||||
|
||||
@ -46,8 +39,21 @@ const symbolId = computed(() => {
|
||||
return `#${prefix}-${icon}`;
|
||||
});
|
||||
|
||||
/** 渲染本地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
src/components/custom/wave-bg.vue
Normal file
61
src/components/custom/wave-bg.vue
Normal file
@ -0,0 +1,61 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { getColorPalette } from '@sa/utils';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* theme color
|
||||
*/
|
||||
themeColor: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const lightColor = computed(() => getColorPalette(props.themeColor, 3));
|
||||
const darkColor = computed(() => getColorPalette(props.themeColor, 6));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="absolute-lt z-1 wh-full overflow-hidden">
|
||||
<div class="absolute -right-300px -top-900px <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 -left-200px -bottom-400px <sm:(-left-100px -bottom-760px)">
|
||||
<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>
|
@ -1,23 +0,0 @@
|
||||
<template>
|
||||
<p>
|
||||
<span>{{ label }}</span>
|
||||
<a class="text-blue-500" :href="link" target="_blank">
|
||||
{{ link }}
|
||||
</a>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'WebSiteLink' });
|
||||
|
||||
interface Props {
|
||||
/** 网址名称 */
|
||||
label: string;
|
||||
/** 网址链接 */
|
||||
link: string;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
Reference in New Issue
Block a user