情人节模块
CI/CD / build (push) Failing after 3m24s Details

This commit is contained in:
13121765685 2026-01-19 14:32:33 +08:00
parent 2ffe162423
commit 4f93347199
27 changed files with 4669 additions and 311 deletions

View File

@ -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. 验证功能流程。

View File

@ -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 属性绑定语法

View File

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

View File

@ -0,0 +1,3 @@
module.exports = {
extends: ['../../.eslintrc.base.json']
}

View File

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

View File

@ -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>
<!-- 应用内页面使用布局组件 --> <!-- 应用内页面使用布局组件 -->

View File

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

View File

@ -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%);
} }
/* 移动端汉堡菜单按钮 */ /* 移动端汉堡菜单按钮 */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
// 350pxPC 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>

View File

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

View File

@ -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/, '')
} }

View File

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

View File

@ -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'}//微信小程序支付
} }

View File

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

View File

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