This commit is contained in:
hantao 2026-01-29 18:46:33 +08:00
commit eb122de994
24 changed files with 4218 additions and 0 deletions

36
.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/

52
README.md Normal file
View File

@ -0,0 +1,52 @@
# Hover Tilt Card Design Website
这是一个基于 Vue.js 和 vanilla-tilt.js 构建的卡片设计网站,展示了多种具有 3D 悬停倾斜效果的卡片组件。
## 功能特点
- **3D 倾斜效果**:使用 vanilla-tilt.js 实现流畅的 3D 倾斜效果
- **多种卡片类型**
- 基础倾斜卡片 (TiltCard)
- 产品卡片 (ProductCard)
- 个人资料卡片 (ProfileCard)
- **响应式设计**:适配各种屏幕尺寸
- **动态光影效果**:根据鼠标位置产生逼真的光影变化
## 技术栈
- Vue 3
- vanilla-tilt.js
- Vite
- CSS Grid & Flexbox
## 组件说明
### TiltCard
基础倾斜卡片组件,适用于展示一般信息内容,包含标题、描述和图标插槽。
### ProductCard
产品展示卡片,专为电商产品设计,包含产品图片、价格和操作按钮。
### ProfileCard
个人资料卡片,适合展示用户信息,包含头像、姓名、职位和个人简介。
## 使用方法
1. 克隆项目
2. 安装依赖:`npm install`
3. 启动开发服务器:`npm run dev`
4. 访问 `http://localhost:5174`
## 自定义选项
所有卡片组件都支持自定义 vanilla-tilt 选项,包括:
- `max`: 最大倾斜角度
- `speed`: 动画速度
- `glare`: 是否启用眩光效果
- `scale`: 倾斜时的缩放比例
- `perspective`: 透视距离
## 设计理念
该项目旨在展示现代网页设计中的交互体验,通过微妙的 3D 倾斜效果增强用户参与感,同时保持界面简洁美观。

15
index.html Normal file
View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
<script type="module" src="node_modules/hover-tilt/dist/hover-tilt.js"></script>
</body>
</html>

8
jsconfig.json Normal file
View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

2854
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "card",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@tweakpane/plugin-essentials": "^0.2.1",
"hover-tilt": "^1.0.0",
"lodash-es": "^4.17.23",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"tweakpane": "^4.0.5",
"vanilla-tilt": "^1.8.1",
"vue": "^3.5.26",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.3",
"vite": "^7.3.0",
"vite-plugin-vue-devtools": "^8.0.5"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

96
src/App.vue Normal file
View File

@ -0,0 +1,96 @@
<template>
<main class="app-container">
<header class="toolbar">
<h1>Card Holographic Editor</h1>
<div class="actions">
<button @click="store.resetConfig">重置设计</button>
</div>
</header>
<section class="preview-zone">
<CardRoot />
</section>
<div id="tweakpane-container"></div>
</main>
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
import { useCardStore } from '@/store/cardStore';
import { useTweakpane } from '@/composables/useTweakpane';
import CardRoot from '@/components/Card/CardRoot.vue';
const store = useCardStore();
// App
useTweakpane('tweakpane-container');
onMounted(() => {
console.log('Editor Initialized with Config:', store.config.id);
console.table(store.cssVariables)
});
</script>
<style>
/* 全局基础样式清空 */
body, html {
margin: 0;
padding: 0;
background-color: #0f0f0f;
color: white;
font-family: 'Inter', system-ui, sans-serif;
overflow: hidden;
}
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
}
.toolbar {
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2rem;
background: rgba(255, 255, 255, 0.05);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.preview-zone {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
perspective: 2000px; /* 为整体场景增加透视感 */
}
.card-stage {
/* 这个容器持有所有的 CSS 变量映射 */
display: flex;
align-items: center;
justify-content: center;
}
/* Tweakpane 容器样式 */
#tweakpane-container {
position: fixed;
top: 70px; /* 避开 toolbar */
right: 20px;
z-index: 9999;
}
/* 针对移动端的微调 */
@media (max-width: 768px) {
#tweakpane-container {
right: 0;
left: 0;
top: auto;
bottom: 0;
width: 100%;
}
}
</style>

90
src/assets/base.css Normal file
View File

@ -0,0 +1,90 @@
/* color palette from <https://github.com/vuejs/theme> */
#app {
min-height: 100vh;
background: var(--color-background);
}
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

1
src/assets/logo.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

52
src/assets/main.css Normal file
View File

