情人节模块
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. 需求分析
|
## 1. 需求分析
|
||||||
|
|
||||||
根据 `index.js` 中的 API 功能,需要实现以下功能:
|
根据 `index.js` 中的 API 功能,需要实现以下功能:
|
||||||
- 协议列表展示
|
|
||||||
- 协议状态修改
|
* 协议列表展示
|
||||||
- 协议删除
|
|
||||||
- 协议详情查看
|
* 协议状态修改
|
||||||
- 协议创建
|
|
||||||
- 协议更新
|
* 协议删除
|
||||||
|
|
||||||
|
* 协议详情查看
|
||||||
|
|
||||||
|
* 协议创建
|
||||||
|
|
||||||
|
* 协议更新
|
||||||
|
|
||||||
## 2. 页面设计
|
## 2. 页面设计
|
||||||
|
|
||||||
遵循 Element Plus 企业级管理系统设计风格,页面包含:
|
遵循 Element Plus 企业级管理系统设计风格,页面包含:
|
||||||
- 卡片式布局
|
|
||||||
- 表格展示协议列表
|
* 卡片式布局
|
||||||
- 状态标签显示协议状态
|
|
||||||
- 操作按钮(编辑、删除、启用/禁用)
|
* 表格展示协议列表
|
||||||
- 表单用于创建/编辑协议
|
|
||||||
- 弹窗用于详情查看和表单操作
|
* 状态标签显示协议状态
|
||||||
|
|
||||||
|
* 操作按钮(编辑、删除、启用/禁用)
|
||||||
|
|
||||||
|
* 表单用于创建/编辑协议
|
||||||
|
|
||||||
|
* 弹窗用于详情查看和表单操作
|
||||||
|
|
||||||
## 3. 实现步骤
|
## 3. 实现步骤
|
||||||
|
|
||||||
### 3.1 页面结构设计
|
### 3.1 页面结构设计
|
||||||
- 使用 `el-card` 包裹主要内容
|
|
||||||
- 卡片头部包含标题和创建按钮
|
* 使用 `el-card` 包裹主要内容
|
||||||
- 表格展示协议列表,包含协议类型、版本、状态、语言、创建时间等字段
|
|
||||||
- 操作列包含查看详情、编辑、删除、启用/禁用按钮
|
* 卡片头部包含标题和创建按钮
|
||||||
|
|
||||||
|
* 表格展示协议列表,包含协议类型、版本、状态、语言、创建时间等字段
|
||||||
|
|
||||||
|
* 操作列包含查看详情、编辑、删除、启用/禁用按钮
|
||||||
|
|
||||||
### 3.2 功能实现
|
### 3.2 功能实现
|
||||||
- **列表查询**:调用 `getAgreementList` API 获取协议列表
|
|
||||||
- **状态修改**:点击启用/禁用按钮,调用 `updateAgreementStatus` API
|
* **列表查询**:调用 `getAgreementList` API 获取协议列表
|
||||||
- **删除协议**:点击删除按钮,调用 `deleteAgreement` API,带确认提示
|
|
||||||
- **查看详情**:点击详情按钮,弹窗展示协议详情
|
* **状态修改**:点击启用/禁用按钮,调用 `updateAgreementStatus` API
|
||||||
- **创建协议**:点击创建按钮,弹窗显示表单,调用 `createAgreement` API
|
|
||||||
- **编辑协议**:点击编辑按钮,弹窗显示表单,调用 `updateAgreement` API
|
* **删除协议**:点击删除按钮,调用 `deleteAgreement` API,带确认提示
|
||||||
|
|
||||||
|
* **查看详情**:点击详情按钮,弹窗展示协议详情
|
||||||
|
|
||||||
|
* **创建协议**:点击创建按钮,弹窗显示表单,调用 `createAgreement` API
|
||||||
|
|
||||||
|
* **编辑协议**:点击编辑按钮,弹窗显示表单,调用 `updateAgreement` API
|
||||||
|
|
||||||
### 3.3 路由配置
|
### 3.3 路由配置
|
||||||
|
|
||||||
在 `permissionRoutes` 数组中添加协议管理路由:
|
在 `permissionRoutes` 数组中添加协议管理路由:
|
||||||
- 路径:`agreement-management`
|
|
||||||
- 名称:`AdminAgreement`
|
* 路径:`agreement-management`
|
||||||
- 组件:`AdminAgreement`
|
|
||||||
- 标题:`admin.layout.agreementManagement`
|
* 名称:`AdminAgreement`
|
||||||
- 图标:`Document`
|
|
||||||
- 菜单顺序:合理位置
|
* 组件:`AdminAgreement`
|
||||||
|
|
||||||
|
* 标题:`admin.layout.agreementManagement`
|
||||||
|
|
||||||
|
* 图标:`Document`
|
||||||
|
|
||||||
|
* 菜单顺序:合理位置
|
||||||
|
|
||||||
### 3.4 响应式设计
|
### 3.4 响应式设计
|
||||||
- 表格在移动端自动调整布局
|
|
||||||
- 弹窗在移动端自适应宽度
|
* 表格在移动端自动调整布局
|
||||||
- 表单元素在不同屏幕尺寸下保持良好的用户体验
|
|
||||||
|
* 弹窗在移动端自适应宽度
|
||||||
|
|
||||||
|
* 表单元素在不同屏幕尺寸下保持良好的用户体验
|
||||||
|
|
||||||
## 4. 代码实现
|
## 4. 代码实现
|
||||||
|
|
||||||
### 4.1 创建 `index.vue` 文件
|
### 4.1 创建 `index.vue` 文件
|
||||||
- 使用 Composition API
|
|
||||||
- 导入必要的组件和 API 类
|
* 使用 Composition API
|
||||||
- 实现数据响应式
|
|
||||||
- 实现方法逻辑
|
* 导入必要的组件和 API 类
|
||||||
- 实现模板结构
|
|
||||||
- 添加样式
|
* 实现数据响应式
|
||||||
|
|
||||||
|
* 实现方法逻辑
|
||||||
|
|
||||||
|
* 实现模板结构
|
||||||
|
|
||||||
|
* 添加样式
|
||||||
|
|
||||||
### 4.2 Vue3 属性绑定语法
|
### 4.2 Vue3 属性绑定语法
|
||||||
|
|
||||||
所有属性绑定严格使用 Vue3 语法:
|
所有属性绑定严格使用 Vue3 语法:
|
||||||
- 错误:`label="{{label}}"`
|
|
||||||
- 正确:`:label="label"`
|
* 错误:`label="{{label}}"`
|
||||||
- 示例:
|
|
||||||
- `:data="tableData"`
|
* 正确:`:label="label"`
|
||||||
- `:loading="loading"`
|
|
||||||
- `:visible.sync="dialogVisible"`
|
* 示例:
|
||||||
- `:type="scope.row.status === 1 ? 'success' : 'warning'"`
|
|
||||||
- `@click="handleEdit(scope.row)"`
|
* `:data="tableData"`
|
||||||
|
|
||||||
|
* `:loading="loading"`
|
||||||
|
|
||||||
|
* `:visible.sync="dialogVisible"`
|
||||||
|
|
||||||
|
* `:type="scope.row.status === 1 ? 'success' : 'warning'"`
|
||||||
|
|
||||||
|
* `@click="handleEdit(scope.row)"`
|
||||||
|
|
||||||
### 4.3 具体实现细节
|
### 4.3 具体实现细节
|
||||||
- **表格配置**:使用 `:data` 绑定表格数据,`:loading` 绑定加载状态
|
|
||||||
- **弹窗配置**:使用 `:visible.sync` 控制弹窗显示/隐藏
|
* **表格配置**:使用 `:data` 绑定表格数据,`:loading` 绑定加载状态
|
||||||
- **表单配置**:使用 `v-model` 绑定表单数据,`:rules` 绑定验证规则
|
|
||||||
- **按钮配置**:使用 `@click` 绑定点击事件,`:type` 绑定按钮类型
|
* **弹窗配置**:使用 `:visible.sync` 控制弹窗显示/隐藏
|
||||||
- **状态标签**:使用 `:type` 绑定标签类型,动态根据状态值变化
|
|
||||||
|
* **表单配置**:使用 `v-model` 绑定表单数据,`:rules` 绑定验证规则
|
||||||
|
|
||||||
|
* **按钮配置**:使用 `@click` 绑定点击事件,`:type` 绑定按钮类型
|
||||||
|
|
||||||
|
* **状态标签**:使用 `:type` 绑定标签类型,动态根据状态值变化
|
||||||
|
|
||||||
### 4.4 更新路由配置
|
### 4.4 更新路由配置
|
||||||
|
|
||||||
在 `router/index.js` 中添加协议管理路由
|
在 `router/index.js` 中添加协议管理路由
|
||||||
|
|
||||||
## 5. 预期效果
|
## 5. 预期效果
|
||||||
- 页面布局符合设计风格指南
|
|
||||||
- 所有功能正常工作
|
* 页面布局符合设计风格指南
|
||||||
- 响应式设计适配不同设备
|
|
||||||
- 交互流畅,反馈清晰
|
* 所有功能正常工作
|
||||||
- 严格遵循 Vue3 属性绑定语法
|
|
||||||
|
* 响应式设计适配不同设备
|
||||||
|
|
||||||
|
* 交互流畅,反馈清晰
|
||||||
|
|
||||||
|
* 严格遵循 Vue3 属性绑定语法
|
||||||
|
|
||||||
## 6. 技术要点
|
## 6. 技术要点
|
||||||
- Vue3 Composition API
|
|
||||||
- Element Plus 组件库
|
* Vue3 Composition API
|
||||||
- API 异步调用
|
|
||||||
- 响应式设计
|
* Element Plus 组件库
|
||||||
- 中英文切换支持
|
|
||||||
- 严格的 Vue3 属性绑定语法
|
* API 异步调用
|
||||||
|
|
||||||
|
* 响应式设计
|
||||||
|
|
||||||
|
* 中英文切换支持
|
||||||
|
|
||||||
|
* 严格的 Vue3 属性绑定语法
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -246,7 +246,7 @@
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
z-index: var(--z-index-modal-backdrop);
|
z-index: 300;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transition: all var(--transition-base);
|
transition: all var(--transition-base);
|
||||||
|
|
@ -255,6 +255,7 @@
|
||||||
.sidebar-open .sidebar-overlay {
|
.sidebar-open .sidebar-overlay {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
|
z-index: 300 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
module.exports = {
|
||||||
|
extends: ['../../.eslintrc.base.json']
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,10 @@
|
||||||
"@deotaland/utils": "workspace:*",
|
"@deotaland/utils": "workspace:*",
|
||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
"@google/genai": "^1.27.0",
|
"@google/genai": "^1.27.0",
|
||||||
|
"@mediapipe/camera_utils": "^0.3.1675466862",
|
||||||
|
"@mediapipe/hands": "^0.4.1675469240",
|
||||||
"@splinetool/runtime": "^1.12.29",
|
"@splinetool/runtime": "^1.12.29",
|
||||||
|
"@tweenjs/tween.js": "^25.0.0",
|
||||||
"@twind/core": "^1.1.3",
|
"@twind/core": "^1.1.3",
|
||||||
"@twind/preset-autoprefix": "^1.0.7",
|
"@twind/preset-autoprefix": "^1.0.7",
|
||||||
"@twind/preset-tailwind": "^1.1.4",
|
"@twind/preset-tailwind": "^1.1.4",
|
||||||
|
|
@ -26,6 +29,7 @@
|
||||||
"country-state-city": "^3.2.1",
|
"country-state-city": "^3.2.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"element-plus": "^2.11.7",
|
"element-plus": "^2.11.7",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
"install": "^0.13.0",
|
"install": "^0.13.0",
|
||||||
"jose": "^6.1.1",
|
"jose": "^6.1.1",
|
||||||
"motion-v": "^1.7.4",
|
"motion-v": "^1.7.4",
|
||||||
|
|
@ -33,6 +37,7 @@
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"pinia-plugin-persistedstate": "^4.7.1",
|
"pinia-plugin-persistedstate": "^4.7.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"three": "^0.180.0",
|
"three": "^0.180.0",
|
||||||
"twind": "^0.16.19",
|
"twind": "^0.16.19",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
|
|
|
||||||
|
|
@ -69,18 +69,15 @@ onMounted(() => {
|
||||||
'fullscreen-mode': isFullScreenPage,
|
'fullscreen-mode': isFullScreenPage,
|
||||||
'homepage-mode': isHomePage
|
'homepage-mode': isHomePage
|
||||||
}">
|
}">
|
||||||
<!-- <div v-if="qmLoading" class="sidebar-overlay" :class="{ 'sidebar-overlay-active': qmLoading }"></div> -->
|
|
||||||
<DtLoadingCom v-if="qmLoading" />
|
<DtLoadingCom v-if="qmLoading" />
|
||||||
<!-- 登录页面全屏显示 -->
|
<!-- 登录页面全屏显示 -->
|
||||||
<main style="position: relative;height: 100%;width: 100%;" v-if="isLoginPage">
|
<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" />
|
<DtLoadingCom v-if="loading" />
|
||||||
<router-view />
|
<router-view />
|
||||||
</main>
|
</main>
|
||||||
<!-- 全屏页面(如创建项目) -->
|
<!-- 全屏页面(如创建项目) -->
|
||||||
<main v-else-if="isFullScreenPage" class="fullscreen-content">
|
<main v-else-if="isFullScreenPage" class="fullscreen-content">
|
||||||
<DtLoadingCom v-if="loading" />
|
<DtLoadingCom v-if="loading" />
|
||||||
<!-- <div class="sidebar-overlay" :class="{ 'sidebar-overlay-active': loading }"></div> -->
|
|
||||||
<router-view />
|
<router-view />
|
||||||
</main>
|
</main>
|
||||||
<!-- 应用内页面使用布局组件 -->
|
<!-- 应用内页面使用布局组件 -->
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
<div class="paying-text">{{ 'PayLoading' }}</div>
|
<div class="paying-text">{{ 'PayLoading' }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="close-button" @click="onClose" aria-label="关闭">
|
<button class="close-button" @click="onClose" aria-label="关闭">
|
||||||
<el-icon class="close-icon"><CloseBold /></el-icon>
|
<el-icon class="close-icon"><CloseBold /></el-icon>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -237,9 +236,8 @@ import StripePaymentForm from '@/components/StripePaymentForm.vue'
|
||||||
import { Country, State } from 'country-state-city'
|
import { Country, State } from 'country-state-city'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { PayServer,isWeChatBrowser } from '@deotaland/utils'
|
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 { PurchaseModal as PurchaseModalClass } from './index.js'
|
||||||
import { WechatBus } from '@deotaland/utils'
|
|
||||||
const payserver = new PayServer();
|
const payserver = new PayServer();
|
||||||
const purchaseModal = new PurchaseModalClass();
|
const purchaseModal = new PurchaseModalClass();
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
|
@ -612,7 +610,7 @@ const updateStates = () => {
|
||||||
/* Blog Layout Styles */
|
/* Blog Layout Styles */
|
||||||
.purchase-overlay {
|
.purchase-overlay {
|
||||||
position: fixed; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(6px);
|
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 {
|
.purchase-container {
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
<!-- 移动端隐藏的操作按钮 -->
|
<!-- 移动端隐藏的操作按钮 -->
|
||||||
<div class="header-actions" v-if="!isMobile">
|
<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">
|
<el-dropdown trigger="click" @command="handleUserCommand">
|
||||||
<div class="user-avatar">
|
<div class="user-avatar">
|
||||||
<el-avatar :size="32" :src="currentUser.avatarUrl">
|
<el-avatar :size="32" :src="currentUser.avatarUrl">
|
||||||
|
|
@ -47,7 +47,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 主题切换 -->
|
<!-- 主题切换 -->
|
||||||
<ThemeToggle />
|
<ThemeToggle ref="themeToggleRef" />
|
||||||
|
|
||||||
<!-- 语言切换 -->
|
<!-- 语言切换 -->
|
||||||
<LanguageToggle />
|
<LanguageToggle />
|
||||||
|
|
@ -137,227 +137,183 @@
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
|
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
|
||||||
import LanguageToggle from '@/components/ui/LanguageToggle.vue'
|
import LanguageToggle from '@/components/ui/LanguageToggle.vue'
|
||||||
|
|
||||||
// 图标组件
|
|
||||||
import {
|
import {
|
||||||
Menu as MenuIcon,
|
Menu as MenuIcon,
|
||||||
Search as SearchIcon,
|
Search as SearchIcon,
|
||||||
Bell as NotificationIcon,
|
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
Right as LogoutIcon,
|
|
||||||
ArrowDown as ChevronDownIcon,
|
ArrowDown as ChevronDownIcon,
|
||||||
Close as XIcon,
|
Right as LogoutIcon,
|
||||||
Cpu as BrainIcon
|
Close as XIcon
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
|
const themeToggleRef = ref(null)
|
||||||
export default {
|
const props = defineProps({
|
||||||
name: 'AppHeader',
|
sidebarVisible: {
|
||||||
components: {
|
type: Boolean,
|
||||||
ThemeToggle,
|
default: true
|
||||||
LanguageToggle,
|
|
||||||
MenuIcon,
|
|
||||||
SearchIcon,
|
|
||||||
NotificationIcon,
|
|
||||||
UserIcon,
|
|
||||||
LogoutIcon,
|
|
||||||
ChevronDownIcon,
|
|
||||||
XIcon,
|
|
||||||
BrainIcon
|
|
||||||
},
|
},
|
||||||
props: {
|
isValentinePage: {
|
||||||
sidebarVisible: {
|
type: Boolean,
|
||||||
type: Boolean,
|
default: false
|
||||||
default: true
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
|
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 }) {
|
id: 2,
|
||||||
const { t } = useI18n()
|
text: '您的作品获得了新的点赞',
|
||||||
const router = useRouter()
|
time: new Date(Date.now() - 1000 * 60 * 30),
|
||||||
const authStore = useAuthStore()
|
icon: 'UserIcon',
|
||||||
|
read: false
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
// 响应式状态
|
const handleResize = () => {
|
||||||
const isMobile = ref(window.innerWidth < 768)
|
isMobile.value = window.innerWidth < 768
|
||||||
const searchVisible = ref(false)
|
}
|
||||||
const notificationsVisible = ref(false)
|
|
||||||
const searchQuery = ref('')
|
|
||||||
const searchInput = ref(null)
|
|
||||||
|
|
||||||
// 计算属性
|
const toggleSearch = async () => {
|
||||||
const currentUser = computed(() => authStore.user)
|
searchVisible.value = !searchVisible.value
|
||||||
const notificationCount = ref(3) // 模拟通知数量
|
if (searchVisible.value) {
|
||||||
|
await nextTick()
|
||||||
const headerClasses = computed(() => ({
|
searchInput.value?.focus()
|
||||||
'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 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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -372,6 +328,121 @@ export default {
|
||||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 400;
|
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'" />
|
<CreationIcon v-else-if="item.icon === 'CreationIcon'" />
|
||||||
<GalleryIcon v-else-if="item.icon === 'GalleryIcon'" />
|
<GalleryIcon v-else-if="item.icon === 'GalleryIcon'" />
|
||||||
<OrdersIcon v-else-if="item.icon === 'OrdersIcon'" />
|
<OrdersIcon v-else-if="item.icon === 'OrdersIcon'" />
|
||||||
|
<GreetingCardIcon v-else-if="item.icon === 'GreetingCardIcon'" />
|
||||||
<UserIcon v-else-if="item.icon === 'UserIcon'" />
|
<UserIcon v-else-if="item.icon === 'UserIcon'" />
|
||||||
</div>
|
</div>
|
||||||
<transition name="fade">
|
<transition name="fade">
|
||||||
|
|
@ -105,7 +106,8 @@ import {
|
||||||
Folder as ProjectIcon,
|
Folder as ProjectIcon,
|
||||||
Bell as NotificationIcon,
|
Bell as NotificationIcon,
|
||||||
Key as ApiIcon,
|
Key as ApiIcon,
|
||||||
ShoppingCartFull
|
ShoppingCartFull,
|
||||||
|
Postcard as GreetingCardIcon
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
|
|
||||||
// 定义组件名称(用于调试和递归组件)
|
// 定义组件名称(用于调试和递归组件)
|
||||||
|
|
@ -118,6 +120,10 @@ const props = defineProps({
|
||||||
collapsed: {
|
collapsed: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
isValentinePage: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -143,7 +149,8 @@ const userRole = computed(() => ({
|
||||||
}[authStore.user?.user_role || authStore.user?.userRole||'1'])) // 可切换为 'free' 测试不同样式
|
}[authStore.user?.user_role || authStore.user?.userRole||'1'])) // 可切换为 'free' 测试不同样式
|
||||||
const sidebarClasses = computed(() => ({
|
const sidebarClasses = computed(() => ({
|
||||||
'sidebar-mobile': isMobile.value,
|
'sidebar-mobile': isMobile.value,
|
||||||
'show': isMobile.value && !props.collapsed
|
'show': isMobile.value && !props.collapsed,
|
||||||
|
'valentine-sidebar': props.isValentinePage
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 核心菜单项 (6个主要功能)
|
// 核心菜单项 (6个主要功能)
|
||||||
|
|
@ -176,6 +183,13 @@ const coreMenuItems = computed(() => [
|
||||||
icon: 'OrdersIcon',
|
icon: 'OrdersIcon',
|
||||||
badge: null
|
badge: null
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'greeting-card',
|
||||||
|
path: '/greeting-card',
|
||||||
|
label: t('sidebar.greetingCard'),
|
||||||
|
icon: 'GreetingCardIcon',
|
||||||
|
badge: null
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'user-center',
|
id: 'user-center',
|
||||||
path: '/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 {
|
.dark .app-sidebar {
|
||||||
--sidebar-bg: #1f2937;
|
--sidebar-bg: #1f2937;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@
|
||||||
<header class="header-container">
|
<header class="header-container">
|
||||||
<AppHeader
|
<AppHeader
|
||||||
:sidebar-visible="sidebarVisible"
|
:sidebar-visible="sidebarVisible"
|
||||||
|
ref="headerRef"
|
||||||
@toggle-sidebar="toggleSidebar"
|
@toggle-sidebar="toggleSidebar"
|
||||||
|
:is-valentine-page="isValentinePage"
|
||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -14,16 +16,16 @@
|
||||||
<!-- 侧边栏容器 -->
|
<!-- 侧边栏容器 -->
|
||||||
<aside
|
<aside
|
||||||
class="sidebar-container"
|
class="sidebar-container"
|
||||||
:class="{ 'sidebar-visible': sidebarVisible }"
|
:class="{ 'sidebar-visible': sidebarVisible, 'valentine-theme': isValentinePage }"
|
||||||
>
|
>
|
||||||
<AppSidebar
|
<AppSidebar
|
||||||
:collapsed="!sidebarVisible"
|
:collapsed="!sidebarVisible"
|
||||||
@navigate="handleNavigate"
|
@navigate="handleNavigate"
|
||||||
|
:is-valentine-page="isValentinePage"
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
<!-- 主内容区域 -->
|
<!-- 主内容区域 -->
|
||||||
<div class="main-content" :class="{ 'sidebar-collapsed': !sidebarVisible && !isMobile }">
|
<div class="main-content" :class="{ 'sidebar-collapsed': !sidebarVisible && !isMobile }">
|
||||||
<!-- <div class="sidebar-overlay" :class="{ 'sidebar-overlay-active': loading }"></div> -->
|
|
||||||
<DtLoadingCom v-if="loading" />
|
<DtLoadingCom v-if="loading" />
|
||||||
<!-- 面包屑导航 -->
|
<!-- 面包屑导航 -->
|
||||||
<!-- <BreadcrumbNavigation class="breadcrumb-container" /> -->
|
<!-- <BreadcrumbNavigation class="breadcrumb-container" /> -->
|
||||||
|
|
@ -45,8 +47,10 @@
|
||||||
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import AppHeader from './AppHeader.vue'
|
import AppHeader from './AppHeader.vue'
|
||||||
import AppSidebar from './AppSidebar.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({
|
defineOptions({
|
||||||
name: 'MainLayout'
|
name: 'MainLayout'
|
||||||
|
|
@ -84,6 +88,9 @@ const isMobile = computed(() => state.isMobile)
|
||||||
const isTablet = computed(() => state.isTablet)
|
const isTablet = computed(() => state.isTablet)
|
||||||
const isDesktop = computed(() => state.isDesktop)
|
const isDesktop = computed(() => state.isDesktop)
|
||||||
|
|
||||||
|
// 判断当前是否为情人节贺卡页面
|
||||||
|
const isValentinePage = computed(() => route.path === '/greeting-card')
|
||||||
|
|
||||||
// 响应式断点更新
|
// 响应式断点更新
|
||||||
const updateBreakpoints = () => {
|
const updateBreakpoints = () => {
|
||||||
state.screenWidth = window.innerWidth
|
state.screenWidth = window.innerWidth
|
||||||
|
|
@ -108,6 +115,7 @@ const toggleSidebar = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
// 处理导航
|
// 处理导航
|
||||||
const handleNavigate = (route) => {
|
const handleNavigate = (route) => {
|
||||||
router.replace(route)
|
router.replace(route)
|
||||||
|
|
@ -125,7 +133,16 @@ const handleResize = () => {
|
||||||
updateBreakpoints()
|
updateBreakpoints()
|
||||||
}, 150)
|
}, 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(() => {
|
onMounted(() => {
|
||||||
updateBreakpoints()
|
updateBreakpoints()
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
:disabled="transitioning"
|
:disabled="transitioning"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@click="toggleTheme"
|
@click="toggleTheme()"
|
||||||
class="theme-toggle-btn"
|
class="theme-toggle-btn"
|
||||||
:class="{ 'dark': isDark }"
|
:class="{ 'dark': isDark }"
|
||||||
:disabled="transitioning"
|
:disabled="transitioning"
|
||||||
|
|
@ -48,9 +48,7 @@ const props = defineProps({
|
||||||
default: ''
|
default: ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 组件现在是自管理的,不需要对外发射事件
|
// 组件现在是自管理的,不需要对外发射事件
|
||||||
|
|
||||||
// 主题状态
|
// 主题状态
|
||||||
const isDark = ref(false)
|
const isDark = ref(false)
|
||||||
const transitioning = 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
|
if (transitioning.value) return
|
||||||
|
|
||||||
transitioning.value = true
|
transitioning.value = true
|
||||||
|
if(type!==null){
|
||||||
isDark.value = !isDark.value
|
isDark.value = type=== 'dark'
|
||||||
|
}else{
|
||||||
|
isDark.value = !isDark.value
|
||||||
|
}
|
||||||
applyTheme()
|
applyTheme()
|
||||||
|
|
||||||
// 动画效果结束后重置transitioning状态
|
// 动画效果结束后重置transitioning状态
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
transitioning.value = false
|
transitioning.value = false
|
||||||
|
|
@ -105,6 +107,9 @@ if (window.matchMedia) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
defineExpose({
|
||||||
|
toggleTheme
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ export default {
|
||||||
gallery: '画廊',
|
gallery: '画廊',
|
||||||
orders: '订单',
|
orders: '订单',
|
||||||
orderManagement: '订单',
|
orderManagement: '订单',
|
||||||
|
greetingCard: '贺卡',
|
||||||
apiKeys: 'API密钥',
|
apiKeys: 'API密钥',
|
||||||
settings: '设置',
|
settings: '设置',
|
||||||
mainMenu: '主要菜单',
|
mainMenu: '主要菜单',
|
||||||
|
|
@ -1557,6 +1558,62 @@ export default {
|
||||||
hasInviteCode: '已有邀请码?',
|
hasInviteCode: '已有邀请码?',
|
||||||
inviteCodePlaceholder: '填写邀请码升级为免费会员',
|
inviteCodePlaceholder: '填写邀请码升级为免费会员',
|
||||||
logout: '退出登录'
|
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: {
|
en: {
|
||||||
|
|
@ -1674,6 +1731,7 @@ export default {
|
||||||
gallery: 'Gallery',
|
gallery: 'Gallery',
|
||||||
orders: 'Orders',
|
orders: 'Orders',
|
||||||
orderManagement: 'Orders',
|
orderManagement: 'Orders',
|
||||||
|
greetingCard: 'Greeting Card',
|
||||||
apiKeys: 'API Keys',
|
apiKeys: 'API Keys',
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
mainMenu: 'Main Menu',
|
mainMenu: 'Main Menu',
|
||||||
|
|
@ -3094,6 +3152,62 @@ export default {
|
||||||
hasInviteCode: 'Already have an invite code?',
|
hasInviteCode: 'Already have an invite code?',
|
||||||
inviteCodePlaceholder: 'Enter invite code to upgrade to free membership',
|
inviteCodePlaceholder: 'Enter invite code to upgrade to free membership',
|
||||||
logout: 'Logout'
|
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 NotFound = () => import('../views/NotFound.vue')
|
||||||
const Waitlist = () => import('../views/Waitlist.vue')
|
const Waitlist = () => import('../views/Waitlist.vue')
|
||||||
const KefuReduce = () => import('../views/kefuReduce.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 = () => {
|
const isPortraitMobile = () => {
|
||||||
return window.innerWidth < 768 && window.innerHeight > window.innerWidth
|
return window.innerWidth < 768 && window.innerHeight > window.innerWidth
|
||||||
}
|
}
|
||||||
|
|
@ -46,6 +50,18 @@ NProgress.configure({
|
||||||
})// 开启轻量模式(顶部细线)
|
})// 开启轻量模式(顶部细线)
|
||||||
// 路由配置
|
// 路由配置
|
||||||
const routes = [
|
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',
|
path: '/agreement/:type',
|
||||||
name: 'agreement',
|
name: 'agreement',
|
||||||
|
|
@ -73,7 +89,7 @@ const routes = [
|
||||||
path: '/czhome',
|
path: '/czhome',
|
||||||
name: 'czhome',
|
name: 'czhome',
|
||||||
component: ModernHome,
|
component: ModernHome,
|
||||||
meta: { requiresAuth: true, keepAlive: false }
|
meta: { requiresAuth: false, keepAlive: false }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/register',
|
path: '/register',
|
||||||
|
|
@ -139,6 +155,12 @@ export const freeRoutes = [
|
||||||
component: OrderDetail,
|
component: OrderDetail,
|
||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/greeting-card',
|
||||||
|
name: 'greeting-card',
|
||||||
|
component: GreetingCard,
|
||||||
|
meta: { requiresAuth: true, keepAlive: false }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/agent-management',
|
path: '/agent-management',
|
||||||
name: 'agent-management',
|
name: 'agent-management',
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@ import { ref, computed } from 'vue'
|
||||||
|
|
||||||
export const useThemeStore = defineStore('theme', () => {
|
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 = () => {
|
const toggleTheme = () => {
|
||||||
|
|
@ -18,12 +18,12 @@ export const useThemeStore = defineStore('theme', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const setTheme = (newTheme) => {
|
const setTheme = (newTheme) => {
|
||||||
isDark.value = newTheme === 'dark'
|
isDark.value = (newTheme === 'dark')
|
||||||
localStorage.setItem('theme', theme.value)
|
localStorage.setItem('theme', newTheme)
|
||||||
applyTheme()
|
applyTheme()
|
||||||
}
|
}
|
||||||
|
|
||||||
const applyTheme = () => {
|
const applyTheme = () => {// 应用当前主题到文档
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
if (isDark.value) {
|
if (isDark.value) {
|
||||||
root.classList.add('dark')
|
root.classList.add('dark')
|
||||||
|
|
@ -55,6 +55,5 @@ export const useThemeStore = defineStore('theme', () => {
|
||||||
persist: {
|
persist: {
|
||||||
key: 'theme',
|
key: 'theme',
|
||||||
storage: localStorage,
|
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 {
|
.welcome-visual {
|
||||||
height: 300px;
|
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;
|
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 {
|
.stat-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -387,7 +387,6 @@ const handleSaveProject = (index,item,type='image')=>{
|
||||||
const createProject = async ()=>{
|
const createProject = async ()=>{
|
||||||
const {id} = await PluginProject.createProject();
|
const {id} = await PluginProject.createProject();
|
||||||
// 创建新项目后,将当前路由跳转到 project/项目id/系列
|
// 创建新项目后,将当前路由跳转到 project/项目id/系列
|
||||||
|
|
||||||
await router.replace(`/project/${id}/${series.value}`);
|
await router.replace(`/project/${id}/${series.value}`);
|
||||||
projectId.value = id;
|
projectId.value = id;
|
||||||
getProjectInfo(id);
|
getProjectInfo(id);
|
||||||
|
|
@ -406,6 +405,7 @@ const getProjectInfo = async (id)=>{
|
||||||
id: card.id || Date.now() + Math.random().toString(36).substr(2, 9)
|
id: card.id || Date.now() + Math.random().toString(36).substr(2, 9)
|
||||||
}));
|
}));
|
||||||
projectInfo.value.tags = [series.value];
|
projectInfo.value.tags = [series.value];
|
||||||
|
updateProjectInfo(projectInfo.value);
|
||||||
}
|
}
|
||||||
//更新项目信息
|
//更新项目信息
|
||||||
const updateProjectInfo = async (newProjectInfo)=>{
|
const updateProjectInfo = async (newProjectInfo)=>{
|
||||||
|
|
@ -1136,6 +1136,11 @@ const init = ()=>{
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
projectId.value = route.params.id;
|
projectId.value = route.params.id;
|
||||||
series.value = route.params.series;
|
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'){
|
if(projectId.value === 'new'){
|
||||||
createProject();
|
createProject();
|
||||||
return
|
return
|
||||||
|
|
@ -1190,7 +1195,7 @@ onUnmounted(() => {// 禁用轮询
|
||||||
top:8px;
|
top:8px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
z-index: 1000;
|
z-index: 100;
|
||||||
width: 98.6%;
|
width: 98.6%;
|
||||||
background-color: rgba(255, 255, 255, 0.95);
|
background-color: rgba(255, 255, 255, 0.95);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
|
|
|
||||||
|
|
@ -1287,7 +1287,7 @@ onUnmounted(() => {// 禁用轮询
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
z-index: 1000;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark .header-nav {
|
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问题
|
// 配置代理解决CORS问题
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
// target: 'https://api.deotaland.ai',
|
target: 'https://api.deotaland.ai',
|
||||||
target: 'http://api.deotaland.local',
|
// target: 'http://api.deotaland.local',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path) => path.replace(/^\/api/, '')
|
rewrite: (path) => path.replace(/^\/api/, '')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@
|
||||||
/* background: red; */
|
/* background: red; */
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
-webkit-backdrop-filter: blur(2px);
|
-webkit-backdrop-filter: blur(2px);
|
||||||
z-index: 100;
|
z-index: 300;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ const pay = {
|
||||||
createPaymentintention:{url:'/createPaymentintention',method:'POST'},// 创建支付意图
|
createPaymentintention:{url:'/createPaymentintention',method:'POST'},// 创建支付意图
|
||||||
createCheckoutSession:{url:'/createCheckoutSession',method:'POST'},// 创建会话支付(购物车)
|
createCheckoutSession:{url:'/createCheckoutSession',method:'POST'},// 创建会话支付(购物车)
|
||||||
createPayorOrder:{url:'/api-core/front/stripe/create-and-checkout',method:'POST'},//根据产品ID创建订单并跳转支付
|
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'},//刷新支付金额
|
calculateUnitAmount:{url:'/api-core/front/stripe/calculate-unit-amount',method:'POST'},//刷新支付金额
|
||||||
createWechatPay:{url:'/api-core/front/wechat/create',method:'POST'}//微信小程序支付
|
createWechatPay:{url:'/api-core/front/wechat/create',method:'POST'}//微信小程序支付
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ const getEnvBaseURL = () => {
|
||||||
// }
|
// }
|
||||||
var baseURL = '';
|
var baseURL = '';
|
||||||
const hostname = window.location.hostname;
|
const hostname = window.location.hostname;
|
||||||
if(hostname=='localhost'||hostname=='192.168.0.146'){
|
if(hostname=='localhost'||hostname=='192.168.101.2'){
|
||||||
baseURL = '/api'
|
baseURL = '/api'
|
||||||
}else if(hostname.indexOf('deotaland.ai')>-1||hostname.indexOf('deota.cn')>-1){
|
}else if(hostname.indexOf('deotaland.ai')>-1||hostname.indexOf('deota.cn')>-1){
|
||||||
baseURL = 'https://api.deotaland.ai'
|
baseURL = 'https://api.deotaland.ai'
|
||||||
|
|
@ -210,7 +210,6 @@ export const request = {
|
||||||
requestConfig.data = data;
|
requestConfig.data = data;
|
||||||
}
|
}
|
||||||
if(config.isLoading&&window.setElLoading){
|
if(config.isLoading&&window.setElLoading){
|
||||||
|
|
||||||
closeMethods = window.setElLoading(config.isqp)
|
closeMethods = window.setElLoading(config.isqp)
|
||||||
}
|
}
|
||||||
return service(requestConfig);
|
return service(requestConfig);
|
||||||
|
|
|
||||||
182
pnpm-lock.yaml
182
pnpm-lock.yaml
|
|
@ -135,9 +135,18 @@ importers:
|
||||||
'@google/genai':
|
'@google/genai':
|
||||||
specifier: ^1.27.0
|
specifier: ^1.27.0
|
||||||
version: 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':
|
'@splinetool/runtime':
|
||||||
specifier: ^1.12.29
|
specifier: ^1.12.29
|
||||||
version: 1.12.29
|
version: 1.12.29
|
||||||
|
'@tweenjs/tween.js':
|
||||||
|
specifier: ^25.0.0
|
||||||
|
version: 25.0.0
|
||||||
'@twind/core':
|
'@twind/core':
|
||||||
specifier: ^1.1.3
|
specifier: ^1.1.3
|
||||||
version: 1.1.3
|
version: 1.1.3
|
||||||
|
|
@ -168,6 +177,9 @@ importers:
|
||||||
element-plus:
|
element-plus:
|
||||||
specifier: ^2.11.7
|
specifier: ^2.11.7
|
||||||
version: 2.11.7(vue@3.5.24)
|
version: 2.11.7(vue@3.5.24)
|
||||||
|
html2canvas:
|
||||||
|
specifier: ^1.4.1
|
||||||
|
version: 1.4.1
|
||||||
install:
|
install:
|
||||||
specifier: ^0.13.0
|
specifier: ^0.13.0
|
||||||
version: 0.13.0
|
version: 0.13.0
|
||||||
|
|
@ -189,6 +201,9 @@ importers:
|
||||||
pinia-plugin-persistedstate:
|
pinia-plugin-persistedstate:
|
||||||
specifier: ^4.7.1
|
specifier: ^4.7.1
|
||||||
version: 4.7.1(pinia@3.0.4)
|
version: 4.7.1(pinia@3.0.4)
|
||||||
|
qrcode:
|
||||||
|
specifier: ^1.5.4
|
||||||
|
version: 1.5.4
|
||||||
three:
|
three:
|
||||||
specifier: ^0.180.0
|
specifier: ^0.180.0
|
||||||
version: 0.180.0
|
version: 0.180.0
|
||||||
|
|
@ -887,6 +902,14 @@ packages:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
dev: true
|
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:
|
/@nodelib/fs.scandir@2.1.5:
|
||||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
@ -1281,6 +1304,10 @@ packages:
|
||||||
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
|
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@tweenjs/tween.js@25.0.0:
|
||||||
|
resolution: {integrity: sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@twind/core@1.1.3:
|
/@twind/core@1.1.3:
|
||||||
resolution: {integrity: sha512-/B/aNFerMb2IeyjSJy3SJxqVxhrT77gBDknLMiZqXIRr4vNJqiuhx7KqUSRzDCwUmyGuogkamz+aOLzN6MeSLw==}
|
resolution: {integrity: sha512-/B/aNFerMb2IeyjSJy3SJxqVxhrT77gBDknLMiZqXIRr4vNJqiuhx7KqUSRzDCwUmyGuogkamz+aOLzN6MeSLw==}
|
||||||
engines: {node: '>=14.15.0'}
|
engines: {node: '>=14.15.0'}
|
||||||
|
|
@ -1880,6 +1907,11 @@ packages:
|
||||||
/balanced-match@1.0.2:
|
/balanced-match@1.0.2:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
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:
|
/base64-js@1.5.1:
|
||||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||||
|
|
||||||
|
|
@ -1967,6 +1999,11 @@ packages:
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/camelcase@5.3.1:
|
||||||
|
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/caniuse-lite@1.0.30001759:
|
/caniuse-lite@1.0.30001759:
|
||||||
resolution: {integrity: sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==}
|
resolution: {integrity: sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
@ -1996,6 +2033,14 @@ packages:
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
dev: true
|
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:
|
/cliui@8.0.1:
|
||||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
@ -2103,6 +2148,12 @@ packages:
|
||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
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:
|
/cssesc@3.0.0:
|
||||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
@ -2149,6 +2200,11 @@ packages:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
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:
|
/deep-is@0.1.4:
|
||||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
@ -2167,6 +2223,10 @@ packages:
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/dijkstrajs@1.0.3:
|
||||||
|
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/doctrine@3.0.0:
|
/doctrine@3.0.0:
|
||||||
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
|
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
@ -2707,6 +2767,14 @@ packages:
|
||||||
flat-cache: 4.0.1
|
flat-cache: 4.0.1
|
||||||
dev: true
|
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:
|
/find-up@5.0.0:
|
||||||
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -2833,7 +2901,6 @@ packages:
|
||||||
/get-caller-file@2.0.5:
|
/get-caller-file@2.0.5:
|
||||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||||
engines: {node: 6.* || 8.* || >= 10.*}
|
engines: {node: 6.* || 8.* || >= 10.*}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/get-intrinsic@1.3.0:
|
/get-intrinsic@1.3.0:
|
||||||
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||||
|
|
@ -2986,6 +3053,14 @@ packages:
|
||||||
resolution: {integrity: sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==}
|
resolution: {integrity: sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==}
|
||||||
dev: false
|
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:
|
/htmlparser2@6.1.0:
|
||||||
resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==}
|
resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -3336,6 +3411,13 @@ packages:
|
||||||
quansync: 0.2.11
|
quansync: 0.2.11
|
||||||
dev: true
|
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:
|
/locate-path@6.0.0:
|
||||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -3628,6 +3710,13 @@ packages:
|
||||||
word-wrap: 1.2.5
|
word-wrap: 1.2.5
|
||||||
dev: true
|
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:
|
/p-limit@3.1.0:
|
||||||
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -3635,6 +3724,13 @@ packages:
|
||||||
yocto-queue: 0.1.0
|
yocto-queue: 0.1.0
|
||||||
dev: true
|
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:
|
/p-locate@5.0.0:
|
||||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -3642,6 +3738,11 @@ packages:
|
||||||
p-limit: 3.1.0
|
p-limit: 3.1.0
|
||||||
dev: true
|
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:
|
/package-json-from-dist@1.0.1:
|
||||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||||
|
|
||||||
|
|
@ -3663,7 +3764,6 @@ packages:
|
||||||
/path-exists@4.0.0:
|
/path-exists@4.0.0:
|
||||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/path-is-absolute@1.0.1:
|
/path-is-absolute@1.0.1:
|
||||||
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
|
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
|
||||||
|
|
@ -3761,6 +3861,11 @@ packages:
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/pngjs@5.0.0:
|
||||||
|
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||||
|
engines: {node: '>=10.13.0'}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/postcss-selector-parser@6.1.2:
|
/postcss-selector-parser@6.1.2:
|
||||||
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
|
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
@ -3825,6 +3930,16 @@ packages:
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
dev: true
|
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:
|
/quansync@0.2.11:
|
||||||
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
@ -3873,7 +3988,10 @@ packages:
|
||||||
/require-directory@2.1.1:
|
/require-directory@2.1.1:
|
||||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||||
engines: {node: '>=0.10.0'}
|
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:
|
/resolve-from@4.0.0:
|
||||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||||
|
|
@ -3982,6 +4100,10 @@ packages:
|
||||||
hasBin: true
|
hasBin: true
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/set-blocking@2.0.0:
|
||||||
|
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/setimmediate@1.0.5:
|
/setimmediate@1.0.5:
|
||||||
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
@ -4190,6 +4312,12 @@ packages:
|
||||||
source-map-support: 0.5.21
|
source-map-support: 0.5.21
|
||||||
dev: true
|
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:
|
/text-table@0.2.0:
|
||||||
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
|
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
@ -4473,6 +4601,12 @@ packages:
|
||||||
/util-deprecate@1.0.2:
|
/util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
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):
|
/vite@7.2.2(terser@5.44.1):
|
||||||
resolution: {integrity: sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==}
|
resolution: {integrity: sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
|
@ -4710,6 +4844,10 @@ packages:
|
||||||
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
|
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/which-module@2.0.1:
|
||||||
|
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/which@2.0.2:
|
/which@2.0.2:
|
||||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
@ -4761,6 +4899,15 @@ packages:
|
||||||
worker-timers-worker: 9.0.12
|
worker-timers-worker: 9.0.12
|
||||||
dev: false
|
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:
|
/wrap-ansi@7.0.0:
|
||||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -4798,16 +4945,45 @@ packages:
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/y18n@4.0.3:
|
||||||
|
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/y18n@5.0.8:
|
/y18n@5.0.8:
|
||||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
dev: true
|
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:
|
/yargs-parser@21.1.1:
|
||||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
dev: true
|
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:
|
/yargs@17.7.2:
|
||||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue