This commit is contained in:
dap
2026-04-16 19:56:08 +08:00
74 changed files with 3040 additions and 267 deletions

View File

@@ -1,67 +1,69 @@
<script lang="ts" setup>
import type { NotificationItem } from './types';
import { Bell, MailCheck } from '@vben/icons';
import { Bell, CircleCheckBig, CircleX, MailCheck } from '@vben/icons';
import { $t } from '@vben/locales';
import { VbenButton, VbenIconButton, VbenPopover, VbenScrollbar } from '@vben-core/shadcn-ui';
import {
VbenButton,
VbenIconButton,
VbenPopover,
VbenScrollbar,
} from '@vben-core/shadcn-ui';
import { useToggle } from '@vueuse/core';
interface Props {
/**
* 显示圆点
*/
dot?: boolean;
/**
* 消息列表
*/
notifications?: NotificationItem[];
}
defineOptions({ name: 'NotificationPopup' });
withDefaults(defineProps<Props>(), {
dot: false,
notifications: () => [],
});
withDefaults(
defineProps<{
/** 显示圆点 */
dot?: boolean;
/** 消息列表 */
notifications?: NotificationItem[];
}>(),
{
dot: false,
notifications: () => [],
},
);
const emit = defineEmits<{
clear: [];
makeAll: [];
onClick: [NotificationItem];
read: [NotificationItem];
viewAll: [];
}>();
const [open, toggle] = useToggle();
function close() {
const close = () => {
open.value = false;
}
};
function handleViewAll() {
const handleViewAll = () => {
emit('viewAll');
close();
}
};
function handleMakeAll() {
const handleMakeAll = () => {
emit('makeAll');
}
};
function handleClear() {
const handleClear = () => {
emit('clear');
}
function handleClick(item: NotificationItem) {
emit('read', item);
}
};
</script>
<template>
<VbenPopover v-model:open="open" content-class="relative right-2 w-90 p-0">
<template #trigger>
<div class="mr-2 flex-center h-full" @click.stop="toggle()">
<VbenIconButton class="bell-button relative text-foreground">
<span v-if="dot" class="absolute top-0.5 right-0.5 size-2 rounded-sm bg-primary"></span>
<span
v-if="dot"
class="absolute top-0.5 right-0.5 size-2 rounded-sm bg-primary"
></span>
<Bell class="size-4" />
</VbenIconButton>
</div>
@@ -83,29 +85,60 @@ function handleClick(item: NotificationItem) {
<template v-for="item in notifications" :key="item.title">
<li
class="relative flex w-full cursor-pointer items-start gap-5 border-t border-border p-3 hover:bg-accent"
@click="handleClick(item)"
@click="emit('onClick', item)"
>
<span
v-if="!item.isRead"
class="absolute top-2 right-2 size-2 rounded-sm bg-primary"
></span>
<slot name="content" :item="item">
<span
v-if="!item.isRead"
class="absolute top-2 right-2 size-2 rounded-sm bg-primary"
></span>
<span class="relative flex size-10 shrink-0 overflow-hidden rounded-full">
<img
:src="item.avatar"
class="aspect-square h-full w-full object-cover"
role="img"
/>
</span>
<div class="flex flex-col gap-1 leading-none">
<p class="font-semibold">{{ item.title }}</p>
<p class="my-1 line-clamp-2 text-xs text-muted-foreground">
{{ item.message }}
</p>
<p class="line-clamp-2 text-xs text-muted-foreground">
{{ item.date }}
</p>
</div>
<span
class="relative flex size-10 shrink-0 overflow-hidden rounded-full"
>
<img
:src="item.avatar"
class="aspect-square size-full object-cover"
/>
</span>
<div class="flex flex-col gap-1 leading-none">
<p class="font-semibold">{{ item.title }}</p>
<p class="my-1 line-clamp-2 text-xs text-muted-foreground">
{{ item.message }}
</p>
<p class="line-clamp-2 text-xs text-muted-foreground">
{{ item.date }}
</p>
</div>
<div
class="absolute top-1/2 right-3 flex -translate-y-1/2 flex-row gap-1"
>
<slot name="action" :item="item">
<slot name="action-prepend" :item="item"></slot>
<VbenIconButton
v-if="!item.isRead"
size="xs"
variant="ghost"
class="h-6 px-2"
:tooltip="$t('common.confirm')"
@click.stop="emit('read', item)"
>
<CircleCheckBig class="size-4" />
</VbenIconButton>
<VbenIconButton
v-if="item.isRead"
size="xs"
variant="ghost"
class="h-6 px-2 text-destructive"
:tooltip="$t('common.delete')"
@click.stop="emit('remove', item)"
>
<CircleX class="size-4" />
</VbenIconButton>
<slot name="action-append" :item="item"></slot>
</slot>
</div>
</slot>
</li>
</template>
</ul>
@@ -117,7 +150,9 @@ function handleClick(item: NotificationItem) {
</div>
</template>
<div class="flex items-center justify-between border-t border-border px-4 py-3">
<div
class="flex items-center justify-between border-t border-border px-4 py-3"
>
<VbenButton
:disabled="notifications.length <= 0"
size="sm"

View File

@@ -6,6 +6,15 @@ interface NotificationItem {
message: string;
title: string;
userId: number | string;
/**
* 跳转链接,可以是路由路径或完整 URL
* @example '/dashboard' 或 'https://example.com'
*/
link?: string;
query?: Record<string, any>;
state?: Record<string, any>;
/** 业务字段 */
[key: string]: any;
}
export type { NotificationItem };

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import type {
CustomPreferencesField,
CustomPreferencesRecord,
} from '@vben/preferences';
import { computed } from 'vue';
import { $t } from '@vben/locales';
import InputItem from '../input-item.vue';
import NumberFieldItem from '../number-field-item.vue';
import SelectItem from '../select-item.vue';
import SwitchItem from '../switch-item.vue';
defineOptions({
name: 'PreferenceCustomFields',
});
const props = defineProps<{
fields: Array<CustomPreferencesField>;
values: CustomPreferencesRecord;
}>();
const emit = defineEmits<{
update: [updates: CustomPreferencesRecord];
}>();
function handleUpdate(key: string, value: boolean | number | string) {
emit('update', { [key]: value });
}
function handleBooleanUpdate(key: string, value: boolean | undefined) {
handleUpdate(key, value ?? false);
}
function resolveNumberValue(value: unknown) {
return typeof value === 'number' && Number.isFinite(value)
? value
: undefined;
}
function handleNumberUpdate(key: string, value: number | undefined) {
const resolvedValue = resolveNumberValue(value);
if (resolvedValue !== undefined) {
handleUpdate(key, resolvedValue);
}
}
function handleStringUpdate(key: string, value: string | undefined) {
handleUpdate(key, value ?? '');
}
const resolvedFields = computed(() => {
return props.fields.map((field) => {
return {
...field,
label: $t(field.label),
options:
field.component === 'select'
? field.options.map((option) => ({
...option,
label: $t(option.label),
}))
: undefined,
placeholder: field.placeholder ? $t(field.placeholder) : '',
tip: field.tip ? $t(field.tip) : '',
};
});
});
</script>
<template>
<template v-for="field in resolvedFields" :key="field.key">
<SwitchItem
v-if="field.component === 'switch'"
:disabled="field.disabled"
:model-value="Boolean(values[field.key])"
:tip="field.tip"
v-bind="field.componentProps"
@update:model-value="handleBooleanUpdate(field.key, $event)"
>
{{ field.label }}
</SwitchItem>
<NumberFieldItem
v-else-if="field.component === 'number'"
:disabled="field.disabled"
:model-value="resolveNumberValue(values[field.key])"
:placeholder="field.placeholder"
:tip="field.tip"
v-bind="field.componentProps"
@update:model-value="handleNumberUpdate(field.key, $event)"
>
{{ field.label }}
</NumberFieldItem>
<SelectItem
v-else-if="field.component === 'select'"
:disabled="field.disabled"
:items="field.options"
:model-value="String(values[field.key] ?? '')"
:placeholder="field.placeholder"
:tip="field.tip"
v-bind="field.componentProps"
@update:model-value="handleStringUpdate(field.key, $event)"
>
{{ field.label }}
</SelectItem>
<InputItem
v-else
:disabled="field.disabled"
:model-value="String(values[field.key] ?? '')"
:placeholder="field.placeholder"
:tip="field.tip"
v-bind="field.componentProps"
@update:model-value="handleStringUpdate(field.key, $event)"
>
{{ field.label }}
</InputItem>
</template>
</template>

View File

@@ -1,4 +1,5 @@
export { default as Block } from './block.vue';
export { default as Custom } from './custom/custom.vue';
export { default as Animation } from './general/animation.vue';
export { default as General } from './general/general.vue';
export { default as Breadcrumb } from './layout/breadcrumb.vue';

View File

@@ -16,10 +16,12 @@ withDefaults(
disabled?: boolean;
items?: SelectOption[];
placeholder?: string;
tip?: string;
}>(),
{
disabled: false,
placeholder: '',
tip: '',
items: () => [],
},
);
@@ -32,7 +34,7 @@ const slots = useSlots();
<template>
<div
:class="{
'hover:bg-accent': !slots.tip,
'hover:bg-accent': !(slots.tip || tip),
'pointer-events-none opacity-50': disabled,
}"
class="my-1 flex w-full items-center justify-between rounded-md px-2 py-1"
@@ -40,11 +42,17 @@ const slots = useSlots();
<span class="flex items-center text-sm">
<slot></slot>
<VbenTooltip v-if="slots.tip" side="bottom">
<VbenTooltip v-if="slots.tip || tip" side="bottom">
<template #trigger>
<CircleHelp class="ml-1 size-3 cursor-help" />
</template>
<slot name="tip"></slot>
<slot name="tip">
<template v-if="tip">
<p v-for="(line, index) in tip.split('\n')" :key="index">
{{ line }}
</p>
</template>
</slot>
</VbenTooltip>
</span>
<div class="relative">

