feat(theme): 新增按钮水波纹样式配置选项

添加按钮水波纹效果的自定义配置功能,支持默认、禁用、内嵌、抖动和欢乐五种样式。用户可在主题设置中选择不同效果,增强交互视觉体验。

- 在主题配置类型中添加 buttonWaveMode 字段
- 新增按钮水波纹配置组件和样式实现
- 更新中英文国际化文本
- 在应用配置中集成水波纹效果
This commit is contained in:
dap
2026-01-28 16:33:27 +08:00
parent cb2a58425f
commit 3cb93fd67c
9 changed files with 200 additions and 1 deletions

View File

@@ -8,6 +8,7 @@ import { App, ConfigProvider, theme } from 'antdv-next';
import { antdLocale } from '#/locales';
import { waveConfigs } from './components/global/button-wave';
import { PopupContext } from './utils/context';
defineOptions({ name: 'App' });
@@ -30,10 +31,17 @@ const tokenTheme = computed(() => {
token: tokens,
};
});
// 按钮水波纹样式配置
const waveConfig = computed(() => {
const { buttonWaveMode } = preferences.theme;
const found = waveConfigs.find((item) => item.name === buttonWaveMode);
return found ? found.wave : {};
});
</script>
<template>
<ConfigProvider :locale="antdLocale" :theme="tokenTheme">
<ConfigProvider :locale="antdLocale" :theme="tokenTheme" :wave="waveConfig">
<App>
<RouterView />
<PopupContext />

View File

@@ -0,0 +1,142 @@
import type { ConfigProviderProps } from 'antdv-next';
import type { ThemePreferences } from '@vben/preferences';
const createHolder = (node: HTMLElement) => {
const { borderWidth } = getComputedStyle(node);
const borderWidthNum = Number.parseInt(borderWidth, 10);
const div = document.createElement('div');
div.style.position = 'absolute';
div.style.inset = `-${borderWidthNum}px`;
div.style.borderRadius = 'inherit';
div.style.background = 'transparent';
div.style.zIndex = '999';
div.style.pointerEvents = 'none';
div.style.overflow = 'hidden';
node.append(div);
return div;
};
const createDot = (
holder: HTMLElement,
color: string,
left: number,
top: number,
size = 0,
) => {
const dot = document.createElement('div');
dot.style.position = 'absolute';
dot.style.left = `${left}px`;
dot.style.top = `${top}px`;
dot.style.width = `${size}px`;
dot.style.height = `${size}px`;
dot.style.borderRadius = '50%';
dot.style.background = color;
dot.style.transform = 'translate3d(-50%, -50%, 0)';
dot.style.transition = 'all 1s ease-out';
holder.append(dot);
return dot;
};
type WaveConfig = NonNullable<ConfigProviderProps['wave']>;
const showInsetEffect: WaveConfig['showEffect'] = (
node,
{ event, component },
) => {
if (component !== 'Button') {
return;
}
const holder = createHolder(node);
const rect = holder.getBoundingClientRect();
const left = event.clientX - rect.left;
const top = event.clientY - rect.top;
const dot = createDot(holder, 'rgba(255, 255, 255, 0.65)', left, top);
requestAnimationFrame(() => {
dot.addEventListener('transitionend', () => {
holder.remove();
});
dot.style.width = '200px';
dot.style.height = '200px';
dot.style.opacity = '0';
});
};
const showShakeEffect: WaveConfig['showEffect'] = (node, { component }) => {
if (component !== 'Button') {
return;
}
const seq = [0, -15, 15, -5, 5, 0];
const itv = 10;
let steps = 0;
const loop = () => {
cancelAnimationFrame((node as any).effectTimeout);
(node as any).effectTimeout = requestAnimationFrame(() => {
const currentStep = Math.floor(steps / itv);
const current = seq[currentStep];
const next = seq[currentStep + 1];
if (next === undefined || next === null) {
node.style.transform = '';
node.style.transition = '';
return;
}
const angle =
(current ?? 0) + ((next - (current ?? 0)) / itv) * (steps % itv);
node.style.transform = `rotate(${angle}deg)`;
node.style.transition = 'none';
steps += 1;
loop();
});
};
loop();
};
const showHappyEffect: WaveConfig['showEffect'] = (
node,
{ event, component },
) => {
if (component !== 'Button') {
return;
}
const holder = createHolder(node);
const rect = holder.getBoundingClientRect();
const left = event.clientX - rect.left;
const top = event.clientY - rect.top;
const color = `hsl(${Math.floor(Math.random() * 360)}, 80%, 70%)`;
const dot = createDot(holder, color, left, top, 16);
requestAnimationFrame(() => {
dot.addEventListener('transitionend', () => {
holder.remove();
});
dot.style.width = '220px';
dot.style.height = '220px';
dot.style.opacity = '0';
});
};
export const waveConfigs: Array<{
name: ThemePreferences['buttonWaveMode'];
wave: WaveConfig;
}> = [
{ name: 'Disabled', wave: { disabled: true } },
{ name: 'Default', wave: {} },
{ name: 'Inset', wave: { showEffect: showInsetEffect } },
{ name: 'Shake', wave: { showEffect: showShakeEffect } },
{ name: 'Happy', wave: { showEffect: showHappyEffect } },
];

