Files
company-celebration/packages/client-mobile/src/views/ShakeView.vue
empty 48d61a1e15 feat: 新增签到墙、摇一摇等功能及开发环境配置
新功能:
- 签到墙页面 (CheckinWallView) 及后端接口
- 摇一摇互动页面 (ShakeView) 及服务
- 头像服务 (avatar.service)
- 微信公众号静默授权登录增强

开发环境:
- 新增 dev-tunnel skill 用于本地调试
- docker-compose.dev.yml 开发环境配置
- 客户端 .env.development 配置文件

其他改进:
- VoteView 投票页面功能增强
- AdminControl 管理控制台更新
- 连接状态管理优化
- 新增马蹄声音效

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 17:25:56 +08:00

371 lines
8.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { useConnectionStore } from '../stores/connection';
import { SOCKET_EVENTS } from '@gala/shared/constants';
const router = useRouter();
const connectionStore = useConnectionStore();
const shakeCount = ref(0);
const isShaking = ref(false);
const hasPermission = ref(false);
const permissionDenied = ref(false);
// 摇一摇检测参数
const SHAKE_THRESHOLD = 15;
const SHAKE_COOLDOWN = 300;
let lastShakeTime = 0;
let lastX = 0, lastY = 0, lastZ = 0;
// 请求设备运动权限iOS 13+
async function requestPermission() {
if (typeof (DeviceMotionEvent as any).requestPermission === 'function') {
try {
const permission = await (DeviceMotionEvent as any).requestPermission();
if (permission === 'granted') {
permissionDenied.value = false;
hasPermission.value = true;
startListening();
} else {
permissionDenied.value = true;
}
} catch (error) {
console.error('Permission request failed:', error);
permissionDenied.value = true;
}
} else {
// 非 iOS 或旧版本,直接开始监听
hasPermission.value = true;
startListening();
}
}
// 开始监听设备运动
function startListening() {
window.addEventListener('devicemotion', handleMotion);
}
// 停止监听
function stopListening() {
window.removeEventListener('devicemotion', handleMotion);
}
// 处理设备运动事件
function handleMotion(event: DeviceMotionEvent) {
const acceleration = event.accelerationIncludingGravity;
if (!acceleration) return;
const { x, y, z } = acceleration;
if (x === null || y === null || z === null) return;
const deltaX = Math.abs(x - lastX);
const deltaY = Math.abs(y - lastY);
const deltaZ = Math.abs(z - lastZ);
const totalDelta = deltaX + deltaY + deltaZ;
if (totalDelta > SHAKE_THRESHOLD) {
const now = Date.now();
if (now - lastShakeTime > SHAKE_COOLDOWN) {
lastShakeTime = now;
onShake();
}
}
lastX = x;
lastY = y;
lastZ = z;
}
// 摇动触发
function onShake() {
shakeCount.value++;
isShaking.value = true;
// 发送摇动计数到服务器
const socket = connectionStore.getSocket();
if (socket?.connected) {
socket.emit(SOCKET_EVENTS.SHAKE_COUNT as any, { count: 1 });
}
// 震动反馈
if (navigator.vibrate) {
navigator.vibrate(50);
}
// 重置动画状态
setTimeout(() => {
isShaking.value = false;
}, 200);
}
// 返回投票页面
function goBack() {
router.push('/vote');
}
onMounted(() => {
if (!connectionStore.isConnected) {
connectionStore.connect();
}
// 自动请求权限
requestPermission();
});
onUnmounted(() => {
stopListening();
});
</script>
<template>
<div class="shake-view">
<!-- Header -->
<header class="header">
<button class="back-btn" @click="goBack">
<span class="back-icon"></span>
返回
</button>
<h1 class="title">摇一摇</h1>
<div class="placeholder"></div>
</header>
<!-- Main Content -->
<main class="content">
<!-- Permission Request -->
<div v-if="!hasPermission && !permissionDenied" class="permission-request">
<div class="permission-icon">📱</div>
<p class="permission-text">需要获取设备运动权限才能使用摇一摇功能</p>
<button class="permission-btn" @click="requestPermission">
授权使用
</button>
</div>
<!-- Permission Denied -->
<div v-else-if="permissionDenied" class="permission-denied">
<div class="denied-icon">🚫</div>
<p class="denied-text">设备运动权限被拒绝</p>
<p class="denied-hint">请按以下步骤开启权限</p>
<div class="denied-steps">
<p>微信内 设置 通用 发现页管理 摇一摇</p>
<p>iPhone 设置 微信 运动与健身</p>
</div>
<button class="retry-btn" @click="requestPermission">
重新授权
</button>
</div>
<!-- Shake Area -->
<div v-else class="shake-area">
<div class="shake-phone" :class="{ shaking: isShaking }">
<div class="phone-screen">
<span class="shake-emoji">🎉</span>
</div>
</div>
<div class="shake-count-display">
<span class="count-label">摇动次数</span>
<span class="count-value">{{ shakeCount }}</span>
</div>
<p class="shake-hint">摇动手机参与互动</p>
</div>
</main>
</div>
</template>
<style lang="scss" scoped>
@use '../assets/styles/variables.scss' as *;
.shake-view {
min-height: 100vh;
background: linear-gradient(135deg, #1a0a0a 0%, #2d1515 50%, #1a0a0a 100%);
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-md;
padding-top: calc(env(safe-area-inset-top) + #{$spacing-md});
background: rgba(0, 0, 0, 0.3);
}
.back-btn {
display: flex;
align-items: center;
gap: 4px;
background: none;
border: none;
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
cursor: pointer;
padding: 8px 12px;
border-radius: 8px;
transition: background 0.2s;
&:active {
background: rgba(255, 255, 255, 0.1);
}
}
.title {
font-size: 18px;
font-weight: 600;
color: $color-gold;
}
.placeholder {
width: 60px;
}
.content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $spacing-xl;
}
// Permission Request
.permission-request,
.permission-denied {
text-align: center;
padding: $spacing-xl;
}
.permission-icon,
.denied-icon {
font-size: 64px;
margin-bottom: $spacing-lg;
}
.permission-text,
.denied-text {
font-size: 16px;
color: rgba(255, 255, 255, 0.8);
margin-bottom: $spacing-lg;
}
.denied-hint {
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
margin-bottom: $spacing-md;
}
.denied-steps {
text-align: left;
background: rgba(255, 255, 255, 0.1);
padding: $spacing-md;
border-radius: 8px;
margin-bottom: $spacing-lg;
p {
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
margin: $spacing-sm 0;
line-height: 1.5;
}
}
.retry-btn {
padding: 12px 24px;
background: transparent;
border: 1px solid $color-gold;
border-radius: 20px;
color: $color-gold;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
&:active {
background: rgba($color-gold, 0.2);
}
}
.permission-btn {
padding: 14px 32px;
background: linear-gradient(135deg, $color-gold 0%, #d4a84b 100%);
border: none;
border-radius: 24px;
color: #1a0a0a;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
&:active {
transform: scale(0.95);
}
}
// Shake Area
.shake-area {
display: flex;
flex-direction: column;
align-items: center;
gap: $spacing-xl;
}
.shake-phone {
width: 120px;
height: 200px;
background: linear-gradient(145deg, #333 0%, #1a1a1a 100%);
border-radius: 24px;
padding: 8px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
transition: transform 0.1s;
&.shaking {
animation: shake 0.2s ease-in-out;
}
}
.phone-screen {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #2d1515 0%, #1a0a0a 100%);
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
}
.shake-emoji {
font-size: 48px;
}
.shake-count-display {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.count-label {
font-size: 14px;
color: rgba(255, 255, 255, 0.6);
}
.count-value {
font-size: 64px;
font-weight: 700;
color: $color-gold;
text-shadow: 0 0 20px rgba($color-gold, 0.5);
}
.shake-hint {
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
}
@keyframes shake {
0%, 100% { transform: translateX(0) rotate(0); }
25% { transform: translateX(-10px) rotate(-5deg); }
75% { transform: translateX(10px) rotate(5deg); }
}
</style>