feat: initialize Annual Gala Interactive System monorepo
- Set up pnpm workspace with 4 packages: shared, server, client-mobile, client-screen - Implement Redis atomic voting with Lua scripts (HINCRBY + distributed lock) - Add optimistic UI with IndexedDB queue for offline resilience - Configure Socket.io with auto-reconnection (infinite retries) - Separate mobile (Vant) and big screen (Pixi.js) dependencies Tech stack: - Frontend Mobile: Vue 3 + Vant + Socket.io-client - Frontend Screen: Vue 3 + Pixi.js + GSAP - Backend: Express + Socket.io + Redis + Prisma/MySQL Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
43
packages/client-mobile/src/App.vue
Normal file
43
packages/client-mobile/src/App.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
import { useConnectionStore } from './stores/connection';
|
||||
import ConnectionStatus from './components/ConnectionStatus.vue';
|
||||
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
onMounted(() => {
|
||||
connectionStore.connect();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
connectionStore.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<ConnectionStatus />
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.app-container {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #fff5f5 0%, #ffffff 100%);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
177
packages/client-mobile/src/assets/styles/global.scss
Normal file
177
packages/client-mobile/src/assets/styles/global.scss
Normal file
@@ -0,0 +1,177 @@
|
||||
@import './variables.scss';
|
||||
|
||||
// Reset
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
font-size: $font-size-md;
|
||||
line-height: 1.5;
|
||||
color: $color-text-primary;
|
||||
background-color: $color-bg-primary;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
// Safe area for notch devices
|
||||
.safe-area-top {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.safe-area-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
// Utility classes
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.text-gold {
|
||||
color: $color-gold;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.bg-primary {
|
||||
background-color: $color-primary;
|
||||
}
|
||||
|
||||
.bg-gold {
|
||||
background-color: $color-gold;
|
||||
}
|
||||
|
||||
// Guochao decorative elements
|
||||
.guochao-border {
|
||||
border: 2px solid $color-gold;
|
||||
border-radius: $radius-lg;
|
||||
position: relative;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid $color-gold;
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: -6px;
|
||||
left: -6px;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
bottom: -6px;
|
||||
right: -6px;
|
||||
border-left: none;
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Gold gradient text
|
||||
.gold-text {
|
||||
background: linear-gradient(135deg, $color-gold-light 0%, $color-gold 50%, $color-gold-dark 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
// Red gradient button
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, $color-primary-light 0%, $color-primary 100%);
|
||||
color: $color-text-inverse;
|
||||
border: none;
|
||||
border-radius: $radius-lg;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
font-size: $font-size-lg;
|
||||
font-weight: 500;
|
||||
box-shadow: $shadow-md;
|
||||
transition: all $transition-normal;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
}
|
||||
|
||||
// Card style
|
||||
.card {
|
||||
background: $color-bg-card;
|
||||
border-radius: $radius-lg;
|
||||
box-shadow: $shadow-md;
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
// Animation keyframes
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Loading skeleton
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
|
||||
// Vant overrides
|
||||
:root {
|
||||
--van-primary-color: #{$color-primary};
|
||||
--van-success-color: #{$color-success};
|
||||
--van-warning-color: #{$color-warning};
|
||||
--van-danger-color: #{$color-error};
|
||||
--van-text-color: #{$color-text-primary};
|
||||
--van-text-color-2: #{$color-text-secondary};
|
||||
--van-text-color-3: #{$color-text-muted};
|
||||
--van-border-color: #{$color-border};
|
||||
--van-background: #{$color-bg-primary};
|
||||
--van-background-2: #{$color-bg-secondary};
|
||||
}
|
||||
72
packages/client-mobile/src/assets/styles/variables.scss
Normal file
72
packages/client-mobile/src/assets/styles/variables.scss
Normal file
@@ -0,0 +1,72 @@
|
||||
// Guochao Red & Gold Theme Variables
|
||||
|
||||
// Primary colors - 国潮红金配色
|
||||
$color-primary: #c41230; // 中国红
|
||||
$color-primary-light: #e8384f;
|
||||
$color-primary-dark: #9a0e26;
|
||||
|
||||
$color-gold: #d4a84b; // 金色
|
||||
$color-gold-light: #f0c96a;
|
||||
$color-gold-dark: #b8923f;
|
||||
|
||||
// Background colors
|
||||
$color-bg-primary: #fff5f5;
|
||||
$color-bg-secondary: #fef8f0;
|
||||
$color-bg-card: #ffffff;
|
||||
|
||||
// Text colors
|
||||
$color-text-primary: #1a1a1a;
|
||||
$color-text-secondary: #666666;
|
||||
$color-text-muted: #999999;
|
||||
$color-text-inverse: #ffffff;
|
||||
|
||||
// Status colors
|
||||
$color-success: #52c41a;
|
||||
$color-warning: #faad14;
|
||||
$color-error: #ff4d4f;
|
||||
$color-info: #1890ff;
|
||||
|
||||
// Border
|
||||
$color-border: #f0f0f0;
|
||||
$color-border-light: #f5f5f5;
|
||||
|
||||
// Shadows
|
||||
$shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
$shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
|
||||
$shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
|
||||
$shadow-gold: 0 4px 12px rgba(212, 168, 75, 0.3);
|
||||
|
||||
// Border radius
|
||||
$radius-sm: 4px;
|
||||
$radius-md: 8px;
|
||||
$radius-lg: 12px;
|
||||
$radius-xl: 16px;
|
||||
$radius-full: 9999px;
|
||||
|
||||
// Spacing
|
||||
$spacing-xs: 4px;
|
||||
$spacing-sm: 8px;
|
||||
$spacing-md: 16px;
|
||||
$spacing-lg: 24px;
|
||||
$spacing-xl: 32px;
|
||||
|
||||
// Font sizes
|
||||
$font-size-xs: 10px;
|
||||
$font-size-sm: 12px;
|
||||
$font-size-md: 14px;
|
||||
$font-size-lg: 16px;
|
||||
$font-size-xl: 18px;
|
||||
$font-size-2xl: 20px;
|
||||
$font-size-3xl: 24px;
|
||||
|
||||
// Z-index
|
||||
$z-index-dropdown: 100;
|
||||
$z-index-sticky: 200;
|
||||
$z-index-fixed: 300;
|
||||
$z-index-modal: 400;
|
||||
$z-index-toast: 500;
|
||||
|
||||
// Transitions
|
||||
$transition-fast: 0.15s ease;
|
||||
$transition-normal: 0.3s ease;
|
||||
$transition-slow: 0.5s ease;
|
||||
88
packages/client-mobile/src/auto-imports.d.ts
vendored
Normal file
88
packages/client-mobile/src/auto-imports.d.ts
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
|
||||
const computed: typeof import('vue')['computed']
|
||||
const createApp: typeof import('vue')['createApp']
|
||||
const createPinia: typeof import('pinia')['createPinia']
|
||||
const customRef: typeof import('vue')['customRef']
|
||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||
const defineComponent: typeof import('vue')['defineComponent']
|
||||
const defineStore: typeof import('pinia')['defineStore']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const getActivePinia: typeof import('pinia')['getActivePinia']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||
const h: typeof import('vue')['h']
|
||||
const inject: typeof import('vue')['inject']
|
||||
const isProxy: typeof import('vue')['isProxy']
|
||||
const isReactive: typeof import('vue')['isReactive']
|
||||
const isReadonly: typeof import('vue')['isReadonly']
|
||||
const isRef: typeof import('vue')['isRef']
|
||||
const mapActions: typeof import('pinia')['mapActions']
|
||||
const mapGetters: typeof import('pinia')['mapGetters']
|
||||
const mapState: typeof import('pinia')['mapState']
|
||||
const mapStores: typeof import('pinia')['mapStores']
|
||||
const mapWritableState: typeof import('pinia')['mapWritableState']
|
||||
const markRaw: typeof import('vue')['markRaw']
|
||||
const nextTick: typeof import('vue')['nextTick']
|
||||
const onActivated: typeof import('vue')['onActivated']
|
||||
const onBeforeMount: typeof import('vue')['onBeforeMount']
|
||||
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
|
||||
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
|
||||
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
|
||||
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
|
||||
const onDeactivated: typeof import('vue')['onDeactivated']
|
||||
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
|
||||
const onMounted: typeof import('vue')['onMounted']
|
||||
const onRenderTracked: typeof import('vue')['onRenderTracked']
|
||||
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
|
||||
const onScopeDispose: typeof import('vue')['onScopeDispose']
|
||||
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
|
||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||
const onUpdated: typeof import('vue')['onUpdated']
|
||||
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const reactive: typeof import('vue')['reactive']
|
||||
const readonly: typeof import('vue')['readonly']
|
||||
const ref: typeof import('vue')['ref']
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const setActivePinia: typeof import('pinia')['setActivePinia']
|
||||
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
|
||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const storeToRefs: typeof import('pinia')['storeToRefs']
|
||||
const toRaw: typeof import('vue')['toRaw']
|
||||
const toRef: typeof import('vue')['toRef']
|
||||
const toRefs: typeof import('vue')['toRefs']
|
||||
const toValue: typeof import('vue')['toValue']
|
||||
const triggerRef: typeof import('vue')['triggerRef']
|
||||
const unref: typeof import('vue')['unref']
|
||||
const useAttrs: typeof import('vue')['useAttrs']
|
||||
const useCssModule: typeof import('vue')['useCssModule']
|
||||
const useCssVars: typeof import('vue')['useCssVars']
|
||||
const useId: typeof import('vue')['useId']
|
||||
const useLink: typeof import('vue-router')['useLink']
|
||||
const useModel: typeof import('vue')['useModel']
|
||||
const useRoute: typeof import('vue-router')['useRoute']
|
||||
const useRouter: typeof import('vue-router')['useRouter']
|
||||
const useSlots: typeof import('vue')['useSlots']
|
||||
const useTemplateRef: typeof import('vue')['useTemplateRef']
|
||||
const watch: typeof import('vue')['watch']
|
||||
const watchEffect: typeof import('vue')['watchEffect']
|
||||
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
||||
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
}
|
||||
14
packages/client-mobile/src/components.d.ts
vendored
Normal file
14
packages/client-mobile/src/components.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ConnectionStatus: typeof import('./components/ConnectionStatus.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
}
|
||||
92
packages/client-mobile/src/components/ConnectionStatus.vue
Normal file
92
packages/client-mobile/src/components/ConnectionStatus.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useConnectionStore } from '../stores/connection';
|
||||
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
const statusClass = computed(() => {
|
||||
switch (connectionStore.connectionStatus) {
|
||||
case 'connected':
|
||||
return 'status-connected';
|
||||
case 'connecting':
|
||||
return 'status-connecting';
|
||||
default:
|
||||
return 'status-disconnected';
|
||||
}
|
||||
});
|
||||
|
||||
const statusText = computed(() => {
|
||||
switch (connectionStore.connectionStatus) {
|
||||
case 'connected':
|
||||
return '已连接';
|
||||
case 'connecting':
|
||||
return '连接中...';
|
||||
default:
|
||||
return '未连接';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="connection-status" :class="statusClass">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">{{ statusText }}</span>
|
||||
<span v-if="connectionStore.latency > 0" class="latency">
|
||||
{{ connectionStore.latency }}ms
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../assets/styles/variables.scss';
|
||||
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
border-radius: $radius-full;
|
||||
font-size: $font-size-xs;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: $shadow-sm;
|
||||
position: fixed;
|
||||
top: env(safe-area-inset-top, 8px);
|
||||
right: $spacing-sm;
|
||||
z-index: $z-index-fixed;
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
transition: background-color $transition-normal;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.latency {
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-xs;
|
||||
}
|
||||
|
||||
&.status-connected {
|
||||
.status-dot {
|
||||
background-color: $color-success;
|
||||
}
|
||||
}
|
||||
|
||||
&.status-connecting {
|
||||
.status-dot {
|
||||
background-color: $color-warning;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
&.status-disconnected {
|
||||
.status-dot {
|
||||
background-color: $color-error;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
283
packages/client-mobile/src/composables/useVoteQueue.ts
Normal file
283
packages/client-mobile/src/composables/useVoteQueue.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { get, set } from 'idb-keyval';
|
||||
import { useConnectionStore } from '../stores/connection';
|
||||
import { SOCKET_EVENTS } from '@gala/shared/constants';
|
||||
import { CONFIG } from '@gala/shared/constants';
|
||||
import type { VoteCategory, VoteSubmitPayload, AckResponse } from '@gala/shared/types';
|
||||
|
||||
interface QueuedVote {
|
||||
localId: string;
|
||||
candidateId: string;
|
||||
category: VoteCategory;
|
||||
clientTimestamp: number;
|
||||
status: 'pending' | 'sending' | 'confirmed' | 'failed';
|
||||
retryCount: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const QUEUE_STORAGE_KEY = 'vote_queue';
|
||||
|
||||
/**
|
||||
* Composable for optimistic UI voting with local queue
|
||||
*/
|
||||
export function useVoteQueue() {
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
// State
|
||||
const queue = ref<QueuedVote[]>([]);
|
||||
const isProcessing = ref(false);
|
||||
const lastSyncTime = ref<number>(0);
|
||||
|
||||
// Computed
|
||||
const pendingVotes = computed(() => queue.value.filter((v) => v.status === 'pending'));
|
||||
const failedVotes = computed(() => queue.value.filter((v) => v.status === 'failed'));
|
||||
const hasPendingVotes = computed(() => pendingVotes.value.length > 0);
|
||||
|
||||
/**
|
||||
* Load queue from IndexedDB
|
||||
*/
|
||||
async function loadQueue(): Promise<void> {
|
||||
try {
|
||||
const stored = await get<QueuedVote[]>(QUEUE_STORAGE_KEY);
|
||||
if (stored && Array.isArray(stored)) {
|
||||
// Reset sending status to pending (in case of page refresh during send)
|
||||
queue.value = stored.map((v) => ({
|
||||
...v,
|
||||
status: v.status === 'sending' ? 'pending' : v.status,
|
||||
}));
|
||||
|
||||
// Process any pending votes
|
||||
if (connectionStore.isConnected) {
|
||||
processQueue();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[VoteQueue] Failed to load queue:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist queue to IndexedDB
|
||||
*/
|
||||
async function persistQueue(): Promise<void> {
|
||||
try {
|
||||
await set(QUEUE_STORAGE_KEY, queue.value);
|
||||
} catch (error) {
|
||||
console.error('[VoteQueue] Failed to persist queue:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a simple UUID
|
||||
*/
|
||||
function generateLocalId(): string {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a vote (optimistic UI)
|
||||
* Returns the localId for tracking
|
||||
*/
|
||||
function enqueue(candidateId: string, category: VoteCategory): string {
|
||||
const localId = generateLocalId();
|
||||
|
||||
const vote: QueuedVote = {
|
||||
localId,
|
||||
candidateId,
|
||||
category,
|
||||
clientTimestamp: Date.now(),
|
||||
status: 'pending',
|
||||
retryCount: 0,
|
||||
};
|
||||
|
||||
queue.value.push(vote);
|
||||
persistQueue();
|
||||
|
||||
// Immediately try to process
|
||||
processQueue();
|
||||
|
||||
return localId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the queue
|
||||
*/
|
||||
async function processQueue(): Promise<void> {
|
||||
const socket = connectionStore.getSocket();
|
||||
if (isProcessing.value || !connectionStore.isConnected || !socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = queue.value.filter((v) => v.status === 'pending');
|
||||
if (pending.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessing.value = true;
|
||||
|
||||
for (const vote of pending) {
|
||||
await processVote(vote);
|
||||
}
|
||||
|
||||
isProcessing.value = false;
|
||||
persistQueue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single vote
|
||||
*/
|
||||
async function processVote(vote: QueuedVote): Promise<void> {
|
||||
const socket = connectionStore.getSocket();
|
||||
if (!socket?.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
vote.status = 'sending';
|
||||
|
||||
const payload: VoteSubmitPayload = {
|
||||
localId: vote.localId,
|
||||
candidateId: vote.candidateId,
|
||||
category: vote.category,
|
||||
clientTimestamp: vote.clientTimestamp,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await new Promise<AckResponse<{ newCount: number }>>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('timeout'));
|
||||
}, 5000);
|
||||
|
||||
socket.emit(
|
||||
SOCKET_EVENTS.VOTE_SUBMIT as any,
|
||||
payload,
|
||||
(response: AckResponse<{ newCount: number }>) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(response);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
vote.status = 'confirmed';
|
||||
connectionStore.addVotedCategory(vote.category);
|
||||
console.log('[VoteQueue] Vote confirmed:', vote.localId);
|
||||
} else {
|
||||
handleVoteError(vote, response.error || 'UNKNOWN_ERROR');
|
||||
}
|
||||
} catch (error) {
|
||||
handleVoteError(vote, 'NETWORK_ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle vote error with retry logic
|
||||
*/
|
||||
function handleVoteError(vote: QueuedVote, error: string): void {
|
||||
vote.retryCount++;
|
||||
vote.error = error;
|
||||
|
||||
// Don't retry for certain errors
|
||||
const nonRetryableErrors = ['ALREADY_VOTED', 'MAX_VOTES_REACHED', 'INVALID_CANDIDATE', 'VOTING_CLOSED'];
|
||||
|
||||
if (nonRetryableErrors.includes(error) || vote.retryCount >= CONFIG.VOTE_QUEUE_MAX_RETRIES) {
|
||||
vote.status = 'failed';
|
||||
console.warn('[VoteQueue] Vote failed permanently:', vote.localId, error);
|
||||
} else {
|
||||
vote.status = 'pending';
|
||||
console.log('[VoteQueue] Vote will retry:', vote.localId, `(attempt ${vote.retryCount})`);
|
||||
|
||||
// Schedule retry with exponential backoff
|
||||
const delay = CONFIG.VOTE_QUEUE_RETRY_DELAY_MS * Math.pow(2, vote.retryCount - 1);
|
||||
setTimeout(() => processQueue(), delay);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get vote status by localId
|
||||
*/
|
||||
function getVoteStatus(localId: string): QueuedVote | undefined {
|
||||
return queue.value.find((v) => v.localId === localId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a category has a pending/confirmed vote
|
||||
*/
|
||||
function hasVoteForCategory(category: VoteCategory): boolean {
|
||||
return queue.value.some(
|
||||
(v) => v.category === category && (v.status === 'pending' || v.status === 'sending' || v.status === 'confirmed')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear confirmed votes from queue
|
||||
*/
|
||||
function clearConfirmed(): void {
|
||||
queue.value = queue.value.filter((v) => v.status !== 'confirmed');
|
||||
persistQueue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry failed votes
|
||||
*/
|
||||
function retryFailed(): void {
|
||||
for (const vote of queue.value) {
|
||||
if (vote.status === 'failed') {
|
||||
vote.status = 'pending';
|
||||
vote.retryCount = 0;
|
||||
vote.error = undefined;
|
||||
}
|
||||
}
|
||||
persistQueue();
|
||||
processQueue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific vote from queue
|
||||
*/
|
||||
function removeVote(localId: string): void {
|
||||
const index = queue.value.findIndex((v) => v.localId === localId);
|
||||
if (index !== -1) {
|
||||
queue.value.splice(index, 1);
|
||||
persistQueue();
|
||||
}
|
||||
}
|
||||
|
||||
// Watch connection status to process queue when reconnected
|
||||
watch(
|
||||
() => connectionStore.isConnected,
|
||||
(connected) => {
|
||||
if (connected && hasPendingVotes.value) {
|
||||
console.log('[VoteQueue] Connection restored, processing pending votes');
|
||||
processQueue();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Load queue on init
|
||||
loadQueue();
|
||||
|
||||
return {
|
||||
// State
|
||||
queue,
|
||||
isProcessing,
|
||||
lastSyncTime,
|
||||
|
||||
// Computed
|
||||
pendingVotes,
|
||||
failedVotes,
|
||||
hasPendingVotes,
|
||||
|
||||
// Actions
|
||||
enqueue,
|
||||
processQueue,
|
||||
getVoteStatus,
|
||||
hasVoteForCategory,
|
||||
clearConfirmed,
|
||||
retryFailed,
|
||||
removeVote,
|
||||
};
|
||||
}
|
||||
16
packages/client-mobile/src/env.d.ts
vendored
Normal file
16
packages/client-mobile/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue';
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
readonly VITE_SOCKET_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
22
packages/client-mobile/src/main.ts
Normal file
22
packages/client-mobile/src/main.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
|
||||
// Vant styles
|
||||
import 'vant/lib/index.css';
|
||||
|
||||
// Global styles
|
||||
import './assets/styles/global.scss';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
// Pinia with persistence
|
||||
const pinia = createPinia();
|
||||
pinia.use(piniaPluginPersistedstate);
|
||||
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
|
||||
app.mount('#app');
|
||||
34
packages/client-mobile/src/router/index.ts
Normal file
34
packages/client-mobile/src/router/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: () => import('../views/HomeView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/vote',
|
||||
name: 'vote',
|
||||
component: () => import('../views/VoteView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/vote/:category',
|
||||
name: 'vote-category',
|
||||
component: () => import('../views/VoteCategoryView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/result',
|
||||
name: 'result',
|
||||
component: () => import('../views/ResultView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
component: () => import('../views/ProfileView.vue'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default router;
|
||||
248
packages/client-mobile/src/stores/connection.ts
Normal file
248
packages/client-mobile/src/stores/connection.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed, shallowRef } from 'vue';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import type {
|
||||
ServerToClientEvents,
|
||||
ClientToServerEvents,
|
||||
ConnectionAckPayload,
|
||||
VoteCategory,
|
||||
SyncStatePayload,
|
||||
} from '@gala/shared/types';
|
||||
import { SOCKET_EVENTS } from '@gala/shared/constants';
|
||||
import { CONFIG } from '@gala/shared/constants';
|
||||
|
||||
type GalaSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
||||
|
||||
export const useConnectionStore = defineStore('connection', () => {
|
||||
// State - use shallowRef for socket to avoid deep reactivity issues
|
||||
const socket = shallowRef<GalaSocket | null>(null);
|
||||
const isConnected = ref(false);
|
||||
const isConnecting = ref(false);
|
||||
const sessionId = ref<string | null>(null);
|
||||
const lastPingTime = ref<number>(0);
|
||||
const latency = ref<number>(0);
|
||||
const reconnectAttempts = ref(0);
|
||||
const userId = ref<string | null>(null);
|
||||
const userName = ref<string | null>(null);
|
||||
const votedCategories = ref<VoteCategory[]>([]);
|
||||
|
||||
// Computed
|
||||
const connectionStatus = computed(() => {
|
||||
if (isConnected.value) return 'connected';
|
||||
if (isConnecting.value) return 'connecting';
|
||||
return 'disconnected';
|
||||
});
|
||||
|
||||
const remainingVotes = computed(() => {
|
||||
return CONFIG.MAX_VOTES_PER_USER - votedCategories.value.length;
|
||||
});
|
||||
|
||||
// Heartbeat interval
|
||||
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let pongTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/**
|
||||
* Connect to WebSocket server
|
||||
*/
|
||||
function connect() {
|
||||
if (socket.value?.connected || isConnecting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isConnecting.value = true;
|
||||
|
||||
const socketInstance = io(import.meta.env.VITE_SOCKET_URL || '', {
|
||||
reconnection: true,
|
||||
reconnectionAttempts: Infinity,
|
||||
reconnectionDelay: CONFIG.RECONNECTION_DELAY_MS,
|
||||
reconnectionDelayMax: CONFIG.RECONNECTION_DELAY_MAX_MS,
|
||||
timeout: CONFIG.HEARTBEAT_TIMEOUT_MS,
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
|
||||
// Connection events
|
||||
socketInstance.on('connect', () => {
|
||||
console.log('[Socket] Connected');
|
||||
isConnected.value = true;
|
||||
isConnecting.value = false;
|
||||
reconnectAttempts.value = 0;
|
||||
|
||||
// Join with user info
|
||||
joinRoom();
|
||||
|
||||
// Start heartbeat
|
||||
startHeartbeat();
|
||||
});
|
||||
|
||||
socketInstance.on('disconnect', (reason) => {
|
||||
console.log('[Socket] Disconnected:', reason);
|
||||
isConnected.value = false;
|
||||
stopHeartbeat();
|
||||
});
|
||||
|
||||
socketInstance.on('connect_error', (error) => {
|
||||
console.error('[Socket] Connection error:', error);
|
||||
isConnecting.value = false;
|
||||
reconnectAttempts.value++;
|
||||
});
|
||||
|
||||
// Custom pong handler
|
||||
socketInstance.on('connection:pong' as any, () => {
|
||||
if (pongTimeout) {
|
||||
clearTimeout(pongTimeout);
|
||||
pongTimeout = null;
|
||||
}
|
||||
latency.value = Date.now() - lastPingTime.value;
|
||||
});
|
||||
|
||||
// Connection acknowledgment
|
||||
socketInstance.on('connection:ack' as any, (data: ConnectionAckPayload) => {
|
||||
sessionId.value = data.sessionId;
|
||||
if (data.reconnected && data.missedEvents) {
|
||||
// Handle missed events
|
||||
console.log('[Socket] Reconnected, processing missed events');
|
||||
}
|
||||
});
|
||||
|
||||
// Sync state
|
||||
socketInstance.on('sync:state' as any, (data: SyncStatePayload) => {
|
||||
if (data.userVotedCategories) {
|
||||
votedCategories.value = data.userVotedCategories;
|
||||
}
|
||||
});
|
||||
|
||||
socket.value = socketInstance as GalaSocket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join room with user info
|
||||
*/
|
||||
function joinRoom() {
|
||||
if (!socket.value || !userId.value) return;
|
||||
|
||||
socket.value.emit(
|
||||
SOCKET_EVENTS.CONNECTION_JOIN as any,
|
||||
{
|
||||
userId: userId.value,
|
||||
userName: userName.value || 'Guest',
|
||||
role: 'user',
|
||||
},
|
||||
(response: any) => {
|
||||
if (response.success) {
|
||||
sessionId.value = response.data.sessionId;
|
||||
if (response.data.votedCategories) {
|
||||
votedCategories.value = response.data.votedCategories;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start heartbeat
|
||||
*/
|
||||
function startHeartbeat() {
|
||||
stopHeartbeat();
|
||||
|
||||
heartbeatInterval = setInterval(() => {
|
||||
if (socket.value?.connected) {
|
||||
lastPingTime.value = Date.now();
|
||||
socket.value.emit(SOCKET_EVENTS.CONNECTION_PING as any);
|
||||
|
||||
// Set pong timeout
|
||||
pongTimeout = setTimeout(() => {
|
||||
console.warn('[Socket] Pong timeout, connection may be unstable');
|
||||
}, CONFIG.HEARTBEAT_TIMEOUT_MS);
|
||||
}
|
||||
}, CONFIG.HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop heartbeat
|
||||
*/
|
||||
function stopHeartbeat() {
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = null;
|
||||
}
|
||||
if (pongTimeout) {
|
||||
clearTimeout(pongTimeout);
|
||||
pongTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from server
|
||||
*/
|
||||
function disconnect() {
|
||||
stopHeartbeat();
|
||||
if (socket.value) {
|
||||
socket.value.disconnect();
|
||||
socket.value = null;
|
||||
}
|
||||
isConnected.value = false;
|
||||
isConnecting.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user info
|
||||
*/
|
||||
function setUser(id: string, name: string) {
|
||||
userId.value = id;
|
||||
userName.value = name;
|
||||
|
||||
// Rejoin if already connected
|
||||
if (socket.value?.connected) {
|
||||
joinRoom();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add voted category
|
||||
*/
|
||||
function addVotedCategory(category: VoteCategory) {
|
||||
if (!votedCategories.value.includes(category)) {
|
||||
votedCategories.value.push(category);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request sync from server
|
||||
*/
|
||||
function requestSync() {
|
||||
if (socket.value?.connected) {
|
||||
socket.value.emit(SOCKET_EVENTS.SYNC_REQUEST as any, {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get socket instance (for advanced usage)
|
||||
*/
|
||||
function getSocket(): GalaSocket | null {
|
||||
return socket.value;
|
||||
}
|
||||
|
||||
return {
|
||||
// State (excluding socket to avoid type inference issues)
|
||||
isConnected,
|
||||
isConnecting,
|
||||
sessionId,
|
||||
latency,
|
||||
reconnectAttempts,
|
||||
userId,
|
||||
userName,
|
||||
votedCategories,
|
||||
|
||||
// Computed
|
||||
connectionStatus,
|
||||
remainingVotes,
|
||||
|
||||
// Actions
|
||||
connect,
|
||||
disconnect,
|
||||
setUser,
|
||||
addVotedCategory,
|
||||
requestSync,
|
||||
getSocket,
|
||||
};
|
||||
});
|
||||
275
packages/client-mobile/src/views/HomeView.vue
Normal file
275
packages/client-mobile/src/views/HomeView.vue
Normal file
@@ -0,0 +1,275 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useConnectionStore } from '../stores/connection';
|
||||
import { showToast } from 'vant';
|
||||
|
||||
const router = useRouter();
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
const userName = ref('');
|
||||
const isLoading = ref(false);
|
||||
|
||||
async function handleEnter() {
|
||||
if (!userName.value.trim()) {
|
||||
showToast('请输入您的姓名');
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
// Generate a simple user ID (in production, this would come from auth)
|
||||
const odrawId = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
connectionStore.setUser(odrawId, userName.value.trim());
|
||||
|
||||
// Wait for connection
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
isLoading.value = false;
|
||||
router.push('/vote');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="home-view safe-area-top safe-area-bottom">
|
||||
<!-- Header decoration -->
|
||||
<div class="header-decoration">
|
||||
<div class="lantern left"></div>
|
||||
<div class="lantern right"></div>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="content">
|
||||
<div class="logo-section">
|
||||
<div class="year-badge">
|
||||
<span class="year">2026</span>
|
||||
<span class="zodiac">马年</span>
|
||||
</div>
|
||||
<h1 class="title gold-text">年会互动系统</h1>
|
||||
<p class="subtitle">投票 · 抽奖 · 互动</p>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="input-wrapper guochao-border">
|
||||
<van-field
|
||||
v-model="userName"
|
||||
placeholder="请输入您的姓名"
|
||||
:border="false"
|
||||
clearable
|
||||
maxlength="20"
|
||||
@keyup.enter="handleEnter"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<van-button
|
||||
class="enter-btn"
|
||||
type="primary"
|
||||
block
|
||||
round
|
||||
:loading="isLoading"
|
||||
loading-text="进入中..."
|
||||
@click="handleEnter"
|
||||
>
|
||||
进入年会
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<div class="features">
|
||||
<div class="feature-item">
|
||||
<van-icon name="like-o" size="24" color="#c41230" />
|
||||
<span>投票评选</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<van-icon name="gift-o" size="24" color="#d4a84b" />
|
||||
<span>幸运抽奖</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<van-icon name="chart-trending-o" size="24" color="#c41230" />
|
||||
<span>实时结果</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<p>© 2026 公司年会</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../assets/styles/variables.scss';
|
||||
|
||||
.home-view {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(180deg, #fff5f5 0%, #fef8f0 50%, #ffffff 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-decoration {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 120px;
|
||||
pointer-events: none;
|
||||
|
||||
.lantern {
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
width: 40px;
|
||||
height: 60px;
|
||||
background: linear-gradient(180deg, $color-primary 0%, $color-primary-dark 100%);
|
||||
border-radius: 50% 50% 45% 45%;
|
||||
box-shadow: 0 4px 12px rgba($color-primary, 0.3);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 16px;
|
||||
height: 12px;
|
||||
background: $color-gold;
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -15px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 2px;
|
||||
height: 15px;
|
||||
background: $color-gold;
|
||||
}
|
||||
|
||||
&.left {
|
||||
left: 20px;
|
||||
animation: swing 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&.right {
|
||||
right: 20px;
|
||||
animation: swing 3s ease-in-out infinite 0.5s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes swing {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-5deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(5deg);
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: $spacing-xl $spacing-lg;
|
||||
padding-top: 100px;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
text-align: center;
|
||||
margin-bottom: $spacing-xl;
|
||||
|
||||
.year-badge {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, $color-primary 0%, $color-primary-dark 100%);
|
||||
color: $color-text-inverse;
|
||||
padding: $spacing-sm $spacing-lg;
|
||||
border-radius: $radius-lg;
|
||||
margin-bottom: $spacing-md;
|
||||
box-shadow: $shadow-md;
|
||||
|
||||
.year {
|
||||
font-size: $font-size-2xl;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.zodiac {
|
||||
font-size: $font-size-sm;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: $color-text-secondary;
|
||||
font-size: $font-size-md;
|
||||
}
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: $spacing-xl;
|
||||
|
||||
.input-wrapper {
|
||||
background: $color-bg-card;
|
||||
margin-bottom: $spacing-md;
|
||||
padding: $spacing-xs;
|
||||
|
||||
:deep(.van-field) {
|
||||
background: transparent;
|
||||
|
||||
.van-field__control {
|
||||
text-align: center;
|
||||
font-size: $font-size-lg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.enter-btn {
|
||||
background: linear-gradient(135deg, $color-primary-light 0%, $color-primary 100%);
|
||||
border: none;
|
||||
height: 48px;
|
||||
font-size: $font-size-lg;
|
||||
font-weight: 500;
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
}
|
||||
|
||||
.features {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: $spacing-md 0;
|
||||
|
||||
.feature-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
|
||||
span {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: $spacing-md;
|
||||
color: $color-text-muted;
|
||||
font-size: $font-size-xs;
|
||||
}
|
||||
</style>
|
||||
29
packages/client-mobile/src/views/ProfileView.vue
Normal file
29
packages/client-mobile/src/views/ProfileView.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { useConnectionStore } from '../stores/connection';
|
||||
|
||||
const connectionStore = useConnectionStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="profile-view safe-area-top safe-area-bottom">
|
||||
<van-nav-bar title="我的" left-arrow @click-left="$router.back()" />
|
||||
<div class="content">
|
||||
<van-cell-group inset>
|
||||
<van-cell title="用户名" :value="connectionStore.userName || '未登录'" />
|
||||
<van-cell title="已投票数" :value="`${connectionStore.votedCategories.length}/7`" />
|
||||
<van-cell title="连接状态" :value="connectionStore.connectionStatus" />
|
||||
</van-cell-group>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.profile-view {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
23
packages/client-mobile/src/views/ResultView.vue
Normal file
23
packages/client-mobile/src/views/ResultView.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
// Placeholder view
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="result-view safe-area-top safe-area-bottom">
|
||||
<van-nav-bar title="投票结果" left-arrow @click-left="$router.back()" />
|
||||
<div class="content">
|
||||
<van-empty description="结果统计中..." />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.result-view {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
23
packages/client-mobile/src/views/VoteCategoryView.vue
Normal file
23
packages/client-mobile/src/views/VoteCategoryView.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
// Placeholder view
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vote-category-view safe-area-top safe-area-bottom">
|
||||
<van-nav-bar title="投票" left-arrow @click-left="$router.back()" />
|
||||
<div class="content">
|
||||
<van-empty description="候选人列表加载中..." />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.vote-category-view {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
227
packages/client-mobile/src/views/VoteView.vue
Normal file
227
packages/client-mobile/src/views/VoteView.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useConnectionStore } from '../stores/connection';
|
||||
import { VOTE_CATEGORIES } from '@gala/shared/types';
|
||||
import type { VoteCategory } from '@gala/shared/types';
|
||||
|
||||
const router = useRouter();
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
// Category display info
|
||||
const categoryInfo: Record<VoteCategory, { name: string; icon: string; color: string }> = {
|
||||
best_employee: { name: '最佳员工', icon: 'star-o', color: '#c41230' },
|
||||
best_team: { name: '最佳团队', icon: 'friends-o', color: '#d4a84b' },
|
||||
best_newcomer: { name: '最佳新人', icon: 'fire-o', color: '#52c41a' },
|
||||
best_innovation: { name: '最佳创新', icon: 'bulb-o', color: '#1890ff' },
|
||||
best_service: { name: '最佳服务', icon: 'service-o', color: '#722ed1' },
|
||||
best_collaboration: { name: '最佳协作', icon: 'cluster-o', color: '#fa8c16' },
|
||||
best_leadership: { name: '最佳领导力', icon: 'medal-o', color: '#eb2f96' },
|
||||
};
|
||||
|
||||
const categories = computed(() => {
|
||||
return VOTE_CATEGORIES.map((category) => ({
|
||||
id: category,
|
||||
...categoryInfo[category],
|
||||
voted: connectionStore.votedCategories.includes(category),
|
||||
}));
|
||||
});
|
||||
|
||||
const votedCount = computed(() => connectionStore.votedCategories.length);
|
||||
const totalCategories = VOTE_CATEGORIES.length;
|
||||
|
||||
function goToCategory(category: VoteCategory) {
|
||||
router.push(`/vote/${category}`);
|
||||
}
|
||||
|
||||
function goToResults() {
|
||||
router.push('/result');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vote-view safe-area-top safe-area-bottom">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<h1 class="title">投票评选</h1>
|
||||
<div class="progress-info">
|
||||
<span class="progress-text">已投 {{ votedCount }}/{{ totalCategories }}</span>
|
||||
<van-progress
|
||||
:percentage="(votedCount / totalCategories) * 100"
|
||||
:show-pivot="false"
|
||||
color="#c41230"
|
||||
track-color="#f5f5f5"
|
||||
stroke-width="6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category list -->
|
||||
<div class="category-list">
|
||||
<div
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
class="category-card"
|
||||
:class="{ voted: category.voted }"
|
||||
@click="goToCategory(category.id)"
|
||||
>
|
||||
<div class="card-icon" :style="{ backgroundColor: category.color + '15' }">
|
||||
<van-icon :name="category.icon" :color="category.color" size="28" />
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<h3 class="card-title">{{ category.name }}</h3>
|
||||
<p class="card-status">
|
||||
<template v-if="category.voted">
|
||||
<van-icon name="success" color="#52c41a" />
|
||||
<span class="voted-text">已投票</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="pending-text">待投票</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<van-icon name="arrow" class="card-arrow" color="#999" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom action -->
|
||||
<div class="bottom-action">
|
||||
<van-button
|
||||
type="primary"
|
||||
block
|
||||
round
|
||||
:disabled="votedCount === 0"
|
||||
@click="goToResults"
|
||||
>
|
||||
查看投票结果
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../assets/styles/variables.scss';
|
||||
|
||||
.vote-view {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-primary;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, $color-primary 0%, $color-primary-dark 100%);
|
||||
color: $color-text-inverse;
|
||||
padding: $spacing-lg;
|
||||
padding-top: calc(env(safe-area-inset-top) + #{$spacing-lg});
|
||||
|
||||
.title {
|
||||
font-size: $font-size-2xl;
|
||||
font-weight: bold;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
.progress-text {
|
||||
display: block;
|
||||
font-size: $font-size-sm;
|
||||
margin-bottom: $spacing-xs;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
:deep(.van-progress) {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
|
||||
.van-progress__portion {
|
||||
background: $color-gold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.category-list {
|
||||
padding: $spacing-md;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.category-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: $color-bg-card;
|
||||
border-radius: $radius-lg;
|
||||
padding: $spacing-md;
|
||||
box-shadow: $shadow-sm;
|
||||
transition: all $transition-normal;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
|
||||
&.voted {
|
||||
background: linear-gradient(135deg, #f6ffed 0%, #ffffff 100%);
|
||||
border: 1px solid #b7eb8f;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: $radius-md;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: $spacing-md;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
flex: 1;
|
||||
|
||||
.card-title {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: 500;
|
||||
color: $color-text-primary;
|
||||
margin-bottom: $spacing-xs;
|
||||
}
|
||||
|
||||
.card-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
font-size: $font-size-sm;
|
||||
|
||||
.voted-text {
|
||||
color: $color-success;
|
||||
}
|
||||
|
||||
.pending-text {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-arrow {
|
||||
margin-left: $spacing-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-action {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: $spacing-md;
|
||||
padding-bottom: calc(env(safe-area-inset-bottom) + #{$spacing-md});
|
||||
background: $color-bg-card;
|
||||
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05);
|
||||
|
||||
:deep(.van-button--primary) {
|
||||
background: linear-gradient(135deg, $color-primary-light 0%, $color-primary 100%);
|
||||
border: none;
|
||||
}
|
||||
|
||||
:deep(.van-button--disabled) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user