@ -0,0 +1,52 @@
@import './base.css';
#app {
width: 100vw;
margin: 0 auto;
padding: 0;
font-weight: normal;
}
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: block;
grid-template-columns: 1fr 1fr;
}
}
:root {
/* 初始化 hover-tilt 变量,防止计算未开始时出现 NaN */
--tilt-x: 50%;
--tilt-y: 50%;
--tilt-active: 0;
--tilt-r-x: 0deg;
--tilt-r-y: 0deg;
}
/* 所有的卡片容器都应该开启 3D 渲染上下文 */
[v-tilt] {
transform-style: preserve-3d;
will-change: transform;
}

View File

@ -0,0 +1,57 @@
<template>
<div class="holo-layer" :class="[config.type, { 'shift': config.colorShift }]" :style="holoStyle"></div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{ config: any }>();
const holoStyle = computed(() => ({
'--o': props.config.opacity,
'--bm': props.config.blendMode,
// 使 hover-tilt
'--p-x': 'var(--tilt-x)',
'--p-y': 'var(--tilt-y)',
}));
</script>
<style scoped>
.holo-layer {
position: absolute;
inset: 0;
opacity: var(--o);
mix-blend-mode: var(--bm);
pointer-events: none;
transition: opacity 0.3s ease;
background-size: 200% 200%;
background-position: var(--p-x) var(--p-y);
}
/* 1. 银河碎钻:使用噪点和点状渐变 */
.galaxy {
background-image:
radial-gradient(circle at var(--p-x) var(--p-y), white 1%, transparent 2%),
url('https://res.cloudinary.com/simey/image/upload/v1631535194/pokemon-cards/glitter.png');
filter: brightness(1.2) contrast(1.5);
}
/* 2. 黄金限定:使用深黄色到亮金色的线性渐变 */
.GOLD .holo-layer {
background-image: linear-gradient(110deg,
transparent 40%, rgba(255,215,0,0.8) 45%, #fff 50%, rgba(255,215,0,0.8) 55%, transparent 60%
);
}
/* 3. 彩虹稀有:经典的光谱渐变 */
.rainbow {
background-image: linear-gradient(115deg,
#ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #4b0082, #8b00ff
);
}
/* 色相旋转动画:让卡片动起来 */
.shift {
filter: hue-rotate(calc(var(--tilt-x) * 3.6deg));
}
</style>

View File

@ -0,0 +1,109 @@
<template>
<div class="scene">
<div class="style-provider">
<hover-tilt
class="card-container"
:max="store.config.interaction.tiltMax"
:speed="store.config.interaction.speed"
:perspective="store.config.interaction.perspective"
:tilt-factor="store.config.interaction.tiltFactor"
:tilt-factor-y="store.config.interaction.tiltFactorY"
:scale-factor="store.config.interaction.scaleFactor"
:enter-delay="store.config.interaction.enterDelay"
:exit-delay="store.config.interaction.exitDelay"
:spring-options="JSON.stringify(store.config.interaction.springOptions)"
:tilt-spring-options="JSON.stringify(store.config.interaction.tiltSpringOptions)"
:shadow="store.config.visualEffects.hoverTiltVisual.shadow"
:shadow-blur="store.config.visualEffects.hoverTiltVisual.shadowBlur"
:blend-mode="store.config.visualEffects.hoverTiltVisual.blendMode"
:glare-intensity="store.config.visualEffects.hoverTiltVisual.glareIntensity"
:glare-hue="store.config.visualEffects.hoverTiltVisual.glareHue"
:glare-mask="store.config.visualEffects.hoverTiltVisual.glareMask"
:glare-mask-mode="store.config.visualEffects.hoverTiltVisual.glareMaskMode"
:glare-mask-composite="store.config.visualEffects.hoverTiltVisual.glareMaskComposite"
:reverse="store.config.interaction.reverse"
:start-x="store.config.interaction.startX"
:start-y="store.config.interaction.startY"
:scale="store.config.interaction.scale"
:transition="store.config.interaction.transition"
:axis="store.config.interaction.axis"
:reset="store.config.interaction.reset"
:reset-to-start="store.config.interaction.resetToStart"
:easing="store.config.interaction.easing"
:glare="store.config.interaction.glare"
:max-glare="store.config.interaction.maxGlare"
:glare-prerender="store.config.interaction.glarePrerender"
:mouse-event-element="store.config.interaction.mouseEventElement"
:full-page-listening="store.config.interaction.fullPageListening"
:gyroscope-min-angle-x="store.config.interaction.gyroscopeMinAngleX"
:gyroscope-max-angle-x="store.config.interaction.gyroscopeMaxAngleX"
:gyroscope-min-angle-y="store.config.interaction.gyroscopeMinAngleY"
:gyroscope-max-angle-y="store.config.interaction.gyroscopeMaxAngleY"
:gyroscope-samples="store.config.interaction.gyroscopeSamples"
:border-radius="store.config.interaction.borderRadius"
>
<div class="card-inner">
<div class="card-content" :style="safeCssVariables">
<img :src="store.config.content.imageUrl"/>
</div>
<CardHolo :config="store.config.visualEffects.holo"/>
</div>
</hover-tilt>
</div>
</div>
</template>
<script setup lang="ts">
import {useCardStore} from '@/store/cardStore';
import CardHolo from "@/components/Card/CardHolo.vue";
import {computed} from 'vue';
const store = useCardStore();
// CSS
const safeCssVariables = computed(() => {
const vars = {...store.cssVariables};
// return ;
// CSS
for (const [key, value] of Object.entries(vars)) {
if (value === undefined || value === null || Number.isNaN(value)) {
vars[key] = '0';
}
}
return vars;
});
</script>
<style scoped>
/* 确保提供变量的容器不会破坏布局 */
.style-provider {
display: contents;
}
.card-inner {
/* 这里的变量会从 .style-provider 继承过来 */
width: var(--card-width);
border-radius: var(--card-radius);
background: var(--card-bg);
box-shadow: var(--card-shadow);
transition: all 0.3s ease;
}
::part(tilt){
border-radius: var(--border-radius,50px);
overflow: hidden;
}
.card-content img {
width: 100%;
}
.card-content {
overflow: hidden;
border-radius: var(--border-radius);
}
</style>

