Merge pull request #48 from Moe-Sakura/dev

Dev
This commit is contained in:
Asuna
2025-12-26 23:56:50 +08:00
committed by GitHub
28 changed files with 911 additions and 2367 deletions

View File

@@ -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>

View File

@@ -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
View File

@@ -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

View File

@@ -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 });
}
}
/**
* 离线提示页面
*/

View File

@@ -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)

View File

@@ -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>

View File

@@ -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);
}
/* 自定义滚动条 */

View File

@@ -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);
}
/* 标题栏 */

View File

@@ -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 {

View File

@@ -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 {

View 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>

View File

@@ -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>

View 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>

View File

@@ -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 效果 */

View File

@@ -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;
}
/* 历史记录项 */

View File

@@ -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);
}
/* 结果列表布局隔离 */

View File

@@ -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);
}
/* 自定义滚动条 */

View File

@@ -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 {

View File

@@ -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);
}
/* 自定义滚动条 */

View File

@@ -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>

View File

@@ -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 }

View File

@@ -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) {

View File

@@ -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,
}
}

View File

@@ -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"
}
]
}

View File

@@ -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 插件

View File

@@ -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

View File

@@ -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'],