mirror of
https://github.com/Moe-Sakura/frontend.git
synced 2026-03-20 05:59:45 +08:00
93
index.html
93
index.html
@@ -9,17 +9,27 @@
|
||||
// 立即检测主题,在任何内容渲染前执行
|
||||
(function() {
|
||||
var d = document.documentElement;
|
||||
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
var isDark = prefersDark; // 默认跟随系统
|
||||
|
||||
try {
|
||||
var saved = localStorage.getItem('ui-state');
|
||||
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
var isDark = saved ? JSON.parse(saved).isDarkMode : prefersDark;
|
||||
if (isDark) d.classList.add('dark');
|
||||
} catch (e) {
|
||||
// 默认跟随系统
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
d.classList.add('dark');
|
||||
if (saved) {
|
||||
var state = JSON.parse(saved);
|
||||
var themeMode = state.themeMode || 'system';
|
||||
|
||||
if (themeMode === 'dark') {
|
||||
isDark = true;
|
||||
} else if (themeMode === 'light') {
|
||||
isDark = false;
|
||||
}
|
||||
// themeMode === 'system' 时使用 prefersDark
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析失败,使用系统偏好
|
||||
}
|
||||
|
||||
if (isDark) d.classList.add('dark');
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
@@ -282,75 +292,6 @@
|
||||
<!-- 禁止 IE 浏览器访问 -->
|
||||
<script>/MSIE|Trident/.test(window.navigator.userAgent)&&(alert("抱歉,本站不支持 IE 浏览器,请用 Chrome 访问。"),window.location.href="https://support.dmeng.net/upgrade-your-browser.html?referrer="+encodeURIComponent(window.location.href))</script>
|
||||
|
||||
<!-- WWDC 2025 液态玻璃 SVG 滤镜 -->
|
||||
<!-- 参考: https://github.com/lucasromerodb/liquid-glass-effect-macos -->
|
||||
<svg style="display: none" aria-hidden="true">
|
||||
<defs>
|
||||
<!-- 液态玻璃扭曲滤镜 - 完整版 (用于大元素) -->
|
||||
<filter
|
||||
id="liquid-glass"
|
||||
x="0%" y="0%" width="100%" height="100%"
|
||||
filterUnits="objectBoundingBox"
|
||||
>
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency="0.01 0.01"
|
||||
numOctaves="1"
|
||||
seed="5"
|
||||
result="turbulence"
|
||||
/>
|
||||
<feComponentTransfer in="turbulence" result="mapped">
|
||||
<feFuncR type="gamma" amplitude="1" exponent="10" offset="0.5" />
|
||||
<feFuncG type="gamma" amplitude="0" exponent="1" offset="0" />
|
||||
<feFuncB type="gamma" amplitude="0" exponent="1" offset="0.5" />
|
||||
</feComponentTransfer>
|
||||
<feGaussianBlur in="turbulence" stdDeviation="3" result="softMap" />
|
||||
<feSpecularLighting
|
||||
in="softMap"
|
||||
surfaceScale="5"
|
||||
specularConstant="1"
|
||||
specularExponent="100"
|
||||
lighting-color="white"
|
||||
result="specLight"
|
||||
>
|
||||
<fePointLight x="-200" y="-200" z="300" />
|
||||
</feSpecularLighting>
|
||||
<feComposite in="specLight" operator="arithmetic" k1="0" k2="1" k3="1" k4="0" result="litImage" />
|
||||
<!-- scale 值控制扭曲程度,20 是微妙的扭曲 -->
|
||||
<feDisplacementMap in="SourceGraphic" in2="softMap" scale="20" xChannelSelector="R" yChannelSelector="G" />
|
||||
</filter>
|
||||
|
||||
<!-- 液态玻璃滤镜 - 轻量版 (用于按钮等小元素,更微妙的扭曲) -->
|
||||
<filter
|
||||
id="liquid-glass-lite"
|
||||
x="0%" y="0%" width="100%" height="100%"
|
||||
filterUnits="objectBoundingBox"
|
||||
>
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency="0.015 0.015"
|
||||
numOctaves="1"
|
||||
seed="3"
|
||||
result="turbulence"
|
||||
/>
|
||||
<feGaussianBlur in="turbulence" stdDeviation="2" result="softMap" />
|
||||
<feSpecularLighting
|
||||
in="softMap"
|
||||
surfaceScale="3"
|
||||
specularConstant="0.8"
|
||||
specularExponent="80"
|
||||
lighting-color="white"
|
||||
result="specLight"
|
||||
>
|
||||
<fePointLight x="-100" y="-100" z="200" />
|
||||
</feSpecularLighting>
|
||||
<feComposite in="specLight" operator="arithmetic" k1="0" k2="1" k3="1" k4="0" result="litImage" />
|
||||
<!-- 更小的 scale 值,扭曲更微妙 -->
|
||||
<feDisplacementMap in="SourceGraphic" in2="softMap" scale="8" xChannelSelector="R" yChannelSelector="G" />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<script>
|
||||
|
||||
@@ -27,14 +27,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/noto-sans-sc": "^5.2.8",
|
||||
"animejs": "^4.2.2",
|
||||
"artalk": "^2.9.1",
|
||||
"lucide-vue-next": "^0.562.0",
|
||||
"pinia": "^3.0.4",
|
||||
"prismjs": "^1.30.0",
|
||||
"snd-lib": "^1.2.4",
|
||||
"vue": "^3.5.26",
|
||||
"vue-prism-editor": "2.0.0-alpha.2"
|
||||
"vue-prism-editor": "2.0.0-alpha.2",
|
||||
"@tanstack/vue-virtual": "^3.13.6"
|
||||
},
|
||||
"packageManager": "pnpm@10.26.2"
|
||||
}
|
||||
|
||||
26
pnpm-lock.yaml
generated
26
pnpm-lock.yaml
generated
@@ -11,9 +11,9 @@ importers:
|
||||
'@fontsource/noto-sans-sc':
|
||||
specifier: ^5.2.8
|
||||
version: 5.2.8
|
||||
animejs:
|
||||
specifier: ^4.2.2
|
||||
version: 4.2.2
|
||||
'@tanstack/vue-virtual':
|
||||
specifier: ^3.13.6
|
||||
version: 3.13.13(vue@3.5.26(typescript@5.9.3))
|
||||
artalk:
|
||||
specifier: ^2.9.1
|
||||
version: 2.9.1(marked@14.1.4)
|
||||
@@ -530,6 +530,14 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7
|
||||
|
||||
'@tanstack/virtual-core@3.13.13':
|
||||
resolution: {integrity: sha512-uQFoSdKKf5S8k51W5t7b2qpfkyIbdHMzAn+AMQvHPxKUPeo1SsGaA4JRISQT87jm28b7z8OEqPcg1IOZagQHcA==}
|
||||
|
||||
'@tanstack/vue-virtual@3.13.13':
|
||||
resolution: {integrity: sha512-Cf2xIEE8nWAfsX0N5nihkPYMeQRT+pHt4NEkuP8rNCn6lVnLDiV8rC8IeIxbKmQC0yPnj4SIBLwXYVf86xxKTQ==}
|
||||
peerDependencies:
|
||||
vue: ^2.7.0 || ^3.0.0
|
||||
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
@@ -671,9 +679,6 @@ packages:
|
||||
alien-signals@3.1.0:
|
||||
resolution: {integrity: sha512-yufC6VpSy8tK3I0lO67pjumo5JvDQVQyr38+3OHqe6CHl1t2VZekKZ7EKKZSqk0cRmE7U7tfZbpXiKNzuc+ckg==}
|
||||
|
||||
animejs@4.2.2:
|
||||
resolution: {integrity: sha512-Ys3RuvLdAeI14fsdKCQy7ytu4057QX6Bb7m4jwmfd6iKmUmLquTwk1ut0e4NtRQgCeq/s2Lv5+oMBjz6c7ZuIg==}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1632,6 +1637,13 @@ snapshots:
|
||||
tailwindcss: 4.1.18
|
||||
vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)
|
||||
|
||||
'@tanstack/virtual-core@3.13.13': {}
|
||||
|
||||
'@tanstack/vue-virtual@3.13.13(vue@3.5.26(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.13.13
|
||||
vue: 3.5.26(typescript@5.9.3)
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
@@ -1846,8 +1858,6 @@ snapshots:
|
||||
|
||||
alien-signals@3.1.0: {}
|
||||
|
||||
animejs@4.2.2: {}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
|
||||
96
public/sw.js
96
public/sw.js
@@ -1,13 +1,28 @@
|
||||
// Service Worker - PWA 支持(简化版)
|
||||
// 离线时仅显示联网提示,不缓存离线内容
|
||||
// Service Worker - PWA 支持(增强缓存版)
|
||||
// 尽可能多地缓存文件以提升加载速度
|
||||
const SW_VERSION = self.__SW_VERSION__ || Date.now().toString(36);
|
||||
const CACHE_NAME = `searchgal-cache-${SW_VERSION}`;
|
||||
|
||||
// 仅缓存静态资源以提升加载速度(非离线使用)
|
||||
// 缓存静态资源(尽可能多)
|
||||
const CACHEABLE_PATTERNS = [
|
||||
/\/assets\/.*\.(js|css)(\?.*)?$/i, // Vite 构建的静态资源
|
||||
/\/assets\/.*\.(js|css|mjs)(\?.*)?$/i, // Vite 构建的静态资源
|
||||
/\.(woff2?|ttf|otf|eot)(\?.*)?$/i, // 字体
|
||||
/\.(png|jpg|jpeg|gif|webp|svg|ico)(\?.*)?$/i, // 图片
|
||||
/\.(png|jpg|jpeg|gif|webp|svg|ico|avif)(\?.*)?$/i, // 图片
|
||||
/\.(mp3|wav|ogg|m4a|aac|flac)(\?.*)?$/i, // 音频(snd-lib 音效)
|
||||
/\.(mp4|webm|ogv)(\?.*)?$/i, // 视频
|
||||
/\.(json|xml|txt)(\?.*)?$/i, // 数据文件
|
||||
/\.(wasm)(\?.*)?$/i, // WebAssembly
|
||||
/\/manifest\.json$/i, // PWA manifest
|
||||
/\/favicon/i, // Favicon 相关
|
||||
/\/apple-touch-icon/i, // iOS 图标
|
||||
/\/android-chrome/i, // Android 图标
|
||||
];
|
||||
|
||||
// 预缓存的核心资源(安装时缓存)
|
||||
const PRECACHE_URLS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/manifest.json',
|
||||
];
|
||||
|
||||
// 永不缓存
|
||||
@@ -16,14 +31,29 @@ const NO_CACHE_PATTERNS = [
|
||||
/\/sw\.js$/,
|
||||
/sockjs-node/,
|
||||
/__vite/,
|
||||
/hot-update/,
|
||||
/\.map$/, // Source maps
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// 安装事件
|
||||
// 安装事件 - 预缓存核心资源
|
||||
// ============================================
|
||||
self.addEventListener('install', () => {
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log(`[SW] Installing version ${SW_VERSION}`);
|
||||
self.skipWaiting();
|
||||
|
||||
// 预缓存失败不应阻止 service worker 安装
|
||||
const precachePromise = caches.open(CACHE_NAME)
|
||||
.then((cache) => {
|
||||
console.log('[SW] Precaching core resources');
|
||||
return cache.addAll(PRECACHE_URLS);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('[SW] Precache failed:', err);
|
||||
});
|
||||
|
||||
event.waitUntil(
|
||||
precachePromise.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
// ============================================
|
||||
@@ -78,23 +108,30 @@ self.addEventListener('fetch', (event) => {
|
||||
// 跳过永不缓存的模式
|
||||
if (NO_CACHE_PATTERNS.some((p) => p.test(url.href))) return;
|
||||
|
||||
// 仅处理同源请求
|
||||
if (url.origin !== location.origin) return;
|
||||
// 同源请求
|
||||
if (url.origin === location.origin) {
|
||||
// HTML 页面请求:网络优先,离线时显示提示
|
||||
if (request.destination === 'document' || url.pathname === '/' || url.pathname.endsWith('.html')) {
|
||||
event.respondWith(handlePageRequest(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// HTML 页面请求:网络优先,离线时显示提示
|
||||
if (request.destination === 'document' || url.pathname === '/' || url.pathname.endsWith('.html')) {
|
||||
event.respondWith(handlePageRequest(request));
|
||||
// 可缓存的静态资源:缓存优先(加速)
|
||||
if (CACHEABLE_PATTERNS.some((p) => p.test(url.pathname) || p.test(url.href))) {
|
||||
event.respondWith(cacheFirst(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// 同源其他资源:网络优先,但也缓存
|
||||
event.respondWith(networkFirst(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// 可缓存的静态资源:缓存优先(加速)
|
||||
if (CACHEABLE_PATTERNS.some((p) => p.test(url.pathname))) {
|
||||
event.respondWith(cacheFirst(request));
|
||||
// 跨域资源:仅缓存可缓存的静态资源(如 CDN 资源)
|
||||
if (CACHEABLE_PATTERNS.some((p) => p.test(url.pathname) || p.test(url.href))) {
|
||||
event.respondWith(cacheFirstCrossOrigin(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// 其他资源:网络优先
|
||||
event.respondWith(networkFirst(request));
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -146,6 +183,27 @@ async function networkFirst(request) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存优先(跨域资源)
|
||||
*/
|
||||
async function cacheFirstCrossOrigin(request) {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
// 跨域请求需要设置 mode
|
||||
const response = await fetch(request, { mode: 'cors', credentials: 'omit' });
|
||||
if (response.ok && response.type !== 'opaque') {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
// 跨域失败时返回空响应
|
||||
return new Response('', { status: 503 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 离线提示页面
|
||||
*/
|
||||
|
||||
19
src/App.vue
19
src/App.vue
@@ -71,9 +71,6 @@ import { imageDB } from '@/utils/imageDB'
|
||||
import { useSearchStore } from '@/stores/search'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import {
|
||||
getSystemTheme,
|
||||
applyTheme,
|
||||
watchSystemTheme,
|
||||
saveCustomCSS,
|
||||
applyCustomCSS,
|
||||
} from '@/utils/theme'
|
||||
@@ -153,7 +150,6 @@ const imageBlobUrls = shallowRef<Map<string, string>>(new Map()) // URL -> Blob
|
||||
const shuffledQueue = shallowRef<string[]>([])
|
||||
let fetchInterval: number | null = null
|
||||
let displayInterval: number | null = null
|
||||
let systemThemeCleanup: (() => void) | null = null
|
||||
|
||||
const MAX_CACHE_SIZE = 10000 // 最大缓存 10000 张图片
|
||||
const CLEANUP_BATCH_SIZE = 2000 // 每次清理 2000 张
|
||||
@@ -492,10 +488,6 @@ onMounted(async () => {
|
||||
uiStore.isCommentsModalOpen = true
|
||||
}
|
||||
|
||||
// 初始化主题 - 跟随系统
|
||||
const systemTheme = getSystemTheme()
|
||||
applyTheme(systemTheme)
|
||||
|
||||
// 恢复保存的搜索状态
|
||||
searchStore.restoreState()
|
||||
|
||||
@@ -506,11 +498,6 @@ onMounted(async () => {
|
||||
if (uiStore.customCSS) {
|
||||
applyCustomCSS(uiStore.customCSS)
|
||||
}
|
||||
|
||||
// 监听系统主题变化
|
||||
systemThemeCleanup = watchSystemTheme((theme) => {
|
||||
applyTheme(theme)
|
||||
})
|
||||
|
||||
// 监听 SW 更新事件
|
||||
window.addEventListener('sw-update-available', (event) => {
|
||||
@@ -542,12 +529,6 @@ onMounted(async () => {
|
||||
onUnmounted(() => {
|
||||
stopAllIntervals()
|
||||
|
||||
// 清理系统主题监听
|
||||
if (systemThemeCleanup) {
|
||||
systemThemeCleanup()
|
||||
systemThemeCleanup = null
|
||||
}
|
||||
|
||||
// 清理所有 Blob URL
|
||||
imageBlobUrls.value.forEach(blobUrl => {
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<template>
|
||||
<Transition
|
||||
:css="false"
|
||||
@enter="onEnter"
|
||||
@leave="onLeave"
|
||||
enter-active-class="duration-[1500ms] ease-in-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="duration-[1500ms] ease-in-out"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="imageUrl"
|
||||
@@ -15,29 +18,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { animate } from '@/composables/useAnime'
|
||||
|
||||
defineProps<{
|
||||
imageUrl: string
|
||||
imageKey: string | number
|
||||
kenBurnsClass: string
|
||||
}>()
|
||||
|
||||
function onEnter(el: Element, done: () => void) {
|
||||
animate(el as HTMLElement, {
|
||||
opacity: [0, 1],
|
||||
duration: 1500,
|
||||
ease: 'inOutQuad',
|
||||
complete: done,
|
||||
})
|
||||
}
|
||||
|
||||
function onLeave(el: Element, done: () => void) {
|
||||
animate(el as HTMLElement, {
|
||||
opacity: [1, 0],
|
||||
duration: 1500,
|
||||
ease: 'inOutQuad',
|
||||
complete: done,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,36 +1,21 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<!-- 评论面板 - macOS 风格浮动窗口 -->
|
||||
<!-- 评论面板 - 模态框 -->
|
||||
<Transition
|
||||
:css="false"
|
||||
@enter="onEnter"
|
||||
@leave="onLeave"
|
||||
enter-active-class="duration-300 ease-out"
|
||||
enter-from-class="opacity-0 scale-[0.98] translate-y-10"
|
||||
enter-to-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-active-class="duration-200 ease-in"
|
||||
leave-from-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-to-class="opacity-0 scale-[0.98] translate-y-10"
|
||||
>
|
||||
<div
|
||||
v-if="uiStore.isCommentsModalOpen"
|
||||
ref="modalRef"
|
||||
:class="[
|
||||
'comments-modal fixed z-[100] flex flex-col shadow-2xl shadow-black/20',
|
||||
isFullscreen
|
||||
? 'inset-0'
|
||||
: 'inset-0 md:inset-6 md:m-auto md:w-[800px] md:min-w-[400px] md:max-w-[calc(100%-3rem)] md:h-[600px] md:max-h-[calc(100%-3rem)] md:rounded-3xl'
|
||||
]"
|
||||
:style="windowStyle"
|
||||
class="comments-modal fixed z-[100] flex flex-col shadow-2xl shadow-black/20 inset-0 md:inset-6 md:m-auto md:w-[900px] md:max-w-[calc(100%-3rem)] md:h-[760px] md:max-h-[calc(100%-3rem)] md:rounded-3xl"
|
||||
>
|
||||
<!-- 调整大小手柄 -->
|
||||
<WindowResizeHandles
|
||||
:is-fullscreen="isFullscreen"
|
||||
@resize="handleResize"
|
||||
/>
|
||||
|
||||
<!-- 顶部导航栏 - 可拖动 -->
|
||||
<!-- 顶部导航栏 -->
|
||||
<div
|
||||
:class="[
|
||||
'comments-header flex-shrink-0 flex items-center justify-between px-3 sm:px-5 py-2.5 sm:py-4 border-b border-white/10 dark:border-slate-700/50 select-none',
|
||||
isFullscreen ? '' : 'md:rounded-t-3xl md:cursor-move'
|
||||
]"
|
||||
@mousedown="handleDragStart"
|
||||
@touchstart="handleDragStart"
|
||||
class="comments-header flex-shrink-0 flex items-center justify-between px-3 sm:px-5 py-2.5 sm:py-4 border-b border-white/10 dark:border-slate-700/50 select-none md:rounded-t-3xl"
|
||||
>
|
||||
<!-- 返回按钮 - 移动端 -->
|
||||
<button
|
||||
@@ -52,16 +37,6 @@
|
||||
|
||||
<!-- 右侧按钮组 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 全屏按钮 - 仅桌面端 -->
|
||||
<button
|
||||
class="hidden md:flex w-8 h-8 rounded-lg items-center justify-center text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-slate-700 transition-all"
|
||||
title="全屏"
|
||||
@click="handleToggleFullscreen"
|
||||
>
|
||||
<Maximize2 v-if="!isFullscreen" :size="16" />
|
||||
<Minimize2 v-else :size="16" />
|
||||
</button>
|
||||
|
||||
<!-- 关闭按钮 - 仅桌面端 -->
|
||||
<button
|
||||
class="hidden md:flex w-8 h-8 rounded-lg items-center justify-center text-gray-500 hover:text-red-500 dark:text-gray-400 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all"
|
||||
@@ -88,42 +63,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import { playTransitionUp, playTransitionDown, playSwipe } from '@/composables/useSound'
|
||||
import { useWindowManager, type ResizeDirection } from '@/composables/useWindowManager'
|
||||
import { animate } from '@/composables/useAnime'
|
||||
import WindowResizeHandles from '@/components/WindowResizeHandles.vue'
|
||||
import { playTransitionUp, playTransitionDown } from '@/composables/useSound'
|
||||
import Artalk from 'artalk/dist/Artalk.mjs'
|
||||
import { MessageCircle, ChevronLeft, X, Maximize2, Minimize2 } from 'lucide-vue-next'
|
||||
import { MessageCircle, ChevronLeft, X } from 'lucide-vue-next'
|
||||
|
||||
interface ArtalkInstance {
|
||||
destroy(): void
|
||||
}
|
||||
|
||||
// 进入/离开动画
|
||||
function onEnter(el: Element, done: () => void) {
|
||||
animate(el as HTMLElement, {
|
||||
opacity: [0, 1],
|
||||
scale: [0.98, 1],
|
||||
translateY: [40, 0],
|
||||
duration: 300,
|
||||
ease: 'outCubic',
|
||||
complete: done,
|
||||
})
|
||||
}
|
||||
|
||||
function onLeave(el: Element, done: () => void) {
|
||||
animate(el as HTMLElement, {
|
||||
opacity: [1, 0],
|
||||
scale: [1, 0.98],
|
||||
translateY: [0, 40],
|
||||
duration: 200,
|
||||
ease: 'inCubic',
|
||||
complete: done,
|
||||
})
|
||||
}
|
||||
|
||||
const uiStore = useUIStore()
|
||||
let artalkInstance: ArtalkInstance | null = null
|
||||
let isClosing = false
|
||||
@@ -159,39 +108,11 @@ function scrollToComment() {
|
||||
setTimeout(tryScroll, 500)
|
||||
}
|
||||
|
||||
// 窗口管理
|
||||
const modalRef = ref<HTMLElement | null>(null)
|
||||
const { isFullscreen, windowStyle, startDrag, startResize, toggleFullscreen, reset } = useWindowManager({
|
||||
minWidth: 400,
|
||||
minHeight: 300,
|
||||
})
|
||||
|
||||
function handleDragStart(e: MouseEvent | TouchEvent) {
|
||||
if ((e.target as HTMLElement).closest('button')) {return}
|
||||
if (modalRef.value) {
|
||||
startDrag(e, modalRef.value)
|
||||
}
|
||||
}
|
||||
|
||||
function handleResize(e: MouseEvent | TouchEvent, direction: ResizeDirection) {
|
||||
if (modalRef.value) {
|
||||
startResize(e, direction, modalRef.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换全屏(带音效)
|
||||
function handleToggleFullscreen() {
|
||||
playSwipe()
|
||||
toggleFullscreen()
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
if (isClosing) {return}
|
||||
isClosing = true
|
||||
|
||||
playTransitionDown()
|
||||
// 重置位置
|
||||
reset()
|
||||
// 关闭模态框
|
||||
uiStore.isCommentsModalOpen = false
|
||||
|
||||
@@ -280,27 +201,12 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 评论面板 - WWDC 2025 液态玻璃效果 */
|
||||
/* 评论面板 - 半透明效果 */
|
||||
.comments-modal {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
background: rgba(var(--color-bg-light, 255, 255, 255), var(--opacity-panel, 0.85));
|
||||
will-change: transform;
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.12),
|
||||
0 0 20px rgba(255, 20, 147, 0.06),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.6);
|
||||
/* 窗口/全屏切换动画 */
|
||||
transition:
|
||||
inset 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
width 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
height 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
min-width 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
max-width 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-radius 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
margin 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border: var(--border-thin, 1px) solid rgba(var(--color-primary, 255, 20, 147), var(--opacity-border, 0.15));
|
||||
box-shadow: var(--shadow-xl, 0 12px 32px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
|
||||
/* 移动端无底部边框 */
|
||||
@@ -310,65 +216,41 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 液态玻璃高光 */
|
||||
.comments-modal::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.4) 0%,
|
||||
rgba(255, 255, 255, 0.1) 30%,
|
||||
transparent 50%
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* 评论面板 - 暗色模式 */
|
||||
.dark .comments-modal {
|
||||
background: rgba(30, 30, 40, 0.5);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
box-shadow:
|
||||
0 -8px 24px rgba(0, 0, 0, 0.2),
|
||||
0 0 20px rgba(255, 105, 180, 0.08),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.1) !important;
|
||||
background: rgba(var(--color-bg-dark, 30, 41, 59), var(--opacity-panel-dark, 0.88));
|
||||
border-color: rgba(var(--color-primary-light, 255, 105, 180), var(--opacity-border-dark, 0.2));
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* 头部样式 */
|
||||
.comments-header {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
background: rgba(var(--color-bg-light, 255, 255, 255), var(--opacity-header, 0.7));
|
||||
}
|
||||
|
||||
.dark .comments-header {
|
||||
background: rgba(30, 41, 59, 0.8) !important;
|
||||
background: rgba(var(--color-bg-dark, 30, 41, 59), var(--opacity-header-dark, 0.7));
|
||||
}
|
||||
|
||||
/* 评论容器样式 */
|
||||
.comments-container {
|
||||
background: rgba(248, 250, 252, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-radius: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid rgba(226, 232, 240, 0.5);
|
||||
background: rgba(248, 250, 252, var(--opacity-header, 0.7));
|
||||
border-radius: var(--radius-lg, 1rem);
|
||||
padding: var(--spacing-md, 1rem);
|
||||
border: var(--border-thin, 1px) solid rgba(226, 232, 240, 0.4);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.comments-container {
|
||||
background: rgba(248, 250, 252, 0.5);
|
||||
padding: 1.25rem;
|
||||
border-radius: 1.25rem;
|
||||
padding: var(--spacing-lg, 1.25rem);
|
||||
border-radius: var(--radius-xl, 1.25rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* 暗色模式评论容器 */
|
||||
.dark .comments-container {
|
||||
background: rgba(51, 65, 85, 0.5) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05) !important;
|
||||
background: rgba(51, 65, 85, var(--opacity-header-dark, 0.7));
|
||||
border: var(--border-thin, 1px) solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
|
||||
@@ -307,61 +307,22 @@ onUnmounted(() => {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
/* WWDC 2025 液态玻璃效果 */
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(12px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(180%);
|
||||
box-shadow:
|
||||
0 6px 12px rgba(0, 0, 0, 0.15),
|
||||
0 0 20px rgba(255, 20, 147, 0.1),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.6),
|
||||
inset 0 -1px 1px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
/* 半透明效果 */
|
||||
background: rgba(var(--color-bg-light, 255, 255, 255), var(--opacity-button, 0.75));
|
||||
box-shadow: var(--shadow-md, 0 4px 12px rgba(0, 0, 0, 0.12));
|
||||
border: var(--border-thin, 1px) solid rgba(var(--color-primary, 255, 20, 147), var(--opacity-border, 0.15));
|
||||
|
||||
/* 性能优化 */
|
||||
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 2.2);
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
/* 液态玻璃高光 */
|
||||
.fab-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.4) 0%,
|
||||
rgba(255, 255, 255, 0.1) 40%,
|
||||
transparent 60%
|
||||
);
|
||||
pointer-events: none;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fab-button:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dark .fab-button {
|
||||
background: rgba(30, 30, 40, 0.4);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
box-shadow:
|
||||
0 6px 12px rgba(0, 0, 0, 0.25),
|
||||
0 0 20px rgba(255, 105, 180, 0.12),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.1),
|
||||
inset 0 -1px 1px rgba(0, 0, 0, 0.1);
|
||||
background: rgba(var(--color-bg-dark, 30, 41, 59), var(--opacity-button-dark, 0.75));
|
||||
border-color: rgba(var(--color-primary-light, 255, 105, 180), var(--opacity-border-dark, 0.2));
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.dark .fab-button::before {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.15) 0%,
|
||||
rgba(255, 255, 255, 0.03) 40%,
|
||||
transparent 60%
|
||||
);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.fab-button {
|
||||
@@ -485,50 +446,17 @@ onUnmounted(() => {
|
||||
/* 站点导航面板 - 液态玻璃效果 */
|
||||
.nav-panel {
|
||||
/* 不设置 position,使用模板中的 fixed */
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
backdrop-filter: blur(16px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.15),
|
||||
0 0 20px rgba(255, 20, 147, 0.08),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.6);
|
||||
background: rgba(var(--color-bg-light, 255, 255, 255), var(--opacity-panel, 0.85));
|
||||
border: var(--border-thin, 1px) solid rgba(var(--color-primary, 255, 20, 147), var(--opacity-border, 0.15));
|
||||
box-shadow: var(--shadow-lg, 0 8px 24px rgba(0, 0, 0, 0.15));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 液态玻璃高光 */
|
||||
.nav-panel::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.4) 0%,
|
||||
rgba(255, 255, 255, 0.1) 30%,
|
||||
transparent 50%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 站点导航面板 - 暗色模式 */
|
||||
.dark .nav-panel {
|
||||
background: rgba(30, 30, 40, 0.4);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.25),
|
||||
0 0 20px rgba(255, 105, 180, 0.1),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.dark .nav-panel::before {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.12) 0%,
|
||||
rgba(255, 255, 255, 0.03) 30%,
|
||||
transparent 50%
|
||||
);
|
||||
background: rgba(var(--color-bg-dark, 30, 41, 59), var(--opacity-panel-dark, 0.88));
|
||||
border-color: rgba(var(--color-primary-light, 255, 105, 180), var(--opacity-border-dark, 0.2));
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* 标题栏 */
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { animate } from '@/composables/useAnime'
|
||||
import {
|
||||
X,
|
||||
ChevronLeft,
|
||||
@@ -68,26 +67,9 @@ const imageStyle = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// 动画函数
|
||||
function onEnter(el: Element, done: () => void) {
|
||||
// 进入动画时播放音效
|
||||
function onEnter() {
|
||||
playTransitionUp()
|
||||
animate(el as HTMLElement, {
|
||||
opacity: [0, 1],
|
||||
scale: [0.95, 1],
|
||||
duration: 250,
|
||||
ease: 'outCubic',
|
||||
complete: done,
|
||||
})
|
||||
}
|
||||
|
||||
function onLeave(el: Element, done: () => void) {
|
||||
animate(el as HTMLElement, {
|
||||
opacity: [1, 0],
|
||||
scale: [1, 0.95],
|
||||
duration: 200,
|
||||
ease: 'inCubic',
|
||||
complete: done,
|
||||
})
|
||||
}
|
||||
|
||||
// 监听图片切换
|
||||
@@ -354,9 +336,13 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
:css="false"
|
||||
enter-active-class="duration-250 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="duration-200 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
@enter="onEnter"
|
||||
@leave="onLeave"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
@@ -546,8 +532,7 @@ onUnmounted(() => {
|
||||
.image-viewer-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
}
|
||||
|
||||
.image-viewer-container {
|
||||
@@ -596,9 +581,8 @@ onUnmounted(() => {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding: 4px 12px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 20px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
@@ -613,7 +597,6 @@ onUnmounted(() => {
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.toolbar-btn:hover {
|
||||
@@ -704,7 +687,6 @@ onUnmounted(() => {
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.nav-prev {
|
||||
@@ -733,9 +715,8 @@ onUnmounted(() => {
|
||||
text-align: center;
|
||||
max-width: 80%;
|
||||
padding: 6px 16px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* 缩略图 */
|
||||
@@ -745,9 +726,8 @@ onUnmounted(() => {
|
||||
padding: 8px;
|
||||
max-width: 90vw;
|
||||
overflow-x: auto;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
<Teleport to="body">
|
||||
<!-- 键盘快捷键面板 -->
|
||||
<Transition
|
||||
:css="false"
|
||||
@enter="onEnter"
|
||||
@leave="onLeave"
|
||||
enter-active-class="duration-200 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="duration-150 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="uiStore.isKeyboardHelpOpen"
|
||||
@@ -115,33 +118,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import { animate } from '@/composables/useAnime'
|
||||
import { playTransitionDown } from '@/composables/useSound'
|
||||
import { Keyboard, X } from 'lucide-vue-next'
|
||||
|
||||
const uiStore = useUIStore()
|
||||
const panelRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function onEnter(el: Element, done: () => void) {
|
||||
animate(el as HTMLElement, {
|
||||
opacity: [0, 1],
|
||||
scale: [0.95, 1],
|
||||
duration: 200,
|
||||
ease: 'outCubic',
|
||||
complete: done,
|
||||
})
|
||||
}
|
||||
|
||||
function onLeave(el: Element, done: () => void) {
|
||||
animate(el as HTMLElement, {
|
||||
opacity: [1, 0],
|
||||
scale: [1, 0.95],
|
||||
duration: 150,
|
||||
ease: 'inCubic',
|
||||
complete: done,
|
||||
})
|
||||
}
|
||||
|
||||
function close() {
|
||||
playTransitionDown()
|
||||
uiStore.isKeyboardHelpOpen = false
|
||||
@@ -150,13 +132,11 @@ function close() {
|
||||
|
||||
<style scoped>
|
||||
.keyboard-help-panel {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
background: rgba(var(--color-bg-light, 255, 255, 255), var(--opacity-panel, 0.85));
|
||||
}
|
||||
|
||||
.dark .keyboard-help-panel {
|
||||
background: rgba(30, 41, 59, 0.9);
|
||||
background: rgba(var(--color-bg-dark, 30, 41, 59), var(--opacity-panel-dark, 0.88));
|
||||
}
|
||||
|
||||
.shortcut-row {
|
||||
|
||||
65
src/components/LazyRender.vue
Normal file
65
src/components/LazyRender.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 距离可视区多少像素时开始渲染 */
|
||||
rootMargin?: string
|
||||
/** 是否只渲染一次(进入后不再隐藏) */
|
||||
once?: boolean
|
||||
/** 占位高度 */
|
||||
minHeight?: string
|
||||
}>()
|
||||
|
||||
const isVisible = ref(false)
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
if (!containerRef.value) return
|
||||
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
isVisible.value = true
|
||||
// 如果是一次性渲染,观察到后就停止
|
||||
if (props.once && observer && containerRef.value) {
|
||||
observer.unobserve(containerRef.value)
|
||||
}
|
||||
} else if (!props.once) {
|
||||
isVisible.value = false
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
rootMargin: props.rootMargin || '200px 0px', // 提前 200px 开始渲染
|
||||
threshold: 0
|
||||
}
|
||||
)
|
||||
|
||||
observer.observe(containerRef.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (observer) {
|
||||
observer.disconnect()
|
||||
observer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
:style="{ minHeight: !isVisible ? (minHeight || '60px') : undefined }"
|
||||
>
|
||||
<slot v-if="isVisible" />
|
||||
<!-- 占位符 -->
|
||||
<div
|
||||
v-else
|
||||
class="animate-pulse bg-gray-200/50 dark:bg-slate-700/50 rounded-xl"
|
||||
:style="{ height: minHeight || '60px' }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
<template>
|
||||
<component
|
||||
:is="tag"
|
||||
class="liquid-glass liquid-glass-shadow"
|
||||
:class="[sizeClass, { 'liquid-glass-interactive': interactive }]"
|
||||
>
|
||||
<!-- 液态扭曲效果层 -->
|
||||
<div class="liquid-glass-effect" />
|
||||
|
||||
<!-- 着色层 -->
|
||||
<div class="liquid-glass-tint" />
|
||||
|
||||
<!-- 高光层 -->
|
||||
<div class="liquid-glass-shine" />
|
||||
|
||||
<!-- 边框层 -->
|
||||
<div class="liquid-glass-border" />
|
||||
|
||||
<!-- 内容层 -->
|
||||
<div class="liquid-glass-content">
|
||||
<slot />
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
/** 渲染的 HTML 标签 */
|
||||
tag?: string
|
||||
/** 是否有交互效果(hover 缩放) */
|
||||
interactive?: boolean
|
||||
/** 圆角大小 */
|
||||
rounded?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
tag: 'div',
|
||||
interactive: false,
|
||||
rounded: 'xl',
|
||||
})
|
||||
|
||||
const sizeClass = computed(() => {
|
||||
const roundedMap: Record<string, string> = {
|
||||
sm: 'rounded-lg',
|
||||
md: 'rounded-xl',
|
||||
lg: 'rounded-2xl',
|
||||
xl: 'rounded-3xl',
|
||||
'2xl': 'rounded-[2rem]',
|
||||
full: 'rounded-full',
|
||||
}
|
||||
return roundedMap[props.rounded] || 'rounded-xl'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 交互式液态玻璃 */
|
||||
.liquid-glass-interactive {
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.liquid-glass-interactive:hover {
|
||||
transform: translateY(-4px) scale(1.02);
|
||||
}
|
||||
|
||||
.liquid-glass-interactive:active {
|
||||
transform: translateY(-2px) scale(1);
|
||||
}
|
||||
|
||||
/* 圆角继承 */
|
||||
.liquid-glass-effect,
|
||||
.liquid-glass-tint,
|
||||
.liquid-glass-shine,
|
||||
.liquid-glass-border {
|
||||
border-radius: inherit;
|
||||
}
|
||||
</style>
|
||||
66
src/components/ResultItem.vue
Normal file
66
src/components/ResultItem.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { Link as LinkIcon } from 'lucide-vue-next'
|
||||
|
||||
defineProps<{
|
||||
index: number
|
||||
source: {
|
||||
title: string
|
||||
url: string
|
||||
}
|
||||
}>()
|
||||
|
||||
// 从URL中提取路径
|
||||
function extractPath(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
return urlObj.pathname + urlObj.search + urlObj.hash
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="result-item group p-3 sm:p-4 rounded-xl
|
||||
bg-white/60 dark:bg-slate-700/60
|
||||
hover:bg-white/80 dark:hover:bg-slate-700/80
|
||||
border border-gray-200/40 dark:border-slate-600/40
|
||||
hover:border-theme-primary/30 dark:hover:border-theme-accent/30"
|
||||
>
|
||||
<!-- 标题行 -->
|
||||
<div class="flex items-start gap-2 sm:gap-3">
|
||||
<span class="text-theme-primary dark:text-theme-accent text-sm font-bold mt-0.5 shrink-0 opacity-60 group-hover:opacity-100">
|
||||
{{ index + 1 }}.
|
||||
</span>
|
||||
<a
|
||||
:href="source.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-gray-800 dark:text-slate-200 group-hover:text-theme-primary dark:group-hover:text-theme-accent font-semibold flex-1 text-sm sm:text-base break-words leading-relaxed"
|
||||
>
|
||||
{{ source.title }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 资源相对路径(从URL中提取) -->
|
||||
<div v-if="source.url" class="flex items-center gap-2 mt-2 ml-6 sm:ml-8">
|
||||
<LinkIcon :size="12" class="text-theme-primary/50 dark:text-theme-accent/50" />
|
||||
<span class="text-xs text-gray-500 dark:text-slate-400 break-all font-mono bg-gray-100/80 dark:bg-slate-800/80 px-2 py-1 rounded">
|
||||
{{ extractPath(source.url) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 结果项 - 简化动画,仅使用 transform */
|
||||
.result-item {
|
||||
transition: transform 0.15s ease-out, background-color 0.15s ease-out;
|
||||
}
|
||||
|
||||
.result-item:hover {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -122,12 +122,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Mode Selector - WWDC 2025 液态玻璃 -->
|
||||
<!-- Search Mode Selector -->
|
||||
<div class="flex justify-center items-center">
|
||||
<div class="mode-switch liquid-mode-switch relative flex p-1.5 rounded-2xl">
|
||||
<!-- 液态玻璃高光 -->
|
||||
<!-- 高光装饰 -->
|
||||
<div class="absolute inset-0 rounded-2xl overflow-hidden pointer-events-none">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-white/40 via-white/10 to-transparent" />
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-white/30 via-white/5 to-transparent" />
|
||||
</div>
|
||||
|
||||
<!-- 滑动背景指示器 -->
|
||||
@@ -590,6 +590,8 @@ watch(() => searchStore.customApi, (newApi) => {
|
||||
}
|
||||
})
|
||||
|
||||
let hasScrolledToResults = false
|
||||
|
||||
async function handleSearch() {
|
||||
if (!searchQuery.value.trim()) {return}
|
||||
|
||||
@@ -597,6 +599,7 @@ async function handleSearch() {
|
||||
searchStore.clearResults()
|
||||
searchStore.isSearching = true
|
||||
searchStore.errorMessage = ''
|
||||
hasScrolledToResults = false // 重置滚动标志
|
||||
|
||||
const searchParams = new URLSearchParams()
|
||||
searchParams.set('game', searchQuery.value.trim())
|
||||
@@ -605,6 +608,19 @@ async function handleSearch() {
|
||||
searchParams.set('api', customApi.value.trim())
|
||||
}
|
||||
|
||||
// 在 game 模式下,搜索开始时就并行发起 VNDB 请求
|
||||
const queryForVndb = searchQuery.value.trim()
|
||||
if (searchMode.value === 'game') {
|
||||
fetchVndbData(queryForVndb).then((vndbData) => {
|
||||
// 检查搜索词是否仍匹配(防止快速切换搜索时数据错乱)
|
||||
if (vndbData && searchStore.searchQuery === queryForVndb) {
|
||||
searchStore.vndbInfo = vndbData
|
||||
}
|
||||
}).catch(() => {
|
||||
// VNDB 请求失败不影响主搜索
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await searchGameStream(searchParams, {
|
||||
onTotal: (total) => {
|
||||
@@ -614,11 +630,11 @@ async function handleSearch() {
|
||||
searchStore.searchProgress = { current, total }
|
||||
},
|
||||
onPlatformResult: (data) => {
|
||||
const isFirstResult = searchStore.platformResults.size === 0
|
||||
searchStore.setPlatformResult(data.name, data)
|
||||
|
||||
// 第一个结果出现时滚动到结果区域
|
||||
if (isFirstResult) {
|
||||
// 等待至少 3 个平台结果后滚动到结果区域(只滚动一次)
|
||||
if (!hasScrolledToResults && searchStore.platformResults.size >= 3) {
|
||||
hasScrolledToResults = true
|
||||
// 使用 requestAnimationFrame + setTimeout 确保 DOM 已更新
|
||||
window.requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
@@ -642,6 +658,22 @@ async function handleSearch() {
|
||||
searchStore.isSearching = false
|
||||
playCelebration() // 搜索完成音效
|
||||
|
||||
// 如果结果不足 3 个但有结果,且还没滚动过,则现在滚动
|
||||
if (!hasScrolledToResults && searchStore.platformResults.size > 0) {
|
||||
hasScrolledToResults = true
|
||||
window.requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
const resultsEl = document.getElementById('results')
|
||||
if (resultsEl) {
|
||||
const headerOffset = 80
|
||||
const elementPosition = resultsEl.getBoundingClientRect().top
|
||||
const offsetPosition = elementPosition + window.pageYOffset - headerOffset
|
||||
window.scrollTo({ top: offsetPosition, behavior: 'smooth' })
|
||||
}
|
||||
}, 50)
|
||||
})
|
||||
}
|
||||
|
||||
// 保存搜索历史
|
||||
const resultCount = searchStore.totalResults
|
||||
saveSearchHistory({
|
||||
@@ -657,14 +689,6 @@ async function handleSearch() {
|
||||
playCaution() // 错误音效
|
||||
},
|
||||
})
|
||||
|
||||
// 获取 VNDB 数据
|
||||
if (searchMode.value === 'game') {
|
||||
const vndbData = await fetchVndbData(searchQuery.value.trim())
|
||||
if (vndbData) {
|
||||
searchStore.vndbInfo = vndbData
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
searchStore.errorMessage =
|
||||
error instanceof Error ? error.message : '搜索失败'
|
||||
@@ -1012,9 +1036,8 @@ defineExpose({
|
||||
|
||||
/* 错误卡片样式 */
|
||||
.error-card {
|
||||
background: linear-gradient(135deg, rgba(254, 242, 242, 0.95), rgba(254, 226, 226, 0.95));
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
background: linear-gradient(135deg, rgba(var(--color-error, 254, 242, 242), var(--opacity-panel, 0.85)), rgba(254, 226, 226, var(--opacity-panel, 0.85)));
|
||||
border-radius: var(--radius-lg, 1rem);
|
||||
border-radius: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
@@ -1138,11 +1161,9 @@ defineExpose({
|
||||
border-radius: 1rem;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.85) 0%,
|
||||
rgba(255, 228, 242, 0.75) 100%
|
||||
rgba(255, 255, 255, 0.95) 0%,
|
||||
rgba(255, 245, 250, 0.92) 100%
|
||||
);
|
||||
backdrop-filter: blur(12px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(180%);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@@ -1209,25 +1230,17 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
|
||||
/* 液态玻璃模式切换器 */
|
||||
/* 模式切换器 - 半透明效果 */
|
||||
.liquid-mode-switch {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(12px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 6px 12px rgba(0, 0, 0, 0.12),
|
||||
0 0 20px rgba(255, 20, 147, 0.06),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.6);
|
||||
background: rgba(var(--color-bg-light, 255, 255, 255), var(--opacity-button, 0.75));
|
||||
border: var(--border-thin, 1px) solid rgba(var(--color-primary, 255, 20, 147), var(--opacity-border, 0.15));
|
||||
box-shadow: var(--shadow-md, 0 4px 12px rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
.dark .liquid-mode-switch {
|
||||
background: rgba(30, 30, 40, 0.4);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
box-shadow:
|
||||
0 6px 12px rgba(0, 0, 0, 0.2),
|
||||
0 0 20px rgba(255, 105, 180, 0.08),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.1);
|
||||
background: rgba(var(--color-bg-dark, 30, 41, 59), var(--opacity-button-dark, 0.75));
|
||||
border-color: rgba(var(--color-primary-light, 255, 105, 180), var(--opacity-border-dark, 0.2));
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* 模式切换按钮 hover 效果 */
|
||||
|
||||
@@ -222,35 +222,30 @@ onUnmounted(() => {
|
||||
.history-modal {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.95) 0%,
|
||||
rgba(255, 251, 235, 0.98) 100%
|
||||
rgba(var(--color-bg-light, 255, 255, 255), var(--opacity-panel, 0.85)) 0%,
|
||||
rgba(255, 253, 245, var(--opacity-panel-hover, 0.9)) 100%
|
||||
);
|
||||
backdrop-filter: blur(40px) saturate(1.5);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(1.5);
|
||||
border: 1px solid rgba(251, 191, 36, 0.2);
|
||||
border: var(--border-thin, 1px) solid rgba(var(--color-warning, 251, 191, 36), var(--opacity-border, 0.15));
|
||||
border-radius: var(--radius-2xl, 1.5rem);
|
||||
}
|
||||
|
||||
/* 历史记录面板 - 右下角弹出 (暗色模式) */
|
||||
.dark .history-modal {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(30, 41, 59, 0.95) 0%,
|
||||
rgba(30, 27, 17, 0.98) 100%
|
||||
rgba(var(--color-bg-dark, 30, 41, 59), var(--opacity-panel-dark, 0.88)) 0%,
|
||||
rgba(30, 27, 17, var(--opacity-panel-dark-hover, 0.92)) 100%
|
||||
) !important;
|
||||
backdrop-filter: blur(40px) saturate(1.5) !important;
|
||||
-webkit-backdrop-filter: blur(40px) saturate(1.5) !important;
|
||||
border: 1px solid rgba(251, 191, 36, 0.1) !important;
|
||||
border: var(--border-thin, 1px) solid rgba(var(--color-warning, 251, 191, 36), var(--opacity-border-dark, 0.2)) !important;
|
||||
}
|
||||
|
||||
/* 头部样式 */
|
||||
.history-header {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
background: rgba(var(--color-bg-light, 255, 255, 255), var(--opacity-header, 0.7));
|
||||
}
|
||||
|
||||
.dark .history-header {
|
||||
background: rgba(30, 41, 59, 0.8) !important;
|
||||
background: rgba(var(--color-bg-dark, 30, 41, 59), var(--opacity-header-dark, 0.7)) !important;
|
||||
}
|
||||
|
||||
/* 历史记录项 */
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
<template>
|
||||
<div v-if="searchStore.hasResults" class="w-full sm:px-4 md:px-6 py-4 sm:py-6 md:py-8 animate-fade-in">
|
||||
<div id="results" class="sm:max-w-5xl sm:mx-auto space-y-4 sm:space-y-6">
|
||||
<!-- 使用 v-memo 优化平台卡片渲染,仅在关键数据变化时重新渲染 -->
|
||||
<div
|
||||
<!-- 使用 v-memo 优化平台卡片渲染 + LazyRender 懒渲染 -->
|
||||
<LazyRender
|
||||
v-for="[platformName, platformData] in searchStore.platformResults"
|
||||
:key="platformName"
|
||||
v-memo="[platformName, platformData.items.length, platformData.displayedCount, platformData.error]"
|
||||
:data-platform="platformName"
|
||||
class="result-card glassmorphism-card rounded-none sm:rounded-2xl shadow-xl hover:shadow-2xl transition-shadow duration-300 animate-fade-in-up border-2 content-auto"
|
||||
:class="getBorderClass(platformData.color)"
|
||||
:once="true"
|
||||
min-height="200px"
|
||||
root-margin="400px 0px"
|
||||
>
|
||||
<div
|
||||
v-memo="[platformName, platformData.name, platformData.color, platformData.items.length, platformData.displayedCount, platformData.error, platformData.url]"
|
||||
:data-platform="platformName"
|
||||
class="result-card rounded-none sm:rounded-2xl animate-fade-in-up border-2 content-auto"
|
||||
:class="getBorderClass(platformData.color)"
|
||||
>
|
||||
<div class="p-4 sm:p-5 md:p-6">
|
||||
<!-- 站点标题行:网站名称 + 推荐标签 + 资源标签 + 结果数 -->
|
||||
<div
|
||||
@@ -22,7 +27,7 @@
|
||||
:href="platformData.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xl sm:text-2xl font-bold flex items-center gap-2 hover:scale-105 transition-all cursor-pointer"
|
||||
class="text-xl sm:text-2xl font-bold flex items-center gap-2 hover:opacity-80 cursor-pointer"
|
||||
:class="getHeaderTextColor(platformData.color)"
|
||||
:title="`访问 ${platformData.name}`"
|
||||
>
|
||||
@@ -76,56 +81,25 @@
|
||||
<span class="text-red-700 dark:text-red-300 font-medium">{{ platformData.error }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果列表 - 使用 contain 优化布局性能 -->
|
||||
<div v-if="getDisplayedResults(platformData).length > 0" class="results-list space-y-2 contain-layout">
|
||||
<div
|
||||
<!-- 搜索结果列表 -->
|
||||
<div v-if="getDisplayedResults(platformData).length > 0" class="results-list contain-layout space-y-2">
|
||||
<ResultItem
|
||||
v-for="(result, index) in getDisplayedResults(platformData)"
|
||||
:key="result.url || index"
|
||||
class="result-item group p-3 sm:p-4 rounded-xl
|
||||
bg-gradient-to-r from-white/70 to-white/50 dark:from-slate-700/70 dark:to-slate-700/50
|
||||
hover:from-white/90 hover:to-white/70 dark:hover:from-slate-700/90 dark:hover:to-slate-700/70
|
||||
border border-gray-200/50 dark:border-slate-600/50
|
||||
hover:border-theme-primary/40 dark:hover:border-theme-accent/40
|
||||
hover:shadow-lg hover:shadow-theme-primary/10 dark:hover:shadow-theme-accent/15
|
||||
transition-colors duration-200"
|
||||
>
|
||||
<!-- 标题行 -->
|
||||
<div class="flex items-start gap-2 sm:gap-3">
|
||||
<span class="text-theme-primary dark:text-theme-accent text-sm font-bold mt-0.5 shrink-0 opacity-60 group-hover:opacity-100 transition-opacity">
|
||||
{{ index + 1 }}.
|
||||
</span>
|
||||
<a
|
||||
:href="result.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-gray-800 dark:text-slate-200 group-hover:text-theme-primary dark:group-hover:text-theme-accent font-semibold flex-1 text-sm sm:text-base break-words transition-colors leading-relaxed"
|
||||
>
|
||||
{{ result.title }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 资源相对路径(从URL中提取) -->
|
||||
<div v-if="result.url" class="flex items-center gap-2 mt-2 ml-6 sm:ml-8">
|
||||
<LinkIcon :size="12" class="text-theme-primary/50 dark:text-theme-accent/50" />
|
||||
<span class="text-xs text-gray-500 dark:text-slate-400 break-all font-mono bg-gray-100/80 dark:bg-slate-800/80 px-2 py-1 rounded">
|
||||
{{ extractPath(result.url) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
:index="index"
|
||||
:source="result"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 加载更多按钮 -->
|
||||
<div v-if="platformData.items.length > platformData.displayedCount" class="load-more mt-6 flex justify-center">
|
||||
<button
|
||||
class="px-6 py-3 rounded-xl
|
||||
bg-gradient-pink text-white font-bold
|
||||
border border-white/30 dark:border-white/20
|
||||
shadow-lg shadow-theme-primary/20 dark:shadow-theme-accent/25
|
||||
hover:shadow-xl hover:shadow-theme-primary/30 dark:hover:shadow-theme-accent/35
|
||||
hover:scale-105
|
||||
active:scale-95
|
||||
transition-all duration-300
|
||||
flex items-center gap-2 glass-gpu"
|
||||
bg-theme-primary/90 dark:bg-theme-accent/90 text-white font-bold
|
||||
border border-white/20
|
||||
hover:scale-105 active:scale-95
|
||||
transition-transform duration-200
|
||||
flex items-center gap-2"
|
||||
@click="loadMore(platformName)"
|
||||
>
|
||||
<ArrowDown :size="18" />
|
||||
@@ -146,6 +120,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LazyRender>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -154,12 +129,13 @@
|
||||
import { useSearchStore } from '@/stores/search'
|
||||
import type { PlatformData } from '@/stores/search'
|
||||
import { playTap } from '@/composables/useSound'
|
||||
import LazyRender from '@/components/LazyRender.vue'
|
||||
import ResultItem from '@/components/ResultItem.vue'
|
||||
import {
|
||||
ExternalLink,
|
||||
AlertTriangle,
|
||||
Crown,
|
||||
List,
|
||||
Link as LinkIcon,
|
||||
ArrowDown,
|
||||
CheckCircle,
|
||||
Star,
|
||||
@@ -181,18 +157,6 @@ import {
|
||||
|
||||
const searchStore = useSearchStore()
|
||||
|
||||
// 从URL中提取路径
|
||||
function extractPath(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
// 返回路径部分(去掉域名)
|
||||
return urlObj.pathname + urlObj.search + urlObj.hash
|
||||
} catch {
|
||||
// 如果URL解析失败,返回完整URL
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
// 获取站点所有结果的唯一标签
|
||||
function getUniqueTags(platformData: PlatformData) {
|
||||
const allTags = new Set<string>()
|
||||
@@ -215,13 +179,13 @@ function loadMore(platformName: string) {
|
||||
searchStore.loadMoreResults(platformName, 20)
|
||||
}
|
||||
|
||||
// 新增:卡片边框颜色
|
||||
// 新增:卡片边框颜色(去掉 hover 过渡)
|
||||
function getBorderClass(color: string) {
|
||||
const classes: Record<string, string> = {
|
||||
lime: 'border-lime-300 dark:border-lime-700/50 hover:border-lime-400 dark:hover:border-lime-600',
|
||||
white: 'border-gray-300 dark:border-slate-600 hover:border-gray-400 dark:hover:border-slate-500',
|
||||
gold: 'border-yellow-300 dark:border-yellow-700/50 hover:border-yellow-400 dark:hover:border-yellow-600',
|
||||
red: 'border-red-300 dark:border-red-700/50 hover:border-red-400 dark:hover:border-red-600',
|
||||
lime: 'border-lime-300 dark:border-lime-700/50',
|
||||
white: 'border-gray-300 dark:border-slate-600',
|
||||
gold: 'border-yellow-300 dark:border-yellow-700/50',
|
||||
red: 'border-red-300 dark:border-red-700/50',
|
||||
}
|
||||
return classes[color] || 'border-gray-300 dark:border-slate-600'
|
||||
}
|
||||
@@ -248,12 +212,12 @@ function getHeaderTextColor(color: string) {
|
||||
return classes[color] || 'text-gray-700 dark:text-gray-300'
|
||||
}
|
||||
|
||||
// 新增:推荐标签样式(更醒目)
|
||||
// 新增:推荐标签样式(使用纯色,避免渐变)
|
||||
function getRecommendChipClass(color: string) {
|
||||
const classes: Record<string, string> = {
|
||||
lime: 'bg-gradient-to-r from-lime-400 to-green-500 text-white border-lime-500',
|
||||
gold: 'bg-gradient-to-r from-yellow-400 to-orange-500 text-white border-yellow-500',
|
||||
red: 'bg-gradient-to-r from-red-400 to-pink-500 text-white border-red-500',
|
||||
lime: 'bg-lime-500 text-white border-lime-600',
|
||||
gold: 'bg-yellow-500 text-white border-yellow-600',
|
||||
red: 'bg-red-500 text-white border-red-600',
|
||||
}
|
||||
return classes[color] || ''
|
||||
}
|
||||
@@ -346,69 +310,31 @@ function getTagLabel(tag: string) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 平台卡片 - WWDC 2025 液态玻璃效果 */
|
||||
/* 平台卡片 - 半透明效果 */
|
||||
.result-card {
|
||||
animation-delay: calc(var(--index, 0) * 0.1s);
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 400px;
|
||||
|
||||
/* WWDC 2025 液态玻璃效果 */
|
||||
background: rgba(255, 255, 255, 0.3) !important;
|
||||
backdrop-filter: blur(16px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3) !important;
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.12),
|
||||
0 0 20px rgba(255, 20, 147, 0.06),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.6) !important;
|
||||
}
|
||||
|
||||
/* 液态玻璃高光 */
|
||||
.result-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.5) 0%,
|
||||
rgba(255, 255, 255, 0.1) 30%,
|
||||
transparent 50%
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.result-card:hover::before {
|
||||
opacity: 1;
|
||||
background: rgba(var(--color-bg-light, 255, 255, 255), var(--opacity-card, 0.8)) !important;
|
||||
border: var(--border-thin, 1px) solid rgba(var(--color-primary, 255, 20, 147), var(--opacity-border, 0.15)) !important;
|
||||
box-shadow: var(--shadow-md, 0 4px 16px rgba(0, 0, 0, 0.1)) !important;
|
||||
}
|
||||
|
||||
/* 暗色模式 */
|
||||
.dark .result-card {
|
||||
background: rgba(30, 30, 40, 0.45) !important;
|
||||
border-color: rgba(255, 255, 255, 0.15) !important;
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.2),
|
||||
0 0 20px rgba(255, 105, 180, 0.08),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.1) !important;
|
||||
background: rgba(var(--color-bg-dark, 30, 41, 59), var(--opacity-card-dark, 0.8)) !important;
|
||||
border-color: rgba(var(--color-primary-light, 255, 105, 180), var(--opacity-border-dark, 0.2)) !important;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25) !important;
|
||||
}
|
||||
|
||||
/* 结果项 - 液态玻璃效果 */
|
||||
/* 结果项 - 简化动画,仅使用 transform */
|
||||
.result-item {
|
||||
transform: translate3d(0, 0, 0);
|
||||
transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
|
||||
position: relative;
|
||||
transition: transform 0.15s ease-out, background-color 0.15s ease-out;
|
||||
}
|
||||
|
||||
.result-item:hover {
|
||||
transform: translate3d(4px, 0, 0);
|
||||
box-shadow: 0 4px 16px rgba(255, 20, 147, 0.1);
|
||||
}
|
||||
|
||||
.dark .result-item:hover {
|
||||
box-shadow: 0 4px 16px rgba(255, 105, 180, 0.15);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* 结果列表布局隔离 */
|
||||
|
||||
@@ -1,42 +1,25 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<!-- 设置面板 - macOS 风格浮动窗口 -->
|
||||
<!-- 设置面板 - 模态框 -->
|
||||
<Transition
|
||||
:css="false"
|
||||
@enter="onEnter"
|
||||
@leave="onLeave"
|
||||
enter-active-class="duration-300 ease-out"
|
||||
enter-from-class="opacity-0 scale-[0.98] translate-y-10"
|
||||
enter-to-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-active-class="duration-200 ease-in"
|
||||
leave-from-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-to-class="opacity-0 scale-[0.98] translate-y-10"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="modalRef"
|
||||
:class="[
|
||||
'fixed z-[100] flex flex-col settings-page shadow-2xl shadow-black/20',
|
||||
isFullscreen
|
||||
? 'inset-0'
|
||||
: 'inset-0 md:inset-6 md:m-auto md:w-[600px] md:min-w-[400px] md:max-w-[800px] md:h-[500px] md:max-h-[calc(100%-3rem)] md:rounded-3xl'
|
||||
]"
|
||||
:style="windowStyle"
|
||||
class="fixed z-[100] flex flex-col settings-page shadow-2xl shadow-black/20 inset-0 md:inset-6 md:m-auto md:w-[800px] md:max-w-[calc(100%-3rem)] md:h-[700px] md:max-h-[calc(100%-3rem)] md:rounded-3xl"
|
||||
>
|
||||
<!-- 调整大小手柄 -->
|
||||
<WindowResizeHandles
|
||||
:is-fullscreen="isFullscreen"
|
||||
@resize="handleResize"
|
||||
/>
|
||||
|
||||
<!-- 顶部导航栏 - 可拖动 -->
|
||||
<!-- 顶部导航栏 -->
|
||||
<div
|
||||
v-anime:100="'slideUp'"
|
||||
:class="[
|
||||
'flex-shrink-0 flex items-center justify-between px-4 sm:px-6 py-3 sm:py-4 border-b border-white/10 dark:border-slate-700/50 glassmorphism-navbar select-none',
|
||||
isFullscreen ? '' : 'md:rounded-t-3xl md:cursor-move'
|
||||
]"
|
||||
@mousedown="handleDragStart"
|
||||
@touchstart="handleDragStart"
|
||||
class="flex-shrink-0 flex items-center justify-between px-4 sm:px-6 py-3 sm:py-4 border-b border-white/10 dark:border-slate-700/50 glassmorphism-navbar select-none md:rounded-t-3xl"
|
||||
>
|
||||
<!-- 返回按钮 - 仅移动端 -->
|
||||
<button
|
||||
v-tap
|
||||
class="flex items-center gap-1 text-[#ff1493] dark:text-[#ff69b4] font-medium transition-colors md:hidden"
|
||||
class="flex items-center gap-1 text-[#ff1493] dark:text-[#ff69b4] font-medium transition-colors active:scale-95 md:hidden"
|
||||
@click="close"
|
||||
>
|
||||
<ChevronLeft :size="24" />
|
||||
@@ -53,23 +36,12 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 保存按钮 -->
|
||||
<button
|
||||
v-tap
|
||||
class="px-4 py-1.5 rounded-full text-white text-sm font-semibold bg-gradient-to-r from-[#ff1493] to-[#d946ef] shadow-lg shadow-pink-500/25"
|
||||
class="px-4 py-1.5 rounded-full text-white text-sm font-semibold bg-[#ff1493] hover:bg-[#e0117f] active:scale-95 transition-all shadow-lg shadow-pink-500/25"
|
||||
@click="save"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
|
||||
<!-- 全屏按钮 - 仅桌面端 -->
|
||||
<button
|
||||
class="hidden md:flex w-8 h-8 rounded-lg items-center justify-center text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-slate-700 transition-all"
|
||||
title="全屏"
|
||||
@click="handleToggleFullscreen"
|
||||
>
|
||||
<Maximize2 v-if="!isFullscreen" :size="16" />
|
||||
<Minimize2 v-else :size="16" />
|
||||
</button>
|
||||
|
||||
<!-- 关闭按钮 - 仅桌面端 -->
|
||||
<button
|
||||
class="hidden md:flex w-8 h-8 rounded-lg items-center justify-center text-gray-500 hover:text-red-500 dark:text-gray-400 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all"
|
||||
@@ -84,9 +56,62 @@
|
||||
<!-- 内容区域 -->
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar">
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 py-6 sm:py-8 space-y-6">
|
||||
<!-- 主题设置卡片 -->
|
||||
<div
|
||||
class="settings-card"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-500 to-purple-500 flex items-center justify-center shadow-lg shadow-violet-500/30">
|
||||
<Palette :size="20" class="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-gray-800 dark:text-white">外观主题</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-slate-400">选择亮色、暗色或跟随系统</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主题选项 -->
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<button
|
||||
v-for="option in themeOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
:class="[
|
||||
'flex flex-col items-center gap-2 p-4 rounded-xl transition-all duration-200',
|
||||
uiStore.themeMode === option.value
|
||||
? 'bg-[#ff1493]/10 border-2 border-[#ff1493] dark:border-[#ff69b4]'
|
||||
: 'bg-slate-50 dark:bg-slate-800/60 border-2 border-transparent hover:border-pink-200 dark:hover:border-pink-900'
|
||||
]"
|
||||
@click="handleThemeChange(option.value)"
|
||||
>
|
||||
<!-- 图标 -->
|
||||
<div
|
||||
:class="[
|
||||
'w-10 h-10 rounded-xl flex items-center justify-center transition-colors',
|
||||
uiStore.themeMode === option.value
|
||||
? 'bg-[#ff1493] text-white'
|
||||
: 'bg-gray-200 dark:bg-slate-700 text-gray-600 dark:text-slate-400'
|
||||
]"
|
||||
>
|
||||
<component :is="option.icon" :size="20" />
|
||||
</div>
|
||||
<!-- 标签 -->
|
||||
<span
|
||||
:class="[
|
||||
'text-sm font-medium',
|
||||
uiStore.themeMode === option.value
|
||||
? 'text-[#ff1493] dark:text-[#ff69b4]'
|
||||
: 'text-gray-700 dark:text-slate-300'
|
||||
]"
|
||||
>
|
||||
{{ option.label }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API 设置卡片 -->
|
||||
<div
|
||||
v-anime:150="'cardIn'"
|
||||
class="settings-card"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
@@ -100,11 +125,10 @@
|
||||
</div>
|
||||
|
||||
<!-- API 选项列表 -->
|
||||
<div v-anime-stagger:50="'slideRight'" class="space-y-2">
|
||||
<div class="space-y-2">
|
||||
<button
|
||||
v-for="option in apiOptions"
|
||||
:key="option.value"
|
||||
v-tap
|
||||
type="button"
|
||||
:class="[
|
||||
'w-full flex flex-col sm:flex-row sm:items-center sm:justify-between p-3 sm:p-4 rounded-xl transition-all duration-200 text-left',
|
||||
@@ -136,11 +160,11 @@
|
||||
{{ option.label }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 移动端:URL 显示在第二行;桌面端:显示在右侧 -->
|
||||
<!-- 移动端:URL 显示在第二行;桌面端:显示在右侧靠右 -->
|
||||
<span
|
||||
v-if="option.value !== 'custom'"
|
||||
v-text-scroll
|
||||
class="text-xs text-gray-400 dark:text-slate-500 font-mono mt-1.5 sm:mt-0 ml-8 sm:ml-0 flex-1 min-w-0"
|
||||
class="text-xs text-gray-400 dark:text-slate-500 font-mono mt-1.5 sm:mt-0 ml-8 sm:ml-auto sm:text-right truncate max-w-[50%]"
|
||||
>
|
||||
{{ getApiUrl(option.value) }}
|
||||
</span>
|
||||
@@ -190,7 +214,6 @@
|
||||
|
||||
<!-- 自定义样式卡片 -->
|
||||
<div
|
||||
v-anime:200="'cardIn'"
|
||||
class="settings-card"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
@@ -230,7 +253,6 @@
|
||||
|
||||
<!-- 重置区域 -->
|
||||
<div
|
||||
v-anime:250="'cardIn'"
|
||||
class="settings-card bg-red-50/50 dark:bg-red-950/20 border-red-200/50 dark:border-red-900/30"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -244,8 +266,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-tap
|
||||
class="px-4 py-2 rounded-xl text-red-600 dark:text-red-400 font-medium bg-white dark:bg-slate-800 border border-red-200 dark:border-red-800/50 hover:bg-red-50 dark:hover:bg-red-950/50 transition-colors"
|
||||
class="px-4 py-2 rounded-xl text-red-600 dark:text-red-400 font-medium bg-white dark:bg-slate-800 border border-red-200 dark:border-red-800/50 hover:bg-red-50 dark:hover:bg-red-950/50 active:scale-95 transition-all"
|
||||
@click="reset"
|
||||
>
|
||||
重置
|
||||
@@ -261,8 +282,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { animate } from '@/composables/useAnime'
|
||||
import { playTap, playCelebration, playToggle, playType, playSwipe } from '@/composables/useSound'
|
||||
import { playTap, playCelebration, playToggle, playType } from '@/composables/useSound'
|
||||
|
||||
// Prism Editor
|
||||
import { PrismEditor } from 'vue-prism-editor'
|
||||
@@ -289,8 +309,6 @@ function handleTyping() {
|
||||
lastTypingSound = now
|
||||
}
|
||||
}
|
||||
import { useWindowManager, type ResizeDirection } from '@/composables/useWindowManager'
|
||||
import WindowResizeHandles from '@/components/WindowResizeHandles.vue'
|
||||
import {
|
||||
Settings as SettingsIcon,
|
||||
ChevronLeft,
|
||||
@@ -301,10 +319,27 @@ import {
|
||||
RotateCcw,
|
||||
Check,
|
||||
Github,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
X,
|
||||
Palette,
|
||||
Sun,
|
||||
Moon,
|
||||
Monitor,
|
||||
} from 'lucide-vue-next'
|
||||
import { useUIStore, type ThemeMode } from '@/stores/ui'
|
||||
|
||||
const uiStore = useUIStore()
|
||||
|
||||
// 主题选项
|
||||
const themeOptions = [
|
||||
{ value: 'light' as ThemeMode, label: '亮色', icon: Sun },
|
||||
{ value: 'system' as ThemeMode, label: '系统', icon: Monitor },
|
||||
{ value: 'dark' as ThemeMode, label: '暗色', icon: Moon },
|
||||
]
|
||||
|
||||
function handleThemeChange(mode: ThemeMode) {
|
||||
playToggle()
|
||||
uiStore.setThemeMode(mode)
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
isOpen: boolean
|
||||
@@ -317,62 +352,6 @@ const emit = defineEmits<{
|
||||
save: [customApi: string, customCSS: string]
|
||||
}>()
|
||||
|
||||
// 窗口管理
|
||||
const modalRef = ref<HTMLElement | null>(null)
|
||||
const { isFullscreen, windowStyle, startDrag, startResize, toggleFullscreen, reset: resetWindow } = useWindowManager({
|
||||
minWidth: 400,
|
||||
minHeight: 300,
|
||||
})
|
||||
|
||||
// 进入/离开动画
|
||||
function onEnter(el: Element, done: () => void) {
|
||||
animate(el as HTMLElement, {
|
||||
opacity: [0, 1],
|
||||
scale: [0.98, 1],
|
||||
translateY: [40, 0],
|
||||
duration: 400,
|
||||
ease: 'outCubic',
|
||||
complete: done,
|
||||
})
|
||||
}
|
||||
|
||||
function onLeave(el: Element, done: () => void) {
|
||||
animate(el as HTMLElement, {
|
||||
opacity: [1, 0],
|
||||
scale: [1, 0.98],
|
||||
translateY: [0, 40],
|
||||
duration: 300,
|
||||
ease: 'inCubic',
|
||||
complete: done,
|
||||
})
|
||||
}
|
||||
|
||||
function handleDragStart(e: MouseEvent | TouchEvent) {
|
||||
if ((e.target as HTMLElement).closest('button')) {return}
|
||||
if (modalRef.value) {
|
||||
startDrag(e, modalRef.value)
|
||||
}
|
||||
}
|
||||
|
||||
function handleResize(e: MouseEvent | TouchEvent, direction: ResizeDirection) {
|
||||
if (modalRef.value) {
|
||||
startResize(e, direction, modalRef.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换全屏(带音效)
|
||||
function handleToggleFullscreen() {
|
||||
playSwipe()
|
||||
toggleFullscreen()
|
||||
}
|
||||
|
||||
// 关闭时重置窗口状态
|
||||
watch(() => props.isOpen, (newVal) => {
|
||||
if (!newVal) {
|
||||
resetWindow()
|
||||
}
|
||||
})
|
||||
|
||||
// API 服务器选项
|
||||
const apiOptions = [
|
||||
{ value: 'cfapi', label: 'Cloudflare Workers' },
|
||||
@@ -484,27 +463,12 @@ function reset() {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 设置面板 - WWDC 2025 液态玻璃效果 */
|
||||
/* 设置面板 - 半透明效果 */
|
||||
.settings-page {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
background: rgba(var(--color-bg-light, 255, 255, 255), var(--opacity-panel, 0.85));
|
||||
will-change: transform;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.12),
|
||||
0 0 20px rgba(255, 20, 147, 0.06),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.6);
|
||||
/* 窗口/全屏切换动画 */
|
||||
transition:
|
||||
inset 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
width 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
height 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
min-width 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
max-width 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-radius 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
margin 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border: var(--border-thin, 1px) solid rgba(var(--color-primary, 255, 20, 147), var(--opacity-border, 0.15));
|
||||
box-shadow: var(--shadow-xl, 0 12px 32px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
|
||||
/* 移动端无底部边框 */
|
||||
@@ -514,52 +478,27 @@ function reset() {
|
||||
}
|
||||
}
|
||||
|
||||
/* 液态玻璃高光 */
|
||||
.settings-page::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.4) 0%,
|
||||
rgba(255, 255, 255, 0.1) 30%,
|
||||
transparent 50%
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* 设置面板 - 暗色模式 */
|
||||
.dark .settings-page {
|
||||
background: rgba(30, 30, 40, 0.5);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
box-shadow:
|
||||
0 -8px 24px rgba(0, 0, 0, 0.2),
|
||||
0 0 20px rgba(255, 105, 180, 0.08),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.1) !important;
|
||||
background: rgba(var(--color-bg-dark, 30, 41, 59), var(--opacity-panel-dark, 0.88));
|
||||
border-color: rgba(var(--color-primary-light, 255, 105, 180), var(--opacity-border-dark, 0.2));
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* 设置卡片 - 亮色模式 */
|
||||
.settings-card {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-radius: 1.25rem;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
box-shadow:
|
||||
0 4px 24px -4px rgba(0, 0, 0, 0.08),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.6) inset;
|
||||
background: rgba(var(--color-bg-light, 255, 255, 255), var(--opacity-card-inner, 0.75));
|
||||
border-radius: var(--radius-xl, 1.25rem);
|
||||
padding: var(--spacing-lg, 1.25rem);
|
||||
border: var(--border-thin, 1px) solid rgba(var(--color-primary, 255, 20, 147), var(--opacity-border, 0.15));
|
||||
box-shadow: var(--shadow-md, 0 4px 16px rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
|
||||
/* 设置卡片 - 暗色模式 */
|
||||
.dark .settings-card {
|
||||
background: rgba(30, 41, 59, 0.8) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||
box-shadow:
|
||||
0 4px 24px -4px rgba(0, 0, 0, 0.4),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05) inset !important;
|
||||
background: rgba(var(--color-bg-dark, 30, 41, 59), var(--opacity-card-inner-dark, 0.75));
|
||||
border: var(--border-thin, 1px) solid rgba(var(--color-primary-light, 255, 105, 180), var(--opacity-border-dark, 0.2));
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
|
||||
@@ -219,16 +219,10 @@ async function saveBackgroundImage() {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
/* WWDC 2025 液态玻璃效果 */
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(12px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 6px 12px rgba(0, 0, 0, 0.15),
|
||||
0 0 20px rgba(255, 20, 147, 0.1),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.6),
|
||||
inset 0 -1px 1px rgba(0, 0, 0, 0.05);
|
||||
/* 半透明效果 */
|
||||
background: rgba(var(--color-bg-light, 255, 255, 255), var(--opacity-button, 0.75));
|
||||
border: var(--border-thin, 1px) solid rgba(var(--color-primary, 255, 20, 147), var(--opacity-border, 0.15));
|
||||
box-shadow: var(--shadow-md, 0 4px 12px rgba(0, 0, 0, 0.12));
|
||||
|
||||
color: rgb(199, 21, 133);
|
||||
cursor: pointer;
|
||||
@@ -242,27 +236,6 @@ async function saveBackgroundImage() {
|
||||
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 2.2);
|
||||
}
|
||||
|
||||
/* 液态玻璃高光 */
|
||||
.toolbar-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.4) 0%,
|
||||
rgba(255, 255, 255, 0.1) 40%,
|
||||
transparent 60%
|
||||
);
|
||||
pointer-events: none;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.toolbar-button:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.toolbar-button {
|
||||
width: 44px;
|
||||
@@ -279,23 +252,10 @@ async function saveBackgroundImage() {
|
||||
|
||||
/* 暗色主题 */
|
||||
.dark .toolbar-button {
|
||||
background: rgba(30, 30, 40, 0.4);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
box-shadow:
|
||||
0 6px 12px rgba(0, 0, 0, 0.25),
|
||||
0 0 20px rgba(255, 105, 180, 0.12),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.1),
|
||||
inset 0 -1px 1px rgba(0, 0, 0, 0.1);
|
||||
color: rgb(255, 179, 217);
|
||||
}
|
||||
|
||||
.dark .toolbar-button::before {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.15) 0%,
|
||||
rgba(255, 255, 255, 0.03) 40%,
|
||||
transparent 60%
|
||||
);
|
||||
background: rgba(var(--color-bg-dark, 30, 41, 59), var(--opacity-button-dark, 0.75));
|
||||
border-color: rgba(var(--color-primary-light, 255, 105, 180), var(--opacity-border-dark, 0.2));
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
color: rgb(var(--color-primary-light, 255, 179, 217));
|
||||
}
|
||||
|
||||
.toolbar-button:hover {
|
||||
|
||||
@@ -1,36 +1,22 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<!-- VNDB 信息面板 - macOS 风格浮动窗口 -->
|
||||
<!-- VNDB 信息面板 - 模态框 -->
|
||||
<Transition
|
||||
:css="false"
|
||||
@enter="onEnter"
|
||||
@leave="onLeave"
|
||||
enter-active-class="duration-300 ease-out"
|
||||
enter-from-class="opacity-0 scale-[0.98] translate-y-10"
|
||||
enter-to-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-active-class="duration-200 ease-in"
|
||||
leave-from-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-to-class="opacity-0 scale-[0.98] translate-y-10"
|
||||
>
|
||||
<div
|
||||
v-if="uiStore.isVndbPanelOpen && searchStore.vndbInfo"
|
||||
ref="modalRef"
|
||||
:class="[
|
||||
'fixed z-50 flex flex-col vndb-page shadow-2xl shadow-black/20',
|
||||
isFullscreen
|
||||
? 'inset-0'
|
||||
: 'inset-0 md:inset-6 md:m-auto md:w-[800px] md:min-w-[700px] md:max-w-[1000px] md:h-[700px] md:max-h-[calc(100%-3rem)] md:rounded-3xl'
|
||||
]"
|
||||
:style="windowStyle"
|
||||
class="fixed z-50 flex flex-col vndb-page shadow-2xl shadow-black/20 inset-0 md:inset-6 md:m-auto md:w-[900px] md:max-w-[calc(100%-3rem)] md:h-[800px] md:max-h-[calc(100%-3rem)] md:rounded-3xl"
|
||||
>
|
||||
<!-- 调整大小手柄 -->
|
||||
<WindowResizeHandles
|
||||
:is-fullscreen="isFullscreen"
|
||||
@resize="handleResize"
|
||||
/>
|
||||
|
||||
<!-- 顶部导航栏 - 可拖动 -->
|
||||
<!-- 顶部导航栏 -->
|
||||
<div
|
||||
:class="[
|
||||
'flex-shrink-0 flex items-center justify-between px-4 sm:px-6 py-3 sm:py-4 border-b border-white/10 dark:border-slate-700/50 glassmorphism-navbar select-none',
|
||||
isFullscreen ? '' : 'md:rounded-t-3xl md:cursor-move'
|
||||
]"
|
||||
@mousedown="handleDragStart"
|
||||
@touchstart="handleDragStart"
|
||||
class="flex-shrink-0 flex items-center justify-between px-4 sm:px-6 py-3 sm:py-4 border-b border-white/10 dark:border-slate-700/50 glassmorphism-navbar select-none md:rounded-t-3xl"
|
||||
>
|
||||
<!-- 返回按钮 - 移动端 -->
|
||||
<button
|
||||
@@ -55,7 +41,7 @@
|
||||
class="flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium transition-all"
|
||||
:class="isTranslatingAll
|
||||
? 'bg-gray-200 dark:bg-slate-700 text-gray-500 dark:text-slate-400 cursor-wait'
|
||||
: 'text-white bg-gradient-to-r from-violet-500 to-purple-600 shadow-lg shadow-violet-500/25 hover:shadow-xl'"
|
||||
: 'text-white bg-violet-500 hover:bg-violet-600'"
|
||||
:disabled="isTranslatingAll"
|
||||
@click="handleTranslateAll"
|
||||
>
|
||||
@@ -78,22 +64,12 @@
|
||||
:href="vndbUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-white bg-gradient-to-r from-[#ff1493] to-[#d946ef] shadow-lg shadow-pink-500/25 hover:shadow-xl transition-shadow"
|
||||
class="flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-white bg-[#ff1493] hover:bg-[#e6007f]"
|
||||
>
|
||||
<ExternalLink :size="14" />
|
||||
<span class="hidden sm:inline">VNDB</span>
|
||||
</a>
|
||||
|
||||
<!-- 全屏按钮 - 仅桌面端 -->
|
||||
<button
|
||||
class="hidden md:flex w-8 h-8 rounded-lg items-center justify-center text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-slate-700 transition-all"
|
||||
title="全屏"
|
||||
@click="handleToggleFullscreen"
|
||||
>
|
||||
<Maximize2 v-if="!isFullscreen" :size="16" />
|
||||
<Minimize2 v-else :size="16" />
|
||||
</button>
|
||||
|
||||
<!-- 关闭按钮 - 仅桌面端 -->
|
||||
<button
|
||||
class="hidden md:flex w-8 h-8 rounded-lg items-center justify-center text-gray-500 hover:text-red-500 dark:text-gray-400 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all"
|
||||
@@ -515,7 +491,7 @@
|
||||
<button
|
||||
v-for="(screenshot, index) in searchStore.vndbInfo.screenshots"
|
||||
:key="index"
|
||||
class="group block overflow-hidden rounded-xl shadow-md hover:shadow-xl transition-all"
|
||||
class="group block overflow-hidden rounded-xl hover:scale-[1.02] transition-transform"
|
||||
@click="openGallery(index + 1)"
|
||||
>
|
||||
<img
|
||||
@@ -540,8 +516,7 @@ import { ref, watch, computed, nextTick } from 'vue'
|
||||
import { useSearchStore, type VndbCharacter, type VndbQuote } from '@/stores/search'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import { translateText, fetchVndbCharacters, fetchVndbQuotes } from '@/api/search'
|
||||
import { playClick, playSuccess, playError, playToggle, playTransitionUp, playTransitionDown, playSwipe } from '@/composables/useSound'
|
||||
import { animate } from '@/composables/useAnime'
|
||||
import { playClick, playSuccess, playError, playToggle, playTransitionUp, playTransitionDown } from '@/composables/useSound'
|
||||
import { useImageViewer } from '@/composables/useImageViewer'
|
||||
import {
|
||||
BookOpen,
|
||||
@@ -558,8 +533,6 @@ import {
|
||||
Loader,
|
||||
Bot,
|
||||
Image,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
X,
|
||||
Tag,
|
||||
Link2,
|
||||
@@ -569,35 +542,10 @@ import {
|
||||
Users,
|
||||
Quote,
|
||||
} from 'lucide-vue-next'
|
||||
import { useWindowManager, type ResizeDirection } from '@/composables/useWindowManager'
|
||||
import WindowResizeHandles from '@/components/WindowResizeHandles.vue'
|
||||
|
||||
// 图片预览
|
||||
const imageViewer = useImageViewer()
|
||||
|
||||
// 进入/离开动画
|
||||
function onEnter(el: Element, done: () => void) {
|
||||
animate(el as HTMLElement, {
|
||||
opacity: [0, 1],
|
||||
scale: [0.98, 1],
|
||||
translateY: [40, 0],
|
||||
duration: 300,
|
||||
ease: 'outCubic',
|
||||
complete: done,
|
||||
})
|
||||
}
|
||||
|
||||
function onLeave(el: Element, done: () => void) {
|
||||
animate(el as HTMLElement, {
|
||||
opacity: [1, 0],
|
||||
scale: [1, 0.98],
|
||||
translateY: [0, 40],
|
||||
duration: 200,
|
||||
ease: 'inCubic',
|
||||
complete: done,
|
||||
})
|
||||
}
|
||||
|
||||
const searchStore = useSearchStore()
|
||||
const uiStore = useUIStore()
|
||||
const isTranslating = ref(false)
|
||||
@@ -650,31 +598,7 @@ function toggleSection(section: keyof typeof expandedSections.value) {
|
||||
expandedSections.value[section] = !expandedSections.value[section]
|
||||
}
|
||||
|
||||
// 窗口管理
|
||||
const modalRef = ref<HTMLElement | null>(null)
|
||||
const { isFullscreen, windowStyle, startDrag, startResize, toggleFullscreen, reset } = useWindowManager({
|
||||
minWidth: 400,
|
||||
minHeight: 300,
|
||||
})
|
||||
|
||||
function handleDragStart(e: MouseEvent | TouchEvent) {
|
||||
if ((e.target as HTMLElement).closest('button, a')) {return}
|
||||
if (modalRef.value) {
|
||||
startDrag(e, modalRef.value)
|
||||
}
|
||||
}
|
||||
|
||||
function handleResize(e: MouseEvent | TouchEvent, direction: ResizeDirection) {
|
||||
if (modalRef.value) {
|
||||
startResize(e, direction, modalRef.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换全屏(带音效)
|
||||
function handleToggleFullscreen() {
|
||||
playSwipe()
|
||||
toggleFullscreen()
|
||||
}
|
||||
|
||||
// 计算 VNDB URL
|
||||
const vndbUrl = computed(() => {
|
||||
@@ -730,8 +654,14 @@ watch(() => searchStore.vndbInfo, async (newInfo) => {
|
||||
|
||||
// 检查缓存的截图是否已加载
|
||||
if (newInfo?.screenshots && newInfo.screenshots.length > 0) {
|
||||
const vnIdForScreenshots = newInfo?.id
|
||||
nextTick(() => {
|
||||
requestAnimationFrame(() => {
|
||||
// 只有当有有效的游戏 ID 时才进行竞态检查
|
||||
// 如果没有 ID,则无法进行有意义的检查,直接处理截图
|
||||
if (vnIdForScreenshots && currentVnId.value !== vnIdForScreenshots) {
|
||||
return
|
||||
}
|
||||
const screenshotImgs = modalRef.value?.querySelectorAll('img[alt*="截图"]')
|
||||
if (screenshotImgs) {
|
||||
for (let i = 0; i < screenshotImgs.length; i++) {
|
||||
@@ -768,8 +698,6 @@ async function loadCharactersAndQuotes(vnId: string) {
|
||||
watch(() => uiStore.isVndbPanelOpen, (isOpen) => {
|
||||
if (isOpen) {
|
||||
playTransitionUp()
|
||||
} else {
|
||||
reset()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1160,27 +1088,12 @@ function formatRelation(relation: string): string {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* VNDB 面板 - WWDC 2025 液态玻璃效果 */
|
||||
/* VNDB 面板 - 半透明效果 */
|
||||
.vndb-page {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
background: rgba(var(--color-bg-light, 255, 255, 255), var(--opacity-panel, 0.85));
|
||||
will-change: transform;
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.12),
|
||||
0 0 20px rgba(255, 20, 147, 0.06),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.6);
|
||||
/* 窗口/全屏切换动画 */
|
||||
transition:
|
||||
inset 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
width 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
height 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
min-width 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
max-width 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-radius 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
margin 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border: var(--border-thin, 1px) solid rgba(var(--color-primary, 255, 20, 147), var(--opacity-border, 0.15));
|
||||
box-shadow: var(--shadow-xl, 0 12px 32px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
|
||||
/* 移动端无底部边框 */
|
||||
@@ -1190,52 +1103,27 @@ function formatRelation(relation: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/* 液态玻璃高光 */
|
||||
.vndb-page::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.4) 0%,
|
||||
rgba(255, 255, 255, 0.1) 30%,
|
||||
transparent 50%
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* VNDB 面板 - 暗色模式 */
|
||||
.dark .vndb-page {
|
||||
background: rgba(30, 30, 40, 0.5);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
box-shadow:
|
||||
0 -8px 24px rgba(0, 0, 0, 0.2),
|
||||
0 0 20px rgba(255, 105, 180, 0.08),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.1) !important;
|
||||
background: rgba(var(--color-bg-dark, 30, 41, 59), var(--opacity-panel-dark, 0.88));
|
||||
border-color: rgba(var(--color-primary-light, 255, 105, 180), var(--opacity-border-dark, 0.2));
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* VNDB 卡片 - 亮色模式 */
|
||||
.vndb-card {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-radius: 1.25rem;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
box-shadow:
|
||||
0 4px 24px -4px rgba(0, 0, 0, 0.08),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.6) inset;
|
||||
background: rgba(var(--color-bg-light, 255, 255, 255), var(--opacity-card-inner, 0.75));
|
||||
border-radius: var(--radius-xl, 1.25rem);
|
||||
padding: var(--spacing-lg, 1.25rem);
|
||||
border: var(--border-thin, 1px) solid rgba(var(--color-primary, 255, 20, 147), var(--opacity-border, 0.15));
|
||||
box-shadow: var(--shadow-md, 0 4px 16px rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
|
||||
/* VNDB 卡片 - 暗色模式 */
|
||||
.dark .vndb-card {
|
||||
background: rgba(30, 41, 59, 0.8) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||
box-shadow:
|
||||
0 4px 24px -4px rgba(0, 0, 0, 0.4),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05) inset !important;
|
||||
background: rgba(var(--color-bg-dark, 30, 41, 59), var(--opacity-card-inner-dark, 0.75));
|
||||
border: var(--border-thin, 1px) solid rgba(var(--color-primary-light, 255, 105, 180), var(--opacity-border-dark, 0.2));
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
<template>
|
||||
<!-- 调整大小的手柄 - 仅桌面端且非全屏时显示 -->
|
||||
<template v-if="!isFullscreen">
|
||||
<!-- 边缘手柄 - 在内部但靠近边缘 -->
|
||||
<div
|
||||
class="hidden md:block absolute top-0 left-6 right-6 h-1.5 cursor-n-resize hover:bg-pink-500/50 transition-colors z-[60]"
|
||||
@mousedown.stop.prevent="(e) => $emit('resize', e, 'n')"
|
||||
@touchstart.stop.prevent="(e) => $emit('resize', e, 'n')"
|
||||
/>
|
||||
<div
|
||||
class="hidden md:block absolute bottom-0 left-6 right-6 h-1.5 cursor-s-resize hover:bg-pink-500/50 transition-colors z-[60]"
|
||||
@mousedown.stop.prevent="(e) => $emit('resize', e, 's')"
|
||||
@touchstart.stop.prevent="(e) => $emit('resize', e, 's')"
|
||||
/>
|
||||
<div
|
||||
class="hidden md:block absolute left-0 top-6 bottom-6 w-1.5 cursor-w-resize hover:bg-pink-500/50 transition-colors z-[60]"
|
||||
@mousedown.stop.prevent="(e) => $emit('resize', e, 'w')"
|
||||
@touchstart.stop.prevent="(e) => $emit('resize', e, 'w')"
|
||||
/>
|
||||
<div
|
||||
class="hidden md:block absolute right-0 top-6 bottom-6 w-1.5 cursor-e-resize hover:bg-pink-500/50 transition-colors z-[60]"
|
||||
@mousedown.stop.prevent="(e) => $emit('resize', e, 'e')"
|
||||
@touchstart.stop.prevent="(e) => $emit('resize', e, 'e')"
|
||||
/>
|
||||
|
||||
<!-- 角落手柄 - 更大更明显 -->
|
||||
<div
|
||||
class="hidden md:block absolute top-0 left-0 w-6 h-6 cursor-nw-resize hover:bg-pink-500/50 transition-colors z-[60] rounded-tl-3xl"
|
||||
@mousedown.stop.prevent="(e) => $emit('resize', e, 'nw')"
|
||||
@touchstart.stop.prevent="(e) => $emit('resize', e, 'nw')"
|
||||
/>
|
||||
<div
|
||||
class="hidden md:block absolute top-0 right-0 w-6 h-6 cursor-ne-resize hover:bg-pink-500/50 transition-colors z-[60] rounded-tr-3xl"
|
||||
@mousedown.stop.prevent="(e) => $emit('resize', e, 'ne')"
|
||||
@touchstart.stop.prevent="(e) => $emit('resize', e, 'ne')"
|
||||
/>
|
||||
<div
|
||||
class="hidden md:block absolute bottom-0 left-0 w-6 h-6 cursor-sw-resize hover:bg-pink-500/50 transition-colors z-[60] rounded-bl-3xl"
|
||||
@mousedown.stop.prevent="(e) => $emit('resize', e, 'sw')"
|
||||
@touchstart.stop.prevent="(e) => $emit('resize', e, 'sw')"
|
||||
/>
|
||||
<div
|
||||
class="hidden md:block absolute bottom-0 right-0 w-6 h-6 cursor-se-resize hover:bg-pink-500/50 transition-colors z-[60] rounded-br-3xl"
|
||||
@mousedown.stop.prevent="(e) => $emit('resize', e, 'se')"
|
||||
@touchstart.stop.prevent="(e) => $emit('resize', e, 'se')"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ResizeDirection } from '@/composables/useWindowManager'
|
||||
|
||||
defineProps<{
|
||||
isFullscreen: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
resize: [e: MouseEvent | TouchEvent, direction: ResizeDirection]
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,332 +0,0 @@
|
||||
/**
|
||||
* Anime.js v4 动画系统 - 替代 Framer Motion
|
||||
* 提供声明式动画 API,支持进入/离开动画
|
||||
*/
|
||||
|
||||
import { animate as animeAnimate, utils, createTimeline } from 'animejs'
|
||||
import { ref, onMounted, watch, nextTick, type Ref, type DirectiveBinding } from 'vue'
|
||||
|
||||
// 动画预设 - 使用 anime.js v4 语法
|
||||
export const presets = {
|
||||
// 淡入淡出
|
||||
fadeIn: { opacity: [0, 1], duration: 300, ease: 'outQuad' },
|
||||
fadeOut: { opacity: [1, 0], duration: 200, ease: 'inQuad' },
|
||||
|
||||
// 滑入滑出
|
||||
slideUp: { opacity: [0, 1], translateY: [40, 0], duration: 400, ease: 'outCubic' },
|
||||
slideDown: { opacity: [1, 0], translateY: [0, 40], duration: 300, ease: 'inCubic' },
|
||||
slideLeft: { opacity: [0, 1], translateX: [40, 0], duration: 400, ease: 'outCubic' },
|
||||
slideRight: { opacity: [0, 1], translateX: [-40, 0], duration: 400, ease: 'outCubic' },
|
||||
|
||||
// 缩放
|
||||
scaleIn: { opacity: [0, 1], scale: [0.95, 1], duration: 350, ease: 'outBack' },
|
||||
scaleOut: { opacity: [1, 0], scale: [1, 0.95], duration: 250, ease: 'inQuad' },
|
||||
|
||||
// 弹性
|
||||
springIn: { opacity: [0, 1], scale: [0.9, 1], translateY: [20, 0], duration: 500, ease: 'outElastic(1, 0.5)' },
|
||||
springOut: { opacity: [1, 0], scale: [1, 0.95], translateY: [0, 20], duration: 300, ease: 'inQuad' },
|
||||
|
||||
// 窗口动画
|
||||
windowIn: {
|
||||
opacity: [0, 1],
|
||||
scale: [0.98, 1],
|
||||
translateY: [40, 0],
|
||||
duration: 400,
|
||||
ease: 'outCubic',
|
||||
},
|
||||
windowOut: {
|
||||
opacity: [1, 0],
|
||||
scale: [1, 0.98],
|
||||
translateY: [0, 40],
|
||||
duration: 300,
|
||||
ease: 'inCubic',
|
||||
},
|
||||
|
||||
// 卡片动画
|
||||
cardIn: { opacity: [0, 1], translateY: [30, 0], duration: 400, ease: 'outQuart' },
|
||||
|
||||
// 按钮交互
|
||||
buttonPress: { scale: [1, 0.95], duration: 100, ease: 'outQuad' },
|
||||
buttonRelease: { scale: [0.95, 1], duration: 200, ease: 'outBack' },
|
||||
buttonHover: { scale: [1, 1.05], duration: 200, ease: 'outQuad' },
|
||||
|
||||
// 微交互
|
||||
pulse: { scale: [1, 1.05, 1], duration: 300, ease: 'inOutQuad' },
|
||||
shake: { translateX: [0, -10, 10, -10, 10, 0], duration: 400, ease: 'inOutQuad' },
|
||||
bounce: { translateY: [0, -10, 0], duration: 400, ease: 'outBounce' },
|
||||
wiggle: { rotate: [0, -3, 3, -3, 3, 0], duration: 400, ease: 'inOutQuad' },
|
||||
}
|
||||
|
||||
export type PresetName = keyof typeof presets
|
||||
|
||||
interface AnimationParams {
|
||||
opacity?: number[]
|
||||
scale?: number[] | number
|
||||
translateX?: number[] | number
|
||||
translateY?: number[] | number
|
||||
rotate?: number[]
|
||||
duration?: number
|
||||
ease?: string
|
||||
delay?: number
|
||||
complete?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行动画 - 使用 anime.js v4 API
|
||||
*/
|
||||
export function animate(
|
||||
target: HTMLElement | string | NodeList | HTMLElement[],
|
||||
animation: AnimationParams | PresetName,
|
||||
options?: Partial<AnimationParams>,
|
||||
) {
|
||||
const params = typeof animation === 'string'
|
||||
? { ...presets[animation], ...options }
|
||||
: { ...animation, ...options }
|
||||
|
||||
const { complete, ...animParams } = params
|
||||
|
||||
const anim = animeAnimate(target, animParams)
|
||||
|
||||
if (complete) {
|
||||
anim.then(complete)
|
||||
}
|
||||
|
||||
return anim
|
||||
}
|
||||
|
||||
/**
|
||||
* 交错动画 - 用于列表项
|
||||
*/
|
||||
export function stagger(
|
||||
targets: HTMLElement[] | NodeList | string,
|
||||
animation: AnimationParams | PresetName,
|
||||
staggerDelay = 50,
|
||||
options?: Partial<AnimationParams>,
|
||||
) {
|
||||
const params = typeof animation === 'string' ? { ...presets[animation] } : { ...animation }
|
||||
const { complete, ...animParams } = { ...params, ...options }
|
||||
|
||||
const anim = animeAnimate(targets, {
|
||||
...animParams,
|
||||
delay: utils.stagger(staggerDelay),
|
||||
})
|
||||
|
||||
if (complete) {
|
||||
anim.then(complete)
|
||||
}
|
||||
|
||||
return anim
|
||||
}
|
||||
|
||||
/**
|
||||
* 组合使用 - 进入/离开动画
|
||||
*/
|
||||
export function useAnimePresence(
|
||||
isVisible: Ref<boolean>,
|
||||
enterAnimation: PresetName | AnimationParams = 'fadeIn',
|
||||
exitAnimation: PresetName | AnimationParams = 'fadeOut',
|
||||
) {
|
||||
const elementRef = ref<HTMLElement | null>(null)
|
||||
const isAnimating = ref(false)
|
||||
const shouldRender = ref(isVisible.value)
|
||||
|
||||
watch(isVisible, async (visible) => {
|
||||
if (visible) {
|
||||
shouldRender.value = true
|
||||
await nextTick()
|
||||
if (elementRef.value) {
|
||||
isAnimating.value = true
|
||||
const params = typeof enterAnimation === 'string' ? presets[enterAnimation] : enterAnimation
|
||||
animate(elementRef.value, params, {
|
||||
complete: () => {
|
||||
isAnimating.value = false
|
||||
},
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if (elementRef.value) {
|
||||
isAnimating.value = true
|
||||
const params = typeof exitAnimation === 'string' ? presets[exitAnimation] : exitAnimation
|
||||
animate(elementRef.value, params, {
|
||||
complete: () => {
|
||||
isAnimating.value = false
|
||||
shouldRender.value = false
|
||||
},
|
||||
})
|
||||
} else {
|
||||
shouldRender.value = false
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
return {
|
||||
elementRef,
|
||||
isAnimating,
|
||||
shouldRender,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 元素挂载动画
|
||||
*/
|
||||
export function useAnimeOnMount(
|
||||
animation: PresetName | AnimationParams = 'fadeIn',
|
||||
delay = 0,
|
||||
) {
|
||||
const elementRef = ref<HTMLElement | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
if (elementRef.value) {
|
||||
const params = typeof animation === 'string' ? presets[animation] : animation
|
||||
animate(elementRef.value, params, { delay })
|
||||
}
|
||||
})
|
||||
|
||||
return elementRef
|
||||
}
|
||||
|
||||
/**
|
||||
* 交互动画 Hook
|
||||
*/
|
||||
export function useInteraction(elementRef: Ref<HTMLElement | null>) {
|
||||
const isHovered = ref(false)
|
||||
const isPressed = ref(false)
|
||||
|
||||
function onMouseEnter() {
|
||||
isHovered.value = true
|
||||
if (elementRef.value) {
|
||||
animate(elementRef.value, 'buttonHover')
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
isHovered.value = false
|
||||
if (elementRef.value && !isPressed.value) {
|
||||
animate(elementRef.value, { scale: 1, duration: 200 })
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseDown() {
|
||||
isPressed.value = true
|
||||
if (elementRef.value) {
|
||||
animate(elementRef.value, 'buttonPress')
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
isPressed.value = false
|
||||
if (elementRef.value) {
|
||||
animate(elementRef.value, 'buttonRelease')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isHovered,
|
||||
isPressed,
|
||||
handlers: {
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
onMouseDown,
|
||||
onMouseUp,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v-anime 指令 - 入场动画
|
||||
* 用法: v-anime="'slideUp'" 或 v-anime="{ opacity: [0, 1], translateY: [20, 0] }"
|
||||
*/
|
||||
export const vAnime = {
|
||||
mounted(el: HTMLElement, binding: DirectiveBinding<PresetName | AnimationParams>) {
|
||||
const animation = binding.value || 'fadeIn'
|
||||
const delay = binding.arg ? parseInt(binding.arg) : 0
|
||||
const params = typeof animation === 'string' ? presets[animation] : animation
|
||||
|
||||
// 设置初始状态
|
||||
el.style.opacity = '0'
|
||||
|
||||
animate(el, params, { delay })
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* v-anime-stagger 指令 - 子元素交错动画
|
||||
* 用法: v-anime-stagger="'slideUp'" 或 v-anime-stagger:100="'cardIn'"
|
||||
*/
|
||||
export const vAnimeStagger = {
|
||||
mounted(el: HTMLElement, binding: DirectiveBinding<PresetName | AnimationParams>) {
|
||||
const animation = binding.value || 'fadeIn'
|
||||
const staggerDelay = binding.arg ? parseInt(binding.arg) : 50
|
||||
const params = typeof animation === 'string' ? presets[animation] : animation
|
||||
|
||||
const children = el.children
|
||||
|
||||
// 设置初始状态
|
||||
Array.from(children).forEach((child) => {
|
||||
;(child as HTMLElement).style.opacity = '0'
|
||||
})
|
||||
|
||||
stagger(children as unknown as HTMLElement[], params, staggerDelay)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* v-hover 指令 - 悬停动画
|
||||
* 用法: v-hover 或 v-hover="{ scale: 1.05 }"
|
||||
*/
|
||||
export const vHover = {
|
||||
mounted(el: HTMLElement, binding: DirectiveBinding<AnimationParams>) {
|
||||
const hoverAnimation = binding.value || { scale: 1.05 }
|
||||
|
||||
el.addEventListener('mouseenter', () => {
|
||||
animate(el, {
|
||||
...hoverAnimation,
|
||||
duration: 200,
|
||||
ease: 'outQuad',
|
||||
})
|
||||
})
|
||||
|
||||
el.addEventListener('mouseleave', () => {
|
||||
animate(el, {
|
||||
scale: 1,
|
||||
duration: 200,
|
||||
ease: 'outQuad',
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* v-tap 指令 - 点击动画
|
||||
* 用法: v-tap
|
||||
*/
|
||||
export const vTap = {
|
||||
mounted(el: HTMLElement) {
|
||||
el.addEventListener('mousedown', () => {
|
||||
animate(el, {
|
||||
scale: 0.95,
|
||||
duration: 100,
|
||||
ease: 'outQuad',
|
||||
})
|
||||
})
|
||||
|
||||
el.addEventListener('mouseup', () => {
|
||||
animate(el, {
|
||||
scale: 1,
|
||||
duration: 200,
|
||||
ease: 'outBack',
|
||||
})
|
||||
})
|
||||
|
||||
el.addEventListener('mouseleave', () => {
|
||||
animate(el, {
|
||||
scale: 1,
|
||||
duration: 200,
|
||||
ease: 'outQuad',
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// 导出工具
|
||||
export { utils, createTimeline }
|
||||
@@ -1,10 +1,8 @@
|
||||
/**
|
||||
* 自定义进度条 - 使用 anime.js 替代 nprogress
|
||||
* 自定义进度条 - 使用原生 CSS 动画
|
||||
* 功能:页面顶部进度条 + 右上角 spinner
|
||||
*/
|
||||
|
||||
import { animate as animeAnimate } from 'animejs'
|
||||
|
||||
// 状态
|
||||
let progress = 0
|
||||
let activeRequests = 0
|
||||
@@ -18,7 +16,6 @@ const config = {
|
||||
minimum: 0.08,
|
||||
trickleSpeed: 200,
|
||||
speed: 300,
|
||||
easing: 'easeOutQuad',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,7 +65,7 @@ function createElements() {
|
||||
box-shadow:
|
||||
0 0 10px rgba(255, 20, 147, 0.7),
|
||||
0 0 20px rgba(217, 70, 239, 0.5);
|
||||
transition: opacity 0.2s ease;
|
||||
transition: width 0.3s ease-out, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes progress-gradient {
|
||||
@@ -146,11 +143,7 @@ function set(n: number) {
|
||||
progress = n
|
||||
|
||||
if (barElement) {
|
||||
animeAnimate(barElement, {
|
||||
width: `${n * 100}%`,
|
||||
duration: config.speed,
|
||||
ease: config.easing,
|
||||
})
|
||||
barElement.style.width = `${n * 100}%`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,18 +228,16 @@ export function doneProgress() {
|
||||
// 然后淡出
|
||||
setTimeout(() => {
|
||||
if (barElement) {
|
||||
animeAnimate(barElement, {
|
||||
opacity: 0,
|
||||
duration: 200,
|
||||
ease: 'easeOutQuad',
|
||||
complete: () => {
|
||||
if (barElement) {
|
||||
barElement.style.width = '0%'
|
||||
}
|
||||
isStarted = false
|
||||
progress = 0
|
||||
},
|
||||
})
|
||||
barElement.style.opacity = '0'
|
||||
|
||||
// 重置状态
|
||||
setTimeout(() => {
|
||||
if (barElement) {
|
||||
barElement.style.width = '0%'
|
||||
}
|
||||
isStarted = false
|
||||
progress = 0
|
||||
}, 200)
|
||||
}
|
||||
|
||||
if (spinnerElement) {
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
|
||||
export interface WindowManagerOptions {
|
||||
/** 最小宽度 */
|
||||
minWidth?: number
|
||||
/** 最小高度 */
|
||||
minHeight?: number
|
||||
/** 初始宽度 */
|
||||
initialWidth?: number
|
||||
/** 初始高度 */
|
||||
initialHeight?: number
|
||||
}
|
||||
|
||||
export type ResizeDirection = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw' | null
|
||||
|
||||
export function useWindowManager(options: WindowManagerOptions = {}) {
|
||||
const {
|
||||
minWidth = 400,
|
||||
minHeight = 300,
|
||||
initialWidth = 0,
|
||||
initialHeight = 0,
|
||||
} = options
|
||||
|
||||
// 状态
|
||||
const isFullscreen = ref(false)
|
||||
const isDragging = ref(false)
|
||||
const isResizing = ref(false)
|
||||
const resizeDirection = ref<ResizeDirection>(null)
|
||||
|
||||
// 位置和大小
|
||||
const position = ref({ x: 0, y: 0 })
|
||||
const size = ref({ width: initialWidth, height: initialHeight })
|
||||
|
||||
// 拖动相关变量
|
||||
let startX = 0
|
||||
let startY = 0
|
||||
let initialPosX = 0
|
||||
let initialPosY = 0
|
||||
let initialWidth_ = 0
|
||||
let initialHeight_ = 0
|
||||
let _elementRef: HTMLElement | null = null
|
||||
|
||||
// 计算样式 - 使用 transform 提升性能
|
||||
const windowStyle = computed(() => {
|
||||
if (isFullscreen.value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const style: Record<string, string> = {}
|
||||
|
||||
// 位置偏移 - 使用 transform 避免布局重排
|
||||
if (position.value.x !== 0 || position.value.y !== 0) {
|
||||
style.transform = `translate(${position.value.x}px, ${position.value.y}px)`
|
||||
}
|
||||
|
||||
// 自定义大小
|
||||
if (size.value.width > 0) {
|
||||
style.width = `${size.value.width}px`
|
||||
style.minWidth = `${minWidth}px`
|
||||
style.maxWidth = 'none'
|
||||
}
|
||||
if (size.value.height > 0) {
|
||||
style.height = `${size.value.height}px`
|
||||
style.minHeight = `${minHeight}px`
|
||||
style.maxHeight = 'none'
|
||||
}
|
||||
|
||||
return Object.keys(style).length > 0 ? style : undefined
|
||||
})
|
||||
|
||||
// 开始拖动
|
||||
function startDrag(e: MouseEvent | TouchEvent, element: HTMLElement) {
|
||||
if (isFullscreen.value || isResizing.value) {return}
|
||||
|
||||
isDragging.value = true
|
||||
_elementRef = element
|
||||
|
||||
const point = e instanceof MouseEvent ? e : e.touches[0]
|
||||
startX = point.clientX
|
||||
startY = point.clientY
|
||||
initialPosX = position.value.x
|
||||
initialPosY = position.value.y
|
||||
|
||||
document.addEventListener('mousemove', onDrag)
|
||||
document.addEventListener('mouseup', stopDrag)
|
||||
document.addEventListener('touchmove', onDrag, { passive: false })
|
||||
document.addEventListener('touchend', stopDrag)
|
||||
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
function onDrag(e: MouseEvent | TouchEvent) {
|
||||
if (!isDragging.value) {return}
|
||||
|
||||
const point = e instanceof MouseEvent ? e : e.touches[0]
|
||||
const deltaX = point.clientX - startX
|
||||
const deltaY = point.clientY - startY
|
||||
|
||||
position.value = {
|
||||
x: initialPosX + deltaX,
|
||||
y: initialPosY + deltaY,
|
||||
}
|
||||
|
||||
if (e instanceof TouchEvent) {e.preventDefault()}
|
||||
}
|
||||
|
||||
function stopDrag() {
|
||||
isDragging.value = false
|
||||
document.removeEventListener('mousemove', onDrag)
|
||||
document.removeEventListener('mouseup', stopDrag)
|
||||
document.removeEventListener('touchmove', onDrag)
|
||||
document.removeEventListener('touchend', stopDrag)
|
||||
}
|
||||
|
||||
// 开始调整大小
|
||||
function startResize(e: MouseEvent | TouchEvent, direction: ResizeDirection, element: HTMLElement) {
|
||||
if (isFullscreen.value || isDragging.value) {return}
|
||||
|
||||
isResizing.value = true
|
||||
resizeDirection.value = direction
|
||||
_elementRef = element
|
||||
|
||||
const rect = element.getBoundingClientRect()
|
||||
const point = e instanceof MouseEvent ? e : e.touches[0]
|
||||
|
||||
startX = point.clientX
|
||||
startY = point.clientY
|
||||
initialPosX = position.value.x
|
||||
initialPosY = position.value.y
|
||||
initialWidth_ = size.value.width || rect.width
|
||||
initialHeight_ = size.value.height || rect.height
|
||||
|
||||
document.addEventListener('mousemove', onResize)
|
||||
document.addEventListener('mouseup', stopResize)
|
||||
document.addEventListener('touchmove', onResize, { passive: false })
|
||||
document.addEventListener('touchend', stopResize)
|
||||
}
|
||||
|
||||
function onResize(e: MouseEvent | TouchEvent) {
|
||||
if (!isResizing.value || !resizeDirection.value) {return}
|
||||
|
||||
const point = e instanceof MouseEvent ? e : e.touches[0]
|
||||
const deltaX = point.clientX - startX
|
||||
const deltaY = point.clientY - startY
|
||||
const dir = resizeDirection.value
|
||||
|
||||
let newWidth = initialWidth_
|
||||
let newHeight = initialHeight_
|
||||
|
||||
// 东西方向调整宽度 - 直接使用 delta(不乘2,因为窗口居中)
|
||||
if (dir.includes('e')) {
|
||||
newWidth = Math.max(minWidth, initialWidth_ + deltaX * 2)
|
||||
}
|
||||
if (dir.includes('w')) {
|
||||
newWidth = Math.max(minWidth, initialWidth_ - deltaX * 2)
|
||||
}
|
||||
|
||||
// 南北方向调整高度
|
||||
if (dir.includes('s')) {
|
||||
newHeight = Math.max(minHeight, initialHeight_ + deltaY * 2)
|
||||
}
|
||||
if (dir.includes('n')) {
|
||||
newHeight = Math.max(minHeight, initialHeight_ - deltaY * 2)
|
||||
}
|
||||
|
||||
size.value = { width: newWidth, height: newHeight }
|
||||
|
||||
if (e instanceof TouchEvent) {e.preventDefault()}
|
||||
}
|
||||
|
||||
function stopResize() {
|
||||
isResizing.value = false
|
||||
resizeDirection.value = null
|
||||
document.removeEventListener('mousemove', onResize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
document.removeEventListener('touchmove', onResize)
|
||||
document.removeEventListener('touchend', stopResize)
|
||||
}
|
||||
|
||||
// 切换全屏
|
||||
function toggleFullscreen() {
|
||||
isFullscreen.value = !isFullscreen.value
|
||||
}
|
||||
|
||||
// 重置
|
||||
function reset() {
|
||||
position.value = { x: 0, y: 0 }
|
||||
size.value = { width: initialWidth, height: initialHeight }
|
||||
isFullscreen.value = false
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stopDrag()
|
||||
stopResize()
|
||||
})
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isFullscreen,
|
||||
isDragging,
|
||||
isResizing,
|
||||
position,
|
||||
size,
|
||||
// 计算属性
|
||||
windowStyle,
|
||||
// 方法
|
||||
startDrag,
|
||||
startResize,
|
||||
toggleFullscreen,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,42 @@
|
||||
"desc": "一个免费分享Gal,Cos,Asmr的网站(∠・ω< )⌒★",
|
||||
"url": "https://jiuliacg.com",
|
||||
"logo": "https://img.alicdn.com/imgextra/i4/2201716776317/O1CN01kwy3QY1wXF2tUVkUx_!!2201716776317.webp"
|
||||
},
|
||||
{
|
||||
"name": "御爱同萌",
|
||||
"desc": "禦愛同萌!一個以非營利為目的的交流社區",
|
||||
"url": "https://www.ai2.moe/",
|
||||
"logo": "https://www.ai2.moe/uploads/monthly_2023_06/logo.png"
|
||||
},
|
||||
{
|
||||
"name": "TouchGal",
|
||||
"desc": "TouchGal 是一个一站式 Galgame 文化社区。提供Galgame 论坛、Galgame 下载等服务。承诺永久免费, 高质量。为Galgame 爱好者提供一片净土!",
|
||||
"url": "https://www.touchgal.us/",
|
||||
"logo": "https://www.touchgal.us/favicon.webp"
|
||||
},
|
||||
{
|
||||
"name": "鲲 Galgame 论坛",
|
||||
"desc": "世界上最萌的 Galgame 论坛! 现阶段世界上最先进的 Galgame 资源发布网站! 永远不会有广告! 永远免费! Galgame 下载, Galgame 资源网站",
|
||||
"url": "https://www.kungal.com/",
|
||||
"logo": "https://www.kungal.com/favicon.webp"
|
||||
},
|
||||
{
|
||||
"name": "鲲 Galgame 补丁",
|
||||
"desc": "开源, 免费, 零门槛, 纯手写, 最先进的 Galgame 补丁资源下载站, 提供 Windows, 安卓, KRKR, Tyranor 等各类平台的 Galgame 补丁资源下载。永远免费!",
|
||||
"url": "https://www.moyu.moe/",
|
||||
"logo": "https://www.moyu.moe/favicon.webp"
|
||||
},
|
||||
{
|
||||
"name": "书音的图书馆",
|
||||
"desc": "书音的图书馆 - 免费、开源、零门槛的 视觉小说 / Galgame 档案库",
|
||||
"url": "https://shionlib.com/zh",
|
||||
"logo": "https://shionlib.com/favicon.ico"
|
||||
},
|
||||
{
|
||||
"name": "鸟白岛演绎厅",
|
||||
"desc": "鸟白岛演绎厅提供最全的专辑音乐收录,在这里你可以看到ACGN相关的专辑信息及其文件下载,另有交流社区可以沟通和申请收录等,快来看看吧。",
|
||||
"url": "https://www.summerpockets.com/",
|
||||
"logo": "https://res.summerpockets.com/favicon.ico"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -34,18 +34,11 @@ import { vRipple } from './directives/vRipple'
|
||||
// 文本滚动指令
|
||||
import { vTextScroll } from './composables/useTextScroll'
|
||||
|
||||
// Anime.js 动画指令
|
||||
import { vAnime, vAnimeStagger, vHover, vTap } from './composables/useAnime'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 注册全局指令
|
||||
app.directive('ripple', vRipple)
|
||||
app.directive('text-scroll', vTextScroll)
|
||||
app.directive('anime', vAnime)
|
||||
app.directive('anime-stagger', vAnimeStagger)
|
||||
app.directive('hover', vHover)
|
||||
app.directive('tap', vTap)
|
||||
const pinia = createPinia()
|
||||
|
||||
// 配置 Pinia 插件
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
// 主题模式类型
|
||||
export type ThemeMode = 'system' | 'light' | 'dark'
|
||||
|
||||
// 持久化的 UI 状态类型
|
||||
export interface PersistedUIState {
|
||||
// 主题
|
||||
themeMode: ThemeMode
|
||||
isDarkMode: boolean
|
||||
customCSS: string
|
||||
|
||||
@@ -30,6 +34,7 @@ const SESSION_KEY = 'ui-session-state'
|
||||
|
||||
// 默认持久化状态
|
||||
const DEFAULT_PERSISTED_STATE: PersistedUIState = {
|
||||
themeMode: 'system',
|
||||
isDarkMode: false,
|
||||
customCSS: '',
|
||||
isCommentsModalOpen: false,
|
||||
@@ -48,8 +53,10 @@ export const useUIStore = defineStore('ui', () => {
|
||||
const isInitialized = ref(false)
|
||||
|
||||
// 主题相关
|
||||
const themeMode = ref<ThemeMode>('system')
|
||||
const isDarkMode = ref(false)
|
||||
const customCSS = ref('')
|
||||
let systemThemeCleanup: (() => void) | null = null
|
||||
|
||||
// 模态框状态
|
||||
const isCommentsModalOpen = ref(false)
|
||||
@@ -105,16 +112,63 @@ export const useUIStore = defineStore('ui', () => {
|
||||
})
|
||||
|
||||
// 方法 - 主题
|
||||
|
||||
/**
|
||||
* 获取系统主题偏好
|
||||
*/
|
||||
function getSystemTheme(): boolean {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用主题到 DOM
|
||||
*/
|
||||
function applyTheme(dark: boolean) {
|
||||
isDarkMode.value = dark
|
||||
document.documentElement.classList.toggle('dark', dark)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置主题模式
|
||||
*/
|
||||
function setThemeMode(mode: ThemeMode) {
|
||||
themeMode.value = mode
|
||||
|
||||
// 清理之前的系统主题监听
|
||||
if (systemThemeCleanup) {
|
||||
systemThemeCleanup()
|
||||
systemThemeCleanup = null
|
||||
}
|
||||
|
||||
if (mode === 'system') {
|
||||
// 应用系统主题
|
||||
applyTheme(getSystemTheme())
|
||||
|
||||
// 监听系统主题变化
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
if (themeMode.value === 'system') {
|
||||
applyTheme(e.matches)
|
||||
}
|
||||
}
|
||||
mediaQuery.addEventListener('change', handler)
|
||||
systemThemeCleanup = () => mediaQuery.removeEventListener('change', handler)
|
||||
} else {
|
||||
// 应用固定主题
|
||||
applyTheme(mode === 'dark')
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDarkMode() {
|
||||
isDarkMode.value = !isDarkMode.value
|
||||
document.documentElement.classList.toggle('dark', isDarkMode.value)
|
||||
localStorage.setItem('darkMode', isDarkMode.value ? 'true' : 'false')
|
||||
// 切换主题模式:system -> light -> dark -> system
|
||||
const modes: ThemeMode[] = ['system', 'light', 'dark']
|
||||
const currentIndex = modes.indexOf(themeMode.value)
|
||||
const nextIndex = (currentIndex + 1) % modes.length
|
||||
setThemeMode(modes[nextIndex])
|
||||
}
|
||||
|
||||
function setDarkMode(value: boolean) {
|
||||
isDarkMode.value = value
|
||||
document.documentElement.classList.toggle('dark', value)
|
||||
localStorage.setItem('darkMode', value ? 'true' : 'false')
|
||||
setThemeMode(value ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
function setCustomCSS(css: string) {
|
||||
@@ -213,7 +267,7 @@ export const useUIStore = defineStore('ui', () => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved) {
|
||||
const parsed: Partial<PersistedUIState> = JSON.parse(saved)
|
||||
isDarkMode.value = parsed.isDarkMode ?? DEFAULT_PERSISTED_STATE.isDarkMode
|
||||
themeMode.value = parsed.themeMode ?? DEFAULT_PERSISTED_STATE.themeMode
|
||||
customCSS.value = parsed.customCSS ?? DEFAULT_PERSISTED_STATE.customCSS
|
||||
showSearchHistory.value = parsed.showSearchHistory ?? DEFAULT_PERSISTED_STATE.showSearchHistory
|
||||
}
|
||||
@@ -249,7 +303,7 @@ export const useUIStore = defineStore('ui', () => {
|
||||
function savePersistedState() {
|
||||
try {
|
||||
const state: Partial<PersistedUIState> = {
|
||||
isDarkMode: isDarkMode.value,
|
||||
themeMode: themeMode.value,
|
||||
customCSS: customCSS.value,
|
||||
showSearchHistory: showSearchHistory.value,
|
||||
lastVisitTime: Date.now(),
|
||||
@@ -280,7 +334,7 @@ export const useUIStore = defineStore('ui', () => {
|
||||
|
||||
// 监听需要持久化的状态变化(localStorage - 长期偏好)
|
||||
watch(
|
||||
[isDarkMode, customCSS, showSearchHistory],
|
||||
[themeMode, customCSS, showSearchHistory],
|
||||
() => {
|
||||
if (isInitialized.value) {
|
||||
savePersistedState()
|
||||
@@ -313,15 +367,8 @@ export const useUIStore = defineStore('ui', () => {
|
||||
// 加载会话状态(刷新恢复)
|
||||
loadSessionState()
|
||||
|
||||
// 如果没有保存的主题偏好,跟随系统
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (!saved) {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
isDarkMode.value = prefersDark
|
||||
}
|
||||
|
||||
// 应用主题
|
||||
document.documentElement.classList.toggle('dark', isDarkMode.value)
|
||||
// 应用主题模式
|
||||
setThemeMode(themeMode.value)
|
||||
|
||||
isInitialized.value = true
|
||||
|
||||
@@ -362,6 +409,7 @@ export const useUIStore = defineStore('ui', () => {
|
||||
return {
|
||||
// 状态
|
||||
isInitialized,
|
||||
themeMode,
|
||||
isDarkMode,
|
||||
customCSS,
|
||||
isCommentsModalOpen,
|
||||
@@ -385,6 +433,7 @@ export const useUIStore = defineStore('ui', () => {
|
||||
activeModalsCount,
|
||||
|
||||
// 方法
|
||||
setThemeMode,
|
||||
toggleDarkMode,
|
||||
setDarkMode,
|
||||
setCustomCSS,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -90,10 +90,6 @@ export default defineConfig({
|
||||
if (id.includes('/lucide-vue-next/')) {
|
||||
return 'ui-libs';
|
||||
}
|
||||
// 动画库
|
||||
if (id.includes('/animejs/')) {
|
||||
return 'anime';
|
||||
}
|
||||
// Artalk 评论
|
||||
if (id.includes('/artalk/')) {
|
||||
return 'artalk';
|
||||
@@ -125,7 +121,6 @@ export default defineConfig({
|
||||
'vue',
|
||||
'pinia',
|
||||
'lucide-vue-next',
|
||||
'animejs',
|
||||
],
|
||||
// 排除不需要预构建的
|
||||
exclude: ['artalk'],
|
||||
|
||||
Reference in New Issue
Block a user