View File

@ -0,0 +1,29 @@
<template>
<div id="tweakpane-container" class="pane-wrapper"></div>
</template>
<script setup lang="ts">
import { useTweakpane } from '@/composables/useTweakpane';
// Tweakpane
useTweakpane('tweakpane-container');
</script>
<style scoped>
.pane-wrapper {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
width: 280px;
max-height: calc(100vh - 40px);
overflow-y: auto;
}
/* 深度定制 Tweakpane 的样式,使其更符合你的 UI 风格 */
:deep(.tp-dfv) {
border-radius: 8px;
backdrop-filter: blur(10px);
background-color: rgba(0, 0, 0, 0.7);
}
</style>

View File

@ -0,0 +1,44 @@
import { ref, onMounted, onUnmounted, reactive } from 'vue';
// @ts-ignore
export function useTilt(targetRef: ref<HTMLElement | null>) {
const state = reactive({
x: 0.5, y: 0.5,
rx: 0, ry: 0,
active: false
});
let rafId: number;
const target = { x: 0.5, y: 0.5 };
const update = () => {
// 线性插值:当前值 + (目标值 - 当前值) * 系数
state.x += (target.x - state.x) * 0.1;
state.y += (target.y - state.y) * 0.1;
// 计算旋转角度
state.rx = (state.y - 0.5) * -30; // 绕X轴转上下倾斜
state.ry = (state.x - 0.5) * 30; // 绕Y轴转左右倾斜
rafId = requestAnimationFrame(update);
};
const onMouseMove = (e: MouseEvent) => {
if (!targetRef.value) return;
const { left, top, width, height } = targetRef.value.getBoundingClientRect();
target.x = (e.clientX - left) / width;
target.y = (e.clientY - top) / height;
};
onMounted(() => {
rafId = requestAnimationFrame(update);
window.addEventListener('mousemove', onMouseMove);
});
onUnmounted(() => {
cancelAnimationFrame(rafId);
window.removeEventListener('mousemove', onMouseMove);
});
return { state };
}

View File

