情人节模块
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. 需求分析
根据 `index.js` 中的 API 功能,需要实现以下功能:
- 协议列表展示
- 协议状态修改
- 协议删除
- 协议详情查看
- 协议创建
- 协议更新
* 协议列表展示
* 协议状态修改
* 协议删除
* 协议详情查看
* 协议创建
* 协议更新
## 2. 页面设计
遵循 Element Plus 企业级管理系统设计风格,页面包含:
- 卡片式布局
- 表格展示协议列表
- 状态标签显示协议状态
- 操作按钮(编辑、删除、启用/禁用)
- 表单用于创建/编辑协议
- 弹窗用于详情查看和表单操作
* 卡片式布局
* 表格展示协议列表
* 状态标签显示协议状态
* 操作按钮(编辑、删除、启用/禁用)
* 表单用于创建/编辑协议
* 弹窗用于详情查看和表单操作
## 3. 实现步骤
### 3.1 页面结构设计
- 使用 `el-card` 包裹主要内容
- 卡片头部包含标题和创建按钮
- 表格展示协议列表,包含协议类型、版本、状态、语言、创建时间等字段
- 操作列包含查看详情、编辑、删除、启用/禁用按钮
* 使用 `el-card` 包裹主要内容
* 卡片头部包含标题和创建按钮
* 表格展示协议列表,包含协议类型、版本、状态、语言、创建时间等字段
* 操作列包含查看详情、编辑、删除、启用/禁用按钮
### 3.2 功能实现
- **列表查询**:调用 `getAgreementList` API 获取协议列表
- **状态修改**:点击启用/禁用按钮,调用 `updateAgreementStatus` API
- **删除协议**:点击删除按钮,调用 `deleteAgreement` API带确认提示
- **查看详情**:点击详情按钮,弹窗展示协议详情
- **创建协议**:点击创建按钮,弹窗显示表单,调用 `createAgreement` API
- **编辑协议**:点击编辑按钮,弹窗显示表单,调用 `updateAgreement` API
* **列表查询**:调用 `getAgreementList` API 获取协议列表
* **状态修改**:点击启用/禁用按钮,调用 `updateAgreementStatus` API
* **删除协议**:点击删除按钮,调用 `deleteAgreement` API带确认提示
* **查看详情**:点击详情按钮,弹窗展示协议详情
* **创建协议**:点击创建按钮,弹窗显示表单,调用 `createAgreement` API
* **编辑协议**:点击编辑按钮,弹窗显示表单,调用 `updateAgreement` API
### 3.3 路由配置
`permissionRoutes` 数组中添加协议管理路由:
- 路径:`agreement-management`
- 名称:`AdminAgreement`
- 组件:`AdminAgreement`
- 标题:`admin.layout.agreementManagement`
- 图标:`Document`
- 菜单顺序:合理位置
* 路径:`agreement-management`
* 名称:`AdminAgreement`
* 组件:`AdminAgreement`
* 标题:`admin.layout.agreementManagement`
* 图标:`Document`
* 菜单顺序:合理位置
### 3.4 响应式设计
- 表格在移动端自动调整布局
- 弹窗在移动端自适应宽度
- 表单元素在不同屏幕尺寸下保持良好的用户体验
* 表格在移动端自动调整布局
* 弹窗在移动端自适应宽度
* 表单元素在不同屏幕尺寸下保持良好的用户体验
## 4. 代码实现
### 4.1 创建 `index.vue` 文件
- 使用 Composition API
- 导入必要的组件和 API 类
- 实现数据响应式
- 实现方法逻辑
- 实现模板结构
- 添加样式
* 使用 Composition API
* 导入必要的组件和 API 类
* 实现数据响应式
* 实现方法逻辑
* 实现模板结构
* 添加样式
### 4.2 Vue3 属性绑定语法
所有属性绑定严格使用 Vue3 语法:
- 错误:`label="{{label}}"`
- 正确:`:label="label"`
- 示例:
- `:data="tableData"`
- `:loading="loading"`
- `:visible.sync="dialogVisible"`
- `:type="scope.row.status === 1 ? 'success' : 'warning'"`
- `@click="handleEdit(scope.row)"`
* 错误:`label="{{label}}"`
* 正确:`:label="label"`
* 示例:
* `:data="tableData"`
* `:loading="loading"`
* `:visible.sync="dialogVisible"`
* `:type="scope.row.status === 1 ? 'success' : 'warning'"`
* `@click="handleEdit(scope.row)"`
### 4.3 具体实现细节
- **表格配置**:使用 `:data` 绑定表格数据,`:loading` 绑定加载状态
- **弹窗配置**:使用 `:visible.sync` 控制弹窗显示/隐藏
- **表单配置**:使用 `v-model` 绑定表单数据,`:rules` 绑定验证规则
- **按钮配置**:使用 `@click` 绑定点击事件,`:type` 绑定按钮类型
- **状态标签**:使用 `:type` 绑定标签类型,动态根据状态值变化
* **表格配置**:使用 `:data` 绑定表格数据,`:loading` 绑定加载状态
* **弹窗配置**:使用 `:visible.sync` 控制弹窗显示/隐藏
* **表单配置**:使用 `v-model` 绑定表单数据,`:rules` 绑定验证规则
* **按钮配置**:使用 `@click` 绑定点击事件,`:type` 绑定按钮类型
* **状态标签**:使用 `:type` 绑定标签类型,动态根据状态值变化
### 4.4 更新路由配置
`router/index.js` 中添加协议管理路由
## 5. 预期效果
- 页面布局符合设计风格指南
- 所有功能正常工作
- 响应式设计适配不同设备
- 交互流畅,反馈清晰
- 严格遵循 Vue3 属性绑定语法
* 页面布局符合设计风格指南
* 所有功能正常工作
* 响应式设计适配不同设备
* 交互流畅,反馈清晰
* 严格遵循 Vue3 属性绑定语法
## 6. 技术要点
- Vue3 Composition API
- Element Plus 组件库
- API 异步调用
- 响应式设计
- 中英文切换支持
- 严格的 Vue3 属性绑定语法
* Vue3 Composition API
* Element Plus 组件库
* API 异步调用
* 响应式设计
* 中英文切换支持
* 严格的 Vue3 属性绑定语法

