情人节模块
CI/CD / build (push) Failing after 3m24s
Details
CI/CD / build (push) Failing after 3m24s
Details
This commit is contained in:
parent
2ffe162423
commit
4f93347199
|
|
@ -0,0 +1,53 @@
|
|||
我将在 `apps/frontend/src/views/GreetingCard/GreetingCard.vue` 中实现七夕情人节贺卡功能。
|
||||
|
||||
根据您的要求:
|
||||
|
||||
1. **不使用 Tailwind CSS**。
|
||||
2. **图片功能为文件上传**(非填写地址)。我将实现本地文件读取(转换为 Base64)来模拟上传效果,确保用户可以直接选择本地图片进行预览和保存。
|
||||
|
||||
实施计划如下:
|
||||
|
||||
1. **功能概览与用户提示**:
|
||||
|
||||
* 在页面顶部添加美观的提示区域,说明:“下单时可选择绑定贺卡,收到货时将额外赠送一张带有二维码的贺卡,扫码即可查看您的祝福。”
|
||||
|
||||
* 使用自定义 CSS (`<style scoped>`) 实现浪漫粉色系视觉风格。
|
||||
|
||||
2. **贺卡管理 (增删改查)**:
|
||||
|
||||
* **列表视图**: 使用 Grid 布局展示贺卡列表。
|
||||
|
||||
* **添加/编辑弹窗**: 使用 Element Plus (`el-dialog`) 组件。
|
||||
|
||||
* **图片上传**: 使用 `el-upload` 组件。
|
||||
|
||||
* 设置为**点击选择文件**模式。
|
||||
|
||||
* 拦截默认上传行为,使用 `FileReader` 将图片转换为 Base64 格式进行本地预览和存储。
|
||||
|
||||
* **祝福寄语**: `el-input` (type="textarea") 用于输入祝福。
|
||||
|
||||
* **删除操作**: `el-popconfirm` 确认后删除。
|
||||
|
||||
3. **模拟数据**:
|
||||
|
||||
* 初始化包含示例贺卡的数据列表。
|
||||
|
||||
* 数据结构: `id`, `imageUrl` (Base64字符串), `message`, `createTime`.
|
||||
|
||||
4. **技术栈**:
|
||||
|
||||
* **Vue 3 组合式 API**
|
||||
|
||||
* **Element Plus** (Upload, Dialog, Button, Card, etc.)
|
||||
|
||||
* **CSS / SCSS** (Scoped styles)
|
||||
|
||||
**实施步骤**:
|
||||
|
||||
1. 清理文件并搭建基础 UI 结构。
|
||||
2. 实现图片上传并转 Base64 的核心逻辑。
|
||||
3. 完成添加、编辑、删除功能的逻辑绑定。
|
||||
4. 编写 CSS 样式美化页面。
|
||||
5. 验证功能流程。
|
||||
|
||||
|
|
@ -1,95 +1,162 @@
|
|||
# 协议管理页面实现计划
|
||||
|
||||
## 1. 需求分析
|
||||
|
||||
根据 `index.js` 中的 API 功能,需要实现以下功能:
|
||||
- 协议列表展示
|
||||
- 协议状态修改
|
||||
- 协议删除
|
||||
- 协议详情查看
|
||||
- 协议创建
|
||||
- 协议更新
|
||||
|
||||
* 协议列表展示
|
||||
|
||||
* 协议状态修改
|
||||
|
||||
* 协议删除
|
||||
|
||||
* 协议详情查看
|
||||
|
||||
* 协议创建
|
||||
|
||||
* 协议更新
|
||||
|
||||
## 2. 页面设计
|
||||
|
||||
遵循 Element Plus 企业级管理系统设计风格,页面包含:
|
||||
- 卡片式布局
|
||||
- 表格展示协议列表
|
||||
- 状态标签显示协议状态
|
||||
- 操作按钮(编辑、删除、启用/禁用)
|
||||
- 表单用于创建/编辑协议
|
||||
- 弹窗用于详情查看和表单操作
|
||||
|
||||
* 卡片式布局
|
||||
|
||||
* 表格展示协议列表
|
||||
|
||||
* 状态标签显示协议状态
|
||||
|
||||
* 操作按钮(编辑、删除、启用/禁用)
|
||||
|
||||
* 表单用于创建/编辑协议
|
||||
|
||||
* 弹窗用于详情查看和表单操作
|
||||
|
||||
## 3. 实现步骤
|
||||
|
||||
### 3.1 页面结构设计
|
||||
- 使用 `el-card` 包裹主要内容
|
||||
- 卡片头部包含标题和创建按钮
|
||||
- 表格展示协议列表,包含协议类型、版本、状态、语言、创建时间等字段
|
||||
- 操作列包含查看详情、编辑、删除、启用/禁用按钮
|
||||
|
||||
* 使用 `el-card` 包裹主要内容
|
||||
|
||||
* 卡片头部包含标题和创建按钮
|
||||
|
||||
* 表格展示协议列表,包含协议类型、版本、状态、语言、创建时间等字段
|
||||
|
||||
* 操作列包含查看详情、编辑、删除、启用/禁用按钮
|
||||
|
||||
### 3.2 功能实现
|
||||
- **列表查询**:调用 `getAgreementList` API 获取协议列表
|
||||
- **状态修改**:点击启用/禁用按钮,调用 `updateAgreementStatus` API
|
||||
- **删除协议**:点击删除按钮,调用 `deleteAgreement` API,带确认提示
|
||||
- **查看详情**:点击详情按钮,弹窗展示协议详情
|
||||
- **创建协议**:点击创建按钮,弹窗显示表单,调用 `createAgreement` API
|
||||
- **编辑协议**:点击编辑按钮,弹窗显示表单,调用 `updateAgreement` API
|
||||
|
||||
* **列表查询**:调用 `getAgreementList` API 获取协议列表
|
||||
|
||||
* **状态修改**:点击启用/禁用按钮,调用 `updateAgreementStatus` API
|
||||
|
||||
* **删除协议**:点击删除按钮,调用 `deleteAgreement` API,带确认提示
|
||||
|
||||
* **查看详情**:点击详情按钮,弹窗展示协议详情
|
||||
|
||||
* **创建协议**:点击创建按钮,弹窗显示表单,调用 `createAgreement` API
|
||||
|
||||
* **编辑协议**:点击编辑按钮,弹窗显示表单,调用 `updateAgreement` API
|
||||
|
||||
### 3.3 路由配置
|
||||
|
||||
在 `permissionRoutes` 数组中添加协议管理路由:
|
||||
- 路径:`agreement-management`
|
||||
- 名称:`AdminAgreement`
|
||||
- 组件:`AdminAgreement`
|
||||
- 标题:`admin.layout.agreementManagement`
|
||||
- 图标:`Document`
|
||||
- 菜单顺序:合理位置
|
||||
|
||||
* 路径:`agreement-management`
|
||||
|
||||
* 名称:`AdminAgreement`
|
||||
|
||||
* 组件:`AdminAgreement`
|
||||
|
||||
* 标题:`admin.layout.agreementManagement`
|
||||
|
||||
* 图标:`Document`
|
||||
|
||||
* 菜单顺序:合理位置
|
||||
|
||||
### 3.4 响应式设计
|
||||
- 表格在移动端自动调整布局
|
||||
- 弹窗在移动端自适应宽度
|
||||
- 表单元素在不同屏幕尺寸下保持良好的用户体验
|
||||
|
||||
* 表格在移动端自动调整布局
|
||||
|
||||
* 弹窗在移动端自适应宽度
|
||||
|
||||
* 表单元素在不同屏幕尺寸下保持良好的用户体验
|
||||
|
||||
## 4. 代码实现
|
||||
|
||||
### 4.1 创建 `index.vue` 文件
|
||||
- 使用 Composition API
|
||||
- 导入必要的组件和 API 类
|
||||
- 实现数据响应式
|
||||
- 实现方法逻辑
|
||||
- 实现模板结构
|
||||
- 添加样式
|
||||
|
||||
* 使用 Composition API
|
||||
|
||||
* 导入必要的组件和 API 类
|
||||
|
||||
* 实现数据响应式
|
||||
|
||||
* 实现方法逻辑
|
||||
|
||||
* 实现模板结构
|
||||
|
||||
* 添加样式
|
||||
|
||||
### 4.2 Vue3 属性绑定语法
|
||||
|
||||
所有属性绑定严格使用 Vue3 语法:
|
||||
- 错误:`label="{{label}}"`
|
||||
- 正确:`:label="label"`
|
||||
- 示例:
|
||||
- `:data="tableData"`
|
||||
- `:loading="loading"`
|
||||
- `:visible.sync="dialogVisible"`
|
||||
- `:type="scope.row.status === 1 ? 'success' : 'warning'"`
|
||||
- `@click="handleEdit(scope.row)"`
|
||||
|
||||
* 错误:`label="{{label}}"`
|
||||
|
||||
* 正确:`:label="label"`
|
||||
|
||||
* 示例:
|
||||
|
||||
* `:data="tableData"`
|
||||
|
||||
* `:loading="loading"`
|
||||
|
||||
* `:visible.sync="dialogVisible"`
|
||||
|
||||
* `:type="scope.row.status === 1 ? 'success' : 'warning'"`
|
||||
|
||||
* `@click="handleEdit(scope.row)"`
|
||||
|
||||
### 4.3 具体实现细节
|
||||
- **表格配置**:使用 `:data` 绑定表格数据,`:loading` 绑定加载状态
|
||||
- **弹窗配置**:使用 `:visible.sync` 控制弹窗显示/隐藏
|
||||
- **表单配置**:使用 `v-model` 绑定表单数据,`:rules` 绑定验证规则
|
||||
- **按钮配置**:使用 `@click` 绑定点击事件,`:type` 绑定按钮类型
|
||||
- **状态标签**:使用 `:type` 绑定标签类型,动态根据状态值变化
|
||||
|
||||
* **表格配置**:使用 `:data` 绑定表格数据,`:loading` 绑定加载状态
|
||||
|
||||
* **弹窗配置**:使用 `:visible.sync` 控制弹窗显示/隐藏
|
||||
|
||||
* **表单配置**:使用 `v-model` 绑定表单数据,`:rules` 绑定验证规则
|
||||
|
||||
* **按钮配置**:使用 `@click` 绑定点击事件,`:type` 绑定按钮类型
|
||||
|
||||
* **状态标签**:使用 `:type` 绑定标签类型,动态根据状态值变化
|
||||
|
||||
### 4.4 更新路由配置
|
||||
|
||||
在 `router/index.js` 中添加协议管理路由
|
||||
|
||||
## 5. 预期效果
|
||||
- 页面布局符合设计风格指南
|
||||
- 所有功能正常工作
|
||||
- 响应式设计适配不同设备
|
||||
- 交互流畅,反馈清晰
|
||||
- 严格遵循 Vue3 属性绑定语法
|
||||
|
||||
* 页面布局符合设计风格指南
|
||||
|
||||
* 所有功能正常工作
|
||||
|
||||
* 响应式设计适配不同设备
|
||||
|
||||
* 交互流畅,反馈清晰
|
||||
|
||||
* 严格遵循 Vue3 属性绑定语法
|
||||
|
||||
## 6. 技术要点
|
||||
- Vue3 Composition API
|
||||
- Element Plus 组件库
|
||||
- API 异步调用
|
||||
- 响应式设计
|
||||
- 中英文切换支持
|
||||
- 严格的 Vue3 属性绑定语法
|
||||
|
||||
* Vue3 Composition API
|
||||
|
||||
* Element Plus 组件库
|
||||
|
||||
* API 异步调用
|
||||
|
||||
* 响应式设计
|
||||
|
||||
* 中英文切换支持
|
||||
|
||||
* 严格的 Vue3 属性绑定语法
|
||||
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@
|
|||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: var(--z-index-modal-backdrop);
|
||||
z-index: 300;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all var(--transition-base);
|
||||
|
|
@ -255,6 +255,7 @@
|
|||
.sidebar-open .sidebar-overlay {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
z-index: 300 !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
module.exports = {
|
||||
extends: ['../../.eslintrc.base.json']
|
||||
}
|
||||
|
|
@ -15,7 +15,10 @@
|
|||
"@deotaland/utils": "workspace:*",
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@google/genai": "^1.27.0",
|
||||
"@mediapipe/camera_utils": "^0.3.1675466862",
|
||||
"@mediapipe/hands": "^0.4.1675469240",
|
||||
"@splinetool/runtime": "^1.12.29",
|
||||
"@tweenjs/tween.js": "^25.0.0",
|
||||
"@twind/core": "^1.1.3",
|
||||
"@twind/preset-autoprefix": "^1.0.7",
|
||||
"@twind/preset-tailwind": "^1.1.4",
|
||||
|
|
@ -26,6 +29,7 @@
|
|||
"country-state-city": "^3.2.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"element-plus": "^2.11.7",
|
||||
"html2canvas": "^1.4.1",
|
||||
"install": "^0.13.0",
|
||||
"jose": "^6.1.1",
|
||||
"motion-v": "^1.7.4",
|
||||
|
|
@ -33,6 +37,7 @@
|
|||
"nprogress": "^0.2.0",
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"three": "^0.180.0",
|
||||
"twind": "^0.16.19",
|
||||
"vue": "^3.5.24",
|
||||
|
|
|
|||
|
|
@ -69,18 +69,15 @@ onMounted(() => {
|
|||
'fullscreen-mode': isFullScreenPage,
|
||||
'homepage-mode': isHomePage
|
||||
}">
|
||||
<!-- <div v-if="qmLoading" class="sidebar-overlay" :class="{ 'sidebar-overlay-active': qmLoading }"></div> -->
|
||||
<DtLoadingCom v-if="qmLoading" />
|
||||
<!-- 登录页面全屏显示 -->
|
||||
<main style="position: relative;height: 100%;width: 100%;" v-if="isLoginPage">
|
||||
<!-- <div v-if="loading" class="sidebar-overlay" :class="{ 'sidebar-overlay-active': loading }"></div> -->
|
||||
<DtLoadingCom v-if="loading" />
|
||||
<router-view />
|
||||
</main>
|
||||
<!-- 全屏页面(如创建项目) -->
|
||||
<main v-else-if="isFullScreenPage" class="fullscreen-content">
|
||||
<DtLoadingCom v-if="loading" />
|
||||
<!-- <div class="sidebar-overlay" :class="{ 'sidebar-overlay-active': loading }"></div> -->
|
||||
<router-view />
|
||||
</main>
|
||||
<!-- 应用内页面使用布局组件 -->
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@
|
|||
<div class="paying-text">{{ 'PayLoading' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="close-button" @click="onClose" aria-label="关闭">
|
||||
<el-icon class="close-icon"><CloseBold /></el-icon>
|
||||
</button>
|
||||
|
|
@ -237,9 +236,8 @@ import StripePaymentForm from '@/components/StripePaymentForm.vue'
|
|||
import { Country, State } from 'country-state-city'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { PayServer,isWeChatBrowser } from '@deotaland/utils'
|
||||
import { requestUtils,clientApi,environmentUtils } from '@deotaland/utils'
|
||||
import { requestUtils,clientApi,environmentUtils,WechatBus } from '@deotaland/utils'
|
||||
import { PurchaseModal as PurchaseModalClass } from './index.js'
|
||||
import { WechatBus } from '@deotaland/utils'
|
||||
const payserver = new PayServer();
|
||||
const purchaseModal = new PurchaseModalClass();
|
||||
const props = defineProps({
|
||||
|
|
@ -612,7 +610,7 @@ const updateStates = () => {
|
|||
/* Blog Layout Styles */
|
||||
.purchase-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(6px);
|
||||
z-index: 1002; display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
.purchase-container {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
<!-- 移动端隐藏的操作按钮 -->
|
||||
<div class="header-actions" v-if="!isMobile">
|
||||
<!-- 用户菜单 -->
|
||||
<div class="user-menu" v-if="currentUser">
|
||||
<div class="user-menu" v-if="token">
|
||||
<el-dropdown trigger="click" @command="handleUserCommand">
|
||||
<div class="user-avatar">
|
||||
<el-avatar :size="32" :src="currentUser.avatarUrl">
|
||||
|
|
@ -47,7 +47,7 @@
|
|||
</div>
|
||||
|
||||
<!-- 主题切换 -->
|
||||
<ThemeToggle />
|
||||
<ThemeToggle ref="themeToggleRef" />
|
||||
|
||||
<!-- 语言切换 -->
|
||||
<LanguageToggle />
|
||||
|
|
@ -137,227 +137,183 @@
|
|||
</header>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
|
||||
import LanguageToggle from '@/components/ui/LanguageToggle.vue'
|
||||
|
||||
// 图标组件
|
||||
import {
|
||||
import {
|
||||
Menu as MenuIcon,
|
||||
Search as SearchIcon,
|
||||
Bell as NotificationIcon,
|
||||
User as UserIcon,
|
||||
Right as LogoutIcon,
|
||||
ArrowDown as ChevronDownIcon,
|
||||
Close as XIcon,
|
||||
Cpu as BrainIcon
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'AppHeader',
|
||||
components: {
|
||||
ThemeToggle,
|
||||
LanguageToggle,
|
||||
MenuIcon,
|
||||
SearchIcon,
|
||||
NotificationIcon,
|
||||
UserIcon,
|
||||
LogoutIcon,
|
||||
ChevronDownIcon,
|
||||
XIcon,
|
||||
BrainIcon
|
||||
Right as LogoutIcon,
|
||||
Close as XIcon
|
||||
} from '@element-plus/icons-vue'
|
||||
const themeToggleRef = ref(null)
|
||||
const props = defineProps({
|
||||
sidebarVisible: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
props: {
|
||||
sidebarVisible: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
isValentinePage: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['toggle-sidebar'])
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const isMobile = ref(window.innerWidth < 768)
|
||||
const searchVisible = ref(false)
|
||||
const notificationsVisible = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const searchInput = ref(null)
|
||||
const token = window.localStorage.getItem('token')
|
||||
|
||||
const currentUser = computed(() => authStore.user)
|
||||
const notificationCount = ref(3)
|
||||
|
||||
const headerClasses = computed(() => ({
|
||||
'mobile-header': isMobile.value,
|
||||
'desktop-header': !isMobile.value,
|
||||
'valentine-header': props.isValentinePage
|
||||
}))
|
||||
|
||||
const searchSuggestions = ref([
|
||||
{ id: 1, text: 'Dashboard', icon: 'UserIcon' },
|
||||
{ id: 2, text: 'Projects', icon: 'UserIcon' },
|
||||
{ id: 3, text: 'Settings', icon: 'UserIcon' }
|
||||
])
|
||||
|
||||
const notifications = ref([
|
||||
{
|
||||
id: 1,
|
||||
text: '新项目已创建完成',
|
||||
time: new Date(),
|
||||
icon: 'UserIcon',
|
||||
read: false
|
||||
},
|
||||
emits: ['toggle-sidebar'],
|
||||
setup(props, { emit }) {
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
{
|
||||
id: 2,
|
||||
text: '您的作品获得了新的点赞',
|
||||
time: new Date(Date.now() - 1000 * 60 * 30),
|
||||
icon: 'UserIcon',
|
||||
read: false
|
||||
}
|
||||
])
|
||||
|
||||
// 响应式状态
|
||||
const isMobile = ref(window.innerWidth < 768)
|
||||
const searchVisible = ref(false)
|
||||
const notificationsVisible = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const searchInput = ref(null)
|
||||
const handleResize = () => {
|
||||
isMobile.value = window.innerWidth < 768
|
||||
}
|
||||
|
||||
// 计算属性
|
||||
const currentUser = computed(() => authStore.user)
|
||||
const notificationCount = ref(3) // 模拟通知数量
|
||||
|
||||
const headerClasses = computed(() => ({
|
||||
'mobile-header': isMobile.value,
|
||||
'desktop-header': !isMobile.value
|
||||
}))
|
||||
|
||||
// 模拟搜索建议
|
||||
const searchSuggestions = ref([
|
||||
{ id: 1, text: 'Dashboard', icon: 'UserIcon' },
|
||||
{ id: 2, text: 'Projects', icon: 'UserIcon' },
|
||||
{ id: 3, text: 'Settings', icon: 'UserIcon' }
|
||||
])
|
||||
|
||||
// 模拟通知数据
|
||||
const notifications = ref([
|
||||
{
|
||||
id: 1,
|
||||
text: '新项目已创建完成',
|
||||
time: new Date(),
|
||||
icon: 'UserIcon',
|
||||
read: false
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
text: '您的作品获得了新的点赞',
|
||||
time: new Date(Date.now() - 1000 * 60 * 30),
|
||||
icon: 'UserIcon',
|
||||
read: false
|
||||
}
|
||||
])
|
||||
|
||||
// 窗口大小变化处理
|
||||
const handleResize = () => {
|
||||
isMobile.value = window.innerWidth < 768
|
||||
}
|
||||
|
||||
// 切换搜索
|
||||
const toggleSearch = async () => {
|
||||
searchVisible.value = !searchVisible.value
|
||||
if (searchVisible.value) {
|
||||
await nextTick()
|
||||
searchInput.value?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const closeSearch = () => {
|
||||
searchVisible.value = false
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
searchQuery.value = ''
|
||||
searchInput.value?.focus()
|
||||
}
|
||||
|
||||
// 搜索操作
|
||||
const performSearch = () => {
|
||||
if (searchQuery.value.trim()) {
|
||||
console.log('搜索:', searchQuery.value)
|
||||
router.push(`/search?q=${encodeURIComponent(searchQuery.value)}`)
|
||||
closeSearch()
|
||||
}
|
||||
}
|
||||
|
||||
const selectSuggestion = (suggestion) => {
|
||||
searchQuery.value = suggestion.text
|
||||
performSearch()
|
||||
}
|
||||
|
||||
// 通知操作
|
||||
const toggleNotifications = () => {
|
||||
notificationsVisible.value = !notificationsVisible.value
|
||||
}
|
||||
|
||||
const markAsRead = (id) => {
|
||||
const notification = notifications.value.find(n => n.id === id)
|
||||
if (notification) {
|
||||
notification.read = true
|
||||
notificationCount.value = Math.max(0, notificationCount.value - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const markAllAsRead = () => {
|
||||
notifications.value.forEach(n => n.read = true)
|
||||
notificationCount.value = 0
|
||||
}
|
||||
|
||||
// 用户菜单操作
|
||||
const handleUserCommand = async (command) => {
|
||||
switch (command) {
|
||||
case 'profile':
|
||||
router.push('/profile')
|
||||
break
|
||||
case 'settings':
|
||||
router.push('/settings')
|
||||
break
|
||||
case 'logout':
|
||||
try {
|
||||
await authStore.logout(()=>{
|
||||
// router.push('/login')
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('登出失败:', error)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 时间格式化
|
||||
const formatTime = (time) => {
|
||||
const now = new Date()
|
||||
const diff = now - time
|
||||
const minutes = Math.floor(diff / (1000 * 60))
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (minutes < 1) return '刚刚'
|
||||
if (minutes < 60) return `${minutes}分钟前`
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
return `${days}天前`
|
||||
}
|
||||
|
||||
// 点击外部关闭下拉菜单
|
||||
const handleClickOutside = (event) => {
|
||||
if (!event.target.closest('.app-header')) {
|
||||
searchVisible.value = false
|
||||
notificationsVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handleResize)
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
return {
|
||||
t,
|
||||
isMobile,
|
||||
searchVisible,
|
||||
notificationsVisible,
|
||||
searchQuery,
|
||||
searchInput,
|
||||
searchSuggestions,
|
||||
notifications,
|
||||
notificationCount,
|
||||
currentUser,
|
||||
headerClasses,
|
||||
toggleSearch,
|
||||
closeSearch,
|
||||
clearSearch,
|
||||
performSearch,
|
||||
selectSuggestion,
|
||||
toggleNotifications,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
handleUserCommand,
|
||||
formatTime
|
||||
}
|
||||
const toggleSearch = async () => {
|
||||
searchVisible.value = !searchVisible.value
|
||||
if (searchVisible.value) {
|
||||
await nextTick()
|
||||
searchInput.value?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const closeSearch = () => {
|
||||
searchVisible.value = false
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
searchQuery.value = ''
|
||||
searchInput.value?.focus()
|
||||
}
|
||||
|
||||
const performSearch = () => {
|
||||
if (searchQuery.value.trim()) {
|
||||
console.log('搜索:', searchQuery.value)
|
||||
router.push(`/search?q=${encodeURIComponent(searchQuery.value)}`)
|
||||
closeSearch()
|
||||
}
|
||||
}
|
||||
|
||||
const selectSuggestion = (suggestion) => {
|
||||
searchQuery.value = suggestion.text
|
||||
performSearch()
|
||||
}
|
||||
|
||||
const toggleNotifications = () => {
|
||||
notificationsVisible.value = !notificationsVisible.value
|
||||
}
|
||||
|
||||
const markAsRead = (id) => {
|
||||
const notification = notifications.value.find(n => n.id === id)
|
||||
if (notification) {
|
||||
notification.read = true
|
||||
notificationCount.value = Math.max(0, notificationCount.value - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const markAllAsRead = () => {
|
||||
notifications.value.forEach(n => n.read = true)
|
||||
notificationCount.value = 0
|
||||
}
|
||||
|
||||
const handleUserCommand = async (command) => {
|
||||
switch (command) {
|
||||
case 'profile':
|
||||
router.push('/profile')
|
||||
break
|
||||
case 'settings':
|
||||
router.push('/settings')
|
||||
break
|
||||
case 'logout':
|
||||
try {
|
||||
await authStore.logout(()=>{
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('登出失败:', error)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (time) => {
|
||||
const now = new Date()
|
||||
const diff = now - time
|
||||
const minutes = Math.floor(diff / (1000 * 60))
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (minutes < 1) return '刚刚'
|
||||
if (minutes < 60) return `${minutes}分钟前`
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
return `${days}天前`
|
||||
}
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (!event.target.closest('.app-header')) {
|
||||
searchVisible.value = false
|
||||
notificationsVisible.value = false
|
||||
}
|
||||
}
|
||||
const applyTheme = (type=null) => {
|
||||
themeToggleRef.value?.toggleTheme(type)
|
||||
}
|
||||
defineExpose({
|
||||
applyTheme
|
||||
})
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handleResize)
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -372,6 +328,121 @@ export default {
|
|||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
z-index: 400;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* 情人节主题头部 */
|
||||
.app-header.valentine-header {
|
||||
background: #fff5f7 !important;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.4) !important;
|
||||
box-shadow: 0 4px 12px rgba(255, 154, 158, 0.2) !important;
|
||||
}
|
||||
|
||||
.app-header.valentine-header .brand-name {
|
||||
background: linear-gradient(135deg, #fff 0%, #ff99b7 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
text-shadow: 0 1px 2px rgba(255, 107, 157, 0.3);
|
||||
}
|
||||
|
||||
.app-header.valentine-header .mobile-menu-button {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.app-header.valentine-header .mobile-menu-button:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.app-header.valentine-header .action-button {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.app-header.valentine-header .action-button:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.app-header.valentine-header .user-avatar {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.app-header.valentine-header .user-avatar:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.app-header.valentine-header .user-name {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.app-header.valentine-header .dropdown-icon {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.app-header.valentine-header .search-dropdown {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid rgba(255, 154, 158, 0.2);
|
||||
}
|
||||
|
||||
.app-header.valentine-header .search-icon {
|
||||
color: #ff6b9d;
|
||||
}
|
||||
|
||||
.app-header.valentine-header .search-input {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.app-header.valentine-header .clear-search-button {
|
||||
color: #ff6b9d;
|
||||
}
|
||||
|
||||
.app-header.valentine-header .clear-search-button:hover {
|
||||
background: rgba(255, 107, 157, 0.15);
|
||||
}
|
||||
|
||||
.app-header.valentine-header .search-suggestion:hover {
|
||||
background: rgba(255, 107, 157, 0.15);
|
||||
}
|
||||
|
||||
.app-header.valentine-header .notifications-panel {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid rgba(255, 154, 158, 0.2);
|
||||
}
|
||||
|
||||
.app-header.valentine-header .notifications-header h3 {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.app-header.valentine-header .mark-all-read {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.app-header.valentine-header .mark-all-read:hover {
|
||||
background: rgba(255, 107, 157, 0.15);
|
||||
}
|
||||
|
||||
.app-header.valentine-header .notification-item:hover {
|
||||
background: rgba(255, 107, 157, 0.1);
|
||||
}
|
||||
|
||||
.app-header.valentine-header .notification-item.unread {
|
||||
background: rgba(255, 107, 157, 0.08);
|
||||
}
|
||||
|
||||
.app-header.valentine-header .notification-text {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.app-header.valentine-header .notification-time {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.app-header.valentine-header .no-notifications {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.app-header.valentine-header .notification-icon {
|
||||
background: linear-gradient(135deg, #ff6b9d 0%, #ff8a80 100%);
|
||||
}
|
||||
|
||||
/* 移动端汉堡菜单按钮 */
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
<CreationIcon v-else-if="item.icon === 'CreationIcon'" />
|
||||
<GalleryIcon v-else-if="item.icon === 'GalleryIcon'" />
|
||||
<OrdersIcon v-else-if="item.icon === 'OrdersIcon'" />
|
||||
<GreetingCardIcon v-else-if="item.icon === 'GreetingCardIcon'" />
|
||||
<UserIcon v-else-if="item.icon === 'UserIcon'" />
|
||||
</div>
|
||||
<transition name="fade">
|
||||
|
|
@ -105,7 +106,8 @@ import {
|
|||
Folder as ProjectIcon,
|
||||
Bell as NotificationIcon,
|
||||
Key as ApiIcon,
|
||||
ShoppingCartFull
|
||||
ShoppingCartFull,
|
||||
Postcard as GreetingCardIcon
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
// 定义组件名称(用于调试和递归组件)
|
||||
|
|
@ -118,6 +120,10 @@ const props = defineProps({
|
|||
collapsed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isValentinePage: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -143,7 +149,8 @@ const userRole = computed(() => ({
|
|||
}[authStore.user?.user_role || authStore.user?.userRole||'1'])) // 可切换为 'free' 测试不同样式
|
||||
const sidebarClasses = computed(() => ({
|
||||
'sidebar-mobile': isMobile.value,
|
||||
'show': isMobile.value && !props.collapsed
|
||||
'show': isMobile.value && !props.collapsed,
|
||||
'valentine-sidebar': props.isValentinePage
|
||||
}))
|
||||
|
||||
// 核心菜单项 (6个主要功能)
|
||||
|
|
@ -176,6 +183,13 @@ const coreMenuItems = computed(() => [
|
|||
icon: 'OrdersIcon',
|
||||
badge: null
|
||||
},
|
||||
{
|
||||
id: 'greeting-card',
|
||||
path: '/greeting-card',
|
||||
label: t('sidebar.greetingCard'),
|
||||
icon: 'GreetingCardIcon',
|
||||
badge: null
|
||||
},
|
||||
{
|
||||
id: 'user-center',
|
||||
path: '/user-center',
|
||||
|
|
@ -765,6 +779,112 @@ onUnmounted(() => {
|
|||
}
|
||||
}
|
||||
|
||||
/* 情人节主题 */
|
||||
.app-sidebar.valentine-sidebar {
|
||||
background: linear-gradient(180deg, #fff5f7 0%, #ffe4e8 50%, #ffd1dc 100%) !important;
|
||||
border-right: 1px solid rgba(255, 107, 157, 0.2) !important;
|
||||
}
|
||||
|
||||
.app-sidebar.valentine-sidebar .nav-item {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 107, 157, 0.1);
|
||||
box-shadow:
|
||||
0 2px 8px rgba(255, 107, 157, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.app-sidebar.valentine-sidebar .nav-item:hover {
|
||||
background: rgba(255, 107, 157, 0.15);
|
||||
color: #ff6b9d;
|
||||
transform: translateY(-3px) scale(1.02);
|
||||
box-shadow:
|
||||
0 12px 32px rgba(255, 107, 157, 0.25),
|
||||
0 4px 12px rgba(255, 107, 157, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||
border-color: rgba(255, 107, 157, 0.4);
|
||||
}
|
||||
|
||||
.app-sidebar.valentine-sidebar .nav-item.active {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(255, 107, 157, 0.25) 0%,
|
||||
rgba(255, 138, 128, 0.2) 50%,
|
||||
rgba(255, 183, 77, 0.15) 100%);
|
||||
color: #ff6b9d;
|
||||
font-weight: 600;
|
||||
border-color: rgba(255, 107, 157, 0.5);
|
||||
box-shadow:
|
||||
0 16px 40px rgba(255, 107, 157, 0.35),
|
||||
0 6px 20px rgba(255, 107, 157, 0.25),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.35),
|
||||
0 0 0 1px rgba(255, 107, 157, 0.15);
|
||||
}
|
||||
|
||||
.app-sidebar.valentine-sidebar .nav-item.active .nav-icon svg {
|
||||
filter: drop-shadow(0 0 8px rgba(255, 107, 157, 0.5));
|
||||
}
|
||||
|
||||
.app-sidebar.valentine-sidebar .nav-item:hover .nav-icon svg {
|
||||
filter: drop-shadow(0 4px 8px rgba(255, 107, 157, 0.4));
|
||||
}
|
||||
|
||||
.app-sidebar.valentine-sidebar .sidebar-footer {
|
||||
border-top: 1px solid rgba(255, 107, 157, 0.15);
|
||||
}
|
||||
|
||||
.app-sidebar.valentine-sidebar .sidebar-footer::before {
|
||||
background: linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 107, 157, 0.3) 20%,
|
||||
rgba(255, 107, 157, 0.5) 50%,
|
||||
rgba(255, 107, 157, 0.3) 80%,
|
||||
transparent 100%);
|
||||
}
|
||||
|
||||
.app-sidebar.valentine-sidebar .user-profile {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid rgba(255, 107, 157, 0.2);
|
||||
}
|
||||
|
||||
.app-sidebar.valentine-sidebar .user-profile:hover {
|
||||
box-shadow: 0 0 12px rgba(255, 107, 157, 0.3);
|
||||
}
|
||||
|
||||
.app-sidebar.valentine-sidebar .role-badge.free {
|
||||
background: linear-gradient(135deg, #ff6b9d 0%, #ff8a80 100%);
|
||||
box-shadow: 0 0 6px rgba(255, 107, 157, 0.5);
|
||||
}
|
||||
|
||||
.app-sidebar.valentine-sidebar .role-badge.creator {
|
||||
background: linear-gradient(135deg, #ff8a80 0%, #ffb74d 100%);
|
||||
box-shadow: 0 0 6px rgba(255, 107, 157, 0.5);
|
||||
}
|
||||
|
||||
.app-sidebar.valentine-sidebar .user-profile-collapsed {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border: 1px solid rgba(255, 107, 157, 0.25);
|
||||
}
|
||||
|
||||
.app-sidebar.valentine-sidebar .user-profile-collapsed:hover {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-color: rgba(255, 107, 157, 0.35);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(255, 107, 157, 0.2),
|
||||
0 2px 8px rgba(255, 107, 157, 0.15);
|
||||
}
|
||||
|
||||
.app-sidebar.valentine-sidebar .user-profile-collapsed .user-avatar-container {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(255, 107, 157, 0.15) 0%,
|
||||
rgba(255, 138, 128, 0.25) 100%);
|
||||
}
|
||||
|
||||
.app-sidebar.valentine-sidebar .user-profile-collapsed:hover .user-avatar-container {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(255, 107, 157, 0.25) 0%,
|
||||
rgba(255, 138, 128, 0.35) 100%);
|
||||
box-shadow: 0 0 12px rgba(255, 107, 157, 0.3);
|
||||
}
|
||||
|
||||
/* 深色主题 */
|
||||
.dark .app-sidebar {
|
||||
--sidebar-bg: #1f2937;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@
|
|||
<header class="header-container">
|
||||
<AppHeader
|
||||
:sidebar-visible="sidebarVisible"
|
||||
ref="headerRef"
|
||||
@toggle-sidebar="toggleSidebar"
|
||||
:is-valentine-page="isValentinePage"
|
||||
/>
|
||||
</header>
|
||||
|
||||
|
|
@ -14,16 +16,16 @@
|
|||
<!-- 侧边栏容器 -->
|
||||
<aside
|
||||
class="sidebar-container"
|
||||
:class="{ 'sidebar-visible': sidebarVisible }"
|
||||
:class="{ 'sidebar-visible': sidebarVisible, 'valentine-theme': isValentinePage }"
|
||||
>
|
||||
<AppSidebar
|
||||
:collapsed="!sidebarVisible"
|
||||
@navigate="handleNavigate"
|
||||
@navigate="handleNavigate"
|
||||
:is-valentine-page="isValentinePage"
|
||||
/>
|
||||
</aside>
|
||||
<!-- 主内容区域 -->
|
||||
<div class="main-content" :class="{ 'sidebar-collapsed': !sidebarVisible && !isMobile }">
|
||||
<!-- <div class="sidebar-overlay" :class="{ 'sidebar-overlay-active': loading }"></div> -->
|
||||
<DtLoadingCom v-if="loading" />
|
||||
<!-- 面包屑导航 -->
|
||||
<!-- <BreadcrumbNavigation class="breadcrumb-container" /> -->
|
||||
|
|
@ -45,8 +47,10 @@
|
|||
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import AppHeader from './AppHeader.vue'
|
||||
import AppSidebar from './AppSidebar.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
const headerRef = ref(null)
|
||||
const themeStore = useThemeStore()
|
||||
// 注册组件
|
||||
defineOptions({
|
||||
name: 'MainLayout'
|
||||
|
|
@ -84,6 +88,9 @@ const isMobile = computed(() => state.isMobile)
|
|||
const isTablet = computed(() => state.isTablet)
|
||||
const isDesktop = computed(() => state.isDesktop)
|
||||
|
||||
// 判断当前是否为情人节贺卡页面
|
||||
const isValentinePage = computed(() => route.path === '/greeting-card')
|
||||
|
||||
// 响应式断点更新
|
||||
const updateBreakpoints = () => {
|
||||
state.screenWidth = window.innerWidth
|
||||
|
|
@ -108,6 +115,7 @@ const toggleSidebar = () => {
|
|||
}
|
||||
}
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
// 处理导航
|
||||
const handleNavigate = (route) => {
|
||||
router.replace(route)
|
||||
|
|
@ -125,7 +133,16 @@ const handleResize = () => {
|
|||
updateBreakpoints()
|
||||
}, 150)
|
||||
}
|
||||
|
||||
const beforeTheme = ref('')
|
||||
// 监听路由变化,如果是 /greeting-card 则切换到亮色主题
|
||||
watch(() => route.path, (newPath) => {
|
||||
if (newPath === '/greeting-card') {
|
||||
beforeTheme.value = window.localStorage.getItem('theme') || 'light'
|
||||
headerRef.value?.applyTheme('light')
|
||||
} else {
|
||||
headerRef.value?.applyTheme(beforeTheme.value)
|
||||
}
|
||||
}, { immediate: true })
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
updateBreakpoints()
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
:disabled="transitioning"
|
||||
>
|
||||
<button
|
||||
@click="toggleTheme"
|
||||
@click="toggleTheme()"
|
||||
class="theme-toggle-btn"
|
||||
:class="{ 'dark': isDark }"
|
||||
:disabled="transitioning"
|
||||
|
|
@ -48,9 +48,7 @@ const props = defineProps({
|
|||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
// 组件现在是自管理的,不需要对外发射事件
|
||||
|
||||
// 主题状态
|
||||
const isDark = ref(false)
|
||||
const transitioning = ref(false)
|
||||
|
|
@ -66,14 +64,18 @@ onMounted(() => {
|
|||
})
|
||||
|
||||
// 切换主题
|
||||
const toggleTheme = () => {
|
||||
const toggleTheme = (type=null) => {
|
||||
if(window.location.href.includes('/greeting-card')&&type===null){
|
||||
return
|
||||
}
|
||||
if (transitioning.value) return
|
||||
|
||||
transitioning.value = true
|
||||
|
||||
isDark.value = !isDark.value
|
||||
if(type!==null){
|
||||
isDark.value = type=== 'dark'
|
||||
}else{
|
||||
isDark.value = !isDark.value
|
||||
}
|
||||
applyTheme()
|
||||
|
||||
// 动画效果结束后重置transitioning状态
|
||||
setTimeout(() => {
|
||||
transitioning.value = false
|
||||
|
|
@ -105,6 +107,9 @@ if (window.matchMedia) {
|
|||
}
|
||||
})
|
||||
}
|
||||
defineExpose({
|
||||
toggleTheme
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export default {
|
|||
gallery: '画廊',
|
||||
orders: '订单',
|
||||
orderManagement: '订单',
|
||||
greetingCard: '贺卡',
|
||||
apiKeys: 'API密钥',
|
||||
settings: '设置',
|
||||
mainMenu: '主要菜单',
|
||||
|
|
@ -1557,6 +1558,62 @@ export default {
|
|||
hasInviteCode: '已有邀请码?',
|
||||
inviteCodePlaceholder: '填写邀请码升级为免费会员',
|
||||
logout: '退出登录'
|
||||
},
|
||||
confessionCard: {
|
||||
gestureHint: '请隔空比心',
|
||||
gestureSubtext: '对着摄像头做出比心手势,让粒子汇聚成爱意',
|
||||
enableCamera: '开启摄像头',
|
||||
disableCamera: '关闭摄像头',
|
||||
manualTrigger: '直接打开信件'
|
||||
},
|
||||
greetingCard: {
|
||||
pageTitle: '情人节 · 爱的传情',
|
||||
pageSubtitle: '为TA定制专属心意',
|
||||
pageDescription: '下单时选择绑定贺卡,收货时将随附一张印有二维码的精美实体贺卡。TA 扫码即可查收您的专属祝福,让爱意跨越屏幕,温暖彼此的心。',
|
||||
tipTitle: '💕 爱的传递方式',
|
||||
tipSubtitle: '让每一份心意都触手可及',
|
||||
tipFeatures: {
|
||||
feature1: {
|
||||
title: '专属实体贺卡',
|
||||
description: '精美印刷,质感十足'
|
||||
},
|
||||
feature2: {
|
||||
title: '二维码绑定',
|
||||
description: '扫码即可查看您的专属祝福'
|
||||
},
|
||||
feature3: {
|
||||
title: '随货附赠',
|
||||
description: '收货时惊喜送达,温暖TA的心'
|
||||
}
|
||||
},
|
||||
createButton: '创建心意',
|
||||
editButton: '编辑',
|
||||
deleteButton: '删除',
|
||||
deleteConfirm: '确定要删除这张贺卡吗?',
|
||||
deleteSuccess: '心意已删除',
|
||||
emptyTitle: '还没有为TA准备贺卡',
|
||||
emptyDescription: '快去创建一份心意吧!',
|
||||
dialogCreateTitle: '创建心意',
|
||||
dialogEditTitle: '编辑心意',
|
||||
imageUploadLabel: '上传图片',
|
||||
imageUploadPlaceholder: '点击或拖拽上传图片',
|
||||
imageUploadHint: '请上传图片文件',
|
||||
messageLabel: '祝福寄语',
|
||||
messagePlaceholder: '写下您想对TA说的情话...',
|
||||
messageRequired: '请输入祝福寄语',
|
||||
imageRequired: '请上传一张图片',
|
||||
cancelButton: '取消',
|
||||
saveButton: '保存',
|
||||
saveButtonLoading: '保存中...',
|
||||
createSuccess: '心意已创建',
|
||||
updateSuccess: '心意已更新',
|
||||
cardDate: '创建时间',
|
||||
previewDialogTitle: '贺卡预览',
|
||||
cardMessage: '愿你我如星辰般永恒相守',
|
||||
exportButton: '导出图片',
|
||||
exportSuccess: '图片导出成功',
|
||||
exportFailed: '图片导出失败,请重试',
|
||||
previewButton: '预览'
|
||||
}
|
||||
},
|
||||
en: {
|
||||
|
|
@ -1674,6 +1731,7 @@ export default {
|
|||
gallery: 'Gallery',
|
||||
orders: 'Orders',
|
||||
orderManagement: 'Orders',
|
||||
greetingCard: 'Greeting Card',
|
||||
apiKeys: 'API Keys',
|
||||
settings: 'Settings',
|
||||
mainMenu: 'Main Menu',
|
||||
|
|
@ -3094,6 +3152,62 @@ export default {
|
|||
hasInviteCode: 'Already have an invite code?',
|
||||
inviteCodePlaceholder: 'Enter invite code to upgrade to free membership',
|
||||
logout: 'Logout'
|
||||
},
|
||||
confessionCard: {
|
||||
gestureHint: 'Make a Heart Gesture',
|
||||
gestureSubtext: 'Make a heart gesture to the camera to let particles gather into love',
|
||||
enableCamera: 'Enable Camera',
|
||||
disableCamera: 'Disable Camera',
|
||||
manualTrigger: 'Directly Open the Letter'
|
||||
},
|
||||
greetingCard: {
|
||||
pageTitle: 'Valentine\'s Day · Love Expressions',
|
||||
pageSubtitle: 'Create a Special Message for Your Love',
|
||||
pageDescription: 'When placing an order, choose to bind a greeting card. A beautiful physical card with a QR code will be included with your delivery. Your loved one can scan the code to receive your exclusive blessing, letting love transcend the screen and warm each other\'s hearts.',
|
||||
tipTitle: '💕 Ways to Share Your Love',
|
||||
tipSubtitle: 'Make Every Message Reachable',
|
||||
tipFeatures: {
|
||||
feature1: {
|
||||
title: 'Exclusive Physical Card',
|
||||
description: 'Beautiful printing, premium quality'
|
||||
},
|
||||
feature2: {
|
||||
title: 'QR Code Binding',
|
||||
description: 'Scan to view your exclusive blessing'
|
||||
},
|
||||
feature3: {
|
||||
title: 'Included with Delivery',
|
||||
description: 'Surprise delivery, warming their heart'
|
||||
}
|
||||
},
|
||||
createButton: 'Create Message',
|
||||
editButton: 'Edit',
|
||||
deleteButton: 'Delete',
|
||||
deleteConfirm: 'Are you sure you want to delete this greeting card?',
|
||||
deleteSuccess: 'Message deleted successfully',
|
||||
emptyTitle: 'No greeting cards yet',
|
||||
emptyDescription: 'Create a special message for your loved one!',
|
||||
dialogCreateTitle: 'Create Message',
|
||||
dialogEditTitle: 'Edit Message',
|
||||
imageUploadLabel: 'Upload Image',
|
||||
imageUploadPlaceholder: 'Click or drag to upload image',
|
||||
imageUploadHint: 'Please upload an image file',
|
||||
messageLabel: 'Blessing Message',
|
||||
messagePlaceholder: 'Write your love message...',
|
||||
messageRequired: 'Please enter a blessing message',
|
||||
imageRequired: 'Please upload an image',
|
||||
cancelButton: 'Cancel',
|
||||
saveButton: 'Save',
|
||||
saveButtonLoading: 'Saving...',
|
||||
createSuccess: 'Message created successfully',
|
||||
updateSuccess: 'Message updated successfully',
|
||||
cardDate: 'Created',
|
||||
previewDialogTitle: 'Greeting Card Preview',
|
||||
cardMessage: 'May you and I, like constellations in the night sky, stay bound in timeless union.',
|
||||
exportButton: 'Export Image',
|
||||
exportSuccess: 'Image exported successfully',
|
||||
exportFailed: 'Image export failed, please try again',
|
||||
previewButton: 'Preview'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ const UserCenter = () => import('../views/user/index.vue')
|
|||
const NotFound = () => import('../views/NotFound.vue')
|
||||
const Waitlist = () => import('../views/Waitlist.vue')
|
||||
const KefuReduce = () => import('../views/kefuReduce.vue')
|
||||
const CardPreview = () => import('../views/cardPreview/index.vue')
|
||||
const ConfessionElectronicCard = () => import('../views/cardPreview/ConfessionElectronicCard.vue')
|
||||
const GreetingCard = () => import('../views/GreetingCard/GreetingCard.vue')
|
||||
|
||||
const isPortraitMobile = () => {
|
||||
return window.innerWidth < 768 && window.innerHeight > window.innerWidth
|
||||
}
|
||||
|
|
@ -46,6 +50,18 @@ NProgress.configure({
|
|||
})// 开启轻量模式(顶部细线)
|
||||
// 路由配置
|
||||
const routes = [
|
||||
{
|
||||
path: '/confession-electronic-card',
|
||||
name: 'confession-electronic-card',
|
||||
component: ConfessionElectronicCard,
|
||||
meta: { requiresAuth: false, keepAlive: false, fullScreen: true }
|
||||
},
|
||||
{
|
||||
path: '/card-preview',
|
||||
name: 'card-preview',
|
||||
component: CardPreview,
|
||||
meta: { requiresAuth: false, keepAlive: false, fullScreen: true }
|
||||
},
|
||||
{
|
||||
path: '/agreement/:type',
|
||||
name: 'agreement',
|
||||
|
|
@ -73,7 +89,7 @@ const routes = [
|
|||
path: '/czhome',
|
||||
name: 'czhome',
|
||||
component: ModernHome,
|
||||
meta: { requiresAuth: true, keepAlive: false }
|
||||
meta: { requiresAuth: false, keepAlive: false }
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
|
|
@ -139,6 +155,12 @@ export const freeRoutes = [
|
|||
component: OrderDetail,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/greeting-card',
|
||||
name: 'greeting-card',
|
||||
component: GreetingCard,
|
||||
meta: { requiresAuth: true, keepAlive: false }
|
||||
},
|
||||
{
|
||||
path: '/agent-management',
|
||||
name: 'agent-management',
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import { ref, computed } from 'vue'
|
|||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
// 状态
|
||||
const isDark = ref(false)
|
||||
const isDark = ref(false)// 是否是暗黑主题
|
||||
|
||||
// 计算属性
|
||||
const theme = computed(() => isDark.value ? 'dark' : 'light')
|
||||
const theme = computed(() => isDark.value ? 'dark' : 'light')// 当前主题
|
||||
|
||||
// 方法
|
||||
const toggleTheme = () => {
|
||||
|
|
@ -18,12 +18,12 @@ export const useThemeStore = defineStore('theme', () => {
|
|||
}
|
||||
|
||||
const setTheme = (newTheme) => {
|
||||
isDark.value = newTheme === 'dark'
|
||||
localStorage.setItem('theme', theme.value)
|
||||
isDark.value = (newTheme === 'dark')
|
||||
localStorage.setItem('theme', newTheme)
|
||||
applyTheme()
|
||||
}
|
||||
|
||||
const applyTheme = () => {
|
||||
const applyTheme = () => {// 应用当前主题到文档
|
||||
const root = document.documentElement
|
||||
if (isDark.value) {
|
||||
root.classList.add('dark')
|
||||
|
|
@ -55,6 +55,5 @@ export const useThemeStore = defineStore('theme', () => {
|
|||
persist: {
|
||||
key: 'theme',
|
||||
storage: localStorage,
|
||||
paths: ['isDark']
|
||||
}
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,288 @@
|
|||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="$t('greetingCard.previewDialogTitle')"
|
||||
:width="dialogWidth"
|
||||
custom-class="card-preview-dialog"
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
append-to-body
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="preview-container">
|
||||
<div ref="cardRef" class="card-preview">
|
||||
<div class="card-content">
|
||||
<div class="card-image-section">
|
||||
<img :src="imageUrl" alt="Card Image" class="preview-image" />
|
||||
</div>
|
||||
<div class="card-message-section">
|
||||
<p class="preview-message">{{ $t('greetingCard.cardMessage') }}</p>
|
||||
</div>
|
||||
<div class="card-qrcode-section">
|
||||
<div ref="qrcodeRef" class="qrcode-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="handleClose">{{ $t('greetingCard.cancelButton') }}</el-button>
|
||||
<el-button type="primary" class="export-btn" @click="exportImage" :loading="exporting">
|
||||
<el-icon><Download /></el-icon> {{ $t('greetingCard.exportButton') }}
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, nextTick, computed } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Download } from '@element-plus/icons-vue';
|
||||
import QRCode from 'qrcode';
|
||||
import html2canvas from 'html2canvas';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
imageUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const visible = ref(false);
|
||||
const cardRef = ref(null);
|
||||
const qrcodeRef = ref(null);
|
||||
const exporting = ref(false);
|
||||
|
||||
const CARD_URL = '我是leo';
|
||||
|
||||
const dialogWidth = computed(() => {
|
||||
if (typeof window === 'undefined') return '400px';
|
||||
return window.innerWidth < 768 ? '95%' : '400px';
|
||||
});
|
||||
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
visible.value = newVal;
|
||||
if (newVal) {
|
||||
nextTick(() => {
|
||||
generateQRCode();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
watch(visible, (newVal) => {
|
||||
emit('update:modelValue', newVal);
|
||||
});
|
||||
|
||||
const generateQRCode = async () => {
|
||||
if (!qrcodeRef.value) return;
|
||||
|
||||
try {
|
||||
qrcodeRef.value.innerHTML = '';
|
||||
const canvas = document.createElement('canvas');
|
||||
await QRCode.toCanvas(canvas, CARD_URL, {
|
||||
width: 70,
|
||||
margin: 0,
|
||||
color: {
|
||||
dark: '#d63384',
|
||||
light: '#ffffff'
|
||||
}
|
||||
});
|
||||
qrcodeRef.value.appendChild(canvas);
|
||||
} catch (error) {
|
||||
console.error('QR Code generation failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const exportImage = async () => {
|
||||
if (!cardRef.value) return;
|
||||
|
||||
exporting.value = true;
|
||||
|
||||
try {
|
||||
const canvas = await html2canvas(cardRef.value, {
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
backgroundColor: '#fff5f7',
|
||||
logging: false
|
||||
});
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.download = `greeting-card-${Date.now()}.png`;
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.click();
|
||||
|
||||
ElMessage.success(t('greetingCard.exportSuccess'));
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
ElMessage.error(t('greetingCard.exportFailed'));
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.preview-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #fff5f7 0%, #ffe4e8 100%);
|
||||
}
|
||||
|
||||
.card-preview {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 32px rgba(255, 107, 157, 0.2);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
}
|
||||
|
||||
.card-image-section {
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.card-message-section {
|
||||
padding: 24px 20px;
|
||||
background: linear-gradient(135deg, #fff5f7 0%, #ffe4e8 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.preview-message {
|
||||
margin: 0;
|
||||
color: #d63384;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1.8;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-family: 'Georgia', 'Times New Roman', 'Playfair Display', 'Brush Script MT', cursive;
|
||||
text-shadow: 0 2px 4px rgba(214, 51, 132, 0.2);
|
||||
letter-spacing: 1px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.card-qrcode-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
background: linear-gradient(135deg, #fff5f7 0%, #ffe4e8 100%);
|
||||
}
|
||||
|
||||
.qrcode-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.qrcode-container canvas {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
background: linear-gradient(135deg, #ff6b9d 0%, #ff8a80 100%);
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 12px rgba(255, 107, 157, 0.3);
|
||||
padding: 12px 24px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.export-btn:hover {
|
||||
background: linear-gradient(135deg, #ff5a8d 0%, #ff7a70 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(255, 107, 157, 0.4);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.preview-container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.card-preview {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.card-image-section {
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.card-message-section {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.preview-message {
|
||||
font-size: 1.15rem;
|
||||
font-family: 'Georgia', 'Times New Roman', 'Playfair Display', 'Brush Script MT', cursive;
|
||||
}
|
||||
|
||||
.card-qrcode-section {
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.qrcode-container canvas {
|
||||
width: 60px !important;
|
||||
height: 60px !important;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
flex-direction: column-reverse;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dialog-footer .el-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -688,6 +688,25 @@ onMounted(() => {
|
|||
.welcome-visual {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.stat-icon :deep(.el-icon) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 统计卡片区 */
|
||||
|
|
@ -745,6 +764,19 @@ onMounted(() => {
|
|||
font-size: 24px;
|
||||
}
|
||||
|
||||
.stat-icon :deep(.el-icon) {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stat-icon :deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -387,7 +387,6 @@ const handleSaveProject = (index,item,type='image')=>{
|
|||
const createProject = async ()=>{
|
||||
const {id} = await PluginProject.createProject();
|
||||
// 创建新项目后,将当前路由跳转到 project/项目id/系列
|
||||
|
||||
await router.replace(`/project/${id}/${series.value}`);
|
||||
projectId.value = id;
|
||||
getProjectInfo(id);
|
||||
|
|
@ -406,6 +405,7 @@ const getProjectInfo = async (id)=>{
|
|||
id: card.id || Date.now() + Math.random().toString(36).substr(2, 9)
|
||||
}));
|
||||
projectInfo.value.tags = [series.value];
|
||||
updateProjectInfo(projectInfo.value);
|
||||
}
|
||||
//更新项目信息
|
||||
const updateProjectInfo = async (newProjectInfo)=>{
|
||||
|
|
@ -1136,6 +1136,11 @@ const init = ()=>{
|
|||
const route = useRoute();
|
||||
projectId.value = route.params.id;
|
||||
series.value = route.params.series;
|
||||
if(series.value!='D1'&&series.value!='E1'){
|
||||
series.value = 'D1';
|
||||
router.replace(`/project/${projectId.value}/${series.value}`);
|
||||
return
|
||||
}
|
||||
if(projectId.value === 'new'){
|
||||
createProject();
|
||||
return
|
||||
|
|
@ -1190,7 +1195,7 @@ onUnmounted(() => {// 禁用轮询
|
|||
top:8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
z-index: 100;
|
||||
width: 98.6%;
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
|
|
|
|||
|
|
@ -1287,7 +1287,7 @@ onUnmounted(() => {// 禁用轮询
|
|||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
z-index: 1000;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
html.dark .header-nav {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,589 @@
|
|||
<template>
|
||||
<div class="confession-card-container">
|
||||
<div ref="canvasContainer" class="canvas-container"></div>
|
||||
|
||||
<div class="ui-overlay">
|
||||
<div class="gesture-hint" v-if="!isGestureDetected">
|
||||
<div class="hint-icon">💕</div>
|
||||
<div class="hint-text">{{ $t('confessionCard.gestureHint') }}</div>
|
||||
<div class="hint-subtext">{{ $t('confessionCard.gestureSubtext') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 文字出现后再显示图片,增加淡入动画 -->
|
||||
<transition name="fade">
|
||||
<div class="image-display" v-if="isGestureDetected && showImage">
|
||||
<img :src="confessionImage" alt="Confession" class="confession-image" />
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div class="camera-toggle" @click="toggleCamera">
|
||||
<span v-if="!isCameraActive">{{ $t('confessionCard.enableCamera') }}</span>
|
||||
<span v-else>{{ $t('confessionCard.disableCamera') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<video ref="videoElement" class="hidden-video" autoplay playsinline></video>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import * as THREE from 'three'
|
||||
import { Hands } from '@mediapipe/hands'
|
||||
import { Camera } from '@mediapipe/camera_utils'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
const canvasContainer = ref(null)
|
||||
const videoElement = ref(null)
|
||||
const isCameraActive = ref(false)
|
||||
const isGestureDetected = ref(false)
|
||||
const showImage = ref(false)
|
||||
|
||||
// 针对中文环境优化换行,确保每行文字不过长
|
||||
const confessionText = computed(() => {
|
||||
return locale.value === 'zh'
|
||||
? `亲爱的,\n遇见你是我这辈子最幸运的事。\n你的笑容是我最大的动力,\n我想陪你走过每一个春夏秋冬。\n情人节快乐,我爱你!💖`
|
||||
: 'I LOVE YOU\nFOREVER & ALWAYS'
|
||||
})
|
||||
|
||||
const confessionImage = ref('https://images.unsplash.com/photo-1518199266791-5375a83190b7?w=400&h=400&fit=crop')
|
||||
|
||||
let scene, camera, renderer, particles
|
||||
let hands, cameraUtils
|
||||
let animationId
|
||||
let particlePositions = [] // 当前位置
|
||||
let originalPositions = [] // 初始散乱位置
|
||||
let targetPositions = [] // 文字目标位置
|
||||
const particleCount = 35000 // 增加粒子数量以提高清晰度
|
||||
let isAssembling = false
|
||||
let restoreTimeout = null
|
||||
|
||||
// 生成圆形纹理,让粒子更好看
|
||||
const createCircleTexture = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 32
|
||||
canvas.height = 32
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.beginPath()
|
||||
ctx.arc(16, 16, 14, 0, 2 * Math.PI)
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fill()
|
||||
const texture = new THREE.CanvasTexture(canvas)
|
||||
return texture
|
||||
}
|
||||
|
||||
const getThemeColors = () => {
|
||||
const isDark = themeStore.isDark
|
||||
return {
|
||||
background: isDark ? 0x0a0a1a : 0x1a0a2e,
|
||||
particleColors: [
|
||||
new THREE.Color(0xff6b9d),
|
||||
new THREE.Color(0xffb6c1), // LightPink
|
||||
new THREE.Color(0xff69b4), // HotPink
|
||||
new THREE.Color(0xff1493), // DeepPink
|
||||
new THREE.Color(0xffffff) // White for sparkle
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const initThreeScene = () => {
|
||||
const width = canvasContainer.value.clientWidth
|
||||
const height = canvasContainer.value.clientHeight
|
||||
|
||||
scene = new THREE.Scene()
|
||||
const themeColors = getThemeColors()
|
||||
scene.background = new THREE.Color(themeColors.background)
|
||||
|
||||
// 调整相机位置,保证视野合适
|
||||
camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000)
|
||||
camera.position.z = 60
|
||||
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
|
||||
renderer.setSize(width, height)
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||
canvasContainer.value.appendChild(renderer.domElement)
|
||||
|
||||
createParticles()
|
||||
animate()
|
||||
}
|
||||
|
||||
const createParticles = () => {
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
const positions = new Float32Array(particleCount * 3)
|
||||
const colors = new Float32Array(particleCount * 3)
|
||||
const sizes = new Float32Array(particleCount)
|
||||
|
||||
const themeColors = getThemeColors()
|
||||
const colorPalette = themeColors.particleColors
|
||||
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
// 初始状态:散布在空间中
|
||||
const x = (Math.random() - 0.5) * 150
|
||||
const y = (Math.random() - 0.5) * 100
|
||||
const z = (Math.random() - 0.5) * 100
|
||||
|
||||
positions[i * 3] = x
|
||||
positions[i * 3 + 1] = y
|
||||
positions[i * 3 + 2] = z
|
||||
|
||||
const color = colorPalette[Math.floor(Math.random() * colorPalette.length)]
|
||||
colors[i * 3] = color.r
|
||||
colors[i * 3 + 1] = color.g
|
||||
colors[i * 3 + 2] = color.b
|
||||
|
||||
sizes[i] = Math.random() // 随机大小
|
||||
|
||||
particlePositions.push({ x, y, z })
|
||||
originalPositions.push({ x, y, z }) // 记录初始位置用于复原
|
||||
}
|
||||
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
|
||||
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3))
|
||||
geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1))
|
||||
|
||||
const material = new THREE.PointsMaterial({
|
||||
size: 0.6, // 调整粒子大小
|
||||
vertexColors: true,
|
||||
map: createCircleTexture(),
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending
|
||||
})
|
||||
|
||||
particles = new THREE.Points(geometry, material)
|
||||
scene.add(particles)
|
||||
}
|
||||
|
||||
|
||||
const getTextPositions = (text) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
// 1. 调整字体大小 (稍微减小)
|
||||
const fontSize = 50
|
||||
const font = `bold ${fontSize}px "Noto Serif SC", serif`
|
||||
|
||||
// 预计算文本宽高
|
||||
ctx.font = font
|
||||
const lines = text.split('\n')
|
||||
let maxWidth = 0
|
||||
lines.forEach(line => {
|
||||
maxWidth = Math.max(maxWidth, ctx.measureText(line).width)
|
||||
})
|
||||
|
||||
const lineHeight = fontSize * 1.5
|
||||
const totalHeight = lines.length * lineHeight
|
||||
|
||||
canvas.width = Math.max(maxWidth + 100, 1024)
|
||||
canvas.height = Math.max(totalHeight + 100, 512)
|
||||
|
||||
ctx.fillStyle = '#000000'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = font
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
const startY = (canvas.height - totalHeight) / 2 + lineHeight / 2
|
||||
lines.forEach((line, index) => {
|
||||
ctx.fillText(line, canvas.width / 2, startY + index * lineHeight)
|
||||
})
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
const positions = []
|
||||
|
||||
// 2. 采样密度
|
||||
const step = 3
|
||||
|
||||
for (let y = 0; y < canvas.height; y += step) {
|
||||
for (let x = 0; x < canvas.width; x += step) {
|
||||
const index = (y * canvas.width + x) * 4
|
||||
if (imageData.data[index] > 128) {
|
||||
positions.push({
|
||||
// 3. 缩小文字整体比例 (0.15 -> 0.08)
|
||||
x: (x - canvas.width / 2) * 0.08,
|
||||
|
||||
// 4. 关键:位置下移
|
||||
// (y - canvas.height / 2) 是居中,后面 - 20 是为了把文字整体向下推,给图片留位置
|
||||
y: -(y - canvas.height / 2) * 0.08 - 8,
|
||||
|
||||
z: 0
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return positions
|
||||
}
|
||||
|
||||
const assembleParticles = () => {
|
||||
if (isAssembling) return
|
||||
isAssembling = true
|
||||
|
||||
const textPositions = getTextPositions(confessionText.value)
|
||||
|
||||
// 居中校正
|
||||
if (textPositions.length > 0) {
|
||||
// 简单的重新分配,确保足够的粒子去组成文字
|
||||
targetPositions = textPositions
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
showImage.value = true
|
||||
}, 800)
|
||||
}
|
||||
|
||||
const disperseParticles = () => {
|
||||
if (!isAssembling) return
|
||||
isAssembling = false
|
||||
showImage.value = false
|
||||
}
|
||||
|
||||
const animate = () => {
|
||||
animationId = requestAnimationFrame(animate)
|
||||
|
||||
if (particles) {
|
||||
const positions = particles.geometry.attributes.position.array
|
||||
const time = Date.now() * 0.001
|
||||
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
let target
|
||||
|
||||
if (isAssembling) {
|
||||
// 聚合模式
|
||||
if (i < targetPositions.length) {
|
||||
target = targetPositions[i]
|
||||
} else {
|
||||
// 多余的粒子:让它们在文字周围飞舞,或者隐藏
|
||||
// 这里让它们隐藏到远处,避免干扰文字阅读
|
||||
const original = originalPositions[i]
|
||||
target = {
|
||||
x: original.x * 2,
|
||||
y: original.y * 2,
|
||||
z: 200 // 推到后面去
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 散开模式:回到初始位置(OriginalPositions)
|
||||
// 重点:这里使用 originalPositions 而不是随机漫步
|
||||
const origin = originalPositions[i]
|
||||
|
||||
// 添加一点呼吸效果 (Sine wave),让粒子在原位附近浮动
|
||||
target = {
|
||||
x: origin.x + Math.sin(time + origin.y) * 2,
|
||||
y: origin.y + Math.cos(time + origin.x) * 2,
|
||||
z: origin.z + Math.sin(time + origin.z) * 2
|
||||
}
|
||||
}
|
||||
|
||||
// 差值移动 (Lerp)
|
||||
const px = particlePositions[i].x
|
||||
const py = particlePositions[i].y
|
||||
const pz = particlePositions[i].z
|
||||
|
||||
// 聚合时速度快一点(0.1),散开时慢一点柔和一点(0.05)
|
||||
const speed = isAssembling ? 0.1 : 0.05
|
||||
|
||||
particlePositions[i].x += (target.x - px) * speed
|
||||
particlePositions[i].y += (target.y - py) * speed
|
||||
particlePositions[i].z += (target.z - pz) * speed
|
||||
|
||||
positions[i * 3] = particlePositions[i].x
|
||||
positions[i * 3 + 1] = particlePositions[i].y
|
||||
positions[i * 3 + 2] = particlePositions[i].z
|
||||
}
|
||||
|
||||
particles.geometry.attributes.position.needsUpdate = true
|
||||
|
||||
// 缓慢旋转整个粒子群,增加3D感
|
||||
// 当显示文字时,减少旋转幅度以保证可读性
|
||||
if (isAssembling) {
|
||||
particles.rotation.y = THREE.MathUtils.lerp(particles.rotation.y, 0, 0.05)
|
||||
particles.rotation.x = THREE.MathUtils.lerp(particles.rotation.x, 0, 0.05)
|
||||
} else {
|
||||
particles.rotation.y = Math.sin(time * 0.2) * 0.1
|
||||
particles.rotation.x = Math.cos(time * 0.1) * 0.05
|
||||
}
|
||||
}
|
||||
|
||||
if (renderer && scene && camera) {
|
||||
renderer.render(scene, camera)
|
||||
}
|
||||
}
|
||||
|
||||
const initHandTracking = () => {
|
||||
hands = new Hands({
|
||||
locateFile: (file) => {
|
||||
return `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`
|
||||
}
|
||||
})
|
||||
|
||||
hands.setOptions({
|
||||
maxNumHands: 1, // 只需要检测单手
|
||||
modelComplexity: 1,
|
||||
minDetectionConfidence: 0.7,
|
||||
minTrackingConfidence: 0.5
|
||||
})
|
||||
|
||||
hands.onResults(onHandResults)
|
||||
}
|
||||
|
||||
const onHandResults = (results) => {
|
||||
let gestureFound = false;
|
||||
|
||||
if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) {
|
||||
const landmarks = results.multiHandLandmarks[0]
|
||||
if (detectHeartGesture(landmarks)) {
|
||||
gestureFound = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (gestureFound) {
|
||||
// 检测到手势
|
||||
if (!isGestureDetected.value) {
|
||||
isGestureDetected.value = true
|
||||
assembleParticles()
|
||||
}
|
||||
// 只要还在检测到手势,就清除定时器
|
||||
if (restoreTimeout) {
|
||||
clearTimeout(restoreTimeout)
|
||||
restoreTimeout = null
|
||||
}
|
||||
} else {
|
||||
// 未检测到手势
|
||||
if (isGestureDetected.value && !restoreTimeout) {
|
||||
// 只有当前是激活状态,且没有定时器时,才开始倒计时
|
||||
restoreTimeout = setTimeout(() => {
|
||||
isGestureDetected.value = false
|
||||
disperseParticles() // 恢复粒子
|
||||
restoreTimeout = null
|
||||
}, 3000) // 3秒后恢复
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 拇指和食指比心判断
|
||||
const detectHeartGesture = (landmarks) => {
|
||||
const thumbTip = landmarks[4]
|
||||
const indexTip = landmarks[8]
|
||||
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(thumbTip.x - indexTip.x, 2) +
|
||||
Math.pow(thumbTip.y - indexTip.y, 2)
|
||||
)
|
||||
|
||||
return distance < 0.15 // 稍微放宽一点判定阈值
|
||||
}
|
||||
|
||||
const toggleCamera = async () => {
|
||||
if (isCameraActive.value) {
|
||||
if (cameraUtils) {
|
||||
await cameraUtils.stop()
|
||||
}
|
||||
isCameraActive.value = false
|
||||
} else {
|
||||
try {
|
||||
cameraUtils = new Camera(videoElement.value, {
|
||||
onFrame: async () => {
|
||||
if (hands && videoElement.value) {
|
||||
await hands.send({ image: videoElement.value })
|
||||
}
|
||||
},
|
||||
width: 640,
|
||||
height: 480
|
||||
})
|
||||
|
||||
await cameraUtils.start()
|
||||
isCameraActive.value = true
|
||||
} catch (error) {
|
||||
console.error('Camera access error:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
if (!camera || !renderer || !canvasContainer.value) return
|
||||
|
||||
const width = canvasContainer.value.clientWidth
|
||||
const height = canvasContainer.value.clientHeight
|
||||
|
||||
camera.aspect = width / height
|
||||
camera.updateProjectionMatrix()
|
||||
renderer.setSize(width, height)
|
||||
}
|
||||
|
||||
watch(locale, () => {
|
||||
if (isGestureDetected.value) {
|
||||
// 如果语言切换且当前正在显示文字,重新生成
|
||||
isAssembling = false // 强制重置状态以触发重新计算
|
||||
assembleParticles()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => themeStore.isDark, () => {
|
||||
if (scene) {
|
||||
const themeColors = getThemeColors()
|
||||
scene.background = new THREE.Color(themeColors.background)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initThreeScene()
|
||||
initHandTracking()
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (animationId) cancelAnimationFrame(animationId)
|
||||
if (cameraUtils) cameraUtils.stop()
|
||||
if (renderer) renderer.dispose()
|
||||
if (restoreTimeout) clearTimeout(restoreTimeout)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.confession-card-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #0a0a1a 0%, #1a0a2e 50%, #2a0a3e 100%);
|
||||
}
|
||||
|
||||
html.dark .confession-card-container {
|
||||
background: linear-gradient(135deg, #050510 0%, #0a0515 50%, #100520 100%);
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ui-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gesture-hint {
|
||||
text-align: center;
|
||||
color: white;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.hint-icon {
|
||||
font-size: 80px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
text-shadow: 0 0 20px rgba(255, 107, 157, 0.8);
|
||||
}
|
||||
|
||||
.hint-subtext {
|
||||
font-size: 16px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.image-display {
|
||||
position: absolute;
|
||||
/* 1. 图片位置设定在屏幕上方 10% 处 */
|
||||
top: 10%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
/* 增加透视感 */
|
||||
perspective: 1000px;
|
||||
}
|
||||
.confession-image {
|
||||
/* 2. 修改为矩形尺寸 (类似 4:5 相片比例) */
|
||||
width: 240px;
|
||||
height: 300px;
|
||||
|
||||
/* 3. 相册风格:微圆角 + 白色边框 + 阴影 */
|
||||
border-radius: 4px;
|
||||
border: 10px solid #ffffff;
|
||||
border-bottom: 30px solid #ffffff; /* 底部留白多一点,像拍立得 */
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||
|
||||
object-fit: cover;
|
||||
|
||||
/* 4. 增加一点点旋转,看起来像摆放在那里 */
|
||||
transform: rotate(-2deg);
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
/* 鼠标悬停时摆正图片 */
|
||||
.confession-image:hover {
|
||||
transform: rotate(0deg) scale(1.02);
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
/* 稍微调整 Hint 的位置,避免和图片重叠(如果还没比心的时候) */
|
||||
.gesture-hint {
|
||||
text-align: center;
|
||||
color: white;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
z-index: 20;
|
||||
margin-top: -50px; /* 稍微向上提一点 */
|
||||
}
|
||||
.camera-toggle {
|
||||
position: absolute;
|
||||
bottom: 30px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 12px 24px;
|
||||
background: rgba(255, 107, 157, 0.3);
|
||||
border: 2px solid rgba(255, 107, 157, 0.6);
|
||||
border-radius: 30px;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
backdrop-filter: blur(10px);
|
||||
transition: all 0.3s ease;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.camera-toggle:hover {
|
||||
background: rgba(255, 107, 157, 0.5);
|
||||
transform: translateX(-50%) scale(1.05);
|
||||
}
|
||||
|
||||
.hidden-video {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 简单的淡入淡出动画 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.8s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.05); opacity: 0.8; }
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,754 @@
|
|||
<template>
|
||||
<div class="confession-card-container">
|
||||
<div ref="canvasContainer" class="canvas-container"></div>
|
||||
|
||||
<div class="ui-overlay">
|
||||
<div class="gesture-hint" v-if="!isGestureDetected">
|
||||
<div class="hint-icon">💕</div>
|
||||
<div class="hint-text">{{ $t('confessionCard.gestureHint') }}</div>
|
||||
<div class="hint-subtext">{{ $t('confessionCard.gestureSubtext') }}</div>
|
||||
|
||||
<button class="manual-trigger-btn" @click="manualTrigger">
|
||||
{{ $t('confessionCard.manualTrigger')}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div class="image-display" v-if="isGestureDetected && showImage">
|
||||
<img :src="confessionImage" alt="Confession" class="confession-image" />
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div class="camera-toggle" v-if="cameraInitFailed" @click="toggleCamera">
|
||||
<span>{{ $t('confessionCard.enableCamera') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<video ref="videoElement" class="hidden-video" autoplay playsinline></video>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import * as THREE from 'three'
|
||||
import { Hands } from '@mediapipe/hands'
|
||||
import { Camera } from '@mediapipe/camera_utils'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
const canvasContainer = ref(null)
|
||||
const videoElement = ref(null)
|
||||
const isCameraActive = ref(false)
|
||||
const isGestureDetected = ref(false)
|
||||
const showImage = ref(false)
|
||||
const isMobile = ref(window.innerWidth < 768)
|
||||
const cameraInitFailed = ref(false)
|
||||
const isManuallyTriggered = ref(false)
|
||||
|
||||
// 1. 文字排版:保持较宽的布局
|
||||
const confessionText = computed(() => {
|
||||
if (locale.value === 'zh') {
|
||||
return isMobile.value
|
||||
? `Dear, Meeting you is the luckiest thing in my life. Your smile is my greatest motivation, I want to accompany you through every season. Happy Valentine's Day, I love you!💖`
|
||||
: `Dear, Meeting you is the luckiest thing in my life. Your smile is my greatest motivation, I want to accompany you through every season. Happy Valentine's Day, I love you!💖`
|
||||
} else {
|
||||
return 'I LOVE YOU\nFOREVER & ALWAYS'
|
||||
}
|
||||
})
|
||||
|
||||
const confessionImage = ref('https://images.unsplash.com/photo-1518199266791-5375a83190b7?w=400&h=400&fit=crop')
|
||||
|
||||
let scene, camera, renderer, particles
|
||||
let hands, cameraUtils
|
||||
let animationId
|
||||
let particlePositions = []
|
||||
let originalPositions = []
|
||||
let targetPositions = []
|
||||
let baseSizes = [] // 新增:存储粒子原始大小
|
||||
const particleCount = 18000 // 增加粒子上限
|
||||
let isAssembling = false
|
||||
let restoreTimeout = null
|
||||
|
||||
const createCircleTexture = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 32
|
||||
canvas.height = 32
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.beginPath()
|
||||
ctx.arc(16, 16, 14, 0, 2 * Math.PI)
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fill()
|
||||
const texture = new THREE.CanvasTexture(canvas)
|
||||
return texture
|
||||
}
|
||||
|
||||
const getThemeColors = () => {
|
||||
const isDark = themeStore.isDark
|
||||
return {
|
||||
background: isDark ? 0x0a0a1a : 0x1a0a2e,
|
||||
particleColors: [
|
||||
new THREE.Color(0xff6b9d),
|
||||
new THREE.Color(0xffb6c1),
|
||||
new THREE.Color(0xff69b4),
|
||||
new THREE.Color(0xff1493),
|
||||
new THREE.Color(0xffffff)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const initThreeScene = () => {
|
||||
if (!canvasContainer.value) return
|
||||
const width = canvasContainer.value.clientWidth
|
||||
const height = canvasContainer.value.clientHeight
|
||||
|
||||
scene = new THREE.Scene()
|
||||
const themeColors = getThemeColors()
|
||||
scene.background = new THREE.Color(themeColors.background)
|
||||
|
||||
camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000)
|
||||
|
||||
// 移动端视野拉远 (110),适配宽文字
|
||||
camera.position.z = isMobile.value ? 110 : 60
|
||||
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
|
||||
renderer.setSize(width, height)
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||
canvasContainer.value.appendChild(renderer.domElement)
|
||||
|
||||
createParticles()
|
||||
animate()
|
||||
}
|
||||
|
||||
const createParticles = () => {
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
const positions = new Float32Array(particleCount * 3)
|
||||
const colors = new Float32Array(particleCount * 3)
|
||||
const sizes = new Float32Array(particleCount)
|
||||
baseSizes = new Float32Array(particleCount)
|
||||
|
||||
const themeColors = getThemeColors()
|
||||
const colorPalette = themeColors.particleColors
|
||||
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const x = (Math.random() - 0.5) * 200
|
||||
const y = (Math.random() - 0.5) * 150
|
||||
const z = (Math.random() - 0.5) * 100
|
||||
|
||||
positions[i * 3] = x
|
||||
positions[i * 3 + 1] = y
|
||||
positions[i * 3 + 2] = z
|
||||
|
||||
const color = colorPalette[Math.floor(Math.random() * colorPalette.length)]
|
||||
colors[i * 3] = color.r
|
||||
colors[i * 3 + 1] = color.g
|
||||
colors[i * 3 + 2] = color.b
|
||||
|
||||
// 随机大小并保存到 baseSizes
|
||||
const size = Math.random()
|
||||
sizes[i] = size
|
||||
baseSizes[i] = size
|
||||
|
||||
particlePositions.push({ x, y, z })
|
||||
originalPositions.push({ x, y, z })
|
||||
}
|
||||
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
|
||||
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3))
|
||||
geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1))
|
||||
|
||||
const material = new THREE.PointsMaterial({
|
||||
size: 0.6,
|
||||
vertexColors: true,
|
||||
map: createCircleTexture(),
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending
|
||||
})
|
||||
|
||||
particles = new THREE.Points(geometry, material)
|
||||
scene.add(particles)
|
||||
}
|
||||
|
||||
|
||||
const getTextPositions = (text) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
// 1. 设置字体
|
||||
const fontSize = isMobile.value ? 36 : 50
|
||||
const font = `bold ${fontSize}px "Noto Serif SC", serif`
|
||||
ctx.font = font
|
||||
|
||||
// 2. === 新增:自动换行逻辑 ===
|
||||
|
||||
// 设定最大宽度:手机端屏幕宽度的 80%,PC 端固定宽度
|
||||
// 这里我们用一个虚拟像素值,比如手机 350px,PC 800px
|
||||
const maxLineWidth = isMobile.value ? 350 : 800
|
||||
|
||||
const rawLines = text.split('\n') // 先保留用户手动输入的换行
|
||||
const lines = []
|
||||
|
||||
// 遍历每一段,处理自动换行
|
||||
rawLines.forEach(paragraph => {
|
||||
let currentLine = ''
|
||||
// 将段落拆分成字符数组
|
||||
const characters = paragraph.split('')
|
||||
|
||||
for (let i = 0; i < characters.length; i++) {
|
||||
const char = characters[i]
|
||||
const testLine = currentLine + char
|
||||
const metrics = ctx.measureText(testLine)
|
||||
|
||||
// 如果加上这个字超过了最大宽度,且不是这一行的第一个字
|
||||
if (metrics.width > maxLineWidth && i > 0) {
|
||||
lines.push(currentLine) // 把当前行存入
|
||||
currentLine = char // 新起一行,把这个字放进去
|
||||
} else {
|
||||
currentLine = testLine // 没超标,追加到当前行
|
||||
}
|
||||
}
|
||||
lines.push(currentLine) // 把最后剩下的也存入
|
||||
})
|
||||
|
||||
// 3. 计算 Canvas 尺寸
|
||||
let maxWidth = 0
|
||||
lines.forEach(line => {
|
||||
maxWidth = Math.max(maxWidth, ctx.measureText(line).width)
|
||||
})
|
||||
|
||||
const lineHeight = fontSize * 1.5
|
||||
const totalHeight = lines.length * lineHeight
|
||||
|
||||
// 增加一点 Padding
|
||||
canvas.width = Math.max(maxWidth + 100, 512)
|
||||
canvas.height = Math.max(totalHeight + 100, 256)
|
||||
|
||||
// 4. 开始绘图
|
||||
ctx.fillStyle = '#000000'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = font
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
const startY = (canvas.height - totalHeight) / 2 + lineHeight / 2
|
||||
lines.forEach((line, index) => {
|
||||
ctx.fillText(line, canvas.width / 2, startY + index * lineHeight)
|
||||
})
|
||||
|
||||
// === 动态计算偏移量 ===
|
||||
// 计算文字在 3D 世界中的总高度
|
||||
const textWorldHeight = lines.length * lineHeight * 0.08
|
||||
|
||||
let shiftY = isMobile.value ? -12 : -15 // 默认值
|
||||
|
||||
if (isMobile.value) {
|
||||
// 模拟 CSS 逻辑计算图片实际像素高度
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
|
||||
// CSS: max-width: 65vw, max-height: 35vh, aspect-ratio: 4/5 (0.8)
|
||||
let imgH = vh * 0.35
|
||||
let imgW = imgH * 0.8
|
||||
|
||||
if (imgW > vw * 0.65) {
|
||||
imgW = vw * 0.65
|
||||
imgH = imgW * 1.25
|
||||
}
|
||||
|
||||
// 图片底部像素位置 = Top(12%) + Height + Border(24px)
|
||||
const imgBottomPx = vh * 0.12 + imgH + 24
|
||||
|
||||
// 转换为屏幕比例 (0~1)
|
||||
const imgBottomRatio = imgBottomPx / vh
|
||||
|
||||
// 计算可见高度 (World Units)
|
||||
const visibleHeight = 2 * Math.tan((60 * Math.PI / 180) / 2) * camera.position.z
|
||||
|
||||
// 转换为世界坐标 Y
|
||||
// Screen Top (0) -> NDC Y (+1) -> World Y (+H/2)
|
||||
// Screen Bottom (1) -> NDC Y (-1) -> World Y (-H/2)
|
||||
// Formula: WorldY = (1 - 2 * ratio) * (visibleHeight / 2)
|
||||
const imgBottomWorldY = (1 - 2 * imgBottomRatio) * (visibleHeight / 2)
|
||||
|
||||
// 设定文字顶部在图片底部下方 5 单位处
|
||||
const safeTopY = imgBottomWorldY - 5
|
||||
|
||||
// 计算需要的中心偏移量
|
||||
// Top = Center + HalfHeight => Center = Top - HalfHeight
|
||||
shiftY = safeTopY - textWorldHeight / 2
|
||||
}
|
||||
|
||||
// 5. 提取像素点 (保持之前的优化配置)
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
const positions = []
|
||||
const step = isMobile.value ? 2 : 3
|
||||
|
||||
for (let y = 0; y < canvas.height; y += step) {
|
||||
for (let x = 0; x < canvas.width; x += step) {
|
||||
const index = (y * canvas.width + x) * 4
|
||||
if (imageData.data[index] > 128) {
|
||||
positions.push({
|
||||
x: (x - canvas.width / 2) * (isMobile.value ? 0.11 : 0.08),
|
||||
// 使用动态计算的 shiftY
|
||||
y: -(y - canvas.height / 2) * 0.08 + shiftY,
|
||||
z: 0
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return positions
|
||||
}
|
||||
const assembleParticles = () => {
|
||||
if (isAssembling) return
|
||||
isAssembling = true
|
||||
|
||||
const textPositions = getTextPositions(confessionText.value)
|
||||
if (textPositions.length > 0) {
|
||||
targetPositions = textPositions
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
showImage.value = true
|
||||
}, 800)
|
||||
}
|
||||
|
||||
const disperseParticles = () => {
|
||||
if (!isAssembling) return
|
||||
isAssembling = false
|
||||
showImage.value = false
|
||||
}
|
||||
|
||||
const animate = () => {
|
||||
animationId = requestAnimationFrame(animate)
|
||||
|
||||
if (particles) {
|
||||
const positions = particles.geometry.attributes.position.array
|
||||
const sizes = particles.geometry.attributes.size.array // 获取 size 属性
|
||||
const time = Date.now() * 0.001
|
||||
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
let targetPosition
|
||||
let targetSize
|
||||
|
||||
if (isAssembling) {
|
||||
// === 聚合状态 ===
|
||||
if (i < targetPositions.length) {
|
||||
// 情况 A: 需要用来显示文字的粒子
|
||||
targetPosition = targetPositions[i]
|
||||
targetSize = baseSizes[i] // 恢复原大小
|
||||
} else {
|
||||
// 情况 B: 多余的粒子 -> 隐藏
|
||||
const original = originalPositions[i]
|
||||
targetPosition = {
|
||||
x: original.x * 2,
|
||||
y: original.y * 2,
|
||||
z: 200 // 推远
|
||||
}
|
||||
targetSize = 0 // ★★★ 缩小至0,实现隐藏效果
|
||||
}
|
||||
} else {
|
||||
// === 散开状态 ===
|
||||
const origin = originalPositions[i]
|
||||
targetPosition = {
|
||||
x: origin.x + Math.sin(time + origin.y) * 2,
|
||||
y: origin.y + Math.cos(time + origin.x) * 2,
|
||||
z: origin.z + Math.sin(time + origin.z) * 2
|
||||
}
|
||||
targetSize = baseSizes[i] // 恢复所有粒子显示
|
||||
}
|
||||
|
||||
// --- 1. 位置插值 ---
|
||||
const px = particlePositions[i].x
|
||||
const py = particlePositions[i].y
|
||||
const pz = particlePositions[i].z
|
||||
const moveSpeed = isAssembling ? 0.1 : 0.05
|
||||
|
||||
particlePositions[i].x += (targetPosition.x - px) * moveSpeed
|
||||
particlePositions[i].y += (targetPosition.y - py) * moveSpeed
|
||||
particlePositions[i].z += (targetPosition.z - pz) * moveSpeed
|
||||
|
||||
positions[i * 3] = particlePositions[i].x
|
||||
positions[i * 3 + 1] = particlePositions[i].y
|
||||
positions[i * 3 + 2] = particlePositions[i].z
|
||||
|
||||
// --- 2. 大小插值 (平滑隐藏/显示) ---
|
||||
// 聚合时变化稍快(0.1),视觉更利落
|
||||
sizes[i] += (targetSize - sizes[i]) * 0.1
|
||||
}
|
||||
|
||||
// 必须标记更新
|
||||
particles.geometry.attributes.position.needsUpdate = true
|
||||
particles.geometry.attributes.size.needsUpdate = true
|
||||
|
||||
// 旋转效果
|
||||
if (isAssembling) {
|
||||
particles.rotation.y = THREE.MathUtils.lerp(particles.rotation.y, 0, 0.05)
|
||||
particles.rotation.x = THREE.MathUtils.lerp(particles.rotation.x, 0, 0.05)
|
||||
} else {
|
||||
particles.rotation.y = Math.sin(time * 0.2) * 0.1
|
||||
particles.rotation.x = Math.cos(time * 0.1) * 0.05
|
||||
}
|
||||
}
|
||||
|
||||
if (renderer && scene && camera) {
|
||||
renderer.render(scene, camera)
|
||||
}
|
||||
}
|
||||
|
||||
const initHandTracking = () => {
|
||||
hands = new Hands({
|
||||
locateFile: (file) => {
|
||||
return `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`
|
||||
}
|
||||
})
|
||||
|
||||
hands.setOptions({
|
||||
maxNumHands: 1,
|
||||
modelComplexity: 1,
|
||||
minDetectionConfidence: 0.7,
|
||||
minTrackingConfidence: 0.5
|
||||
})
|
||||
|
||||
hands.onResults(onHandResults)
|
||||
}
|
||||
|
||||
const onHandResults = (results) => {
|
||||
let gestureFound = false;
|
||||
|
||||
if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) {
|
||||
const landmarks = results.multiHandLandmarks[0]
|
||||
if (detectHeartGesture(landmarks)) {
|
||||
gestureFound = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (gestureFound) {
|
||||
if (!isGestureDetected.value) {
|
||||
isGestureDetected.value = true
|
||||
isManuallyTriggered.value = false // 手势触发重置手动标记
|
||||
assembleParticles()
|
||||
}
|
||||
if (restoreTimeout) {
|
||||
clearTimeout(restoreTimeout)
|
||||
restoreTimeout = null
|
||||
}
|
||||
} else {
|
||||
// 如果是手动触发的,不自动散开
|
||||
if (isGestureDetected.value && !restoreTimeout && !isManuallyTriggered.value) {
|
||||
restoreTimeout = setTimeout(() => {
|
||||
isGestureDetected.value = false
|
||||
disperseParticles()
|
||||
restoreTimeout = null
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const manualTrigger = () => {
|
||||
isGestureDetected.value = true
|
||||
isManuallyTriggered.value = true
|
||||
assembleParticles()
|
||||
|
||||
// 清除可能存在的自动重置定时器
|
||||
if (restoreTimeout) {
|
||||
clearTimeout(restoreTimeout)
|
||||
restoreTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
const detectHeartGesture = (landmarks) => {
|
||||
const thumbTip = landmarks[4]
|
||||
const indexTip = landmarks[8]
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(thumbTip.x - indexTip.x, 2) +
|
||||
Math.pow(thumbTip.y - indexTip.y, 2)
|
||||
)
|
||||
return distance < 0.15
|
||||
}
|
||||
|
||||
const toggleCamera = async () => {
|
||||
if (isCameraActive.value) {
|
||||
if (cameraUtils) {
|
||||
await cameraUtils.stop()
|
||||
}
|
||||
isCameraActive.value = false
|
||||
} else {
|
||||
try {
|
||||
cameraUtils = new Camera(videoElement.value, {
|
||||
onFrame: async () => {
|
||||
if (hands && videoElement.value) {
|
||||
await hands.send({ image: videoElement.value })
|
||||
}
|
||||
},
|
||||
width: 640,
|
||||
height: 480
|
||||
})
|
||||
|
||||
await cameraUtils.start()
|
||||
isCameraActive.value = true
|
||||
cameraInitFailed.value = false
|
||||
} catch (error) {
|
||||
console.error('Camera access error:', error)
|
||||
cameraInitFailed.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const initCamera = async () => {
|
||||
try {
|
||||
cameraUtils = new Camera(videoElement.value, {
|
||||
onFrame: async () => {
|
||||
if (hands && videoElement.value) {
|
||||
await hands.send({ image: videoElement.value })
|
||||
}
|
||||
},
|
||||
width: 640,
|
||||
height: 480
|
||||
})
|
||||
|
||||
await cameraUtils.start()
|
||||
isCameraActive.value = true
|
||||
cameraInitFailed.value = false
|
||||
} catch (error) {
|
||||
console.error('Camera auto-init error:', error)
|
||||
cameraInitFailed.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
if (!camera || !renderer || !canvasContainer.value) return
|
||||
|
||||
const width = canvasContainer.value.clientWidth
|
||||
const height = canvasContainer.value.clientHeight
|
||||
|
||||
const wasMobile = isMobile.value
|
||||
isMobile.value = width < 768
|
||||
|
||||
if (wasMobile !== isMobile.value && isGestureDetected.value) {
|
||||
isAssembling = false
|
||||
assembleParticles()
|
||||
}
|
||||
|
||||
camera.aspect = width / height
|
||||
camera.updateProjectionMatrix()
|
||||
|
||||
camera.position.z = isMobile.value ? 110 : 60
|
||||
|
||||
renderer.setSize(width, height)
|
||||
}
|
||||
|
||||
watch(locale, () => {
|
||||
if (isGestureDetected.value) {
|
||||
isAssembling = false
|
||||
assembleParticles()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => themeStore.isDark, () => {
|
||||
if (scene) {
|
||||
const themeColors = getThemeColors()
|
||||
scene.background = new THREE.Color(themeColors.background)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initThreeScene()
|
||||
initHandTracking()
|
||||
initCamera()
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (animationId) cancelAnimationFrame(animationId)
|
||||
if (cameraUtils) cameraUtils.stop()
|
||||
if (renderer) renderer.dispose()
|
||||
if (restoreTimeout) clearTimeout(restoreTimeout)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.confession-card-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #0a0a1a 0%, #1a0a2e 50%, #2a0a3e 100%);
|
||||
}
|
||||
|
||||
html.dark .confession-card-container {
|
||||
background: linear-gradient(135deg, #050510 0%, #0a0515 50%, #100520 100%);
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ui-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gesture-hint {
|
||||
text-align: center;
|
||||
color: white;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.manual-trigger-btn {
|
||||
margin-top: 30px;
|
||||
padding: 10px 24px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 20px;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
pointer-events: auto; /* 必须开启,因为父级 pointer-events: none */
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.manual-trigger-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.hint-icon {
|
||||
font-size: 80px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
text-shadow: 0 0 20px rgba(255, 107, 157, 0.8);
|
||||
}
|
||||
|
||||
.hint-subtext {
|
||||
font-size: 16px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.image-display {
|
||||
position: absolute;
|
||||
top: 10%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
perspective: 1000px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.confession-image {
|
||||
width: 240px;
|
||||
height: 300px;
|
||||
border-radius: 4px;
|
||||
border: 10px solid #ffffff;
|
||||
border-bottom: 30px solid #ffffff;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||
object-fit: cover;
|
||||
transform: rotate(-2deg);
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
|
||||
.confession-image:hover {
|
||||
transform: rotate(0deg) scale(1.02);
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
.camera-toggle {
|
||||
position: absolute;
|
||||
bottom: 30px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 12px 24px;
|
||||
background: rgba(255, 107, 157, 0.3);
|
||||
border: 2px solid rgba(255, 107, 157, 0.6);
|
||||
border-radius: 30px;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
backdrop-filter: blur(10px);
|
||||
transition: all 0.3s ease;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.camera-toggle:hover {
|
||||
background: rgba(255, 107, 157, 0.5);
|
||||
transform: translateX(-50%) scale(1.05);
|
||||
}
|
||||
|
||||
.hidden-video {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.image-display {
|
||||
top: 12%;
|
||||
}
|
||||
|
||||
.confession-image {
|
||||
width: 60vw;
|
||||
height: auto;
|
||||
aspect-ratio: 4/5;
|
||||
border-width: 8px;
|
||||
border-bottom-width: 24px;
|
||||
}
|
||||
|
||||
.hint-icon {
|
||||
font-size: 60px;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.hint-subtext {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.camera-toggle {
|
||||
bottom: 40px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
width: max-content;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.8s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.05); opacity: 0.8; }
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,986 @@
|
|||
<template>
|
||||
<div class="card-preview-container">
|
||||
<div ref="canvasContainer" class="canvas-container"></div>
|
||||
|
||||
<!-- DOM Cursor -->
|
||||
<div ref="domCursor" class="dom-cursor">
|
||||
<div class="cursor-ring"></div>
|
||||
</div>
|
||||
|
||||
<!-- Camera Feed (Hidden or Small) -->
|
||||
<video ref="videoElement" class="input_video" style="display: none;"></video>
|
||||
|
||||
<!-- UI Overlay -->
|
||||
<div class="ui-overlay">
|
||||
<div class="controls">
|
||||
<button @click="toggleCamera" class="control-btn">
|
||||
{{ isCameraActive ? 'Disable Camera' : 'Enable Camera Control' }}
|
||||
</button>
|
||||
<div class="instructions" v-if="isCameraActive">
|
||||
<p>👋 Open Palm to scroll</p>
|
||||
<p>☝️ Point to aim</p>
|
||||
<p>🤏 Pinch to extract</p>
|
||||
<p>🖐️ Release to shatter</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Screen -->
|
||||
<div v-if="loading" class="loading-overlay">
|
||||
<div class="loader">Loading Neural Interface...</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, defineProps, withDefaults } from 'vue';
|
||||
import * as THREE from 'three';
|
||||
import TWEEN from '@tweenjs/tween.js';
|
||||
import { Hands, HAND_CONNECTIONS } from '@mediapipe/hands';
|
||||
import { Camera } from '@mediapipe/camera_utils';
|
||||
|
||||
// --- Props ---
|
||||
interface CardData {
|
||||
id: number | string;
|
||||
title: string;
|
||||
description: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
cards?: CardData[];
|
||||
}>(), {
|
||||
cards: () => [
|
||||
{ id: 1, title: 'NEBULA ONE', description: 'Explore the vast unknown.', color: '#00ffff' },
|
||||
{ id: 2, title: 'CYBER CORE', description: 'Digital consciousness.', color: '#ff00ff' },
|
||||
{ id: 3, title: 'QUANTUM LEAP', description: 'Beyond physics.', color: '#ffff00' },
|
||||
{ id: 4, title: 'VOID WALKER', description: 'Silence in space.', color: '#ff0000' },
|
||||
{ id: 5, title: 'SOLAR FLARE', description: 'Power of the sun.', color: '#ff8800' },
|
||||
{ id: 6, title: 'DARK MATTER', description: 'Invisible forces.', color: '#9900ff' },
|
||||
{ id: 7, title: 'STAR GATE', description: 'Portals to new worlds.', color: '#00ff88' },
|
||||
]
|
||||
});
|
||||
|
||||
// --- State ---
|
||||
const canvasContainer = ref<HTMLElement | null>(null);
|
||||
const domCursor = ref<HTMLElement | null>(null);
|
||||
const videoElement = ref<HTMLVideoElement | null>(null);
|
||||
const loading = ref(true);
|
||||
const isCameraActive = ref(false);
|
||||
|
||||
// --- Cursor Smooth Movement ---
|
||||
let cursorTargetX = 0;
|
||||
let cursorTargetY = 0;
|
||||
let cursorCurrentX = 0;
|
||||
let cursorCurrentY = 0;
|
||||
|
||||
// --- Three.js Variables ---
|
||||
let scene: THREE.Scene;
|
||||
let camera: THREE.PerspectiveCamera;
|
||||
let renderer: THREE.WebGLRenderer;
|
||||
let stars: THREE.Points;
|
||||
let carouselGroup: THREE.Group;
|
||||
let raycaster: THREE.Raycaster;
|
||||
let mouse: THREE.Vector2;
|
||||
|
||||
// --- Interaction Variables ---
|
||||
const cardsMeshes: THREE.Group[] = [];
|
||||
let currentX = 0;
|
||||
let scrollVelocity = 0;
|
||||
let scrollAcceleration = 0;
|
||||
let isDragging = false;
|
||||
let previousMousePosition = { x: 0, y: 0 };
|
||||
let extractedCardGroup: THREE.Group | null = null;
|
||||
let originalCardPosition = new THREE.Vector3();
|
||||
let originalCardRotation = new THREE.Euler();
|
||||
let particleSystem: THREE.Points | null = null;
|
||||
let particleVelocities: Float32Array;
|
||||
let particleOriginalPos: Float32Array;
|
||||
let isPinching = false;
|
||||
let isExtracted = false;
|
||||
|
||||
// --- Damping Physics Constants ---
|
||||
const DAMPING = 0.94; // Damping coefficient (0-1, lower = more damping)
|
||||
const ACCELERATION = 0.12; // Acceleration factor (increased from 0.08 to 0.12, 1.5x faster)
|
||||
const MAX_VELOCITY = 4.5; // Maximum scroll velocity (increased from 3 to 4.5, 1.5x faster)
|
||||
|
||||
// --- Infinite Scroll Variables ---
|
||||
const TOTAL_CARDS = 21; // Total cards to create for infinite scroll effect
|
||||
const ORIGINAL_CARD_COUNT = 7; // Original number of unique cards
|
||||
|
||||
// --- Mediapipe Variables ---
|
||||
let hands: Hands;
|
||||
let cameraUtils: Camera;
|
||||
|
||||
// --- Constants ---
|
||||
const CARD_SPACING = 7.5; // Increased from 5 to 7.5 (1.5x wider spacing)
|
||||
const CARD_WIDTH = 4;
|
||||
const CARD_HEIGHT = 6;
|
||||
const CARD_RADIUS = 0.2; // Rounded corner radius (Reduced from 0.5)
|
||||
|
||||
// --- Initialization ---
|
||||
onMounted(async () => {
|
||||
initThree();
|
||||
createStarField();
|
||||
createCards();
|
||||
|
||||
window.addEventListener('resize', onWindowResize);
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mousedown', onMouseDown);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
|
||||
animate();
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onWindowResize);
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mousedown', onMouseDown);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
|
||||
if (cameraUtils) cameraUtils.stop();
|
||||
if (renderer) renderer.dispose();
|
||||
});
|
||||
|
||||
// --- Three.js Setup ---
|
||||
const initThree = () => {
|
||||
if (!canvasContainer.value) return;
|
||||
|
||||
scene = new THREE.Scene();
|
||||
scene.fog = new THREE.FogExp2(0x000000, 0.02);
|
||||
|
||||
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
|
||||
camera.position.z = 12;
|
||||
camera.position.y = 0;
|
||||
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
canvasContainer.value.appendChild(renderer.domElement);
|
||||
|
||||
// Lights
|
||||
const ambientLight = new THREE.AmbientLight(0x404040);
|
||||
scene.add(ambientLight);
|
||||
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
|
||||
directionalLight.position.set(0, 10, 10);
|
||||
scene.add(directionalLight);
|
||||
|
||||
const pointLight = new THREE.PointLight(0x00ffff, 2, 50);
|
||||
pointLight.position.set(0, 0, 5);
|
||||
scene.add(pointLight);
|
||||
|
||||
// Carousel Group (Linear Layout now)
|
||||
carouselGroup = new THREE.Group();
|
||||
scene.add(carouselGroup);
|
||||
|
||||
// Raycaster
|
||||
raycaster = new THREE.Raycaster();
|
||||
mouse = new THREE.Vector2();
|
||||
};
|
||||
|
||||
// --- Star Field ---
|
||||
const createStarField = () => {
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
const count = 3000;
|
||||
const positions = new Float32Array(count * 3);
|
||||
const colors = new Float32Array(count * 3);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const r = 40 + Math.random() * 60;
|
||||
const theta = 2 * Math.PI * Math.random();
|
||||
const phi = Math.acos(2 * Math.random() - 1);
|
||||
|
||||
positions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
|
||||
positions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
|
||||
positions[i * 3 + 2] = r * Math.cos(phi);
|
||||
|
||||
colors[i * 3] = 0.5 + Math.random() * 0.5;
|
||||
colors[i * 3 + 1] = 0.5 + Math.random() * 0.5;
|
||||
colors[i * 3 + 2] = 1;
|
||||
}
|
||||
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||
|
||||
const material = new THREE.PointsMaterial({
|
||||
size: 0.15,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
blending: THREE.AdditiveBlending
|
||||
});
|
||||
|
||||
stars = new THREE.Points(geometry, material);
|
||||
scene.add(stars);
|
||||
};
|
||||
|
||||
// --- Rounded Card Shape Helper ---
|
||||
const createRoundedRectShape = (width: number, height: number, radius: number) => {
|
||||
const shape = new THREE.Shape();
|
||||
const x = -width / 2;
|
||||
const y = -height / 2;
|
||||
shape.moveTo(x, y + radius);
|
||||
shape.lineTo(x, y + height - radius);
|
||||
shape.quadraticCurveTo(x, y + height, x + radius, y + height);
|
||||
shape.lineTo(x + width - radius, y + height);
|
||||
shape.quadraticCurveTo(x + width, y + height, x + width, y + height - radius);
|
||||
shape.lineTo(x + width, y + radius);
|
||||
shape.quadraticCurveTo(x + width, y, x + width - radius, y);
|
||||
shape.lineTo(x + radius, y);
|
||||
shape.quadraticCurveTo(x, y, x, y + radius);
|
||||
return shape;
|
||||
};
|
||||
|
||||
// --- Card Creation ---
|
||||
const createCards = () => {
|
||||
const shape = createRoundedRectShape(CARD_WIDTH, CARD_HEIGHT, CARD_RADIUS);
|
||||
const extrudeSettings = {
|
||||
steps: 2,
|
||||
depth: 0.05, // Thickness (reduced from 0.2 to 0.05)
|
||||
bevelEnabled: true,
|
||||
bevelThickness: 0.02,
|
||||
bevelSize: 0.02,
|
||||
bevelSegments: 3
|
||||
};
|
||||
|
||||
// Create TOTAL_CARDS cards, cycling through original data
|
||||
for (let i = 0; i < TOTAL_CARDS; i++) {
|
||||
// Get card data by cycling through original cards
|
||||
const dataIndex = i % ORIGINAL_CARD_COUNT;
|
||||
const data = props.cards[dataIndex];
|
||||
|
||||
const cardGroup = new THREE.Group();
|
||||
// Linear arrangement centered around 0
|
||||
const xPos = (i - (TOTAL_CARDS - 1) / 2) * CARD_SPACING;
|
||||
cardGroup.position.set(xPos, 0, 0);
|
||||
|
||||
// --- Back of Card (Default Visible) ---
|
||||
// Use ExtrudeGeometry for physical look
|
||||
const backGeometry = new THREE.ExtrudeGeometry(shape, extrudeSettings);
|
||||
// Center geometry
|
||||
backGeometry.center();
|
||||
|
||||
const backMaterial = new THREE.MeshStandardMaterial({
|
||||
color: 0x111111,
|
||||
roughness: 0.2,
|
||||
metalness: 0.8,
|
||||
emissive: new THREE.Color(data.color),
|
||||
emissiveIntensity: 0.2
|
||||
});
|
||||
const backMesh = new THREE.Mesh(backGeometry, backMaterial);
|
||||
backMesh.name = 'back';
|
||||
|
||||
// --- Front of Card (Content) ---
|
||||
// Plane geometry slightly smaller than card
|
||||
const frontGeometry = new THREE.ShapeGeometry(shape);
|
||||
const frontCanvas = createCardTexture(data);
|
||||
const frontTexture = new THREE.CanvasTexture(frontCanvas);
|
||||
const frontMaterial = new THREE.MeshBasicMaterial({
|
||||
map: frontTexture,
|
||||
side: THREE.FrontSide
|
||||
});
|
||||
const frontMesh = new THREE.Mesh(frontGeometry, frontMaterial);
|
||||
|
||||
// Card Flipper Container
|
||||
const flipper = new THREE.Group();
|
||||
flipper.name = `flipper_${i}`;
|
||||
|
||||
// Back Mesh faces +Z by default
|
||||
backMesh.position.z = 0;
|
||||
flipper.add(backMesh);
|
||||
|
||||
// Front Mesh
|
||||
frontMesh.position.z = -0.15; // Slightly behind back mesh
|
||||
// We need frontMesh to face -Z (opposite to back)
|
||||
frontMesh.rotation.y = Math.PI;
|
||||
flipper.add(frontMesh);
|
||||
|
||||
cardGroup.add(flipper);
|
||||
|
||||
// Store metadata - use original data index for consistency
|
||||
cardGroup.userData = {
|
||||
id: data.id,
|
||||
index: i,
|
||||
originalIndex: dataIndex,
|
||||
isFlipped: false,
|
||||
color: data.color
|
||||
};
|
||||
|
||||
carouselGroup.add(cardGroup);
|
||||
cardsMeshes.push(cardGroup);
|
||||
}
|
||||
};
|
||||
|
||||
const createCardTexture = (data: CardData) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 512;
|
||||
canvas.height = 768;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
// Rounded Corners for Canvas Content
|
||||
const radius = 30; // Matches reduced radius visually
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(radius, 0);
|
||||
ctx.lineTo(512 - radius, 0);
|
||||
ctx.quadraticCurveTo(512, 0, 512, radius);
|
||||
ctx.lineTo(512, 768 - radius);
|
||||
ctx.quadraticCurveTo(512, 768, 512 - radius, 768);
|
||||
ctx.lineTo(radius, 768);
|
||||
ctx.quadraticCurveTo(0, 768, 0, 768 - radius);
|
||||
ctx.lineTo(0, radius);
|
||||
ctx.quadraticCurveTo(0, 0, radius, 0);
|
||||
ctx.closePath();
|
||||
ctx.clip();
|
||||
|
||||
// Background
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, 768);
|
||||
gradient.addColorStop(0, '#000000');
|
||||
gradient.addColorStop(1, '#222222');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, 512, 768);
|
||||
|
||||
// Border
|
||||
ctx.strokeStyle = data.color;
|
||||
ctx.lineWidth = 10;
|
||||
ctx.strokeRect(10, 10, 492, 748);
|
||||
|
||||
// Title
|
||||
ctx.fillStyle = data.color;
|
||||
ctx.font = 'bold 60px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(data.title, 256, 150);
|
||||
|
||||
// Content
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '30px Arial';
|
||||
ctx.fillText(data.description, 256, 300);
|
||||
|
||||
// Footer
|
||||
ctx.font = '20px Monospace';
|
||||
ctx.fillStyle = '#888888';
|
||||
ctx.fillText(`ID: ${data.id}`, 256, 700);
|
||||
|
||||
return canvas;
|
||||
};
|
||||
|
||||
// --- DOM Cursor Functions ---
|
||||
const updateCursorPosition = (x: number, y: number) => {
|
||||
// Convert normalized coordinates (-1 to 1) to screen coordinates
|
||||
cursorTargetX = (x + 1) / 2 * window.innerWidth;
|
||||
cursorTargetY = (-y + 1) / 2 * window.innerHeight;
|
||||
|
||||
if (!domCursor.value) return;
|
||||
domCursor.value.style.display = 'block';
|
||||
};
|
||||
|
||||
const updateCursorSmoothly = () => {
|
||||
if (!domCursor.value) return;
|
||||
|
||||
// Smooth interpolation
|
||||
cursorCurrentX += (cursorTargetX - cursorCurrentX) * 0.3;
|
||||
cursorCurrentY += (cursorTargetY - cursorCurrentY) * 0.3;
|
||||
|
||||
domCursor.value.style.transform = `translate(${cursorCurrentX}px, ${cursorCurrentY}px) translate(-50%, -50%)`;
|
||||
};
|
||||
|
||||
const updateCursorColor = (isPinching: boolean, isHandOpen: boolean, isExtracted: boolean) => {
|
||||
if (!domCursor.value) return;
|
||||
|
||||
let color;
|
||||
let glowColor;
|
||||
|
||||
if (isPinching) {
|
||||
color = 'rgba(255, 51, 51, 0.9)'; // Red when pinching
|
||||
glowColor = 'rgba(255, 51, 51, 0.6)';
|
||||
} else if (isHandOpen && !isExtracted) {
|
||||
color = 'rgba(51, 255, 51, 0.9)'; // Green when open palm
|
||||
glowColor = 'rgba(51, 255, 51, 0.6)';
|
||||
} else {
|
||||
color = 'rgba(255, 255, 255, 0.9)'; // White by default
|
||||
glowColor = 'rgba(255, 255, 255, 0.6)';
|
||||
}
|
||||
|
||||
const ring = domCursor.value.querySelector('.cursor-ring');
|
||||
|
||||
if (ring) {
|
||||
ring.style.borderColor = color;
|
||||
ring.style.boxShadow = `0 0 20px ${glowColor}, 0 0 40px ${glowColor}, inset 0 0 20px ${glowColor}`;
|
||||
}
|
||||
};
|
||||
|
||||
const hideCursor = () => {
|
||||
if (!domCursor.value) return;
|
||||
domCursor.value.style.display = 'none';
|
||||
};
|
||||
|
||||
const showCursor = () => {
|
||||
if (!domCursor.value) return;
|
||||
domCursor.value.style.display = 'block';
|
||||
};
|
||||
|
||||
// --- Particle System ---
|
||||
const createShatterEffect = (cardGroup: THREE.Group) => {
|
||||
if (particleSystem) {
|
||||
scene.remove(particleSystem);
|
||||
particleSystem.geometry.dispose();
|
||||
(particleSystem.material as THREE.Material).dispose();
|
||||
particleSystem = null;
|
||||
}
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
const count = 2000; // More particles
|
||||
const positions = new Float32Array(count * 3);
|
||||
particleOriginalPos = new Float32Array(count * 3);
|
||||
particleVelocities = new Float32Array(count * 3);
|
||||
const colors = new Float32Array(count * 3);
|
||||
|
||||
// White particles as requested
|
||||
const particleColor = new THREE.Color(0xffffff);
|
||||
|
||||
// Get world position of the card
|
||||
const worldPos = new THREE.Vector3();
|
||||
cardGroup.getWorldPosition(worldPos);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Random position within card volume (approx)
|
||||
const x = (Math.random() - 0.5) * CARD_WIDTH;
|
||||
const y = (Math.random() - 0.5) * CARD_HEIGHT;
|
||||
const z = (Math.random() - 0.5) * 0.2;
|
||||
|
||||
positions[i * 3] = worldPos.x + x;
|
||||
positions[i * 3 + 1] = worldPos.y + y;
|
||||
positions[i * 3 + 2] = worldPos.z + z;
|
||||
|
||||
// Original position (relative to card center)
|
||||
particleOriginalPos[i * 3] = x;
|
||||
particleOriginalPos[i * 3 + 1] = y;
|
||||
particleOriginalPos[i * 3 + 2] = z;
|
||||
|
||||
// Explosion velocity
|
||||
particleVelocities[i * 3] = (Math.random() - 0.5) * 0.15;
|
||||
particleVelocities[i * 3 + 1] = (Math.random() - 0.5) * 0.15;
|
||||
particleVelocities[i * 3 + 2] = (Math.random() - 0.5) * 0.15;
|
||||
|
||||
colors[i * 3] = particleColor.r;
|
||||
colors[i * 3 + 1] = particleColor.g;
|
||||
colors[i * 3 + 2] = particleColor.b;
|
||||
}
|
||||
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||
|
||||
const material = new THREE.PointsMaterial({
|
||||
size: 0.05, // Smaller particles
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
blending: THREE.AdditiveBlending
|
||||
});
|
||||
|
||||
particleSystem = new THREE.Points(geometry, material);
|
||||
particleSystem.userData = {
|
||||
age: 0,
|
||||
targetGroup: cardGroup,
|
||||
state: 'exploding'
|
||||
};
|
||||
scene.add(particleSystem);
|
||||
};
|
||||
|
||||
// --- Mediapipe & Interaction ---
|
||||
const toggleCamera = async () => {
|
||||
if (isCameraActive.value) {
|
||||
if (cameraUtils) await cameraUtils.stop();
|
||||
isCameraActive.value = false;
|
||||
cursorGroup.visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
if (!hands) {
|
||||
hands = new Hands({
|
||||
locateFile: (file) => {
|
||||
return `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`;
|
||||
}
|
||||
});
|
||||
|
||||
hands.setOptions({
|
||||
maxNumHands: 1,
|
||||
modelComplexity: 1,
|
||||
minDetectionConfidence: 0.5,
|
||||
minTrackingConfidence: 0.5
|
||||
});
|
||||
|
||||
hands.onResults(onHandsResults);
|
||||
}
|
||||
|
||||
if (videoElement.value) {
|
||||
cameraUtils = new Camera(videoElement.value, {
|
||||
onFrame: async () => {
|
||||
if(videoElement.value) await hands.send({image: videoElement.value});
|
||||
},
|
||||
width: 1280,
|
||||
height: 720
|
||||
});
|
||||
await cameraUtils.start();
|
||||
isCameraActive.value = true;
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onHandsResults = (results: any) => {
|
||||
if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) {
|
||||
const landmarks = results.multiHandLandmarks[0];
|
||||
const indexTip = landmarks[8];
|
||||
const thumbTip = landmarks[4];
|
||||
const palmCenter = landmarks[9];
|
||||
const middleTip = landmarks[12];
|
||||
const ringTip = landmarks[16];
|
||||
const pinkyTip = landmarks[20];
|
||||
|
||||
// Check if hand is open (fingers extended)
|
||||
const isHandOpen =
|
||||
landmarks[8].y < landmarks[5].y &&
|
||||
landmarks[12].y < landmarks[9].y &&
|
||||
landmarks[16].y < landmarks[13].y &&
|
||||
landmarks[20].y < landmarks[17].y;
|
||||
|
||||
// 1. Cursor Mapping (Index Finger) - Update DOM cursor
|
||||
const x = (1 - indexTip.x) * 2 - 1;
|
||||
const y = -(indexTip.y * 2 - 1);
|
||||
|
||||
mouse.x = x;
|
||||
mouse.y = y;
|
||||
|
||||
// Update DOM cursor position
|
||||
updateCursorPosition(x, y);
|
||||
|
||||
// 2. Pinch Detection
|
||||
const pinchDist = Math.hypot(indexTip.x - thumbTip.x, indexTip.y - thumbTip.y);
|
||||
const isNowPinching = pinchDist < 0.05;
|
||||
|
||||
// Update DOM cursor color based on gesture
|
||||
updateCursorColor(isNowPinching, isHandOpen, isExtracted);
|
||||
|
||||
if (isNowPinching && !isPinching) {
|
||||
// Start Pinch
|
||||
isPinching = true;
|
||||
if (!isExtracted) {
|
||||
checkIntersectionAndSelect(true); // Immediate extract on pinch
|
||||
}
|
||||
} else if (!isNowPinching && isPinching) {
|
||||
// End Pinch (Release)
|
||||
isPinching = false;
|
||||
if (isExtracted) {
|
||||
shatterAndReturn();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Scroll Logic (Strictly Open Palm Only)
|
||||
// Only scroll if hand is open, NOT pinching, and NOT extracted
|
||||
if (isHandOpen && !isPinching && !isExtracted) {
|
||||
// Use Palm Center X to drive scroll
|
||||
const palmX = (1 - palmCenter.x) * 2 - 1;
|
||||
|
||||
// Deadzone
|
||||
if (Math.abs(palmX) > 0.15) {
|
||||
const speed = (Math.abs(palmX) - 0.15) * 3.375; // Increased from 2.25 to 3.375 (1.5x faster)
|
||||
const direction = Math.sign(palmX);
|
||||
// Apply acceleration instead of direct position change
|
||||
scrollAcceleration += direction * speed * ACCELERATION;
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
hideCursor();
|
||||
isPinching = false;
|
||||
}
|
||||
};
|
||||
|
||||
const checkIntersectionAndSelect = (doExtract: boolean) => {
|
||||
if (isExtracted && doExtract) return;
|
||||
|
||||
raycaster.setFromCamera(mouse, camera);
|
||||
const intersects = raycaster.intersectObjects(carouselGroup.children, true);
|
||||
|
||||
if (intersects.length > 0) {
|
||||
// Find parent group
|
||||
let obj = intersects[0].object;
|
||||
while(obj.parent && obj.parent !== carouselGroup) {
|
||||
obj = obj.parent;
|
||||
}
|
||||
|
||||
if (obj.userData && obj.userData.index !== undefined) {
|
||||
if (doExtract) {
|
||||
extractCard(obj as THREE.Group);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const extractCard = (cardGroup: THREE.Group) => {
|
||||
if (extractedCardGroup) return;
|
||||
|
||||
isExtracted = true;
|
||||
extractedCardGroup = cardGroup;
|
||||
|
||||
// Save original state (including world position and carousel offset)
|
||||
originalCardPosition.copy(cardGroup.position.clone());
|
||||
originalCardRotation.copy(cardGroup.children[0].rotation.clone());
|
||||
|
||||
// Also save the current carousel offset for proper return
|
||||
cardGroup.userData.carouselOffset = currentX;
|
||||
|
||||
const worldPos = new THREE.Vector3();
|
||||
cardGroup.getWorldPosition(worldPos);
|
||||
|
||||
carouselGroup.remove(cardGroup);
|
||||
scene.add(cardGroup);
|
||||
cardGroup.position.copy(worldPos);
|
||||
|
||||
// Animate to Center Screen
|
||||
const targetPos = new THREE.Vector3(0, 0, 8);
|
||||
|
||||
new TWEEN.Tween(cardGroup.position)
|
||||
.to(targetPos, 400)
|
||||
.easing(TWEEN.Easing.Back.Out)
|
||||
.start();
|
||||
|
||||
// Flip to Front (Rotate flipper)
|
||||
const flipper = cardGroup.children[0];
|
||||
new TWEEN.Tween(flipper.rotation)
|
||||
.to({ y: Math.PI }, 400)
|
||||
.start();
|
||||
};
|
||||
|
||||
const shatterAndReturn = () => {
|
||||
if (!extractedCardGroup) return;
|
||||
|
||||
const card = extractedCardGroup;
|
||||
|
||||
// 1. Create Particles
|
||||
createShatterEffect(card);
|
||||
|
||||
// 2. Hide Card temporarily
|
||||
card.visible = false;
|
||||
|
||||
// 3. Reset State Logic
|
||||
isExtracted = false;
|
||||
extractedCardGroup = null;
|
||||
|
||||
// 4. Return card to carousel group
|
||||
scene.remove(card);
|
||||
carouselGroup.add(card);
|
||||
|
||||
// Reset position and rotation
|
||||
// Adjust for carousel offset to maintain correct position in infinite scroll
|
||||
const carouselOffset = card.userData.carouselOffset || 0;
|
||||
const adjustedPosition = originalCardPosition.clone();
|
||||
adjustedPosition.x += (currentX - carouselOffset);
|
||||
|
||||
card.position.copy(adjustedPosition);
|
||||
card.rotation.set(0, 0, 0);
|
||||
|
||||
// Reset flipper rotation
|
||||
const flipper = card.children[0];
|
||||
flipper.rotation.set(0, 0, 0);
|
||||
|
||||
// Reset user data
|
||||
card.userData.isFlipped = false;
|
||||
|
||||
// Note: Card will be made visible again by particle system animation
|
||||
};
|
||||
|
||||
// --- Mouse Interaction ---
|
||||
const onMouseMove = (event: MouseEvent) => {
|
||||
if (!isCameraActive.value) {
|
||||
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
|
||||
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
|
||||
|
||||
if (isDragging && !isExtracted) {
|
||||
const deltaX = event.clientX - previousMousePosition.x;
|
||||
// Apply acceleration instead of direct position change (increased from 0.075 to 0.1125, 1.5x faster)
|
||||
scrollAcceleration += deltaX * 0.1125 * ACCELERATION;
|
||||
previousMousePosition = { x: event.clientX, y: event.clientY };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseDown = (event: MouseEvent) => {
|
||||
if (!isCameraActive.value) {
|
||||
// If clicked while extracted, shatter
|
||||
if (isExtracted) {
|
||||
shatterAndReturn();
|
||||
return;
|
||||
}
|
||||
|
||||
isDragging = true;
|
||||
previousMousePosition = { x: event.clientX, y: event.clientY };
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
if (!isCameraActive.value) {
|
||||
isDragging = false;
|
||||
// Check click for extract
|
||||
if (!isExtracted) {
|
||||
checkIntersectionAndSelect(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onWindowResize = () => {
|
||||
if (camera && renderer) {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Animation Loop ---
|
||||
const animate = () => {
|
||||
requestAnimationFrame(animate);
|
||||
TWEEN.update();
|
||||
|
||||
// Update cursor smoothly
|
||||
updateCursorSmoothly();
|
||||
|
||||
// Damping Physics Scroll with Infinite Loop
|
||||
// Apply acceleration
|
||||
scrollVelocity += scrollAcceleration;
|
||||
|
||||
// Apply damping (friction)
|
||||
scrollVelocity *= DAMPING;
|
||||
|
||||
// Clamp velocity
|
||||
scrollVelocity = Math.max(-MAX_VELOCITY, Math.min(MAX_VELOCITY, scrollVelocity));
|
||||
|
||||
// Update position
|
||||
currentX += scrollVelocity;
|
||||
carouselGroup.position.x = currentX;
|
||||
|
||||
// Reset acceleration for next frame
|
||||
scrollAcceleration = 0;
|
||||
|
||||
// Infinite Scroll Logic: Reposition cards when they go off-screen
|
||||
const totalWidth = TOTAL_CARDS * CARD_SPACING;
|
||||
const halfWidth = totalWidth / 2;
|
||||
|
||||
carouselGroup.children.forEach((cardGroup: any) => {
|
||||
if (cardGroup !== extractedCardGroup) {
|
||||
const worldX = cardGroup.position.x + currentX;
|
||||
const originalX = cardGroup.userData.originalX || cardGroup.position.x;
|
||||
|
||||
// Store original position if not stored
|
||||
if (!cardGroup.userData.originalX) {
|
||||
cardGroup.userData.originalX = originalX;
|
||||
}
|
||||
|
||||
// If card goes too far left, move it to the right
|
||||
if (worldX < -halfWidth - CARD_SPACING) {
|
||||
cardGroup.position.x += totalWidth;
|
||||
}
|
||||
// If card goes too far right, move it to the left
|
||||
else if (worldX > halfWidth + CARD_SPACING) {
|
||||
cardGroup.position.x -= totalWidth;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Star Animation
|
||||
if (stars) {
|
||||
stars.rotation.y += 0.0005;
|
||||
stars.rotation.x += 0.0002;
|
||||
}
|
||||
|
||||
// Cards Floating Effect
|
||||
if (carouselGroup) {
|
||||
carouselGroup.children.forEach((group: any, i) => {
|
||||
if (group.visible && group !== extractedCardGroup) {
|
||||
group.position.y = Math.sin(Date.now() * 0.001 + i) * 0.2;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Particle Animation
|
||||
if (particleSystem) {
|
||||
const positions = particleSystem.geometry.attributes.position.array as Float32Array;
|
||||
const count = positions.length / 3;
|
||||
particleSystem.userData.age += 1;
|
||||
|
||||
const targetGroup = particleSystem.userData.targetGroup;
|
||||
|
||||
// Calculate world position of target slot in carousel
|
||||
const targetWorldPos = new THREE.Vector3();
|
||||
targetGroup.getWorldPosition(targetWorldPos);
|
||||
|
||||
let allArrived = true;
|
||||
|
||||
for(let i=0; i<count; i++) {
|
||||
const ix = i*3;
|
||||
const iy = i*3+1;
|
||||
const iz = i*3+2;
|
||||
|
||||
if (particleSystem.userData.age < 60) {
|
||||
// Explode Phase
|
||||
positions[ix] += particleVelocities[ix];
|
||||
positions[iy] += particleVelocities[iy];
|
||||
positions[iz] += particleVelocities[iz];
|
||||
allArrived = false;
|
||||
} else {
|
||||
// Return Phase - use current world position of the card
|
||||
const tx = targetWorldPos.x + particleOriginalPos[ix];
|
||||
const ty = targetWorldPos.y + particleOriginalPos[iy];
|
||||
const tz = targetWorldPos.z + particleOriginalPos[iz];
|
||||
|
||||
positions[ix] += (tx - positions[ix]) * 0.15; // Faster return
|
||||
positions[iy] += (ty - positions[iy]) * 0.15;
|
||||
positions[iz] += (tz - positions[iz]) * 0.15;
|
||||
|
||||
if (Math.abs(tx - positions[ix]) > 0.1) allArrived = false;
|
||||
}
|
||||
}
|
||||
|
||||
particleSystem.geometry.attributes.position.needsUpdate = true;
|
||||
|
||||
if (allArrived && particleSystem.userData.age > 60) {
|
||||
// Finish
|
||||
scene.remove(particleSystem);
|
||||
particleSystem = null;
|
||||
// Show card again
|
||||
targetGroup.visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-preview-container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dom-cursor {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
transform: translate(-50%, -50%);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.cursor-ring {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 5px solid rgba(255, 255, 255, 0.9);
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
0 0 10px rgba(255, 255, 255, 0.6),
|
||||
0 0 20px rgba(255, 255, 255, 0.4),
|
||||
inset 0 0 10px rgba(255, 255, 255, 0.2);
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: translate(-50%, -50%) scale(1.08);
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-overlay {
|
||||
position: absolute;
|
||||
bottom: 30px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
text-align: center;
|
||||
z-index: 10;
|
||||
pointer-events: none; /* Let clicks pass through to canvas unless on buttons */
|
||||
}
|
||||
|
||||
.controls {
|
||||
pointer-events: auto;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 20px;
|
||||
border-radius: 15px;
|
||||
border: 1px solid rgba(0, 255, 255, 0.3);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
background: linear-gradient(45deg, #00ffff, #0088ff);
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
border-radius: 25px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 15px rgba(0, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.instructions {
|
||||
margin-top: 10px;
|
||||
color: #aaddff;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.loader {
|
||||
color: #00ffff;
|
||||
font-family: 'Orbitron', sans-serif;
|
||||
font-size: 24px;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 0.5; }
|
||||
50% { opacity: 1; text-shadow: 0 0 10px #00ffff; }
|
||||
100% { opacity: 0.5; }
|
||||
}
|
||||
</style>
|
||||
|
|
@ -66,8 +66,8 @@ export default defineConfig({
|
|||
// 配置代理解决CORS问题
|
||||
proxy: {
|
||||
'/api': {
|
||||
// target: 'https://api.deotaland.ai',
|
||||
target: 'http://api.deotaland.local',
|
||||
target: 'https://api.deotaland.ai',
|
||||
// target: 'http://api.deotaland.local',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
/* background: red; */
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
z-index: 100;
|
||||
z-index: 300;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ const pay = {
|
|||
createPaymentintention:{url:'/createPaymentintention',method:'POST'},// 创建支付意图
|
||||
createCheckoutSession:{url:'/createCheckoutSession',method:'POST'},// 创建会话支付(购物车)
|
||||
createPayorOrder:{url:'/api-core/front/stripe/create-and-checkout',method:'POST'},//根据产品ID创建订单并跳转支付
|
||||
getProductList:{url:'/api-core/front/stripe/product/list',method:'POST'},//获取产品列表
|
||||
getProductList:{url:'/api-core/front/stripe/product/list',method:'POST',isLoading:true},//获取产品列表
|
||||
calculateUnitAmount:{url:'/api-core/front/stripe/calculate-unit-amount',method:'POST'},//刷新支付金额
|
||||
createWechatPay:{url:'/api-core/front/wechat/create',method:'POST'}//微信小程序支付
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const getEnvBaseURL = () => {
|
|||
// }
|
||||
var baseURL = '';
|
||||
const hostname = window.location.hostname;
|
||||
if(hostname=='localhost'||hostname=='192.168.0.146'){
|
||||
if(hostname=='localhost'||hostname=='192.168.101.2'){
|
||||
baseURL = '/api'
|
||||
}else if(hostname.indexOf('deotaland.ai')>-1||hostname.indexOf('deota.cn')>-1){
|
||||
baseURL = 'https://api.deotaland.ai'
|
||||
|
|
@ -210,7 +210,6 @@ export const request = {
|
|||
requestConfig.data = data;
|
||||
}
|
||||
if(config.isLoading&&window.setElLoading){
|
||||
|
||||
closeMethods = window.setElLoading(config.isqp)
|
||||
}
|
||||
return service(requestConfig);
|
||||
|
|
|
|||
182
pnpm-lock.yaml
182
pnpm-lock.yaml
|
|
@ -135,9 +135,18 @@ importers:
|
|||
'@google/genai':
|
||||
specifier: ^1.27.0
|
||||
version: 1.27.0
|
||||
'@mediapipe/camera_utils':
|
||||
specifier: ^0.3.1675466862
|
||||
version: 0.3.1675466862
|
||||
'@mediapipe/hands':
|
||||
specifier: ^0.4.1675469240
|
||||
version: 0.4.1675469240
|
||||
'@splinetool/runtime':
|
||||
specifier: ^1.12.29
|
||||
version: 1.12.29
|
||||
'@tweenjs/tween.js':
|
||||
specifier: ^25.0.0
|
||||
version: 25.0.0
|
||||
'@twind/core':
|
||||
specifier: ^1.1.3
|
||||
version: 1.1.3
|
||||
|
|
@ -168,6 +177,9 @@ importers:
|
|||
element-plus:
|
||||
specifier: ^2.11.7
|
||||
version: 2.11.7(vue@3.5.24)
|
||||
html2canvas:
|
||||
specifier: ^1.4.1
|
||||
version: 1.4.1
|
||||
install:
|
||||
specifier: ^0.13.0
|
||||
version: 0.13.0
|
||||
|
|
@ -189,6 +201,9 @@ importers:
|
|||
pinia-plugin-persistedstate:
|
||||
specifier: ^4.7.1
|
||||
version: 4.7.1(pinia@3.0.4)
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
three:
|
||||
specifier: ^0.180.0
|
||||
version: 0.180.0
|
||||
|
|
@ -887,6 +902,14 @@ packages:
|
|||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
dev: true
|
||||
|
||||
/@mediapipe/camera_utils@0.3.1675466862:
|
||||
resolution: {integrity: sha512-siuXBoUxWo9WL0MeAxIxvxY04bvbtdNl7uCxoJxiAiRtNnCYrurr7Vl5VYQ94P7Sq0gVq6PxIDhWWeZ/pLnSzw==}
|
||||
dev: false
|
||||
|
||||
/@mediapipe/hands@0.4.1675469240:
|
||||
resolution: {integrity: sha512-GxoZvL1mmhJxFxjuyj7vnC++JIuInGznHBin5c7ZSq/RbcnGyfEcJrkM/bMu5K1Mz/2Ko+vEX6/+wewmEHPrHg==}
|
||||
dev: false
|
||||
|
||||
/@nodelib/fs.scandir@2.1.5:
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
|
|
@ -1281,6 +1304,10 @@ packages:
|
|||
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
|
||||
dev: false
|
||||
|
||||
/@tweenjs/tween.js@25.0.0:
|
||||
resolution: {integrity: sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==}
|
||||
dev: false
|
||||
|
||||
/@twind/core@1.1.3:
|
||||
resolution: {integrity: sha512-/B/aNFerMb2IeyjSJy3SJxqVxhrT77gBDknLMiZqXIRr4vNJqiuhx7KqUSRzDCwUmyGuogkamz+aOLzN6MeSLw==}
|
||||
engines: {node: '>=14.15.0'}
|
||||
|
|
@ -1880,6 +1907,11 @@ packages:
|
|||
/balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
/base64-arraybuffer@1.0.2:
|
||||
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
dev: false
|
||||
|
||||
/base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
|
|
@ -1967,6 +1999,11 @@ packages:
|
|||
engines: {node: '>=6'}
|
||||
dev: true
|
||||
|
||||
/camelcase@5.3.1:
|
||||
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||
engines: {node: '>=6'}
|
||||
dev: false
|
||||
|
||||
/caniuse-lite@1.0.30001759:
|
||||
resolution: {integrity: sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==}
|
||||
dev: true
|
||||
|
|
@ -1996,6 +2033,14 @@ packages:
|
|||
clsx: 2.1.1
|
||||
dev: true
|
||||
|
||||
/cliui@6.0.0:
|
||||
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 6.2.0
|
||||
dev: false
|
||||
|
||||
/cliui@8.0.1:
|
||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
|
@ -2103,6 +2148,12 @@ packages:
|
|||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
/css-line-break@2.1.0:
|
||||
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
|
||||
dependencies:
|
||||
utrie: 1.0.2
|
||||
dev: false
|
||||
|
||||
/cssesc@3.0.0:
|
||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||
engines: {node: '>=4'}
|
||||
|
|
@ -2149,6 +2200,11 @@ packages:
|
|||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
/decamelize@1.2.0:
|
||||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: false
|
||||
|
||||
/deep-is@0.1.4:
|
||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||
dev: true
|
||||
|
|
@ -2167,6 +2223,10 @@ packages:
|
|||
engines: {node: '>=8'}
|
||||
dev: true
|
||||
|
||||
/dijkstrajs@1.0.3:
|
||||
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||
dev: false
|
||||
|
||||
/doctrine@3.0.0:
|
||||
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
|
@ -2707,6 +2767,14 @@ packages:
|
|||
flat-cache: 4.0.1
|
||||
dev: true
|
||||
|
||||
/find-up@4.1.0:
|
||||
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
|
||||
engines: {node: '>=8'}
|
||||
dependencies:
|
||||
locate-path: 5.0.0
|
||||
path-exists: 4.0.0
|
||||
dev: false
|
||||
|
||||
/find-up@5.0.0:
|
||||
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -2833,7 +2901,6 @@ packages:
|
|||
/get-caller-file@2.0.5:
|
||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||
engines: {node: 6.* || 8.* || >= 10.*}
|
||||
dev: true
|
||||
|
||||
/get-intrinsic@1.3.0:
|
||||
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||
|
|
@ -2986,6 +3053,14 @@ packages:
|
|||
resolution: {integrity: sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==}
|
||||
dev: false
|
||||
|
||||
/html2canvas@1.4.1:
|
||||
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
dependencies:
|
||||
css-line-break: 2.1.0
|
||||
text-segmentation: 1.0.3
|
||||
dev: false
|
||||
|
||||
/htmlparser2@6.1.0:
|
||||
resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==}
|
||||
dependencies:
|
||||
|
|
@ -3336,6 +3411,13 @@ packages:
|
|||
quansync: 0.2.11
|
||||
dev: true
|
||||
|
||||
/locate-path@5.0.0:
|
||||
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
|
||||
engines: {node: '>=8'}
|
||||
dependencies:
|
||||
p-locate: 4.1.0
|
||||
dev: false
|
||||
|
||||
/locate-path@6.0.0:
|
||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -3628,6 +3710,13 @@ packages:
|
|||
word-wrap: 1.2.5
|
||||
dev: true
|
||||
|
||||
/p-limit@2.3.0:
|
||||
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
||||
engines: {node: '>=6'}
|
||||
dependencies:
|
||||
p-try: 2.2.0
|
||||
dev: false
|
||||
|
||||
/p-limit@3.1.0:
|
||||
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -3635,6 +3724,13 @@ packages:
|
|||
yocto-queue: 0.1.0
|
||||
dev: true
|
||||
|
||||
/p-locate@4.1.0:
|
||||
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
|
||||
engines: {node: '>=8'}
|
||||
dependencies:
|
||||
p-limit: 2.3.0
|
||||
dev: false
|
||||
|
||||
/p-locate@5.0.0:
|
||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -3642,6 +3738,11 @@ packages:
|
|||
p-limit: 3.1.0
|
||||
dev: true
|
||||
|
||||
/p-try@2.2.0:
|
||||
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||
engines: {node: '>=6'}
|
||||
dev: false
|
||||
|
||||
/package-json-from-dist@1.0.1:
|
||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||
|
||||
|
|
@ -3663,7 +3764,6 @@ packages:
|
|||
/path-exists@4.0.0:
|
||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||
engines: {node: '>=8'}
|
||||
dev: true
|
||||
|
||||
/path-is-absolute@1.0.1:
|
||||
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
|
||||
|
|
@ -3761,6 +3861,11 @@ packages:
|
|||
pathe: 2.0.3
|
||||
dev: true
|
||||
|
||||
/pngjs@5.0.0:
|
||||
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
dev: false
|
||||
|
||||
/postcss-selector-parser@6.1.2:
|
||||
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
|
||||
engines: {node: '>=4'}
|
||||
|
|
@ -3825,6 +3930,16 @@ packages:
|
|||
engines: {node: '>=6'}
|
||||
dev: true
|
||||
|
||||
/qrcode@1.5.4:
|
||||
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
dijkstrajs: 1.0.3
|
||||
pngjs: 5.0.0
|
||||
yargs: 15.4.1
|
||||
dev: false
|
||||
|
||||
/quansync@0.2.11:
|
||||
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||
dev: true
|
||||
|
|
@ -3873,7 +3988,10 @@ packages:
|
|||
/require-directory@2.1.1:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/require-main-filename@2.0.0:
|
||||
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||
dev: false
|
||||
|
||||
/resolve-from@4.0.0:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
|
|
@ -3982,6 +4100,10 @@ packages:
|
|||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/set-blocking@2.0.0:
|
||||
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||
dev: false
|
||||
|
||||
/setimmediate@1.0.5:
|
||||
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||
dev: false
|
||||
|
|
@ -4190,6 +4312,12 @@ packages:
|
|||
source-map-support: 0.5.21
|
||||
dev: true
|
||||
|
||||
/text-segmentation@1.0.3:
|
||||
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
|
||||
dependencies:
|
||||
utrie: 1.0.2
|
||||
dev: false
|
||||
|
||||
/text-table@0.2.0:
|
||||
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
|
||||
dev: true
|
||||
|
|
@ -4473,6 +4601,12 @@ packages:
|
|||
/util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
/utrie@1.0.2:
|
||||
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
|
||||
dependencies:
|
||||
base64-arraybuffer: 1.0.2
|
||||
dev: false
|
||||
|
||||
/vite@7.2.2(terser@5.44.1):
|
||||
resolution: {integrity: sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
|
@ -4710,6 +4844,10 @@ packages:
|
|||
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
|
||||
dev: true
|
||||
|
||||
/which-module@2.0.1:
|
||||
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||
dev: false
|
||||
|
||||
/which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
|
@ -4761,6 +4899,15 @@ packages:
|
|||
worker-timers-worker: 9.0.12
|
||||
dev: false
|
||||
|
||||
/wrap-ansi@6.2.0:
|
||||
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||
engines: {node: '>=8'}
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
dev: false
|
||||
|
||||
/wrap-ansi@7.0.0:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -4798,16 +4945,45 @@ packages:
|
|||
engines: {node: '>=12'}
|
||||
dev: true
|
||||
|
||||
/y18n@4.0.3:
|
||||
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||
dev: false
|
||||
|
||||
/y18n@5.0.8:
|
||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||
engines: {node: '>=10'}
|
||||
dev: true
|
||||
|
||||
/yargs-parser@18.1.3:
|
||||
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||
engines: {node: '>=6'}
|
||||
dependencies:
|
||||
camelcase: 5.3.1
|
||||
decamelize: 1.2.0
|
||||
dev: false
|
||||
|
||||
/yargs-parser@21.1.1:
|
||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||
engines: {node: '>=12'}
|
||||
dev: true
|
||||
|
||||
/yargs@15.4.1:
|
||||
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
|
||||
engines: {node: '>=8'}
|
||||
dependencies:
|
||||
cliui: 6.0.0
|
||||
decamelize: 1.2.0
|
||||
find-up: 4.1.0
|
||||
get-caller-file: 2.0.5
|
||||
require-directory: 2.1.1
|
||||
require-main-filename: 2.0.0
|
||||
set-blocking: 2.0.0
|
||||
string-width: 4.2.3
|
||||
which-module: 2.0.1
|
||||
y18n: 4.0.3
|
||||
yargs-parser: 18.1.3
|
||||
dev: false
|
||||
|
||||
/yargs@17.7.2:
|
||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||
engines: {node: '>=12'}
|
||||
|
|
|
|||
Loading…
Reference in New Issue