@ -0,0 +1,258 @@
import { Pane } from 'tweakpane';
import * as EssentialPlugins from '@tweakpane/plugin-essentials';
import { onMounted, onUnmounted } from 'vue';
import { useCardStore } from '../store/cardStore';
import { watch } from 'vue';
import { CARD_PRESETS } from '@/constants/presets';
export function useTweakpane(containerId: string) {
const store = useCardStore();
let pane: Pane;
onMounted(() => {
pane = new Pane({
container: document.getElementById(containerId) as HTMLElement,
title: '卡片配置编辑器',
});
pane.registerPlugin(EssentialPlugins);
// 1. 基础外观文件夹
const appearance = pane.addFolder({ title: '外观样式' });
appearance.addBinding(store.config.appearance, 'width', {
min: 200, max: 1000, step: 10, label: '宽度'
});
appearance.addBinding(store.config.appearance, 'borderRadius', {
min: 0, max: 50, step: 1, label: '圆角'
});
appearance.addBinding(store.config.appearance, 'backgroundColor', {
label: '背景色'
});
// 2. 交互配置 (Hover-tilt)
const interaction = pane.addFolder({ title: '交互参数' });
interaction.addBinding(store.config.interaction, 'tiltMax', {
min: 0, max: 45, label: '最大倾斜'
});
interaction.addBinding(store.config.interaction, 'perspective', {
min: 500, max: 2000, label: '透视距离'
});
// 基础交互参数
interaction.addBinding(store.config.interaction, 'scaleOnHover', {
min: 0.5, max: 2, step: 0.05, label: '悬停缩放'
});
interaction.addBinding(store.config.interaction, 'speed', {
min: 100, max: 2000, step: 100, label: '动画速度'
});
interaction.addBinding(store.config.interaction, 'gyroscope', {
label: '陀螺仪效果'
});
// Hover-tilt 特定选项
interaction.addBinding(store.config.interaction, 'tiltFactor', {
min: 0, max: 3, step: 0.1, label: '倾斜强度(X)'
});
interaction.addBinding(store.config.interaction, 'tiltFactorY', {
min: 0, max: 3, step: 0.1, label: '倾斜强度(Y)'
});
interaction.addBinding(store.config.interaction, 'scaleFactor', {
min: 0.5, max: 2, step: 0.05, label: '缩放系数'
});
interaction.addBinding(store.config.interaction, 'enterDelay', {
min: 0, max: 1000, step: 50, label: '进入延迟(ms)'
});
interaction.addBinding(store.config.interaction, 'exitDelay', {
min: 0, max: 1000, step: 50, label: '退出延迟(ms)'
});
// 弹簧物理选项
const springFolder = interaction.addFolder({ title: '弹簧物理' });
springFolder.addBinding(store.config.interaction.springOptions, 'stiffness', {
min: 0.01, max: 1, step: 0.01, label: '刚度'
});
springFolder.addBinding(store.config.interaction.springOptions, 'damping', {
min: 0.01, max: 1, step: 0.01, label: '阻尼'
});
springFolder.addBinding(store.config.interaction.tiltSpringOptions, 'stiffness', {
min: 0.01, max: 1, step: 0.01, label: '倾斜刚度'
});
springFolder.addBinding(store.config.interaction.tiltSpringOptions, 'damping', {
min: 0.01, max: 1, step: 0.01, label: '倾斜阻尼'
});
// vanilla-tilt.js 属性
const vanillaTiltFolder = interaction.addFolder({ title: 'Vanilla-Tilt 属性' });
vanillaTiltFolder.addBinding(store.config.interaction, 'reverse', {
label: '反转倾斜方向'
});
vanillaTiltFolder.addBinding(store.config.interaction, 'startX', {
min: -90, max: 90, step: 1, label: '起始X倾斜'
});
vanillaTiltFolder.addBinding(store.config.interaction, 'startY', {
min: -90, max: 90, step: 1, label: '起始Y倾斜'
});
vanillaTiltFolder.addBinding(store.config.interaction, 'scale', {
min: 0.5, max: 3, step: 0.1, label: '缩放比例'
});
vanillaTiltFolder.addBinding(store.config.interaction, 'transition', {
label: '启用过渡动画'
});
vanillaTiltFolder.addBinding(store.config.interaction, 'axis', {
options: { '无限制': 'xy', '仅X轴': 'x', '仅Y轴': 'y' },
label: '限制轴向'
});
vanillaTiltFolder.addBinding(store.config.interaction, 'reset', {
label: '退出时重置'
});
vanillaTiltFolder.addBinding(store.config.interaction, 'resetToStart', {
label: '重置到起始位置'
});
vanillaTiltFolder.addBinding(store.config.interaction, 'easing', {
label: '缓动函数'
});
// 眩光效果
const glareFolder = interaction.addFolder({ title: '眩光效果' });
glareFolder.addBinding(store.config.interaction, 'glare', {
label: '启用眩光'
});
glareFolder.addBinding(store.config.interaction, 'maxGlare', {
min: 0, max: 1, step: 0.1, label: '最大眩光透明度'
});
glareFolder.addBinding(store.config.interaction, 'glarePrerender', {
label: '预渲染眩光'
});
// 陀螺仪设置
const gyroFolder = interaction.addFolder({ title: '陀螺仪设置' });
gyroFolder.addBinding(store.config.interaction, 'gyroscopeMinAngleX', {
min: -90, max: 90, step: 1, label: '陀螺仪X最小角度'
});
gyroFolder.addBinding(store.config.interaction, 'gyroscopeMaxAngleX', {
min: -90, max: 90, step: 1, label: '陀螺仪X最大角度'
});
gyroFolder.addBinding(store.config.interaction, 'gyroscopeMinAngleY', {
min: -90, max: 90, step: 1, label: '陀螺仪Y最小角度'
});
gyroFolder.addBinding(store.config.interaction, 'gyroscopeMaxAngleY', {
min: -90, max: 90, step: 1, label: '陀螺仪Y最大角度'
});
gyroFolder.addBinding(store.config.interaction, 'gyroscopeSamples', {
min: 1, max: 50, step: 1, label: '陀螺仪采样数'
});
// 3. 全息材质
const visual = pane.addFolder({ title: '特效材质' });
visual.addBinding(store.config.visualEffects.holo, 'type', {
options: {
None: 'none',
Rainbow: 'rainbow',
Sparkle: 'sparkle',
Galaxy: 'galaxy'
},
label: '材质类型'
});
visual.addBinding(store.config.visualEffects.holo, 'opacity', {
min: 0, max: 1, label: '透明度'
});
// 4. Hover-tilt 视觉效果
const hoverTiltVisual = pane.addFolder({ title: 'Hover-Tilt 视觉' });
hoverTiltVisual.addBinding(store.config.visualEffects.hoverTiltVisual, 'shadow', {
label: '动态阴影'
});
hoverTiltVisual.addBinding(store.config.visualEffects.hoverTiltVisual, 'shadowBlur', {
min: 0, max: 100, step: 1, label: '阴影模糊'
});
hoverTiltVisual.addBinding(store.config.visualEffects.hoverTiltVisual, 'blendMode', {
options: {
'覆盖': 'overlay',
'屏幕': 'screen',
'正片叠底': 'multiply',
'加亮': 'plus-lighter',
'色彩减淡': 'color-dodge'
},
label: '混合模式'
});
hoverTiltVisual.addBinding(store.config.visualEffects.hoverTiltVisual, 'glareIntensity', {
min: 0, max: 4, step: 0.1, label: '眩光强度'
});
hoverTiltVisual.addBinding(store.config.visualEffects.hoverTiltVisual, 'glareHue', {
min: 0, max: 360, step: 1, label: '眩光色调'
});
hoverTiltVisual.addBinding(store.config.visualEffects.hoverTiltVisual, 'glareMask', {
label: '眩光遮罩'
});
hoverTiltVisual.addBinding(store.config.visualEffects.hoverTiltVisual, 'glareMaskMode', {
options: {
'匹配源': 'match-source',
'亮度': 'luminance',
'Alpha': 'alpha',
'无': 'none'
},
label: '遮罩模式'
});
hoverTiltVisual.addBinding(store.config.visualEffects.hoverTiltVisual, 'glareMaskComposite', {
options: {
'相加': 'add',
'相减': 'subtract',
'排除': 'exclude',
'交集': 'intersect'
},
label: '遮罩合成'
});
// 按钮操作
const btn = pane.addButton({ title: '导出 JSON' });
btn.on('click', () => {
console.log(JSON.stringify(store.config, null, 2));
alert('配置已输出到控制台');
});
watch(() => store.config, () => {
pane.refresh(); // Tweakpane 官方提供的 API强制同步 UI 状态
}, { deep: true });
const presetFolder = pane.addFolder({ title: '样式预设' });
const presetOptions = {
options: {
'默认配置': 'DEFAULT',
'银河碎钻': 'GALAXY',
'黄金限定': 'GOLD',
'彩虹稀有': 'RAINBOW'
},
label: '选择预设'
};
const presetInput = presetFolder.addBinding({ preset: 'DEFAULT' }, 'preset', presetOptions);
presetInput.on('change', (ev) => {
const selectedKey = ev.value as keyof typeof CARD_PRESETS;
if (CARD_PRESETS[selectedKey]) {
store.applyPreset(CARD_PRESETS[selectedKey]);
// 关键:预设改变后,通知 Tweakpane 刷新 UI 上的其他滑块位置
pane.refresh();
}
});
// 在 onMounted 内部末尾添加
watch(
() => store.config,
() => {
pane.refresh(); // 核心:让 Tweakpane 的滑块和颜色选择器同步最新的 Store 状态
},
{ deep: true }
);
});
onUnmounted(() => {
if (pane) pane.dispose();
});
return { pane };
}