View File

@ -246,7 +246,7 @@
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: var(--z-index-modal-backdrop);
z-index: 300;
opacity: 0;
visibility: hidden;
transition: all var(--transition-base);
@ -255,6 +255,7 @@
.sidebar-open .sidebar-overlay {
opacity: 1;
visibility: visible;
z-index: 300 !important;
}
}

View File

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

View File

@ -15,7 +15,10 @@
"@deotaland/utils": "workspace:*",
"@element-plus/icons-vue": "^2.3.2",
"@google/genai": "^1.27.0",
"@mediapipe/camera_utils": "^0.3.1675466862",
"@mediapipe/hands": "^0.4.1675469240",
"@splinetool/runtime": "^1.12.29",
"@tweenjs/tween.js": "^25.0.0",
"@twind/core": "^1.1.3",
"@twind/preset-autoprefix": "^1.0.7",
"@twind/preset-tailwind": "^1.1.4",
@ -26,6 +29,7 @@
"country-state-city": "^3.2.1",
"dayjs": "^1.11.13",
"element-plus": "^2.11.7",
"html2canvas": "^1.4.1",
"install": "^0.13.0",
"jose": "^6.1.1",
"motion-v": "^1.7.4",
@ -33,6 +37,7 @@
"nprogress": "^0.2.0",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"qrcode": "^1.5.4",
"three": "^0.180.0",
"twind": "^0.16.19",
"vue": "^3.5.24",

View File