View File

@@ -110,6 +110,7 @@ const defaultPreferences: Preferences = {
},
theme: {
builtinType: 'default',
buttonWaveMode: 'Default',
colorDestructive: 'hsl(348 100% 61%)',
colorPrimary: 'hsl(215 100% 54%)',
colorSuccess: 'hsl(144 57% 58%)',

View File

@@ -231,6 +231,8 @@ interface TabbarPreferences {
interface ThemePreferences {
/** 内置主题名 */
builtinType: BuiltinThemeType;
/** 按钮波纹模式 */
buttonWaveMode: 'Default' | 'Disabled' | 'Happy' | 'Inset' | 'Shake';
/** 错误色 */
colorDestructive: string;
/** 主题色 */

View File

@@ -14,6 +14,7 @@ export { default as Widget } from './layout/widget.vue';
export { default as GlobalShortcutKeys } from './shortcut-keys/global.vue';
export { default as SwitchItem } from './switch-item.vue';
export { default as BuiltinTheme } from './theme/builtin.vue';
export { default as ButtonWaveMode } from './theme/button-wave-mode.vue';
export { default as ColorMode } from './theme/color-mode.vue';
export { default as FontSize } from './theme/font-size.vue';
export { default as Radius } from './theme/radius.vue';

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import { ToggleGroup, ToggleGroupItem } from '@vben-core/shadcn-ui';
defineOptions({
name: 'PreferenceButtonWaveMode',
});
const modelValue = defineModel<string>('themeButtonWaveMode', {
default: 'default',
});
const items = [
{ label: 'Default', value: 'Default' },
{ label: 'Disabled', value: 'Disabled' },
{ label: 'Happy', value: 'Happy' },
{ label: 'Inset', value: 'Inset' },
{ label: 'Shake', value: 'Shake' },
];
</script>
<template>
<ToggleGroup
v-model="modelValue"
class="gap-2"
size="sm"
type="single"
variant="outline"
>
<template v-for="item in items" :key="item.value">
<ToggleGroupItem
:value="item.value"
class="data-[state=on]:bg-primary data-[state=on]:text-primary-foreground h-7 rounded-sm px-2"
>
{{ item.label }}
</ToggleGroupItem>
</template>
</ToggleGroup>
</template>

View File

@@ -40,6 +40,7 @@ import {
Block,
Breadcrumb,
BuiltinTheme,
ButtonWaveMode,
ColorMode,
Content,
Copyright,
@@ -86,6 +87,7 @@ const themeColorPrimary = defineModel<string>('themeColorPrimary');
const themeBuiltinType = defineModel<BuiltinThemeType>('themeBuiltinType');
const themeMode = defineModel<ThemeModeType>('themeMode');
const themeRadius = defineModel<string>('themeRadius');
const themeButtonWaveMode = defineModel<string>('themeButtonWaveMode');
const themeFontSize = defineModel<number>('themeFontSize');
const themeSemiDarkSidebar = defineModel<boolean>('themeSemiDarkSidebar');
const themeSemiDarkHeader = defineModel<boolean>('themeSemiDarkHeader');
@@ -330,6 +332,9 @@ async function handleReset() {
<Block :title="$t('preferences.theme.radius')">
<Radius v-model="themeRadius" />
</Block>
<Block :title="$t('preferences.theme.buttonWaveMode')">
<ButtonWaveMode v-model="themeButtonWaveMode" />
</Block>
<Block :title="$t('preferences.theme.fontSize')">
<FontSize v-model="themeFontSize" />
</Block>

View File

@@ -120,6 +120,7 @@
"theme": {
"title": "Theme",
"radius": "Radius",
"buttonWaveMode": "Button Wave Mode",
"fontSize": "Font Size",
"fontSizeTip": "Adjust global font size with real-time preview",
"light": "Light",

View File

@@ -120,6 +120,7 @@
"theme": {
"title": "主题",
"radius": "圆角",
"buttonWaveMode": "按钮水波纹样式",
"fontSize": "字体大小",
"fontSizeTip": "调整全局字体大小,实时预览效果",
"light": "浅色",