26
src/constants/presets.ts Normal file
View File

@ -0,0 +1,26 @@
export const CARD_PRESETS = {
DEFAULT: {
visualEffects: {
holo: { type: 'none', opacity: 0, blendMode: 'normal', colorShift: false }
},
appearance: { backgroundColor: '#ffffff', borderRadius: 16 }
},
GALAXY: {
visualEffects: {
holo: { type: 'galaxy', opacity: 0.7, blendMode: 'color-dodge', colorShift: true }
},
appearance: { backgroundColor: '#1a1a2e', borderRadius: 20 }
},
GOLD: {
visualEffects: {
holo: { type: 'rainbow', opacity: 0.4, blendMode: 'overlay', colorShift: false }
},
appearance: { backgroundColor: '#f9d423', borderRadius: 12 }
},
RAINBOW: {
visualEffects: {
holo: { type: 'rainbow', opacity: 0.6, blendMode: 'exclusion', colorShift: true }
},
appearance: { backgroundColor: '#efefef', borderRadius: 16 }
}
};

0
src/directives/tilt.ts Normal file
View File

16
src/main.js Normal file
View File

@ -0,0 +1,16 @@
import './assets/main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'; // 导入插件
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate); // 核心步骤:使用插件
app.use(pinia)
app.mount('#app')