View File

@@ -21,10 +21,12 @@ withDefaults(
disabled?: boolean;
items?: SelectOption[];
placeholder?: string;
tip?: string;
}>(),
{
disabled: false,
placeholder: '',
tip: '',
items: () => [],
},
);
@@ -37,7 +39,7 @@ const slots = useSlots();
<template>
<div
:class="{
'hover:bg-accent': !slots.tip,
'hover:bg-accent': !(slots.tip || tip),
'pointer-events-none opacity-50': disabled,
}"
class="my-1 flex w-full items-center justify-between rounded-md px-2 py-1"
@@ -45,11 +47,17 @@ const slots = useSlots();
<span class="flex items-center text-sm">
<slot></slot>
<VbenTooltip v-if="slots.tip" side="bottom">
<VbenTooltip v-if="slots.tip || tip" side="bottom">
<template #trigger>
<CircleHelp class="ml-1 size-3 cursor-help" />
</template>
<slot name="tip"></slot>
<slot name="tip">
<template v-if="tip">
<p v-for="(line, index) in tip.split('\n')" :key="index">
{{ line }}
</p>
</template>
</slot>
</VbenTooltip>
</span>
<Select v-model="selectValue">

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { SupportedLanguagesType } from '@vben/locales';
import type { CustomPreferencesRecord } from '@vben/preferences';
import type {
BreadcrumbStyleType,
BuiltinThemeType,
@@ -22,6 +23,7 @@ import {
clearCache,
preferences,
resetPreferences,
updateCustomPreferences,
usePreferences,
} from '@vben/preferences';
@@ -44,6 +46,7 @@ import {
ColorMode,
Content,
Copyright,
Custom,
FontSize,
Footer,
General,
@@ -179,12 +182,15 @@ const widgetLockScreen = defineModel<boolean>('widgetLockScreen');
const widgetRefresh = defineModel<boolean>('widgetRefresh');
const {
customPreferences,
diffCustomPreference,
diffPreference,
isDark,
isFullContent,
isHeaderNav,
isHeaderSidebarNav,
isMixedNav,
preferencesExtension,
isSideMixedNav,
isSideMode,
isSideNav,
@@ -195,8 +201,42 @@ const [Drawer] = useVbenDrawer();
const activeTab = ref('appearance');
const customPreferencesTab = computed(() => {
return preferencesExtension.value;
});
const customTabLabel = computed(() => {
return customPreferencesTab.value?.tabLabel
? $t(customPreferencesTab.value.tabLabel)
: '';
});
const customTabTitle = computed(() => {
const title =
customPreferencesTab.value?.title || customPreferencesTab.value?.tabLabel;
return title ? $t(title) : '';
});
const mergedDiffPreference = computed(() => {
const result: Record<string, unknown> = {};
if (diffPreference.value) {
Object.assign(result, diffPreference.value);
}
if (diffCustomPreference.value) {
result.custom = diffCustomPreference.value;
}
return Object.keys(result).length > 0 ? result : undefined;
});
const showCustomTab = computed(() => {
return (customPreferencesTab.value?.fields.length ?? 0) > 0;
});
const tabs = computed((): SegmentedItem[] => {
return [
const items: SegmentedItem[] = [
{
label: $t('preferences.appearance'),
value: 'appearance',
@@ -214,6 +254,15 @@ const tabs = computed((): SegmentedItem[] => {
value: 'general',
},
];
if (showCustomTab.value) {
items.push({
label: customTabLabel.value,
value: 'custom',
});
}
return items;
});
const showBreadcrumbConfig = computed(() => {
@@ -226,7 +275,7 @@ const showBreadcrumbConfig = computed(() => {
});
async function handleCopy() {
await copy(JSON.stringify(diffPreference.value, null, 2));
await copy(JSON.stringify(mergedDiffPreference.value, null, 2));
message.copyPreferencesSuccess?.(
$t('preferences.copyPreferencesSuccessTitle'),
@@ -241,12 +290,16 @@ async function handleClearCache() {
}
async function handleReset() {
if (!diffPreference.value) {
if (!mergedDiffPreference.value) {
return;
}
resetPreferences();
await loadLocaleMessages(preferences.app.locale);
}
function handleCustomPreferencesUpdate(updates: CustomPreferencesRecord) {
updateCustomPreferences(updates);
}
</script>
<template>
@@ -259,13 +312,13 @@ async function handleReset() {
<template #extra>
<div class="flex items-center">
<VbenIconButton
:disabled="!diffPreference"
:disabled="!mergedDiffPreference"
:tooltip="$t('preferences.resetTip')"
class="relative"
@click="handleReset"
>
<span
v-if="diffPreference"
v-if="mergedDiffPreference"
class="absolute top-0.5 right-0.5 size-2 rounded-sm bg-primary"
></span>
<RotateCw class="size-4" />
@@ -471,13 +524,22 @@ async function handleReset() {
/>
</Block>
</template>
<template #custom>
<Block :title="customTabTitle">
<Custom
:fields="customPreferencesTab?.fields || []"
:values="customPreferences"
@update="handleCustomPreferencesUpdate"
/>
</Block>
</template>
</VbenSegmented>
</div>
<template #footer>
<VbenButton
v-if="appEnableCopyPreferences"
:disabled="!diffPreference"
:disabled="!mergedDiffPreference"
class="mx-4 w-full"
size="sm"
variant="default"
@@ -487,7 +549,7 @@ async function handleReset() {
{{ $t('preferences.copyPreferences') }}
</VbenButton>
<VbenButton
:disabled="!diffPreference"
:disabled="!mergedDiffPreference"
class="mr-4 w-full"
size="sm"
variant="ghost"