Merge branch 'main' into fix-downloader
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben/access",
|
||||
"version": "5.5.8",
|
||||
"version": "5.5.9",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben/common-ui",
|
||||
"version": "5.5.8",
|
||||
"version": "5.5.9",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
||||
@@ -9,6 +9,7 @@ export * from './loading';
|
||||
export * from './page';
|
||||
export * from './resize';
|
||||
export * from './tippy';
|
||||
export * from './tree';
|
||||
export * from '@vben-core/form-ui';
|
||||
export * from '@vben-core/popup-ui';
|
||||
|
||||
@@ -27,7 +28,6 @@ export {
|
||||
VbenPinInput,
|
||||
VbenSelect,
|
||||
VbenSpinner,
|
||||
VbenTree,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
|
||||
export type { FlattenedItem } from '@vben-core/shadcn-ui';
|
||||
|
||||
@@ -25,7 +25,7 @@ const footerRef = useTemplateRef<HTMLDivElement>('footerRef');
|
||||
const contentStyle = computed<StyleValue>(() => {
|
||||
if (autoContentHeight) {
|
||||
return {
|
||||
height: `calc(var(${CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT}) - ${headerHeight.value}px - ${typeof heightOffset === 'number' ? `${heightOffset}px` : heightOffset})`,
|
||||
height: `calc(var(${CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT}) - ${headerHeight.value}px - ${footerHeight.value}px - ${typeof heightOffset === 'number' ? `${heightOffset}px` : heightOffset})`,
|
||||
overflowY: shouldAutoHeight.value ? 'auto' : 'unset',
|
||||
};
|
||||
}
|
||||
@@ -50,7 +50,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div class="relative flex min-h-full flex-col">
|
||||
<div
|
||||
v-if="
|
||||
description ||
|
||||
@@ -89,16 +89,10 @@ onMounted(() => {
|
||||
<div :class="cn('h-full p-4', contentClass)" :style="contentStyle">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
ref="footerRef"
|
||||
:class="
|
||||
cn(
|
||||
'bg-card align-center absolute bottom-0 left-0 right-0 flex px-6 py-4',
|
||||
footerClass,
|
||||
)
|
||||
"
|
||||
:class="cn('bg-card align-center flex px-6 py-4', footerClass)"
|
||||
>
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
|
||||
1
packages/effects/common-ui/src/components/tree/index.ts
Normal file
1
packages/effects/common-ui/src/components/tree/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Tree } from './tree.vue';
|
||||
25
packages/effects/common-ui/src/components/tree/tree.vue
Normal file
25
packages/effects/common-ui/src/components/tree/tree.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { TreeProps } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { Inbox } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { treePropsDefaults, VbenTree } from '@vben-core/shadcn-ui';
|
||||
|
||||
const props = withDefaults(defineProps<TreeProps>(), treePropsDefaults());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VbenTree v-if="props.treeData?.length > 0" v-bind="props">
|
||||
<template v-for="(_, key) in $slots" :key="key" #[key]="slotProps">
|
||||
<slot :name="key" v-bind="slotProps"> </slot>
|
||||
</template>
|
||||
</VbenTree>
|
||||
<div
|
||||
v-else
|
||||
class="flex-col-center text-muted-foreground cursor-pointer rounded-lg border p-10 text-sm font-medium"
|
||||
>
|
||||
<Inbox class="size-10" />
|
||||
<div class="mt-1">{{ $t('common.noData') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -35,6 +35,10 @@ interface Props {
|
||||
* @zh_CN 按钮文本
|
||||
*/
|
||||
submitButtonText?: string;
|
||||
/**
|
||||
* @zh_CN 是否显示返回按钮
|
||||
*/
|
||||
showBack?: boolean;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
@@ -43,6 +47,7 @@ defineOptions({
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
showBack: true,
|
||||
loginPath: '/auth/login',
|
||||
submitButtonText: '',
|
||||
subTitle: '',
|
||||
@@ -110,7 +115,12 @@ defineExpose({
|
||||
{{ submitButtonText || $t('common.login') }}
|
||||
</slot>
|
||||
</VbenButton>
|
||||
<VbenButton class="mt-4 w-full" variant="outline" @click="goToLogin()">
|
||||
<VbenButton
|
||||
v-if="showBack"
|
||||
class="mt-4 w-full"
|
||||
variant="outline"
|
||||
@click="goToLogin()"
|
||||
>
|
||||
{{ $t('common.back') }}
|
||||
</VbenButton>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { RiDingding } from '@vben/icons';
|
||||
import { SvgDingDingIcon } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { alert, useVbenModal } from '@vben-core/popup-ui';
|
||||
@@ -96,7 +96,7 @@ const handleLogin = () => {
|
||||
:tooltip="$t('authentication.dingdingLogin')"
|
||||
tooltip-side="top"
|
||||
>
|
||||
<RiDingding />
|
||||
<SvgDingDingIcon />
|
||||
</VbenIconButton>
|
||||
<Modal>
|
||||
<div id="dingding_qrcode_login_element"></div>
|
||||
|
||||
@@ -35,6 +35,10 @@ interface Props {
|
||||
* @zh_CN 描述
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @zh_CN 是否显示返回按钮
|
||||
*/
|
||||
showBack?: boolean;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
@@ -44,6 +48,7 @@ defineOptions({
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
description: '',
|
||||
loading: false,
|
||||
showBack: true,
|
||||
loginPath: '/auth/login',
|
||||
submitButtonText: '',
|
||||
subTitle: '',
|
||||
@@ -88,7 +93,12 @@ function goToLogin() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<VbenButton class="mt-4 w-full" variant="outline" @click="goToLogin()">
|
||||
<VbenButton
|
||||
v-if="showBack"
|
||||
class="mt-4 w-full"
|
||||
variant="outline"
|
||||
@click="goToLogin()"
|
||||
>
|
||||
{{ $t('common.back') }}
|
||||
</VbenButton>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { useAppConfig } from '@vben/hooks';
|
||||
import { MdiGithub, MdiGoogle, MdiQqchat, MdiWechat } from '@vben/icons';
|
||||
import {
|
||||
SvgGithubIcon,
|
||||
SvgGoogleIcon,
|
||||
SvgQQChatIcon,
|
||||
SvgWeChatIcon,
|
||||
} from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { VbenIconButton } from '@vben-core/shadcn-ui';
|
||||
@@ -32,28 +37,28 @@ const {
|
||||
tooltip-side="top"
|
||||
class="mb-3"
|
||||
>
|
||||
<MdiWechat />
|
||||
<SvgWeChatIcon />
|
||||
</VbenIconButton>
|
||||
<VbenIconButton
|
||||
:tooltip="$t('authentication.qqLogin')"
|
||||
tooltip-side="top"
|
||||
class="mb-3"
|
||||
>
|
||||
<MdiQqchat />
|
||||
<SvgQQChatIcon />
|
||||
</VbenIconButton>
|
||||
<VbenIconButton
|
||||
:tooltip="$t('authentication.githubLogin')"
|
||||
tooltip-side="top"
|
||||
class="mb-3"
|
||||
>
|
||||
<MdiGithub />
|
||||
<SvgGithubIcon />
|
||||
</VbenIconButton>
|
||||
<VbenIconButton
|
||||
:tooltip="$t('authentication.googleLogin')"
|
||||
tooltip-side="top"
|
||||
class="mb-3"
|
||||
>
|
||||
<MdiGoogle />
|
||||
<SvgGoogleIcon />
|
||||
</VbenIconButton>
|
||||
<DingdingLogin
|
||||
v-if="dingdingAuthConfig"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben/hooks",
|
||||
"version": "5.5.8",
|
||||
"version": "5.5.9",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben/layouts",
|
||||
"version": "5.5.8",
|
||||
"version": "5.5.9",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
||||
@@ -50,7 +50,7 @@ const { authPanelCenter, authPanelLeft, authPanelRight, isDark } =
|
||||
<AuthenticationFormView
|
||||
v-if="authPanelLeft"
|
||||
class="min-h-full w-2/5 flex-1"
|
||||
transition-name="slide-left"
|
||||
data-side="left"
|
||||
>
|
||||
<template v-if="copyright" #copyright>
|
||||
<slot name="copyright">
|
||||
@@ -86,7 +86,14 @@ const { authPanelCenter, authPanelLeft, authPanelRight, isDark } =
|
||||
class="bg-background-deep absolute inset-0 h-full w-full dark:bg-[#070709]"
|
||||
>
|
||||
<div class="login-background absolute left-0 top-0 size-full"></div>
|
||||
<div class="flex-col-center -enter-x mr-20 h-full">
|
||||
<div
|
||||
:key="authPanelLeft ? 'left' : authPanelRight ? 'right' : 'center'"
|
||||
class="flex-col-center mr-20 h-full"
|
||||
:class="{
|
||||
'enter-x': authPanelLeft,
|
||||
'-enter-x': authPanelRight,
|
||||
}"
|
||||
>
|
||||
<template v-if="sloganImage">
|
||||
<img
|
||||
:alt="appName"
|
||||
@@ -110,6 +117,7 @@ const { authPanelCenter, authPanelLeft, authPanelRight, isDark } =
|
||||
<div class="login-background absolute left-0 top-0 size-full"></div>
|
||||
<AuthenticationFormView
|
||||
class="md:bg-background shadow-primary/5 shadow-float w-full rounded-3xl pb-20 md:w-2/3 lg:w-1/2 xl:w-[36%]"
|
||||
data-side="bottom"
|
||||
>
|
||||
<template v-if="copyright" #copyright>
|
||||
<slot name="copyright">
|
||||
@@ -125,7 +133,8 @@ const { authPanelCenter, authPanelLeft, authPanelRight, isDark } =
|
||||
<!-- 右侧认证面板 -->
|
||||
<AuthenticationFormView
|
||||
v-if="authPanelRight"
|
||||
class="min-h-full w-[34%] flex-1"
|
||||
class="min-h-full w-2/5 flex-1"
|
||||
data-side="right"
|
||||
>
|
||||
<template v-if="copyright" #copyright>
|
||||
<slot name="copyright">
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
defineOptions({
|
||||
name: 'AuthenticationFormView',
|
||||
});
|
||||
|
||||
defineProps<{
|
||||
dataSide?: 'bottom' | 'left' | 'right' | 'top';
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -16,7 +20,8 @@ defineOptions({
|
||||
<component
|
||||
:is="Component"
|
||||
:key="route.fullPath"
|
||||
class="enter-x mt-6 w-full sm:mx-auto md:max-w-md"
|
||||
class="side-content mt-6 w-full sm:mx-auto md:max-w-md"
|
||||
:data-side="dataSide"
|
||||
/>
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
|
||||
@@ -158,7 +158,9 @@ function clickLogo() {
|
||||
function autoCollapseMenuByRouteMeta(route: RouteLocationNormalizedLoaded) {
|
||||
// 只在双列模式下生效
|
||||
if (
|
||||
preferences.app.layout === 'sidebar-mixed-nav' &&
|
||||
['header-mixed-nav', 'sidebar-mixed-nav'].includes(
|
||||
preferences.app.layout,
|
||||
) &&
|
||||
route.meta &&
|
||||
route.meta.hideInMenu
|
||||
) {
|
||||
|
||||
@@ -29,7 +29,8 @@ function useNavigation() {
|
||||
return true;
|
||||
}
|
||||
const route = routeMetaMap.get(path);
|
||||
return route?.meta?.openInNewWindow ?? false;
|
||||
// 如果有外链或者设置了在新窗口打开,返回 true
|
||||
return !!(route?.meta?.link || route?.meta?.openInNewWindow);
|
||||
};
|
||||
|
||||
const resolveHref = (path: string): string => {
|
||||
@@ -39,7 +40,13 @@ function useNavigation() {
|
||||
const navigation = async (path: string) => {
|
||||
try {
|
||||
const route = routeMetaMap.get(path);
|
||||
const { openInNewWindow = false, query = {} } = route?.meta ?? {};
|
||||
const { openInNewWindow = false, query = {}, link } = route?.meta ?? {};
|
||||
|
||||
// 检查是否有外链
|
||||
if (link && typeof link === 'string') {
|
||||
openWindow(link, { target: '_blank' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isHttpUrl(path)) {
|
||||
openWindow(path, { target: '_blank' });
|
||||
|
||||
@@ -31,7 +31,7 @@ async function handleUpdate(value: string | undefined) {
|
||||
:model-value="preferences.app.locale"
|
||||
@update:model-value="handleUpdate"
|
||||
>
|
||||
<VbenIconButton>
|
||||
<VbenIconButton class="hover:animate-[shrink_0.3s_ease-in-out]">
|
||||
<Languages class="text-foreground size-4" />
|
||||
</VbenIconButton>
|
||||
</VbenDropdownRadioMenu>
|
||||
|
||||
@@ -27,29 +27,30 @@ const emit = defineEmits<{
|
||||
submit: [Recordable<any>];
|
||||
}>();
|
||||
|
||||
const [Form, { resetForm, validate, getValues }] = useVbenForm(
|
||||
reactive({
|
||||
commonConfig: {
|
||||
hideLabel: true,
|
||||
hideRequiredMark: true,
|
||||
},
|
||||
schema: computed(() => [
|
||||
{
|
||||
component: 'VbenInputPassword' as const,
|
||||
componentProps: {
|
||||
placeholder: $t('ui.widgets.lockScreen.placeholder'),
|
||||
},
|
||||
fieldName: 'lockScreenPassword',
|
||||
formFieldProps: { validateOnBlur: false },
|
||||
label: $t('authentication.password'),
|
||||
rules: z
|
||||
.string()
|
||||
.min(1, { message: $t('ui.widgets.lockScreen.placeholder') }),
|
||||
const [Form, { resetForm, validate, getValues, getFieldComponentRef }] =
|
||||
useVbenForm(
|
||||
reactive({
|
||||
commonConfig: {
|
||||
hideLabel: true,
|
||||
hideRequiredMark: true,
|
||||
},
|
||||
]),
|
||||
showDefaultActions: false,
|
||||
}),
|
||||
);
|
||||
schema: computed(() => [
|
||||
{
|
||||
component: 'VbenInputPassword' as const,
|
||||
componentProps: {
|
||||
placeholder: $t('ui.widgets.lockScreen.placeholder'),
|
||||
},
|
||||
fieldName: 'lockScreenPassword',
|
||||
formFieldProps: { validateOnBlur: false },
|
||||
label: $t('authentication.password'),
|
||||
rules: z
|
||||
.string()
|
||||
.min(1, { message: $t('ui.widgets.lockScreen.placeholder') }),
|
||||
},
|
||||
]),
|
||||
showDefaultActions: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const [Modal] = useVbenModal({
|
||||
onConfirm() {
|
||||
@@ -60,6 +61,13 @@ const [Modal] = useVbenModal({
|
||||
resetForm();
|
||||
}
|
||||
},
|
||||
onOpened() {
|
||||
requestAnimationFrame(() => {
|
||||
getFieldComponentRef('lockScreenPassword')
|
||||
?.$el?.querySelector('[name="lockScreenPassword"]')
|
||||
?.focus();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
|
||||
@@ -37,7 +37,7 @@ const date = useDateFormat(now, 'YYYY-MM-DD dddd', { locales: locale.value });
|
||||
const showUnlockForm = ref(false);
|
||||
const { lockScreenPassword } = storeToRefs(accessStore);
|
||||
|
||||
const [Form, { form, validate }] = useVbenForm(
|
||||
const [Form, { form, validate, getFieldComponentRef }] = useVbenForm(
|
||||
reactive({
|
||||
commonConfig: {
|
||||
hideLabel: true,
|
||||
@@ -75,6 +75,13 @@ async function handleSubmit() {
|
||||
|
||||
function toggleUnlockForm() {
|
||||
showUnlockForm.value = !showUnlockForm.value;
|
||||
if (showUnlockForm.value) {
|
||||
requestAnimationFrame(() => {
|
||||
getFieldComponentRef('password')
|
||||
?.$el?.querySelector('[name="password"]')
|
||||
?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useScrollLock();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { SUPPORT_LANGUAGES } from '@vben/constants';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import InputItem from '../input-item.vue';
|
||||
import SelectItem from '../select-item.vue';
|
||||
import SwitchItem from '../switch-item.vue';
|
||||
|
||||
@@ -12,6 +13,7 @@ defineOptions({
|
||||
const appLocale = defineModel<string>('appLocale');
|
||||
const appDynamicTitle = defineModel<boolean>('appDynamicTitle');
|
||||
const appWatermark = defineModel<boolean>('appWatermark');
|
||||
const appWatermarkContent = defineModel<string>('appWatermarkContent');
|
||||
const appEnableCheckUpdates = defineModel<boolean>('appEnableCheckUpdates');
|
||||
</script>
|
||||
|
||||
@@ -22,9 +24,23 @@ const appEnableCheckUpdates = defineModel<boolean>('appEnableCheckUpdates');
|
||||
<SwitchItem v-model="appDynamicTitle">
|
||||
{{ $t('preferences.dynamicTitle') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem v-model="appWatermark">
|
||||
<SwitchItem
|
||||
v-model="appWatermark"
|
||||
@update:model-value="
|
||||
(val) => {
|
||||
if (!val) appWatermarkContent = '';
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ $t('preferences.watermark') }}
|
||||
</SwitchItem>
|
||||
<InputItem
|
||||
v-if="appWatermark"
|
||||
v-model="appWatermarkContent"
|
||||
:placeholder="$t('preferences.watermarkContent')"
|
||||
>
|
||||
{{ $t('preferences.watermarkContent') }}
|
||||
</InputItem>
|
||||
<SwitchItem v-model="appEnableCheckUpdates">
|
||||
{{ $t('preferences.checkUpdates') }}
|
||||
</SwitchItem>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { SelectOption } from '@vben/types';
|
||||
|
||||
import { useSlots } from 'vue';
|
||||
|
||||
import { CircleHelp } from '@vben/icons';
|
||||
import { CircleHelp, CircleX } from '@vben/icons';
|
||||
|
||||
import { Input, VbenTooltip } from '@vben-core/shadcn-ui';
|
||||
|
||||
@@ -47,6 +47,17 @@ const slots = useSlots();
|
||||
<slot name="tip"></slot>
|
||||
</VbenTooltip>
|
||||
</span>
|
||||
<Input v-model="inputValue" class="h-8 w-[165px]" />
|
||||
<div class="relative">
|
||||
<Input
|
||||
v-model="inputValue"
|
||||
class="h-8 w-[165px]"
|
||||
:placeholder="placeholder"
|
||||
/>
|
||||
<CircleX
|
||||
v-if="inputValue"
|
||||
class="hover:text-foreground text-foreground/60 absolute right-2 top-1/2 size-3 -translate-y-1/2 transform cursor-pointer"
|
||||
@click="() => (inputValue = '')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -104,7 +104,7 @@ function selectColor() {
|
||||
|
||||
watch(
|
||||
() => [modelValue.value, props.isDark] as [BuiltinThemeType, boolean],
|
||||
([themeType, isDark]) => {
|
||||
([themeType, isDark], [_, isDarkPrev]) => {
|
||||
const theme = builtinThemePresets.value.find(
|
||||
(item) => item.type === themeType,
|
||||
);
|
||||
@@ -113,7 +113,9 @@ watch(
|
||||
? theme.darkPrimaryColor || theme.primaryColor
|
||||
: theme.primaryColor;
|
||||
|
||||
themeColorPrimary.value = primaryColor || theme.color;
|
||||
if (!(theme.type === 'custom' && isDark !== isDarkPrev)) {
|
||||
themeColorPrimary.value = primaryColor || theme.color;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -132,14 +134,14 @@ watch(
|
||||
<template v-if="theme.type !== 'custom'">
|
||||
<div
|
||||
:style="{ backgroundColor: theme.color }"
|
||||
class="mx-10 my-2 size-5 rounded-md"
|
||||
class="mx-9 my-2 size-5 rounded-md"
|
||||
></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="size-full px-10 py-2" @click.stop="selectColor">
|
||||
<div class="size-full px-9 py-2" @click.stop="selectColor">
|
||||
<div class="flex-center relative size-5 rounded-sm">
|
||||
<UserRoundPen
|
||||
class="absolute z-10 size-5 opacity-60 group-hover:opacity-100"
|
||||
class="z-1 absolute size-5 opacity-60 group-hover:opacity-100"
|
||||
/>
|
||||
<input
|
||||
ref="colorInput"
|
||||
|
||||
@@ -13,7 +13,7 @@ function clearPreferencesAndLogout() {
|
||||
</script>
|
||||
<template>
|
||||
<Preferences @clear-preferences-and-logout="clearPreferencesAndLogout">
|
||||
<VbenIconButton>
|
||||
<VbenIconButton class="hover:animate-[shrink_0.3s_ease-in-out]">
|
||||
<Settings class="text-foreground size-4" />
|
||||
</VbenIconButton>
|
||||
</Preferences>
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { SegmentedItem } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Copy, RotateCw } from '@vben/icons';
|
||||
import { Copy, Pin, PinOff, RotateCw } from '@vben/icons';
|
||||
import { $t, loadLocaleMessages } from '@vben/locales';
|
||||
import {
|
||||
clearPreferencesCache,
|
||||
@@ -67,7 +67,11 @@ const appColorGrayMode = defineModel<boolean>('appColorGrayMode');
|
||||
const appColorWeakMode = defineModel<boolean>('appColorWeakMode');
|
||||
const appContentCompact = defineModel<ContentCompactType>('appContentCompact');
|
||||
const appWatermark = defineModel<boolean>('appWatermark');
|
||||
const appWatermarkContent = defineModel<string>('appWatermarkContent');
|
||||
const appEnableCheckUpdates = defineModel<boolean>('appEnableCheckUpdates');
|
||||
const appEnableStickyPreferencesNavigationBar = defineModel<boolean>(
|
||||
'appEnableStickyPreferencesNavigationBar',
|
||||
);
|
||||
const appPreferencesButtonPosition = defineModel<PreferencesButtonPositionType>(
|
||||
'appPreferencesButtonPosition',
|
||||
);
|
||||
@@ -240,7 +244,7 @@ async function handleReset() {
|
||||
<Drawer
|
||||
:description="$t('preferences.subtitle')"
|
||||
:title="$t('preferences.title')"
|
||||
class="sm:max-w-sm"
|
||||
class="!border-0 sm:max-w-sm"
|
||||
>
|
||||
<template #extra>
|
||||
<div class="flex items-center">
|
||||
@@ -248,18 +252,44 @@ async function handleReset() {
|
||||
:disabled="!diffPreference"
|
||||
:tooltip="$t('preferences.resetTip')"
|
||||
class="relative"
|
||||
@click="handleReset"
|
||||
>
|
||||
<span
|
||||
v-if="diffPreference"
|
||||
class="bg-primary absolute right-0.5 top-0.5 h-2 w-2 rounded"
|
||||
></span>
|
||||
<RotateCw class="size-4" @click="handleReset" />
|
||||
<RotateCw class="size-4" />
|
||||
</VbenIconButton>
|
||||
<VbenIconButton
|
||||
:tooltip="
|
||||
appEnableStickyPreferencesNavigationBar
|
||||
? $t('preferences.disableStickyPreferencesNavigationBar')
|
||||
: $t('preferences.enableStickyPreferencesNavigationBar')
|
||||
"
|
||||
class="relative"
|
||||
@click="
|
||||
() =>
|
||||
(appEnableStickyPreferencesNavigationBar =
|
||||
!appEnableStickyPreferencesNavigationBar)
|
||||
"
|
||||
>
|
||||
<PinOff
|
||||
v-if="appEnableStickyPreferencesNavigationBar"
|
||||
class="size-4"
|
||||
/>
|
||||
<Pin v-else class="size-4" />
|
||||
</VbenIconButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="p-1">
|
||||
<VbenSegmented v-model="activeTab" :tabs="tabs">
|
||||
<div>
|
||||
<VbenSegmented
|
||||
v-model="activeTab"
|
||||
:tabs="tabs"
|
||||
:class="{
|
||||
'sticky-tabs-header': appEnableStickyPreferencesNavigationBar,
|
||||
}"
|
||||
>
|
||||
<template #general>
|
||||
<Block :title="$t('preferences.general')">
|
||||
<General
|
||||
@@ -267,6 +297,7 @@ async function handleReset() {
|
||||
v-model:app-enable-check-updates="appEnableCheckUpdates"
|
||||
v-model:app-locale="appLocale"
|
||||
v-model:app-watermark="appWatermark"
|
||||
v-model:app-watermark-content="appWatermarkContent"
|
||||
/>
|
||||
</Block>
|
||||
|
||||
@@ -447,3 +478,11 @@ async function handleReset() {
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.sticky-tabs-header [role='tablist']) {
|
||||
position: sticky;
|
||||
top: -12px;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -64,7 +64,7 @@ function toggleTheme(event: MouseEvent) {
|
||||
`circle(0px at ${x}px ${y}px)`,
|
||||
`circle(${endRadius}px at ${x}px ${y}px)`,
|
||||
];
|
||||
document.documentElement.animate(
|
||||
const animate = document.documentElement.animate(
|
||||
{
|
||||
clipPath: isDark.value ? [...clipPath].reverse() : clipPath,
|
||||
},
|
||||
@@ -76,6 +76,9 @@ function toggleTheme(event: MouseEvent) {
|
||||
: '::view-transition-new(root)',
|
||||
},
|
||||
);
|
||||
animate.onfinish = () => {
|
||||
transition.skipTransition();
|
||||
};
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -85,7 +88,7 @@ function toggleTheme(event: MouseEvent) {
|
||||
:aria-label="theme"
|
||||
:class="[`is-${theme}`]"
|
||||
aria-live="polite"
|
||||
class="theme-toggle cursor-pointer border-none bg-none"
|
||||
class="theme-toggle cursor-pointer border-none bg-none hover:animate-[shrink_0.3s_ease-in-out]"
|
||||
v-bind="bindProps"
|
||||
@click.stop="toggleTheme"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben/plugins",
|
||||
"version": "5.5.8",
|
||||
"version": "5.5.9",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import type { EChartsOption } from "echarts";
|
||||
|
||||
import type { Ref } from 'vue';
|
||||
import type { Ref } from "vue";
|
||||
import { computed, nextTick, watch } from "vue";
|
||||
|
||||
import type { Nullable } from '@vben/types';
|
||||
import type { Nullable } from "@vben/types";
|
||||
|
||||
import type EchartsUI from './echarts-ui.vue';
|
||||
import type EchartsUI from "./echarts-ui.vue";
|
||||
|
||||
import { computed, nextTick, watch } from 'vue';
|
||||
|
||||
import { usePreferences } from '@vben/preferences';
|
||||
import { usePreferences } from "@vben/preferences";
|
||||
|
||||
import {
|
||||
tryOnUnmounted,
|
||||
useDebounceFn,
|
||||
useResizeObserver,
|
||||
useTimeoutFn,
|
||||
useWindowSize,
|
||||
} from '@vueuse/core';
|
||||
useWindowSize
|
||||
} from "@vueuse/core";
|
||||
|
||||
import echarts from './echarts';
|
||||
import echarts from "./echarts";
|
||||
|
||||
type EchartsUIType = typeof EchartsUI | undefined;
|
||||
|
||||
@@ -32,6 +31,21 @@ function useEcharts(chartRef: Ref<EchartsUIType>) {
|
||||
const { height, width } = useWindowSize();
|
||||
const resizeHandler: () => void = useDebounceFn(resize, 200);
|
||||
|
||||
const getChartEl = (): HTMLElement | null => {
|
||||
const refValue = chartRef?.value as unknown;
|
||||
if (!refValue) return null;
|
||||
if (refValue instanceof HTMLElement) {
|
||||
return refValue;
|
||||
}
|
||||
const maybeComponent = refValue as { $el?: HTMLElement };
|
||||
return maybeComponent.$el ?? null;
|
||||
};
|
||||
|
||||
const isElHidden = (el: HTMLElement | null): boolean => {
|
||||
if (!el) return true;
|
||||
return el.offsetHeight === 0 || el.offsetWidth === 0;
|
||||
};
|
||||
|
||||
const getOptions = computed((): EChartsOption => {
|
||||
if (!isDark.value) {
|
||||
return {};
|
||||
@@ -54,7 +68,7 @@ function useEcharts(chartRef: Ref<EchartsUIType>) {
|
||||
|
||||
const renderEcharts = (
|
||||
options: EChartsOption,
|
||||
clear = true,
|
||||
clear = true
|
||||
): Promise<Nullable<echarts.ECharts>> => {
|
||||
cacheOptions = options;
|
||||
const currentOptions = {
|
||||
@@ -69,6 +83,13 @@ function useEcharts(chartRef: Ref<EchartsUIType>) {
|
||||
return;
|
||||
}
|
||||
nextTick(() => {
|
||||
const el = getChartEl();
|
||||
if (isElHidden(el)) {
|
||||
useTimeoutFn(async () => {
|
||||
resolve(await renderEcharts(currentOptions));
|
||||
}, 30);
|
||||
return;
|
||||
}
|
||||
useTimeoutFn(() => {
|
||||
if (!chartInstance) {
|
||||
const instance = initCharts();
|
||||
@@ -83,6 +104,10 @@ function useEcharts(chartRef: Ref<EchartsUIType>) {
|
||||
};
|
||||
|
||||
function resize() {
|
||||
const el = getChartEl();
|
||||
if (isElHidden(el)) {
|
||||
return;
|
||||
}
|
||||
chartInstance?.resize({
|
||||
animation: {
|
||||
duration: 300,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben/request",
|
||||
"version": "5.5.8",
|
||||
"version": "5.5.9",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
||||
142
packages/effects/request/src/request-client/modules/sse.test.ts
Normal file
142
packages/effects/request/src/request-client/modules/sse.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import type { RequestClient } from '../request-client';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { SSE } from './sse';
|
||||
|
||||
// 模拟 TextDecoder
|
||||
const OriginalTextDecoder = globalThis.TextDecoder;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
'TextDecoder',
|
||||
class {
|
||||
private decoder = new OriginalTextDecoder();
|
||||
decode(value: Uint8Array, opts?: any) {
|
||||
return this.decoder.decode(value, opts);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// 创建 fetch mock
|
||||
const createFetchMock = (chunks: string[], ok = true) => {
|
||||
const encoder = new TextEncoder();
|
||||
let index = 0;
|
||||
return vi.fn().mockResolvedValue({
|
||||
ok,
|
||||
status: ok ? 200 : 500,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: async () => {
|
||||
if (index < chunks.length) {
|
||||
return { done: false, value: encoder.encode(chunks[index++]) };
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe('sSE', () => {
|
||||
let client: RequestClient;
|
||||
let sse: SSE;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
client = {
|
||||
getBaseUrl: () => 'http://localhost',
|
||||
instance: {
|
||||
interceptors: {
|
||||
request: {
|
||||
handlers: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as RequestClient;
|
||||
sse = new SSE(client);
|
||||
});
|
||||
|
||||
it('should call requestSSE when postSSE is used', async () => {
|
||||
const spy = vi.spyOn(sse, 'requestSSE').mockResolvedValue(undefined);
|
||||
await sse.postSSE('/test', { foo: 'bar' }, { headers: { a: '1' } });
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
'/test',
|
||||
{ foo: 'bar' },
|
||||
{
|
||||
headers: { a: '1' },
|
||||
method: 'POST',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error if fetch response not ok', async () => {
|
||||
vi.stubGlobal('fetch', createFetchMock([], false));
|
||||
await expect(sse.requestSSE('/bad')).rejects.toThrow(
|
||||
'HTTP error! status: 500',
|
||||
);
|
||||
});
|
||||
|
||||
it('should trigger onMessage and onEnd callbacks', async () => {
|
||||
const messages: string[] = [];
|
||||
const onMessage = vi.fn((msg: string) => messages.push(msg));
|
||||
const onEnd = vi.fn();
|
||||
|
||||
vi.stubGlobal('fetch', createFetchMock(['hello', ' world']));
|
||||
|
||||
await sse.requestSSE('/sse', undefined, { onMessage, onEnd });
|
||||
|
||||
expect(onMessage).toHaveBeenCalledTimes(2);
|
||||
expect(messages.join('')).toBe('hello world');
|
||||
// onEnd 不再带参数
|
||||
expect(onEnd).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should apply request interceptors', async () => {
|
||||
const interceptor = vi.fn(async (config) => {
|
||||
config.headers['x-test'] = 'intercepted';
|
||||
return config;
|
||||
});
|
||||
(client.instance.interceptors.request as any).handlers.push({
|
||||
fulfilled: interceptor,
|
||||
});
|
||||
|
||||
// 创建 fetch mock,并挂到全局
|
||||
const fetchMock = createFetchMock(['data']);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await sse.requestSSE('/sse', undefined, {});
|
||||
|
||||
expect(interceptor).toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost/sse',
|
||||
expect.objectContaining({
|
||||
headers: expect.any(Headers),
|
||||
}),
|
||||
);
|
||||
|
||||
const calls = fetchMock.mock?.calls;
|
||||
expect(calls).toBeDefined();
|
||||
expect(calls?.length).toBeGreaterThan(0);
|
||||
|
||||
const init = calls?.[0]?.[1] as RequestInit;
|
||||
expect(init).toBeDefined();
|
||||
|
||||
const headers = init?.headers as Headers;
|
||||
expect(headers?.get('x-test')).toBe('intercepted');
|
||||
expect(headers?.get('accept')).toBe('text/event-stream');
|
||||
});
|
||||
|
||||
it('should throw error when no reader', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: null,
|
||||
}),
|
||||
);
|
||||
await expect(sse.requestSSE('/sse')).rejects.toThrow('No reader');
|
||||
});
|
||||
});
|
||||
136
packages/effects/request/src/request-client/modules/sse.ts
Normal file
136
packages/effects/request/src/request-client/modules/sse.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { AxiosRequestHeaders, InternalAxiosRequestConfig } from 'axios';
|
||||
|
||||
import type { RequestClient } from '../request-client';
|
||||
import type { SseRequestOptions } from '../types';
|
||||
|
||||
/**
|
||||
* SSE模块
|
||||
*/
|
||||
class SSE {
|
||||
private client: RequestClient;
|
||||
|
||||
constructor(client: RequestClient) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
public async postSSE(
|
||||
url: string,
|
||||
data?: any,
|
||||
requestOptions?: SseRequestOptions,
|
||||
) {
|
||||
return this.requestSSE(url, data, {
|
||||
...requestOptions,
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE请求方法
|
||||
* @param url - 请求URL
|
||||
* @param data - 请求数据
|
||||
* @param requestOptions - SSE请求选项
|
||||
*/
|
||||
public async requestSSE(
|
||||
url: string,
|
||||
data?: any,
|
||||
requestOptions?: SseRequestOptions,
|
||||
) {
|
||||
const baseUrl = this.client.getBaseUrl() || '';
|
||||
|
||||
let axiosConfig: InternalAxiosRequestConfig<any> = {
|
||||
url,
|
||||
method: (requestOptions?.method as any) ?? 'GET',
|
||||
headers: {} as AxiosRequestHeaders,
|
||||
};
|
||||
const requestInterceptors = this.client.instance.interceptors
|
||||
.request as any;
|
||||
if (
|
||||
requestInterceptors.handlers &&
|
||||
requestInterceptors.handlers.length > 0
|
||||
) {
|
||||
for (const handler of requestInterceptors.handlers) {
|
||||
if (typeof handler?.fulfilled === 'function') {
|
||||
const next = await handler.fulfilled(axiosConfig as any);
|
||||
if (next) axiosConfig = next as InternalAxiosRequestConfig<any>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const merged = new Headers();
|
||||
Object.entries(
|
||||
(axiosConfig.headers ?? {}) as Record<string, string>,
|
||||
).forEach(([k, v]) => merged.set(k, String(v)));
|
||||
if (requestOptions?.headers) {
|
||||
new Headers(requestOptions.headers).forEach((v, k) => merged.set(k, v));
|
||||
}
|
||||
if (!merged.has('accept')) {
|
||||
merged.set('accept', 'text/event-stream');
|
||||
}
|
||||
|
||||
let bodyInit = requestOptions?.body ?? data;
|
||||
const ct = (merged.get('content-type') || '').toLowerCase();
|
||||
if (
|
||||
bodyInit &&
|
||||
typeof bodyInit === 'object' &&
|
||||
!ArrayBuffer.isView(bodyInit as any) &&
|
||||
!(bodyInit instanceof ArrayBuffer) &&
|
||||
!(bodyInit instanceof Blob) &&
|
||||
!(bodyInit instanceof FormData) &&
|
||||
ct.includes('application/json')
|
||||
) {
|
||||
bodyInit = JSON.stringify(bodyInit);
|
||||
}
|
||||
const requestInit: RequestInit = {
|
||||
...requestOptions,
|
||||
method: axiosConfig.method,
|
||||
headers: merged,
|
||||
body: bodyInit,
|
||||
};
|
||||
|
||||
const response = await fetch(safeJoinUrl(baseUrl, url), requestInit);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
if (!reader) {
|
||||
throw new Error('No reader');
|
||||
}
|
||||
let isEnd = false;
|
||||
while (!isEnd) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
isEnd = true;
|
||||
decoder.decode(new Uint8Array(0), { stream: false });
|
||||
requestOptions?.onEnd?.();
|
||||
reader.releaseLock?.();
|
||||
break;
|
||||
}
|
||||
const content = decoder.decode(value, { stream: true });
|
||||
requestOptions?.onMessage?.(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function safeJoinUrl(baseUrl: string | undefined, url: string): string {
|
||||
if (!baseUrl) {
|
||||
return url; // 没有 baseUrl,直接返回 url
|
||||
}
|
||||
|
||||
// 如果 url 本身就是绝对地址,直接返回
|
||||
if (/^https?:\/\//i.test(url)) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// 如果 baseUrl 是完整 URL,就用 new URL
|
||||
if (/^https?:\/\//i.test(baseUrl)) {
|
||||
return new URL(url, baseUrl).toString();
|
||||
}
|
||||
|
||||
// 否则,当作路径拼接
|
||||
return `${baseUrl.replace(/\/+$/, '')}/${url.replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
export { SSE };
|
||||
@@ -9,6 +9,7 @@ import qs from 'qs';
|
||||
|
||||
import { FileDownloader } from './modules/downloader';
|
||||
import { InterceptorManager } from './modules/interceptor';
|
||||
import { SSE } from './modules/sse';
|
||||
import { FileUploader } from './modules/uploader';
|
||||
|
||||
function getParamsSerializer(
|
||||
@@ -41,12 +42,14 @@ class RequestClient {
|
||||
public addResponseInterceptor: InterceptorManager['addResponseInterceptor'];
|
||||
public download: FileDownloader['download'];
|
||||
|
||||
public readonly instance: AxiosInstance;
|
||||
// 是否正在刷新token
|
||||
public isRefreshing = false;
|
||||
public postSSE: SSE['postSSE'];
|
||||
// 刷新token队列
|
||||
public refreshTokenQueue: ((token: string) => void)[] = [];
|
||||
public requestSSE: SSE['requestSSE'];
|
||||
public upload: FileUploader['upload'];
|
||||
private readonly instance: AxiosInstance;
|
||||
|
||||
/**
|
||||
* 构造函数,用于创建Axios实例
|
||||
@@ -84,6 +87,10 @@ class RequestClient {
|
||||
// 实例化文件下载器
|
||||
const fileDownloader = new FileDownloader(this);
|
||||
this.download = fileDownloader.download.bind(fileDownloader);
|
||||
// 实例化SSE模块
|
||||
const sse = new SSE(this);
|
||||
this.postSSE = sse.postSSE.bind(sse);
|
||||
this.requestSSE = sse.requestSSE.bind(sse);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,6 +110,13 @@ class RequestClient {
|
||||
return this.request<T>(url, { ...config, method: 'GET' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取基础URL
|
||||
*/
|
||||
public getBaseUrl() {
|
||||
return this.instance.defaults.baseURL;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST请求方法
|
||||
*/
|
||||
|
||||
@@ -41,6 +41,14 @@ type RequestContentType =
|
||||
|
||||
type RequestClientOptions = CreateAxiosDefaults & ExtendOptions;
|
||||
|
||||
/**
|
||||
* SSE 请求选项
|
||||
*/
|
||||
interface SseRequestOptions extends RequestInit {
|
||||
onMessage?: (message: string) => void;
|
||||
onEnd?: () => void;
|
||||
}
|
||||
|
||||
interface RequestInterceptorConfig {
|
||||
fulfilled?: (
|
||||
config: ExtendOptions & InternalAxiosRequestConfig,
|
||||
@@ -78,4 +86,5 @@ export type {
|
||||
RequestInterceptorConfig,
|
||||
RequestResponse,
|
||||
ResponseInterceptorConfig,
|
||||
SseRequestOptions,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user