12
src/router/index.js Normal file
View File

@ -0,0 +1,12 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router

138
src/store/cardStore.ts Normal file
View File

@ -0,0 +1,138 @@
import { defineStore } from 'pinia';
import { reactive, computed } from 'vue';
import type { CardConfig } from '@/types/card';
import { merge } from 'lodash-es'; // 推荐安装 lodash-es: pnpm add lodash-es @types/lodash-es
export const useCardStore = defineStore('card', () => {
// --- 1. State: 初始配置 ---
const config = reactive<CardConfig>({
version: "1.0",
id: '222',
metadata: { name: "我的定制卡片", createdAt: Date.now() },
content: {
imageUrl: "https://images.pokemontcg.io/swsh4/25_hires.png",
imageFit: "cover",
imageScale: 1,
imageOffset: { x: 0, y: 0 }
},
appearance: {
width: 300,
aspectRatio: 0.714,
borderRadius: 16,
backgroundColor: "#1a1a1a",
border: { width: 0, color: "#ffffff", style: "solid" },
shadow: { x: 0, y: 15, blur: 30, spread: -10, color: "#000000", opacity: 0.5 }
},
visualEffects: {
holo: { type: "galaxy", intensity: 0.8, blendMode: "color-dodge", opacity: 0.6, colorShift: true },
glare: { enabled: true, opacity: 0.5, blendMode: "overlay" },
hoverTiltVisual: {
shadow: false,
shadowBlur: 12,
blendMode: "overlay",
glareIntensity: 1,
glareHue: 270,
glareMask: "",
glareMaskMode: "match-source",
glareMaskComposite: "add",
// vanilla-tilt.js 眩光相关
glareEnable: false,
glarePosition: "bottom",
glareBorderRadius: "0px",
glareOpacity: 0.5
}
},
interaction: {
// 基础交互参数
tiltMax: 20,
perspective: 1200,
scaleOnHover: 1.05,
speed: 800,
gyroscope: true,
// Hover-tilt 特定选项
tiltFactor: 1,
tiltFactorY: 1,
scaleFactor: 1,
enterDelay: 0,
exitDelay: 200,
// vanilla-tilt.js 属性
reverse: false,
startX: 0,
startY: 0,
scale: 1,
transition: true,
axis: 'xy', // 无限制
reset: true,
resetToStart: true,
easing: "cubic-bezier(.03,.98,.52,.99)",
// 眩光效果
glare: false,
maxGlare: 1,
glarePrerender: false,
// 事件监听
mouseEventElement: '', // 空字符串表示默认元素
fullPageListening: false,
// 陀螺仪设置
gyroscopeMinAngleX: -45,
gyroscopeMaxAngleX: 45,
gyroscopeMinAngleY: -45,
gyroscopeMaxAngleY: 45,
gyroscopeSamples: 10,
// 弹簧物理选项
springOptions: {
stiffness: 0.2,
damping: 0.8
},
tiltSpringOptions: {
stiffness: 0.2,
damping: 0.8
}
}
});
// --- 2. Getters: 核心计算逻辑 ---
// 将 Config 映射为 CSS 变量,供组件直接通过 :style 使用
// src/store/cardStore.ts
const cssVariables = computed(() => {
const conf = config.appearance;
const s = conf.shadow;
return {
'width': `${conf.width}px`,
'--border-radius': `${conf.borderRadius}px`,
'background-color': conf.backgroundColor,
// ✅ 修复:将 shadow.offset 对象转化为字符串
// 假设你的 2DPad 绑定的是 s.offset
'--shadow-x': `${s.x || 0}px`,
'--shadow-y': `${s.y || 0}px`,
// 组合成完整的阴影字符串
'--card-shadow': `${s.x || 0}px ${s.y || 0}px ${s.blur}px ${s.spread}px ${s.color}`,
'--holo-opacity': config.visualEffects.holo.opacity,
};
});
// --- 3. Actions: 副作用处理 ---
const resetConfig = () => {
// 实际开发中可以存储一个 default 副本进行 Object.assign
location.reload();
};
const applyPreset = (preset: Partial<CardConfig>) => {
// 深度合并配置(可使用 lodash-es 的 merge
Object.assign(config.visualEffects.holo, preset.visualEffects?.holo);
Object.assign(config.appearance, preset.appearance);
merge(config, preset);
};
return { config, cssVariables, resetConfig, applyPreset };
}, {
persist: true // 开启插件持久化
});

