From f0fb447fbc68f107f11cdf35639166354507afe8 Mon Sep 17 00:00:00 2001 From: vmoranv <98155299+vmoranv@users.noreply.github.com> Date: Mon, 8 Dec 2025 00:32:50 +0800 Subject: [PATCH] feat: custom plugin api source manager (#3956) * feat: custom plugin api source manager * fix: rename plugin source file in a safer way * chore: turned the way of saving plugin source to backend and refacted some components * style: clean up whitespace and improve logging message formatting --------- Co-authored-by: Soulter <905617992@qq.com> --- astrbot/dashboard/routes/plugin.py | 156 ++++--- .../src/components/shared/ProxySelector.vue | 64 +-- .../locales/en-US/features/extension.json | 30 +- .../locales/zh-CN/features/extension.json | 30 +- dashboard/src/stores/common.js | 48 +- dashboard/src/views/ExtensionPage.vue | 432 +++++++++++++++++- 6 files changed, 633 insertions(+), 127 deletions(-) diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index f2a35dfe1..bcb02bba5 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -4,12 +4,16 @@ import os import ssl import traceback from datetime import datetime +from dataclasses import dataclass +from typing import List, Optional +import hashlib import aiohttp import certifi from quart import request from astrbot.core import DEMO_MODE, file_token_service, logger +from astrbot.api import sp from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.star.filter.command import CommandFilter from astrbot.core.star.filter.command_group import CommandGroupFilter @@ -25,6 +29,13 @@ PLUGIN_UPDATE_CONCURRENCY = ( ) +@dataclass +class RegistrySource: + urls: List[str] + cache_file: str + md5_url: Optional[str] # None means "no remote MD5, always treat cache as stale" + + class PluginRoute(Route): def __init__( self, @@ -45,6 +56,8 @@ class PluginRoute(Route): "/plugin/on": ("POST", self.on_plugin), "/plugin/reload": ("POST", self.reload_plugins), "/plugin/readme": ("GET", self.get_plugin_readme), + "/plugin/source/get": ("GET", self.get_custom_source), + "/plugin/source/save": ("POST", self.save_custom_source), } self.core_lifecycle = core_lifecycle self.plugin_manager = plugin_manager @@ -84,22 +97,15 @@ class PluginRoute(Route): custom = request.args.get("custom_registry") force_refresh = request.args.get("force_refresh", "false").lower() == "true" - cache_file = "data/plugins.json" - - if custom: - urls = [custom] - else: - urls = [ - "https://api.soulter.top/astrbot/plugins", - "https://github.com/AstrBotDevs/AstrBot_Plugins_Collection/raw/refs/heads/main/plugin_cache_original.json", - ] + # 构建注册表源信息 + source = self._build_registry_source(custom) # 如果不是强制刷新,先检查缓存是否有效 cached_data = None if not force_refresh: # 先检查MD5是否匹配,如果匹配则使用缓存 - if await self._is_cache_valid(cache_file): - cached_data = self._load_plugin_cache(cache_file) + if await self._is_cache_valid(source): + cached_data = self._load_plugin_cache(source.cache_file) if cached_data: logger.debug("缓存MD5匹配,使用缓存的插件市场数据") return Response().ok(cached_data).__dict__ @@ -109,7 +115,7 @@ class PluginRoute(Route): ssl_context = ssl.create_default_context(cafile=certifi.where()) connector = aiohttp.TCPConnector(ssl=ssl_context) - for url in urls: + for url in source.urls: try: async with ( aiohttp.ClientSession( @@ -128,11 +134,13 @@ class PluginRoute(Route): logger.warning(f"远程插件市场数据为空: {url}") continue # 继续尝试其他URL或使用缓存 - logger.info("成功获取远程插件市场数据") + logger.info( + f"成功获取远程插件市场数据,包含 {len(remote_data)} 个插件" + ) # 获取最新的MD5并保存到缓存 - current_md5 = await self._get_remote_md5() + current_md5 = await self._fetch_remote_md5(source.md5_url) self._save_plugin_cache( - cache_file, + source.cache_file, remote_data, current_md5, ) @@ -143,7 +151,7 @@ class PluginRoute(Route): # 如果远程获取失败,尝试使用缓存数据 if not cached_data: - cached_data = self._load_plugin_cache(cache_file) + cached_data = self._load_plugin_cache(source.cache_file) if cached_data: logger.warning("远程插件市场数据获取失败,使用缓存数据") @@ -151,24 +159,75 @@ class PluginRoute(Route): return Response().error("获取插件列表失败,且没有可用的缓存数据").__dict__ - async def _is_cache_valid(self, cache_file: str) -> bool: - """检查缓存是否有效(基于MD5)""" - try: - if not os.path.exists(cache_file): - return False + def _build_registry_source(self, custom_url: str | None) -> RegistrySource: + """构建注册表源信息""" + if custom_url: + # 对自定义URL生成一个安全的文件名 + url_hash = hashlib.md5(custom_url.encode()).hexdigest()[:8] + cache_file = f"data/plugins_custom_{url_hash}.json" - # 加载缓存文件 + # 更安全的后缀处理方式 + if custom_url.endswith(".json"): + md5_url = custom_url[:-5] + "-md5.json" + else: + md5_url = custom_url + "-md5.json" + + urls = [custom_url] + else: + cache_file = "data/plugins.json" + md5_url = "https://api.soulter.top/astrbot/plugins-md5" + urls = [ + "https://api.soulter.top/astrbot/plugins", + "https://github.com/AstrBotDevs/AstrBot_Plugins_Collection/raw/refs/heads/main/plugin_cache_original.json", + ] + return RegistrySource(urls=urls, cache_file=cache_file, md5_url=md5_url) + + def _load_cached_md5(self, cache_file: str) -> str | None: + """从缓存文件中加载MD5""" + if not os.path.exists(cache_file): + return None + + try: with open(cache_file, encoding="utf-8") as f: cache_data = json.load(f) + return cache_data.get("md5") + except Exception as e: + logger.warning(f"加载缓存MD5失败: {e}") + return None - cached_md5 = cache_data.get("md5") + async def _fetch_remote_md5(self, md5_url: str | None) -> str | None: + """获取远程MD5""" + if not md5_url: + return None + + try: + ssl_context = ssl.create_default_context(cafile=certifi.where()) + connector = aiohttp.TCPConnector(ssl=ssl_context) + + async with ( + aiohttp.ClientSession( + trust_env=True, + connector=connector, + ) as session, + session.get(md5_url) as response, + ): + if response.status == 200: + data = await response.json() + return data.get("md5", "") + except Exception as e: + logger.debug(f"获取远程MD5失败: {e}") + return None + + async def _is_cache_valid(self, source: RegistrySource) -> bool: + """检查缓存是否有效(基于MD5)""" + try: + cached_md5 = self._load_cached_md5(source.cache_file) if not cached_md5: logger.debug("缓存文件中没有MD5信息") return False - # 获取远程MD5 - remote_md5 = await self._get_remote_md5() - if not remote_md5: + remote_md5 = await self._fetch_remote_md5(source.md5_url) + if remote_md5 is None: logger.warning("无法获取远程MD5,将使用缓存") return True # 如果无法获取远程MD5,认为缓存有效 @@ -182,30 +241,6 @@ class PluginRoute(Route): logger.warning(f"检查缓存有效性失败: {e}") return False - async def _get_remote_md5(self) -> str: - """获取远程插件数据的MD5""" - try: - ssl_context = ssl.create_default_context(cafile=certifi.where()) - connector = aiohttp.TCPConnector(ssl=ssl_context) - - async with ( - aiohttp.ClientSession( - trust_env=True, - connector=connector, - ) as session, - session.get( - "https://api.soulter.top/astrbot/plugins-md5", - ) as response, - ): - if response.status == 200: - data = await response.json() - return data.get("md5", "") - logger.error(f"获取MD5失败,状态码:{response.status}") - return "" - except Exception as e: - logger.error(f"获取远程MD5失败: {e}") - return "" - def _load_plugin_cache(self, cache_file: str): """加载本地缓存的插件市场数据""" try: @@ -547,7 +582,7 @@ class PluginRoute(Route): plugin_dir = os.path.join( self.plugin_manager.plugin_store_path, - plugin_obj.root_dir_name, + plugin_obj.root_dir_name or "", ) if not os.path.isdir(plugin_dir): @@ -572,3 +607,22 @@ class PluginRoute(Route): except Exception as e: logger.error(f"/api/plugin/readme: {traceback.format_exc()}") return Response().error(f"读取README文件失败: {e!s}").__dict__ + + async def get_custom_source(self): + """获取自定义插件源""" + sources = await sp.global_get("custom_plugin_sources", []) + return Response().ok(sources).__dict__ + + async def save_custom_source(self): + """保存自定义插件源""" + try: + data = await request.get_json() + sources = data.get("sources", []) + if not isinstance(sources, list): + return Response().error("sources fields must be a list").__dict__ + + await sp.global_put("custom_plugin_sources", sources) + return Response().ok(None, "保存成功").__dict__ + except Exception as e: + logger.error(f"/api/plugin/source/save: {traceback.format_exc()}") + return Response().error(str(e)).__dict__ diff --git a/dashboard/src/components/shared/ProxySelector.vue b/dashboard/src/components/shared/ProxySelector.vue index d45a0f520..d863fcd85 100644 --- a/dashboard/src/components/shared/ProxySelector.vue +++ b/dashboard/src/components/shared/ProxySelector.vue @@ -12,38 +12,40 @@ -
- - - + + + + + +
+ diff --git a/dashboard/src/i18n/locales/en-US/features/extension.json b/dashboard/src/i18n/locales/en-US/features/extension.json index b1ec35191..ab8d7b855 100644 --- a/dashboard/src/i18n/locales/en-US/features/extension.json +++ b/dashboard/src/i18n/locales/en-US/features/extension.json @@ -33,7 +33,8 @@ "back": "Back", "selectFile": "Select File", "refresh": "Refresh", - "updateAll": "Update All" + "updateAll": "Update All", + "deleteSource": "Delete Source" }, "status": { "enabled": "Enabled", @@ -78,7 +79,27 @@ "allPlugins": "📦 All Extensions", "showFullName": "Full Name", "devDocs": "Extension Development Docs", - "submitRepo": "Submit Extension Repository" + "submitRepo": "Submit Extension Repository", + "customSource": "Custom Extension Source", + "source": "Source", + "availableSources": "Available Sources", + "sourceManagement": "Source Management", + "addSource": "Add Source", + "sourceName": "Source Name", + "sourceUrl": "Source URL", + "defaultSource": "Official Source", + "removeSource": "Remove Source", + "confirmRemoveSource": "Are you sure you want to remove this source?", + "sourceAdded": "Source added successfully", + "sourceRemoved": "Source removed successfully", + "sourceError": "Operation failed", + "selectSource": "Select Source", + "currentSource": "Current Source", + "editSource": "Edit Source", + "sourceUpdated": "Source updated successfully", + "defaultOfficialSource": "Default Official Source", + "sourceExists": "This source already exists", + "installPlugin": "Install Plugin" }, "sort": { "default": "Default", @@ -144,7 +165,10 @@ "dontFillBoth": "Please don't fill in both extension URL and upload file", "supportedFormats": "Supports .zip extension files", "updateAllSuccess": "All upgradable extensions have been updated!", - "updateAllFailed": "{failed} of {total} extensions failed to update:" + "updateAllFailed": "{failed} of {total} extensions failed to update:", + "fillSourceNameAndUrl": "Please fill in the complete source name and URL", + "invalidUrl": "Please enter a valid URL", + "enterJsonUrl": "Please enter a URL that returns plugin list JSON data" }, "upload": { "fromFile": "Install from File", diff --git a/dashboard/src/i18n/locales/zh-CN/features/extension.json b/dashboard/src/i18n/locales/zh-CN/features/extension.json index 6b1521dcc..e31057fd1 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/extension.json +++ b/dashboard/src/i18n/locales/zh-CN/features/extension.json @@ -33,7 +33,8 @@ "back": "返回", "selectFile": "选择文件", "refresh": "刷新", - "updateAll": "更新全部插件" + "updateAll": "更新全部插件", + "deleteSource": "删除源" }, "status": { "enabled": "启用", @@ -78,7 +79,27 @@ "allPlugins": "📦 全部插件", "showFullName": "完整名称", "devDocs": "插件开发文档", - "submitRepo": "提交插件仓库" + "submitRepo": "提交插件仓库", + "customSource": "自定义插件源", + "source": "插件源", + "availableSources": "可用源", + "sourceManagement": "插件源管理", + "addSource": "添加插件源", + "sourceName": "源名称", + "sourceUrl": "源地址", + "defaultSource": "官方插件源", + "removeSource": "删除插件源", + "confirmRemoveSource": "确定要删除此插件源吗?", + "sourceAdded": "插件源添加成功", + "sourceRemoved": "插件源删除成功", + "sourceError": "操作失败", + "selectSource": "选择插件源", + "currentSource": "当前插件源", + "editSource": "编辑插件源", + "sourceUpdated": "插件源更新成功", + "defaultOfficialSource": "默认官方源", + "sourceExists": "该插件源已存在", + "installPlugin": "安装插件" }, "sort": { "default": "默认排序", @@ -144,7 +165,10 @@ "dontFillBoth": "请不要同时填写插件链接和上传文件", "supportedFormats": "支持 .zip 格式的插件文件", "updateAllSuccess": "所有可更新的插件都已更新!", - "updateAllFailed": "有 {failed}/{total} 个插件更新失败:" + "updateAllFailed": "有 {failed}/{total} 个插件更新失败:", + "fillSourceNameAndUrl": "请填写完整的插件源名称和地址", + "invalidUrl": "请输入有效的URL地址", + "enterJsonUrl": "请输入返回插件列表JSON数据的URL地址" }, "upload": { "fromFile": "从文件安装", diff --git a/dashboard/src/stores/common.js b/dashboard/src/stores/common.js index 17d760bcc..fa8dde58b 100644 --- a/dashboard/src/stores/common.js +++ b/dashboard/src/stores/common.js @@ -154,35 +154,43 @@ export const useCommonStore = defineStore({ this.startTime = res.data.data.start_time }) }, - async getPluginCollections(force = false) { + async getPluginCollections(force = false, customSource = null) { // 获取插件市场数据 - if (!force && this.pluginMarketData.length > 0) { + if (!force && this.pluginMarketData.length > 0 && !customSource) { return Promise.resolve(this.pluginMarketData); } - // 如果是强制刷新,添加 force_refresh 参数 - const url = force ? '/api/plugin/market_list?force_refresh=true' : '/api/plugin/market_list'; + // 构建URL + let url = force ? '/api/plugin/market_list?force_refresh=true' : '/api/plugin/market_list'; + if (customSource) { + url += (url.includes('?') ? '&' : '?') + `custom_registry=${encodeURIComponent(customSource)}`; + } return axios.get(url) .then((res) => { let data = [] - for (let key in res.data.data) { - data.push({ - "name": key, - "desc": res.data.data[key].desc, - "author": res.data.data[key].author, - "repo": res.data.data[key].repo, - "installed": false, - "version": res.data.data[key]?.version ? res.data.data[key].version : "未知", - "social_link": res.data.data[key]?.social_link, - "tags": res.data.data[key]?.tags ? res.data.data[key].tags : [], - "logo": res.data.data[key]?.logo ? res.data.data[key].logo : "", - "pinned": res.data.data[key]?.pinned ? res.data.data[key].pinned : false, - "stars": res.data.data[key]?.stars ? res.data.data[key].stars : 0, - "updated_at": res.data.data[key]?.updated_at ? res.data.data[key].updated_at : "", - "display_name": res.data.data[key]?.display_name ? res.data.data[key].display_name : "", - }) + if (res.data.data && typeof res.data.data === 'object') { + for (let key in res.data.data) { + const pluginData = res.data.data[key]; + + data.push({ + "name": pluginData.name || key, // 优先使用插件数据中的name字段,否则使用键名 + "desc": pluginData.desc, + "author": pluginData.author, + "repo": pluginData.repo, + "installed": false, + "version": pluginData?.version ? pluginData.version : "未知", + "social_link": pluginData?.social_link, + "tags": pluginData?.tags ? pluginData.tags : [], + "logo": pluginData?.logo ? pluginData.logo : "", + "pinned": pluginData?.pinned ? pluginData.pinned : false, + "stars": pluginData?.stars ? pluginData.stars : 0, + "updated_at": pluginData?.updated_at ? pluginData.updated_at : "", + "display_name": pluginData?.display_name ? pluginData.display_name : "", + }) + } } + this.pluginMarketData = data; return data; }) diff --git a/dashboard/src/views/ExtensionPage.vue b/dashboard/src/views/ExtensionPage.vue index 37fcc7c2e..a83631c42 100644 --- a/dashboard/src/views/ExtensionPage.vue +++ b/dashboard/src/views/ExtensionPage.vue @@ -67,6 +67,17 @@ const selectedDangerPlugin = ref(null); const showUninstallDialog = ref(false); const pluginToUninstall = ref(null); +// 自定义插件源相关 +const showSourceDialog = ref(false); +const sourceName = ref(""); +const sourceUrl = ref(""); +const customSources = ref([]); +const selectedSource = ref(null); +const showRemoveSourceDialog = ref(false); +const sourceToRemove = ref(null); +const editingSource = ref(false); +const originalSourceUrl = ref(""); + // 插件市场相关 const extension_url = ref(""); const dialog = ref(false); @@ -549,6 +560,156 @@ const cancelDangerInstall = () => { selectedDangerPlugin.value = null; }; +// 自定义插件源管理方法 +const loadCustomSources = async () => { + try { + const res = await axios.get('/api/plugin/source/get'); + if (res.data.status === "ok") { + customSources.value = res.data.data; + } else { + toast(res.data.message, "error"); + } + } catch (e) { + console.warn('Failed to load custom sources:', e); + customSources.value = []; + } + + // 加载当前选中的插件源 + const currentSource = localStorage.getItem('selectedPluginSource'); + if (currentSource) { + selectedSource.value = currentSource; + } +}; + +const saveCustomSources = async () => { + try { + const res = await axios.post('/api/plugin/source/save', { + sources: customSources.value + }); + if (res.data.status !== "ok") { + toast(res.data.message, "error"); + } + } catch (e) { + toast(e, "error"); + } +}; + +const addCustomSource = () => { + editingSource.value = false; + originalSourceUrl.value = ''; + sourceName.value = ''; + sourceUrl.value = ''; + showSourceDialog.value = true; +}; + +const selectPluginSource = (sourceUrl) => { + selectedSource.value = sourceUrl; + if (sourceUrl) { + localStorage.setItem('selectedPluginSource', sourceUrl); + } else { + localStorage.removeItem('selectedPluginSource'); + } + // 重新加载插件市场数据 + refreshPluginMarket(); +}; + +// 获取当前选中的源对象 +const selectedSourceObj = computed(() => { + if (!selectedSource.value) return null; + return customSources.value.find(s => s.url === selectedSource.value) || null; +}); + +const editCustomSource = (source) => { + if (!source) return; + editingSource.value = true; + originalSourceUrl.value = source.url; + sourceName.value = source.name; + sourceUrl.value = source.url; + showSourceDialog.value = true; +}; + +const removeCustomSource = (source) => { + if (!source) return; + sourceToRemove.value = source; + showRemoveSourceDialog.value = true; +}; + +const confirmRemoveSource = () => { + if (sourceToRemove.value) { + customSources.value = customSources.value.filter(s => s.url !== sourceToRemove.value.url); + saveCustomSources(); + + // 如果删除的是当前选中的源,切换到默认源 + if (selectedSource.value === sourceToRemove.value.url) { + selectedSource.value = null; + localStorage.removeItem('selectedPluginSource'); + // 重新加载插件市场数据 + refreshPluginMarket(); + } + + toast(tm('market.sourceRemoved'), 'success'); + showRemoveSourceDialog.value = false; + sourceToRemove.value = null; + } +}; + +const saveCustomSource = () => { + const normalizedUrl = sourceUrl.value.trim(); + + if (!sourceName.value.trim() || !normalizedUrl) { + toast(tm('messages.fillSourceNameAndUrl'), 'error'); + return; + } + + // 检查URL格式 + try { + new URL(normalizedUrl); + } catch (e) { + toast(tm('messages.invalidUrl'), 'error'); + return; + } + + if (editingSource.value) { + // 编辑模式:更新现有源 + const index = customSources.value.findIndex(s => s.url === originalSourceUrl.value); + if (index !== -1) { + customSources.value[index] = { + name: sourceName.value.trim(), + url: normalizedUrl + }; + + // 如果编辑的是当前选中的源,更新选中源 + if (selectedSource.value === originalSourceUrl.value) { + selectedSource.value = normalizedUrl; + localStorage.setItem('selectedPluginSource', selectedSource.value); + // 重新加载插件市场数据 + refreshPluginMarket(); + } + } + } else { + // 添加模式:检查是否已存在 + if (customSources.value.some(source => source.url === normalizedUrl)) { + toast(tm('market.sourceExists'), 'error'); + return; + } + + customSources.value.push({ + name: sourceName.value.trim(), + url: normalizedUrl + }); + } + + saveCustomSources(); + toast(editingSource.value ? tm('market.sourceUpdated') : tm('market.sourceAdded'), 'success'); + + // 重置表单 + sourceName.value = ''; + sourceUrl.value = ''; + editingSource.value = false; + originalSourceUrl.value = ''; + showSourceDialog.value = false; +}; + // 插件市场显示完整插件名称 const trimExtensionName = () => { pluginMarketData.value.forEach(plugin => { @@ -660,7 +821,7 @@ const refreshPluginMarket = async () => { refreshingMarket.value = true; try { // 强制刷新插件市场数据 - const data = await commonStore.getPluginCollections(true); + const data = await commonStore.getPluginCollections(true, selectedSource.value); pluginMarketData.value = data; trimExtensionName(); checkAlreadyInstalled(); @@ -678,6 +839,9 @@ const refreshPluginMarket = async () => { // 生命周期 onMounted(async () => { await getExtensions(); + + // 加载自定义插件源 + loadCustomSources(); // 检查是否有 open_config 参数 let urlParams; @@ -697,7 +861,7 @@ onMounted(async () => { } try { - const data = await commonStore.getPluginCollections(); + const data = await commonStore.getPluginCollections(false, selectedSource.value); pluginMarketData.value = data; trimExtensionName(); checkAlreadyInstalled(); @@ -957,11 +1121,146 @@ watch(marketSearch, (newVal) => { + +
+
+ + +
+ mdi-source-branch + + {{ tm('market.source') }} + + + + + + + {{ tm('market.availableSources') }} + + + + {{ tm('market.defaultSource') }} + + + + + + + {{ source.name }} + {{ source.url }} + + + +
+ + +
+ + +
+ + + + + +
+ +
+
+ - - + + + +
@@ -1127,9 +1426,29 @@ watch(marketSearch, (newVal) => { - {{ tm('market.devDocs') }} | - {{ tm('market.submitRepo') - }} +
+ + {{ tm('market.devDocs') }} + +
+ + {{ tm('market.submitRepo') }} + +
@@ -1249,10 +1568,15 @@ watch(marketSearch, (newVal) => { - - {{ tm('dialogs.install.title') }} - - +
+
+ +
+ +
{{ tm('dialogs.install.title') }}
+ +
+ {{ tm('dialogs.install.fromFile') }} {{ tm('dialogs.install.fromUrl') }} @@ -1263,7 +1587,7 @@ watch(marketSearch, (newVal) => { - + {{ tm('buttons.selectFile') }} @@ -1285,19 +1609,79 @@ watch(marketSearch, (newVal) => {
-
- -
+ +
- - +
+ +
{{ tm('buttons.cancel') }} {{ tm('buttons.install') }} +
+
+
+ + + + + {{ editingSource ? tm('market.editSource') : tm('market.addSource') }} + +
+ + + + +
+ {{ tm('messages.enterJsonUrl') }} +
+
+
+ + + {{ tm('buttons.cancel') }} + {{ tm('buttons.save') }} + +
+
+ + + + + + mdi-alert-circle + {{ tm('dialogs.uninstall.title') }} + + +
{{ tm('market.confirmRemoveSource') }}
+
+ {{ sourceToRemove.name }} +
{{ sourceToRemove.url }}
+
+
+ + + {{ tm('buttons.cancel') }} + {{ tm('buttons.deleteSource') }}
@@ -1342,4 +1726,14 @@ watch(marketSearch, (newVal) => { .plugin-description::-webkit-scrollbar-thumb:hover { background-color: rgba(var(--v-theme-primary-rgb), 0.6); } + +.fab-button { + transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.fab-button:hover { + transform: translateY(-4px) scale(1.05); + box-shadow: 0 12px 20px rgba(var(--v-theme-primary), 0.4); +}