mirror of
https://github.com/imdap/ruoyi-plus-vben5.git
synced 2026-05-10 20:52:10 +08:00
Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user