129
src/types/card.d.ts vendored Normal file
View File

@ -0,0 +1,129 @@
/**
*
*/
export interface CardConfig {
version: string; // 配置文件版本,例如 "1.0.0"
id: string; // 唯一标识符
metadata: {
name: string; // 卡片名称(内部管理用)
createdAt: number; // 创建时间戳
};
// 1. 基础内容配置
content: {
imageUrl: string; // 图片资源地址(支持 base64 或 URL
imageFit: 'cover' | 'contain' | 'fill';
imageScale: number; // 图片内部缩放 (1.0 = 原大)
imageOffset: { x: number; y: number }; // 图片在容器内的偏移
};
// 2. 容器样式配置
appearance: {
width: number; // 卡片宽度
aspectRatio: number; // 宽高比 (例如 0.714)
borderRadius: number; // 圆角大小 (px)
backgroundColor: string; // 兜底背景色
border: {
width: number;
color: string;
style: 'solid' | 'double' | 'groove';
};
shadow: {
x: number;
y: number;
blur: number;
spread: number;
color: string;
opacity: number;
};
};
// 3. 交互逻辑配置 (Hover-tilt 映射)
interaction: {
// 基础交互参数
tiltMax: number; // 最大倾斜角度
perspective: number; // 透视距离
scaleOnHover: number; // 悬停时的放大倍数
speed: number; // 过渡动画速度
gyroscope: boolean; // 是否开启移动端陀螺仪
// Hover-tilt 特定选项
tiltFactor: number; // 水平倾斜强度
tiltFactorY: number; // 垂直倾斜强度
scaleFactor: number; // 缩放系数
enterDelay: number; // 进入延迟 (毫秒)
exitDelay: number; // 退出延迟 (毫秒)
// vanilla-tilt.js 属性
reverse: boolean; // 反转倾斜方向
startX: number; // X轴起始倾斜角度
startY: number; // Y轴起始倾斜角度
scale: number; // 缩放比例
transition: boolean; // 是否设置进入/退出过渡
axis: 'x' | 'y' | 'xy'; // 启用哪个轴xy表示无限制
reset: boolean; // 退出时是否重置倾斜效果
resetToStart: boolean; // 退出重置是否回到起始位置
easing: string; // 进入/退出缓动函数
// 眩光效果
glare: boolean; // 是否启用眩光效果
maxGlare: number; // 最大眩光透明度
glarePrerender: boolean; // 是否预先渲染眩光元素
// 事件监听
mouseEventElement: string; // 监听鼠标事件的元素选择器
fullPageListening: boolean; // 是否在整个文档上监听鼠标事件
// 陀螺仪设置
gyroscopeMinAngleX: number; // X轴陀螺仪最小角度
gyroscopeMaxAngleX: number; // X轴陀螺仪最大角度
gyroscopeMinAngleY: number; // Y轴陀螺仪最小角度
gyroscopeMaxAngleY: number; // Y轴陀螺仪最大角度
gyroscopeSamples: number; // 陀螺仪采样数量
// 弹簧物理选项
springOptions: {
stiffness: number; // 刚度
damping: number; // 阻尼
};
tiltSpringOptions: {
stiffness: number; // 倾斜动画的刚度
damping: number; // 倾斜动画的阻尼
};
};
// 4. 视觉效果配置
visualEffects: {
holo: {
type: 'none' | 'rainbow' | 'sparkle' | 'galaxy' | 'glass';
intensity: number; // 0-1 强度
blendMode: string; // 例如 'color-dodge', 'screen'
opacity: number; // 全息层透明度
colorShift: boolean; // 是否随角度色相旋转
};
glare: {
enabled: boolean;
opacity: number;
blendMode: string;
};
// Hover-tilt 视觉效果
hoverTiltVisual: {
shadow: boolean; // 是否启用动态阴影
shadowBlur: number; // 阴影模糊半径
blendMode: string; // 晕影效果混合模式
glareIntensity: number; // 晕影效果强度
glareHue: number; // 晕影色调值 (0-360)
glareMask: string; // 晕影遮罩
glareMaskMode: 'match-source' | 'luminance' | 'alpha' | 'none'; // 遮罩模式
glareMaskComposite: 'add' | 'subtract' | 'exclude' | 'intersect'; // 遮罩合成
// vanilla-tilt.js 眩光相关
glareEnable: boolean; // 启用眩光效果
glarePosition: string; // 眩光位置
glareBorderRadius: string; // 眩光圆角
glareOpacity: number; // 眩光透明度
};
overlayTexture?: string; // 可选的纹理贴图(如磨砂、拉丝)
};
}

