This commit is contained in:
xingyu4j
2025-11-06 16:50:09 +08:00
190 changed files with 2823 additions and 2247 deletions

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import type { ToolbarType } from './types';
import { computed } from 'vue';
import { preferences, usePreferences } from '@vben/preferences';
import { Copyright } from '../basic/copyright';
@@ -11,6 +13,7 @@ import Toolbar from './toolbar.vue';
interface Props {
appName?: string;
logo?: string;
logoDark?: string;
pageTitle?: string;
pageDescription?: string;
sloganImage?: string;
@@ -20,10 +23,11 @@ interface Props {
clickLogo?: () => void;
}
withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<Props>(), {
appName: '',
copyright: true,
logo: '',
logoDark: '',
pageDescription: '',
pageTitle: '',
sloganImage: '',
@@ -34,6 +38,18 @@ withDefaults(defineProps<Props>(), {
const { authPanelCenter, authPanelLeft, authPanelRight, isDark } =
usePreferences();
/**
* @zh_CN 根据主题选择合适的 logo 图标
*/
const logoSrc = computed(() => {
// 如果是暗色主题且提供了 logoDark则使用暗色主题的 logo
if (isDark.value && props.logoDark) {
return props.logoDark;
}
// 否则使用默认的 logo
return props.logo;
});
</script>
<template>
@@ -50,7 +66,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">
@@ -65,14 +81,21 @@ const { authPanelCenter, authPanelLeft, authPanelRight, isDark } =
<slot name="logo">
<!-- 头部 Logo 和应用名称 -->
<div
v-if="logo || appName"
v-if="logoSrc || appName"
class="absolute left-0 top-0 z-10 flex flex-1"
@click="clickLogo"
>
<div
class="text-foreground lg:text-foreground ml-4 mt-4 flex flex-1 items-center sm:left-6 sm:top-6"
>
<img v-if="logo" :alt="appName" :src="logo" class="mr-2" width="42" />
<img
v-if="logoSrc"
:key="logoSrc"
:alt="appName"
:src="logoSrc"
class="mr-2"
width="42"
/>
<p v-if="appName" class="m-0 text-xl font-medium">
{{ appName }}
</p>
@@ -86,7 +109,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 +140,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 +156,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">

View File

@@ -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>

View File

@@ -13,6 +13,7 @@ import {
LanguageToggle,
PreferencesButton,
ThemeToggle,
TimezoneButton,
} from '../../widgets';
interface Props {
@@ -66,15 +67,21 @@ const rightSlots = computed(() => {
name: 'language-toggle',
});
}
if (preferences.widget.fullscreen) {
if (preferences.widget.timezone) {
list.push({
index: REFERENCE_VALUE + 40,
name: 'timezone',
});
}
if (preferences.widget.fullscreen) {
list.push({
index: REFERENCE_VALUE + 50,
name: 'fullscreen',
});
}
if (preferences.widget.notification) {
list.push({
index: REFERENCE_VALUE + 50,
index: REFERENCE_VALUE + 60,
name: 'notification',
});
}
@@ -166,6 +173,9 @@ function clearPreferencesAndLogout() {
<template v-else-if="slot.name === 'fullscreen'">
<VbenFullScreen class="mr-1" />
</template>
<template v-else-if="slot.name === 'timezone'">
<TimezoneButton class="mr-1 mt-[2px]" />
</template>
</slot>
</template>
</div>

View File

@@ -259,6 +259,7 @@ const headerSlots = computed(() => {
:class="logoClass"
:collapsed="logoCollapsed"
:src="preferences.logo.source"
:src-dark="preferences.logo.sourceDark"
:text="preferences.app.name"
:theme="showHeaderNav ? headerTheme : theme"
@click="clickLogo"
@@ -302,6 +303,9 @@ const headerSlots = computed(() => {
<template #notification>
<slot name="notification"></slot>
</template>
<template #timezone>
<slot name="timezone"></slot>
</template>
<template v-for="item in headerSlots" #[item]>
<slot :name="item"></slot>
</template>
@@ -347,6 +351,8 @@ const headerSlots = computed(() => {
<VbenLogo
v-if="preferences.logo.enable"
:fit="preferences.logo.fit"
:src="preferences.logo.source"
:src-dark="preferences.logo.sourceDark"
:text="preferences.app.name"
:theme="theme"
>

View File

@@ -10,4 +10,5 @@ export * from './notification';
export * from './preferences';
export * from './tenant-dropdown';
export * from './theme-toggle';
export * from './timezone';
export * from './user-dropdown';

View File

@@ -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() {

View File

@@ -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();

View File

@@ -50,6 +50,6 @@ function handleClick() {
<span v-if="$slots.shortcut" class="ml-auto mr-2 text-xs opacity-60">
<slot name="shortcut"></slot>
</span>
<Switch v-model:checked="checked" @click.stop />
<Switch v-model="checked" @click.stop />
</div>
</template>

View File

@@ -0,0 +1 @@
export { default as TimezoneButton } from './timezone-button.vue';

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
import { ref, unref } from 'vue';
import { createIconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { useTimezoneStore } from '@vben/stores';
import { useVbenModal } from '@vben-core/popup-ui';
import {
RadioGroup,
RadioGroupItem,
VbenIconButton,
} from '@vben-core/shadcn-ui';
const TimezoneIcon = createIconifyIcon('fluent-mdl2:world-clock');
const timezoneStore = useTimezoneStore();
const timezoneRef = ref<string | undefined>();
const timezoneOptionsRef = ref<
{
label: string;
value: string;
}[]
>([]);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
onConfirm: async () => {
try {
modalApi.setState({ confirmLoading: true });
const timezone = unref(timezoneRef);
if (timezone) {
await timezoneStore.setTimezone(timezone);
}
modalApi.close();
} finally {
modalApi.setState({ confirmLoading: false });
}
},
async onOpenChange(isOpen) {
if (isOpen) {
timezoneRef.value = unref(timezoneStore.timezone);
timezoneOptionsRef.value = await timezoneStore.getTimezoneOptions();
}
},
});
const handleClick = () => {
modalApi.open();
};
</script>
<template>
<div>
<VbenIconButton
:tooltip="$t('ui.widgets.timezone.setTimezone')"
class="hover:animate-[shrink_0.3s_ease-in-out]"
@click="handleClick"
>
<TimezoneIcon class="text-foreground size-4" />
</VbenIconButton>
<Modal :title="$t('ui.widgets.timezone.setTimezone')">
<div class="timezone-container">
<RadioGroup v-model="timezoneRef" class="flex flex-col gap-2">
<div
class="flex cursor-pointer items-center gap-2"
v-for="item in timezoneOptionsRef"
:key="`container${item.value}`"
>
<RadioGroupItem :id="item.value" :value="item.value" />
<label :for="item.value" class="cursor-pointer">{{
item.label
}}</label>
</div>
</RadioGroup>
</div>
</Modal>
</div>
</template>
<style scoped>
.timezone-container {
padding-left: 20px;
}
</style>