@ -69,18 +69,15 @@ onMounted(() => {
'fullscreen-mode': isFullScreenPage,
'homepage-mode': isHomePage
}">
<!-- <div v-if="qmLoading" class="sidebar-overlay" :class="{ 'sidebar-overlay-active': qmLoading }"></div> -->
<DtLoadingCom v-if="qmLoading" />
<!-- 登录页面全屏显示 -->
<main style="position: relative;height: 100%;width: 100%;" v-if="isLoginPage">
<!-- <div v-if="loading" class="sidebar-overlay" :class="{ 'sidebar-overlay-active': loading }"></div> -->
<DtLoadingCom v-if="loading" />
<router-view />
</main>
<!-- 全屏页面如创建项目 -->
<main v-else-if="isFullScreenPage" class="fullscreen-content">
<DtLoadingCom v-if="loading" />
<!-- <div class="sidebar-overlay" :class="{ 'sidebar-overlay-active': loading }"></div> -->
<router-view />
</main>
<!-- 应用内页面使用布局组件 -->

View File

@ -8,7 +8,6 @@
<div class="paying-text">{{ 'PayLoading' }}</div>
</div>
</div>
<button class="close-button" @click="onClose" aria-label="关闭">
<el-icon class="close-icon"><CloseBold /></el-icon>
</button>
@ -237,9 +236,8 @@ import StripePaymentForm from '@/components/StripePaymentForm.vue'
import { Country, State } from 'country-state-city'
import { useI18n } from 'vue-i18n'
import { PayServer,isWeChatBrowser } from '@deotaland/utils'
import { requestUtils,clientApi,environmentUtils } from '@deotaland/utils'
import { requestUtils,clientApi,environmentUtils,WechatBus } from '@deotaland/utils'
import { PurchaseModal as PurchaseModalClass } from './index.js'
import { WechatBus } from '@deotaland/utils'
const payserver = new PayServer();
const purchaseModal = new PurchaseModalClass();
const props = defineProps({
@ -612,7 +610,7 @@ const updateStates = () => {
/* Blog Layout Styles */
.purchase-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(6px);
z-index: 1002; display: flex; align-items: center; justify-content: center;
z-index: 100; display: flex; align-items: center; justify-content: center;
}
.purchase-container {

View File

@ -25,7 +25,7 @@
<!-- 移动端隐藏的操作按钮 -->
<div class="header-actions" v-if="!isMobile">
<!-- 用户菜单 -->
<div class="user-menu" v-if="currentUser">
<div class="user-menu" v-if="token">
<el-dropdown trigger="click" @command="handleUserCommand">
<div class="user-avatar">
<el-avatar :size="32" :src="currentUser.avatarUrl">
@ -47,7 +47,7 @@
</div>
<!-- 主题切换 -->
<ThemeToggle />
<ThemeToggle ref="themeToggleRef" />
<!-- 语言切换 -->
<LanguageToggle />
@ -137,227 +137,183 @@
</header>
</template>
<script>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
import LanguageToggle from '@/components/ui/LanguageToggle.vue'
//
import {
import {
Menu as MenuIcon,
Search as SearchIcon,
Bell as NotificationIcon,
User as UserIcon,
Right as LogoutIcon,
ArrowDown as ChevronDownIcon,
Close as XIcon,
Cpu as BrainIcon
} from '@element-plus/icons-vue'
export default {
name: 'AppHeader',
components: {
ThemeToggle,
LanguageToggle,
MenuIcon,
SearchIcon,
NotificationIcon,
UserIcon,
LogoutIcon,
ChevronDownIcon,
XIcon,
BrainIcon
Right as LogoutIcon,
Close as XIcon
} from '@element-plus/icons-vue'
const themeToggleRef = ref(null)
const props = defineProps({
sidebarVisible: {
type: Boolean,
default: true
},
props: {
sidebarVisible: {
type: Boolean,
default: true
}
isValentinePage: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['toggle-sidebar'])
const { t } = useI18n()
const router = useRouter()
const authStore = useAuthStore()
const isMobile = ref(window.innerWidth < 768)
const searchVisible = ref(false)
const notificationsVisible = ref(false)
const searchQuery = ref('')
const searchInput = ref(null)
const token = window.localStorage.getItem('token')
const currentUser = computed(() => authStore.user)
const notificationCount = ref(3)
const headerClasses = computed(() => ({
'mobile-header': isMobile.value,
'desktop-header': !isMobile.value,
'valentine-header': props.isValentinePage
}))
const searchSuggestions = ref([
{ id: 1, text: 'Dashboard', icon: 'UserIcon' },
{ id: 2, text: 'Projects', icon: 'UserIcon' },
{ id: 3, text: 'Settings', icon: 'UserIcon' }
])
const notifications = ref([
{
id: 1,
text: '新项目已创建完成',
time: new Date(),
icon: 'UserIcon',
read: false
},
emits: ['toggle-sidebar'],
setup(props, { emit }) {
const { t } = useI18n()
const router = useRouter()
const authStore = useAuthStore()
{
id: 2,
text: '您的作品获得了新的点赞',
time: new Date(Date.now() - 1000 * 60 * 30),
icon: 'UserIcon',
read: false
}
])
//
const isMobile = ref(window.innerWidth < 768)
const searchVisible = ref(false)
const notificationsVisible = ref(false)
const searchQuery = ref('')
const searchInput = ref(null)
const handleResize = () => {
isMobile.value = window.innerWidth < 768
}
//
const currentUser = computed(() => authStore.user)
const notificationCount = ref(3) //
const headerClasses = computed(() => ({
'mobile-header': isMobile.value,
'desktop-header': !isMobile.value
}))
//
const searchSuggestions = ref([
{ id: 1, text: 'Dashboard', icon: 'UserIcon' },
{ id: 2, text: 'Projects', icon: 'UserIcon' },
{ id: 3, text: 'Settings', icon: 'UserIcon' }
])
//
const notifications = ref([
{
id: 1,
text: '新项目已创建完成',
time: new Date(),
icon: 'UserIcon',
read: false
},
{
id: 2,
text: '您的作品获得了新的点赞',
time: new Date(Date.now() - 1000 * 60 * 30),
icon: 'UserIcon',
read: false
}
])
//
const handleResize = () => {
isMobile.value = window.innerWidth < 768
}
//
const toggleSearch = async () => {
searchVisible.value = !searchVisible.value
if (searchVisible.value) {
await nextTick()
searchInput.value?.focus()
}
}
const closeSearch = () => {
searchVisible.value = false
searchQuery.value = ''
}
const clearSearch = () => {
searchQuery.value = ''
searchInput.value?.focus()
}
//
const performSearch = () => {
if (searchQuery.value.trim()) {
console.log('搜索:', searchQuery.value)
router.push(`/search?q=${encodeURIComponent(searchQuery.value)}`)
closeSearch()
}
}
const selectSuggestion = (suggestion) => {
searchQuery.value = suggestion.text
performSearch()
}
//
const toggleNotifications = () => {
notificationsVisible.value = !notificationsVisible.value
}
const markAsRead = (id) => {
const notification = notifications.value.find(n => n.id === id)
if (notification) {
notification.read = true
notificationCount.value = Math.max(0, notificationCount.value - 1)
}
}
const markAllAsRead = () => {
notifications.value.forEach(n => n.read = true)
notificationCount.value = 0
}
//
const handleUserCommand = async (command) => {
switch (command) {
case 'profile':
router.push('/profile')
break
case 'settings':
router.push('/settings')
break
case 'logout':
try {
await authStore.logout(()=>{
// router.push('/login')
})
} catch (error) {
console.error('登出失败:', error)
}
break
}
}
//
const formatTime = (time) => {
const now = new Date()
const diff = now - time
const minutes = Math.floor(diff / (1000 * 60))
const hours = Math.floor(diff / (1000 * 60 * 60))
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes}分钟前`
if (hours < 24) return `${hours}小时前`
return `${days}天前`
}
//
const handleClickOutside = (event) => {
if (!event.target.closest('.app-header')) {
searchVisible.value = false
notificationsVisible.value = false
}
}
onMounted(() => {
window.addEventListener('resize', handleResize)
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
document.removeEventListener('click', handleClickOutside)
})
return {
t,
isMobile,
searchVisible,
notificationsVisible,
searchQuery,
searchInput,
searchSuggestions,
notifications,
notificationCount,
currentUser,
headerClasses,
toggleSearch,
closeSearch,
clearSearch,
performSearch,
selectSuggestion,
toggleNotifications,
markAsRead,
markAllAsRead,
handleUserCommand,
formatTime
}
const toggleSearch = async () => {
searchVisible.value = !searchVisible.value
if (searchVisible.value) {
await nextTick()
searchInput.value?.focus()
}
}
const closeSearch = () => {
searchVisible.value = false
searchQuery.value = ''
}
const clearSearch = () => {
searchQuery.value = ''
searchInput.value?.focus()
}
const performSearch = () => {
if (searchQuery.value.trim()) {
console.log('搜索:', searchQuery.value)
router.push(`/search?q=${encodeURIComponent(searchQuery.value)}`)
closeSearch()
}
}
const selectSuggestion = (suggestion) => {
searchQuery.value = suggestion.text
performSearch()
}
const toggleNotifications = () => {
notificationsVisible.value = !notificationsVisible.value
}
const markAsRead = (id) => {
const notification = notifications.value.find(n => n.id === id)
if (notification) {
notification.read = true
notificationCount.value = Math.max(0, notificationCount.value - 1)
}
}
const markAllAsRead = () => {
notifications.value.forEach(n => n.read = true)
notificationCount.value = 0
}
const handleUserCommand = async (command) => {
switch (command) {
case 'profile':
router.push('/profile')
break
case 'settings':
router.push('/settings')
break
case 'logout':
try {
await authStore.logout(()=>{
})
} catch (error) {
console.error('登出失败:', error)
}
break
}
}
const formatTime = (time) => {
const now = new Date()
const diff = now - time
const minutes = Math.floor(diff / (1000 * 60))
const hours = Math.floor(diff / (1000 * 60 * 60))
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes}分钟前`
if (hours < 24) return `${hours}小时前`
return `${days}天前`
}
const handleClickOutside = (event) => {
if (!event.target.closest('.app-header')) {
searchVisible.value = false
notificationsVisible.value = false
}
}
const applyTheme = (type=null) => {
themeToggleRef.value?.toggleTheme(type)
}
defineExpose({
applyTheme
})
onMounted(() => {
window.addEventListener('resize', handleResize)
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
document.removeEventListener('click', handleClickOutside)
})
</script>
<style scoped>
@ -372,6 +328,121 @@ export default {
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
position: relative;
z-index: 400;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 情人节主题头部 */
.app-header.valentine-header {
background: #fff5f7 !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.4) !important;
box-shadow: 0 4px 12px rgba(255, 154, 158, 0.2) !important;
}
.app-header.valentine-header .brand-name {
background: linear-gradient(135deg, #fff 0%, #ff99b7 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-shadow: 0 1px 2px rgba(255, 107, 157, 0.3);
}
.app-header.valentine-header .mobile-menu-button {
color: #fff;
}
.app-header.valentine-header .mobile-menu-button:hover {
background: rgba(255, 255, 255, 0.25);
}
.app-header.valentine-header .action-button {
color: #fff;
}
.app-header.valentine-header .action-button:hover {
background: rgba(255, 255, 255, 0.25);
color: #fff;
}
.app-header.valentine-header .user-avatar {
background: rgba(255, 255, 255, 0.15);
}
.app-header.valentine-header .user-avatar:hover {
background: rgba(255, 255, 255, 0.25);
}
.app-header.valentine-header .user-name {
color: #fff;
}
.app-header.valentine-header .dropdown-icon {
color: #fff;
}
.app-header.valentine-header .search-dropdown {
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(255, 154, 158, 0.2);
}
.app-header.valentine-header .search-icon {
color: #ff6b9d;
}
.app-header.valentine-header .search-input {
color: #fff;
}
.app-header.valentine-header .clear-search-button {
color: #ff6b9d;
}
.app-header.valentine-header .clear-search-button:hover {
background: rgba(255, 107, 157, 0.15);
}
.app-header.valentine-header .search-suggestion:hover {
background: rgba(255, 107, 157, 0.15);
}
.app-header.valentine-header .notifications-panel {
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(255, 154, 158, 0.2);
}
.app-header.valentine-header .notifications-header h3 {
color: #fff;
}
.app-header.valentine-header .mark-all-read {
color: #fff;
}
.app-header.valentine-header .mark-all-read:hover {
background: rgba(255, 107, 157, 0.15);
}
.app-header.valentine-header .notification-item:hover {
background: rgba(255, 107, 157, 0.1);
}
.app-header.valentine-header .notification-item.unread {
background: rgba(255, 107, 157, 0.08);
}
.app-header.valentine-header .notification-text {
color: #fff;
}
.app-header.valentine-header .notification-time {
color: rgba(255, 255, 255, 0.8);
}
.app-header.valentine-header .no-notifications {
color: rgba(255, 255, 255, 0.8);
}
.app-header.valentine-header .notification-icon {
background: linear-gradient(135deg, #ff6b9d 0%, #ff8a80 100%);
}
/* 移动端汉堡菜单按钮 */

View File

@ -16,6 +16,7 @@
<CreationIcon v-else-if="item.icon === 'CreationIcon'" />
<GalleryIcon v-else-if="item.icon === 'GalleryIcon'" />
<OrdersIcon v-else-if="item.icon === 'OrdersIcon'" />
<GreetingCardIcon v-else-if="item.icon === 'GreetingCardIcon'" />
<UserIcon v-else-if="item.icon === 'UserIcon'" />
</div>
<transition name="fade">
@ -105,7 +106,8 @@ import {
Folder as ProjectIcon,
Bell as NotificationIcon,
Key as ApiIcon,
ShoppingCartFull
ShoppingCartFull,
Postcard as GreetingCardIcon
} from '@element-plus/icons-vue'
//
@ -118,6 +120,10 @@ const props = defineProps({
collapsed: {
type: Boolean,
default: false
},
isValentinePage: {
type: Boolean,
default: false
}
})
@ -143,7 +149,8 @@ const userRole = computed(() => ({
}[authStore.user?.user_role || authStore.user?.userRole||'1'])) // 'free'
const sidebarClasses = computed(() => ({
'sidebar-mobile': isMobile.value,
'show': isMobile.value && !props.collapsed
'show': isMobile.value && !props.collapsed,
'valentine-sidebar': props.isValentinePage
}))
// (6)
@ -176,6 +183,13 @@ const coreMenuItems = computed(() => [
icon: 'OrdersIcon',
badge: null
},
{
id: 'greeting-card',
path: '/greeting-card',
label: t('sidebar.greetingCard'),
icon: 'GreetingCardIcon',
badge: null
},
{
id: 'user-center',
path: '/user-center',
@ -765,6 +779,112 @@ onUnmounted(() => {
}
}
/* 情人节主题 */
.app-sidebar.valentine-sidebar {
background: linear-gradient(180deg, #fff5f7 0%, #ffe4e8 50%, #ffd1dc 100%) !important;
border-right: 1px solid rgba(255, 107, 157, 0.2) !important;
}
.app-sidebar.valentine-sidebar .nav-item {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 107, 157, 0.1);
box-shadow:
0 2px 8px rgba(255, 107, 157, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.15);
}
.app-sidebar.valentine-sidebar .nav-item:hover {
background: rgba(255, 107, 157, 0.15);
color: #ff6b9d;
transform: translateY(-3px) scale(1.02);
box-shadow:
0 12px 32px rgba(255, 107, 157, 0.25),
0 4px 12px rgba(255, 107, 157, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.25);
border-color: rgba(255, 107, 157, 0.4);
}
.app-sidebar.valentine-sidebar .nav-item.active {
background: linear-gradient(135deg,
rgba(255, 107, 157, 0.25) 0%,
rgba(255, 138, 128, 0.2) 50%,
rgba(255, 183, 77, 0.15) 100%);
color: #ff6b9d;
font-weight: 600;
border-color: rgba(255, 107, 157, 0.5);
box-shadow:
0 16px 40px rgba(255, 107, 157, 0.35),
0 6px 20px rgba(255, 107, 157, 0.25),
inset 0 1px 0 rgba(255, 255, 255, 0.35),
0 0 0 1px rgba(255, 107, 157, 0.15);
}
.app-sidebar.valentine-sidebar .nav-item.active .nav-icon svg {
filter: drop-shadow(0 0 8px rgba(255, 107, 157, 0.5));
}
.app-sidebar.valentine-sidebar .nav-item:hover .nav-icon svg {
filter: drop-shadow(0 4px 8px rgba(255, 107, 157, 0.4));
}
.app-sidebar.valentine-sidebar .sidebar-footer {
border-top: 1px solid rgba(255, 107, 157, 0.15);
}
.app-sidebar.valentine-sidebar .sidebar-footer::before {
background: linear-gradient(90deg,
transparent 0%,
rgba(255, 107, 157, 0.3) 20%,
rgba(255, 107, 157, 0.5) 50%,
rgba(255, 107, 157, 0.3) 80%,
transparent 100%);
}
.app-sidebar.valentine-sidebar .user-profile {
background: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 107, 157, 0.2);
}
.app-sidebar.valentine-sidebar .user-profile:hover {
box-shadow: 0 0 12px rgba(255, 107, 157, 0.3);
}
.app-sidebar.valentine-sidebar .role-badge.free {
background: linear-gradient(135deg, #ff6b9d 0%, #ff8a80 100%);
box-shadow: 0 0 6px rgba(255, 107, 157, 0.5);
}
.app-sidebar.valentine-sidebar .role-badge.creator {
background: linear-gradient(135deg, #ff8a80 0%, #ffb74d 100%);
box-shadow: 0 0 6px rgba(255, 107, 157, 0.5);
}
.app-sidebar.valentine-sidebar .user-profile-collapsed {
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(255, 107, 157, 0.25);
}
.app-sidebar.valentine-sidebar .user-profile-collapsed:hover {
background: rgba(255, 255, 255, 0.95);
border-color: rgba(255, 107, 157, 0.35);
box-shadow:
0 8px 32px rgba(255, 107, 157, 0.2),
0 2px 8px rgba(255, 107, 157, 0.15);
}
.app-sidebar.valentine-sidebar .user-profile-collapsed .user-avatar-container {
background: linear-gradient(135deg,
rgba(255, 107, 157, 0.15) 0%,
rgba(255, 138, 128, 0.25) 100%);
}
.app-sidebar.valentine-sidebar .user-profile-collapsed:hover .user-avatar-container {
background: linear-gradient(135deg,
rgba(255, 107, 157, 0.25) 0%,
rgba(255, 138, 128, 0.35) 100%);
box-shadow: 0 0 12px rgba(255, 107, 157, 0.3);
}
/* 深色主题 */
.dark .app-sidebar {
--sidebar-bg: #1f2937;

View File

@ -5,7 +5,9 @@
<header class="header-container">
<AppHeader
:sidebar-visible="sidebarVisible"
ref="headerRef"
@toggle-sidebar="toggleSidebar"
:is-valentine-page="isValentinePage"
/>
</header>
@ -14,16 +16,16 @@
<!-- 侧边栏容器 -->
<aside
class="sidebar-container"
:class="{ 'sidebar-visible': sidebarVisible }"
:class="{ 'sidebar-visible': sidebarVisible, 'valentine-theme': isValentinePage }"
>
<AppSidebar
:collapsed="!sidebarVisible"
@navigate="handleNavigate"
@navigate="handleNavigate"
:is-valentine-page="isValentinePage"
/>
</aside>
<!-- 主内容区域 -->
<div class="main-content" :class="{ 'sidebar-collapsed': !sidebarVisible && !isMobile }">
<!-- <div class="sidebar-overlay" :class="{ 'sidebar-overlay-active': loading }"></div> -->
<DtLoadingCom v-if="loading" />
<!-- 面包屑导航 -->
<!-- <BreadcrumbNavigation class="breadcrumb-container" /> -->
@ -45,8 +47,10 @@
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import AppHeader from './AppHeader.vue'
import AppSidebar from './AppSidebar.vue'
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import { useThemeStore } from '@/stores/theme'
const headerRef = ref(null)
const themeStore = useThemeStore()
//
defineOptions({
name: 'MainLayout'
@ -84,6 +88,9 @@ const isMobile = computed(() => state.isMobile)
const isTablet = computed(() => state.isTablet)
const isDesktop = computed(() => state.isDesktop)
//
const isValentinePage = computed(() => route.path === '/greeting-card')
//
const updateBreakpoints = () => {
state.screenWidth = window.innerWidth
@ -108,6 +115,7 @@ const toggleSidebar = () => {
}
}
const router = useRouter()
const route = useRoute()
//
const handleNavigate = (route) => {
router.replace(route)
@ -125,7 +133,16 @@ const handleResize = () => {
updateBreakpoints()
}, 150)
}
const beforeTheme = ref('')
// /greeting-card
watch(() => route.path, (newPath) => {
if (newPath === '/greeting-card') {
beforeTheme.value = window.localStorage.getItem('theme') || 'light'
headerRef.value?.applyTheme('light')
} else {
headerRef.value?.applyTheme(beforeTheme.value)
}
}, { immediate: true })
//
onMounted(() => {
updateBreakpoints()

View File

@ -7,7 +7,7 @@
:disabled="transitioning"
>
<button
@click="toggleTheme"
@click="toggleTheme()"
class="theme-toggle-btn"
:class="{ 'dark': isDark }"
:disabled="transitioning"
@ -48,9 +48,7 @@ const props = defineProps({
default: ''
}
})
//
//
const isDark = ref(false)
const transitioning = ref(false)
@ -66,14 +64,18 @@ onMounted(() => {
})
//
const toggleTheme = () => {
const toggleTheme = (type=null) => {
if(window.location.href.includes('/greeting-card')&&type===null){
return
}
if (transitioning.value) return
transitioning.value = true
isDark.value = !isDark.value
if(type!==null){
isDark.value = type=== 'dark'
}else{
isDark.value = !isDark.value
}
applyTheme()
// transitioning
setTimeout(() => {
transitioning.value = false
@ -105,6 +107,9 @@ if (window.matchMedia) {
}
})
}
defineExpose({
toggleTheme
})
</script>
<style scoped>

View File

@ -48,6 +48,7 @@ export default {
gallery: '画廊',
orders: '订单',
orderManagement: '订单',
greetingCard: '贺卡',
apiKeys: 'API密钥',
settings: '设置',
mainMenu: '主要菜单',
@ -1557,6 +1558,62 @@ export default {
hasInviteCode: '已有邀请码?',
inviteCodePlaceholder: '填写邀请码升级为免费会员',
logout: '退出登录'
},
confessionCard: {
gestureHint: '请隔空比心',
gestureSubtext: '对着摄像头做出比心手势,让粒子汇聚成爱意',
enableCamera: '开启摄像头',
disableCamera: '关闭摄像头',
manualTrigger: '直接打开信件'
},
greetingCard: {
pageTitle: '情人节 · 爱的传情',
pageSubtitle: '为TA定制专属心意',
pageDescription: '下单时选择绑定贺卡收货时将随附一张印有二维码的精美实体贺卡。TA 扫码即可查收您的专属祝福,让爱意跨越屏幕,温暖彼此的心。',
tipTitle: '💕 爱的传递方式',
tipSubtitle: '让每一份心意都触手可及',
tipFeatures: {
feature1: {
title: '专属实体贺卡',
description: '精美印刷,质感十足'
},
feature2: {
title: '二维码绑定',
description: '扫码即可查看您的专属祝福'
},
feature3: {
title: '随货附赠',
description: '收货时惊喜送达温暖TA的心'
}
},
createButton: '创建心意',
editButton: '编辑',
deleteButton: '删除',
deleteConfirm: '确定要删除这张贺卡吗?',
deleteSuccess: '心意已删除',
emptyTitle: '还没有为TA准备贺卡',
emptyDescription: '快去创建一份心意吧!',
dialogCreateTitle: '创建心意',
dialogEditTitle: '编辑心意',
imageUploadLabel: '上传图片',
imageUploadPlaceholder: '点击或拖拽上传图片',
imageUploadHint: '请上传图片文件',
messageLabel: '祝福寄语',
messagePlaceholder: '写下您想对TA说的情话...',
messageRequired: '请输入祝福寄语',
imageRequired: '请上传一张图片',
cancelButton: '取消',
saveButton: '保存',
saveButtonLoading: '保存中...',
createSuccess: '心意已创建',
updateSuccess: '心意已更新',
cardDate: '创建时间',
previewDialogTitle: '贺卡预览',
cardMessage: '愿你我如星辰般永恒相守',
exportButton: '导出图片',
exportSuccess: '图片导出成功',
exportFailed: '图片导出失败,请重试',
previewButton: '预览'
}
},
en: {
@ -1674,6 +1731,7 @@ export default {
gallery: 'Gallery',
orders: 'Orders',
orderManagement: 'Orders',
greetingCard: 'Greeting Card',
apiKeys: 'API Keys',
settings: 'Settings',
mainMenu: 'Main Menu',
@ -3094,6 +3152,62 @@ export default {
hasInviteCode: 'Already have an invite code?',
inviteCodePlaceholder: 'Enter invite code to upgrade to free membership',
logout: 'Logout'
},
confessionCard: {
gestureHint: 'Make a Heart Gesture',
gestureSubtext: 'Make a heart gesture to the camera to let particles gather into love',
enableCamera: 'Enable Camera',
disableCamera: 'Disable Camera',
manualTrigger: 'Directly Open the Letter'
},
greetingCard: {
pageTitle: 'Valentine\'s Day · Love Expressions',
pageSubtitle: 'Create a Special Message for Your Love',
pageDescription: 'When placing an order, choose to bind a greeting card. A beautiful physical card with a QR code will be included with your delivery. Your loved one can scan the code to receive your exclusive blessing, letting love transcend the screen and warm each other\'s hearts.',
tipTitle: '💕 Ways to Share Your Love',
tipSubtitle: 'Make Every Message Reachable',
tipFeatures: {
feature1: {
title: 'Exclusive Physical Card',
description: 'Beautiful printing, premium quality'
},
feature2: {
title: 'QR Code Binding',
description: 'Scan to view your exclusive blessing'
},
feature3: {
title: 'Included with Delivery',
description: 'Surprise delivery, warming their heart'
}
},
createButton: 'Create Message',
editButton: 'Edit',
deleteButton: 'Delete',
deleteConfirm: 'Are you sure you want to delete this greeting card?',
deleteSuccess: 'Message deleted successfully',
emptyTitle: 'No greeting cards yet',
emptyDescription: 'Create a special message for your loved one!',
dialogCreateTitle: 'Create Message',
dialogEditTitle: 'Edit Message',
imageUploadLabel: 'Upload Image',
imageUploadPlaceholder: 'Click or drag to upload image',
imageUploadHint: 'Please upload an image file',
messageLabel: 'Blessing Message',
messagePlaceholder: 'Write your love message...',
messageRequired: 'Please enter a blessing message',
imageRequired: 'Please upload an image',
cancelButton: 'Cancel',
saveButton: 'Save',
saveButtonLoading: 'Saving...',
createSuccess: 'Message created successfully',
updateSuccess: 'Message updated successfully',
cardDate: 'Created',
previewDialogTitle: 'Greeting Card Preview',
cardMessage: 'May you and I, like constellations in the night sky, stay bound in timeless union.',
exportButton: 'Export Image',
exportSuccess: 'Image exported successfully',
exportFailed: 'Image export failed, please try again',
previewButton: 'Preview'
}
}
},

View File

@ -22,6 +22,10 @@ const UserCenter = () => import('../views/user/index.vue')
const NotFound = () => import('../views/NotFound.vue')
const Waitlist = () => import('../views/Waitlist.vue')
const KefuReduce = () => import('../views/kefuReduce.vue')
const CardPreview = () => import('../views/cardPreview/index.vue')
const ConfessionElectronicCard = () => import('../views/cardPreview/ConfessionElectronicCard.vue')
const GreetingCard = () => import('../views/GreetingCard/GreetingCard.vue')
const isPortraitMobile = () => {
return window.innerWidth < 768 && window.innerHeight > window.innerWidth
}
@ -46,6 +50,18 @@ NProgress.configure({
})// 开启轻量模式(顶部细线)
// 路由配置
const routes = [
{
path: '/confession-electronic-card',
name: 'confession-electronic-card',
component: ConfessionElectronicCard,
meta: { requiresAuth: false, keepAlive: false, fullScreen: true }
},
{
path: '/card-preview',
name: 'card-preview',
component: CardPreview,
meta: { requiresAuth: false, keepAlive: false, fullScreen: true }
},
{
path: '/agreement/:type',
name: 'agreement',
@ -73,7 +89,7 @@ const routes = [
path: '/czhome',
name: 'czhome',
component: ModernHome,
meta: { requiresAuth: true, keepAlive: false }
meta: { requiresAuth: false, keepAlive: false }
},
{
path: '/register',
@ -139,6 +155,12 @@ export const freeRoutes = [
component: OrderDetail,
meta: { requiresAuth: true }
},
{
path: '/greeting-card',
name: 'greeting-card',
component: GreetingCard,
meta: { requiresAuth: true, keepAlive: false }
},
{
path: '/agent-management',
name: 'agent-management',

View File

@ -3,10 +3,10 @@ import { ref, computed } from 'vue'
export const useThemeStore = defineStore('theme', () => {
// 状态
const isDark = ref(false)
const isDark = ref(false)// 是否是暗黑主题
// 计算属性
const theme = computed(() => isDark.value ? 'dark' : 'light')
const theme = computed(() => isDark.value ? 'dark' : 'light')// 当前主题
// 方法
const toggleTheme = () => {
@ -18,12 +18,12 @@ export const useThemeStore = defineStore('theme', () => {
}
const setTheme = (newTheme) => {
isDark.value = newTheme === 'dark'
localStorage.setItem('theme', theme.value)
isDark.value = (newTheme === 'dark')
localStorage.setItem('theme', newTheme)
applyTheme()
}
const applyTheme = () => {
const applyTheme = () => {// 应用当前主题到文档
const root = document.documentElement
if (isDark.value) {
root.classList.add('dark')
@ -55,6 +55,5 @@ export const useThemeStore = defineStore('theme', () => {
persist: {
key: 'theme',
storage: localStorage,
paths: ['isDark']
}
})

File diff suppressed because it is too large Load Diff

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 {
height: 300px;
}
.stat-icon {
width: 48px;
height: 48px;
font-size: 20px;
}
.stat-icon :deep(.el-icon) {
width: 24px;
height: 24px;
}
.stat-value {
font-size: 1.5rem;
}
.stat-label {
font-size: 0.8rem;
}
}
/* 统计卡片区 */
@ -745,6 +764,19 @@ onMounted(() => {
font-size: 24px;
}
.stat-icon :deep(.el-icon) {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
}
.stat-icon :deep(svg) {
width: 100%;
height: 100%;
}
.stat-content {
flex: 1;
}

View File

@ -387,7 +387,6 @@ const handleSaveProject = (index,item,type='image')=>{
const createProject = async ()=>{
const {id} = await PluginProject.createProject();
// project/id/
await router.replace(`/project/${id}/${series.value}`);
projectId.value = id;
getProjectInfo(id);
@ -406,6 +405,7 @@ const getProjectInfo = async (id)=>{
id: card.id || Date.now() + Math.random().toString(36).substr(2, 9)
}));
projectInfo.value.tags = [series.value];
updateProjectInfo(projectInfo.value);
}
//
const updateProjectInfo = async (newProjectInfo)=>{
@ -1136,6 +1136,11 @@ const init = ()=>{
const route = useRoute();
projectId.value = route.params.id;
series.value = route.params.series;
if(series.value!='D1'&&series.value!='E1'){
series.value = 'D1';
router.replace(`/project/${projectId.value}/${series.value}`);
return
}
if(projectId.value === 'new'){
createProject();
return
@ -1190,7 +1195,7 @@ onUnmounted(() => {// 禁用轮询
top:8px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
z-index: 100;
width: 98.6%;
background-color: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);

View File

@ -1287,7 +1287,7 @@ onUnmounted(() => {// 禁用轮询
justify-content: space-between;
padding: 0 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
z-index: 1000;
z-index: 100;
}
html.dark .header-nav {

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问题
proxy: {
'/api': {
// target: 'https://api.deotaland.ai',
target: 'http://api.deotaland.local',
target: 'https://api.deotaland.ai',
// target: 'http://api.deotaland.local',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}

View File

@ -38,7 +38,7 @@
/* background: red; */
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
z-index: 100;
z-index: 300;
opacity: 0;
visibility: hidden;
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);

View File

@ -2,7 +2,7 @@ const pay = {
createPaymentintention:{url:'/createPaymentintention',method:'POST'},// 创建支付意图
createCheckoutSession:{url:'/createCheckoutSession',method:'POST'},// 创建会话支付(购物车)
createPayorOrder:{url:'/api-core/front/stripe/create-and-checkout',method:'POST'},//根据产品ID创建订单并跳转支付
getProductList:{url:'/api-core/front/stripe/product/list',method:'POST'},//获取产品列表
getProductList:{url:'/api-core/front/stripe/product/list',method:'POST',isLoading:true},//获取产品列表
calculateUnitAmount:{url:'/api-core/front/stripe/calculate-unit-amount',method:'POST'},//刷新支付金额
createWechatPay:{url:'/api-core/front/wechat/create',method:'POST'}//微信小程序支付
}

View File

@ -17,7 +17,7 @@ const getEnvBaseURL = () => {
// }
var baseURL = '';
const hostname = window.location.hostname;
if(hostname=='localhost'||hostname=='192.168.0.146'){
if(hostname=='localhost'||hostname=='192.168.101.2'){
baseURL = '/api'
}else if(hostname.indexOf('deotaland.ai')>-1||hostname.indexOf('deota.cn')>-1){
baseURL = 'https://api.deotaland.ai'
@ -210,7 +210,6 @@ export const request = {
requestConfig.data = data;
}
if(config.isLoading&&window.setElLoading){
closeMethods = window.setElLoading(config.isqp)
}
return service(requestConfig);

View File

@ -135,9 +135,18 @@ importers:
'@google/genai':
specifier: ^1.27.0
version: 1.27.0
'@mediapipe/camera_utils':
specifier: ^0.3.1675466862
version: 0.3.1675466862
'@mediapipe/hands':
specifier: ^0.4.1675469240
version: 0.4.1675469240
'@splinetool/runtime':
specifier: ^1.12.29
version: 1.12.29
'@tweenjs/tween.js':
specifier: ^25.0.0
version: 25.0.0
'@twind/core':
specifier: ^1.1.3
version: 1.1.3
@ -168,6 +177,9 @@ importers:
element-plus:
specifier: ^2.11.7
version: 2.11.7(vue@3.5.24)
html2canvas:
specifier: ^1.4.1
version: 1.4.1
install:
specifier: ^0.13.0
version: 0.13.0
@ -189,6 +201,9 @@ importers:
pinia-plugin-persistedstate:
specifier: ^4.7.1
version: 4.7.1(pinia@3.0.4)
qrcode:
specifier: ^1.5.4
version: 1.5.4
three:
specifier: ^0.180.0
version: 0.180.0
@ -887,6 +902,14 @@ packages:
'@jridgewell/sourcemap-codec': 1.5.5
dev: true
/@mediapipe/camera_utils@0.3.1675466862:
resolution: {integrity: sha512-siuXBoUxWo9WL0MeAxIxvxY04bvbtdNl7uCxoJxiAiRtNnCYrurr7Vl5VYQ94P7Sq0gVq6PxIDhWWeZ/pLnSzw==}
dev: false
/@mediapipe/hands@0.4.1675469240:
resolution: {integrity: sha512-GxoZvL1mmhJxFxjuyj7vnC++JIuInGznHBin5c7ZSq/RbcnGyfEcJrkM/bMu5K1Mz/2Ko+vEX6/+wewmEHPrHg==}
dev: false
/@nodelib/fs.scandir@2.1.5:
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@ -1281,6 +1304,10 @@ packages:
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
dev: false
/@tweenjs/tween.js@25.0.0:
resolution: {integrity: sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==}
dev: false
/@twind/core@1.1.3:
resolution: {integrity: sha512-/B/aNFerMb2IeyjSJy3SJxqVxhrT77gBDknLMiZqXIRr4vNJqiuhx7KqUSRzDCwUmyGuogkamz+aOLzN6MeSLw==}
engines: {node: '>=14.15.0'}
@ -1880,6 +1907,11 @@ packages:
/balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
/base64-arraybuffer@1.0.2:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
engines: {node: '>= 0.6.0'}
dev: false
/base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@ -1967,6 +1999,11 @@ packages:
engines: {node: '>=6'}
dev: true
/camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
dev: false
/caniuse-lite@1.0.30001759:
resolution: {integrity: sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==}
dev: true
@ -1996,6 +2033,14 @@ packages:
clsx: 2.1.1
dev: true
/cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
dev: false
/cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@ -2103,6 +2148,12 @@ packages:
shebang-command: 2.0.0
which: 2.0.2
/css-line-break@2.1.0:
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
dependencies:
utrie: 1.0.2
dev: false
/cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@ -2149,6 +2200,11 @@ packages:
dependencies:
ms: 2.1.3
/decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
dev: false
/deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
dev: true
@ -2167,6 +2223,10 @@ packages:
engines: {node: '>=8'}
dev: true
/dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dev: false
/doctrine@3.0.0:
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
engines: {node: '>=6.0.0'}
@ -2707,6 +2767,14 @@ packages:
flat-cache: 4.0.1
dev: true
/find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
dev: false
/find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@ -2833,7 +2901,6 @@ packages:
/get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
dev: true
/get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
@ -2986,6 +3053,14 @@ packages:
resolution: {integrity: sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==}
dev: false
/html2canvas@1.4.1:
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
engines: {node: '>=8.0.0'}
dependencies:
css-line-break: 2.1.0
text-segmentation: 1.0.3
dev: false
/htmlparser2@6.1.0:
resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==}
dependencies:
@ -3336,6 +3411,13 @@ packages:
quansync: 0.2.11
dev: true
/locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
dependencies:
p-locate: 4.1.0
dev: false
/locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@ -3628,6 +3710,13 @@ packages:
word-wrap: 1.2.5
dev: true
/p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
dependencies:
p-try: 2.2.0
dev: false
/p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
@ -3635,6 +3724,13 @@ packages:
yocto-queue: 0.1.0
dev: true
/p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
dependencies:
p-limit: 2.3.0
dev: false
/p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
@ -3642,6 +3738,11 @@ packages:
p-limit: 3.1.0
dev: true
/p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
dev: false
/package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
@ -3663,7 +3764,6 @@ packages:
/path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
dev: true
/path-is-absolute@1.0.1:
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
@ -3761,6 +3861,11 @@ packages:
pathe: 2.0.3
dev: true
/pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
dev: false
/postcss-selector-parser@6.1.2:
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
engines: {node: '>=4'}
@ -3825,6 +3930,16 @@ packages:
engines: {node: '>=6'}
dev: true
/qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
dev: false
/quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
dev: true
@ -3873,7 +3988,10 @@ packages:
/require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
dev: true
/require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
dev: false
/resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
@ -3982,6 +4100,10 @@ packages:
hasBin: true
dev: true
/set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
dev: false
/setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
dev: false
@ -4190,6 +4312,12 @@ packages:
source-map-support: 0.5.21
dev: true
/text-segmentation@1.0.3:
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
dependencies:
utrie: 1.0.2
dev: false
/text-table@0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
dev: true
@ -4473,6 +4601,12 @@ packages:
/util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
/utrie@1.0.2:
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
dependencies:
base64-arraybuffer: 1.0.2
dev: false
/vite@7.2.2(terser@5.44.1):
resolution: {integrity: sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -4710,6 +4844,10 @@ packages:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
dev: true
/which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
dev: false
/which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@ -4761,6 +4899,15 @@ packages:
worker-timers-worker: 9.0.12
dev: false
/wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
dev: false
/wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@ -4798,16 +4945,45 @@ packages:
engines: {node: '>=12'}
dev: true
/y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
dev: false
/y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
dev: true
/yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
dev: false
/yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
dev: true
/yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
dev: false
/yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}