137
test.html Normal file
View File

@ -0,0 +1,137 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>CSS Holographic Card Demo</title>
<style>
:root {
--color1: #00e7ff;
--color2: #ff00e7;
--back: #1a1a1a;
}
body {
background-color: var(--back);
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
font-family: sans-serif;
}
/* 卡片容器:设置透视距离 */
.card-container {
perspective: 1000px;
}
/* 卡片主体 */
.card {
width: 320px;
height: 450px;
position: relative;
border-radius: 15px;
background-image: url('https://images.pokemontcg.io/swsh4/25_hires.png'); /* 示例卡面 */
background-size: cover;
background-position: center;
box-shadow: 0 0 20px rgba(0,0,0,0.5);
/* 关键:通过 JS 更新变量 */
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform 0.1s ease-out;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
}
/* 全息层:彩虹光泽 */
.card::before {
content: "";
position: absolute;
inset: 0;
z-index: 2;
/* 核心:利用渐变和混合模式 */
background: linear-gradient(
115deg,
transparent 20%,
var(--color1) 36%,
var(--color2) 43%,
var(--color1) 50%,
var(--color2) 57%,
var(--color1) 64%,
transparent 80%
);
background-size: 300% 300%;
background-position: var(--pos-x, 50%) var(--pos-y, 50%);
/* 混合模式:这是全息感的来源 */
mix-blend-mode: color-dodge;
opacity: var(--opacity, 0);
transition: opacity 0.3s;
}
/* 光斑层:模拟点光源直射 */
.card::after {
content: "";
position: absolute;
inset: 0;
z-index: 3;
background: radial-gradient(
circle at var(--pos-x, 50%) var(--pos-y, 50%),
rgba(255, 255, 255, 0.6) 0%,
transparent 60%
);
mix-blend-mode: overlay;
opacity: var(--opacity, 0);
transition: opacity 0.3s;
}
.card:hover {
cursor: pointer;
}
</style>
</head>
<body>
<div class="card-container">
<div class="card" id="card"></div>
</div>
<script>
const card = document.getElementById('card');
card.addEventListener('mousemove', (e) => {
const rect = card.getBoundingClientRect();
// 计算鼠标在卡片上的相对坐标 (0 to 1)
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
// 计算旋转角度 (最大旋转 20 度)
const rx = (y - 0.5) * -40; // 垂直旋转
const ry = (x - 0.5) * 40; // 水平旋转
// 计算渐变背景的位置 (根据鼠标反向移动增加灵动感)
const posX = 100 - x * 100;
const posY = 100 - y * 100;
// 更新 CSS 变量
card.style.setProperty('--rx', `${rx}deg`);
card.style.setProperty('--ry', `${ry}deg`);
card.style.setProperty('--pos-x', `${posX}%`);
card.style.setProperty('--pos-y', `${posY}%`);
card.style.setProperty('--opacity', `1`);
});
// 鼠标移出重置
card.addEventListener('mouseleave', () => {
card.style.setProperty('--rx', `0deg`);
card.style.setProperty('--ry', `0deg`);
card.style.setProperty('--opacity', `0`);
});
</script>
</body>
</html>

29
vite.config.js Normal file
View File

@ -0,0 +1,29 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
// 告诉 Vue 编译器,所有以 'hover-' 开头的标签都是自定义元素
isCustomElement: (tag) => tag.startsWith('hover-')
}
}
}),
vueDevTools(),
],
server: {
host: '0.0.0.0',
port: 3000
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})