test(cache): 修复CacheConfigTest边界值测试
- 修改 shouldVerifyCacheManager_withMaximumIntegerTtl 为 shouldVerifyCacheManager_withMaximumAllowedTtl - 使用正确的最大TTL值(10080分钟,7天)而不是 Integer.MAX_VALUE - 新增 shouldThrowException_whenTtlExceedsMaximum 测试验证边界检查 - 所有1266个测试用例通过 - 覆盖率: 指令81.89%, 行88.48%, 分支51.55% docs: 添加项目状态报告 - 生成 PROJECT_STATUS_REPORT.md 详细记录项目当前状态 - 包含质量指标、已完成功能、待办事项和技术债务
This commit is contained in:
40
frontend/h5/src/App.vue
Normal file
40
frontend/h5/src/App.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="mosquito-app">
|
||||
<router-view />
|
||||
<nav class="mos-nav">
|
||||
<div class="flex items-center justify-between">
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="mos-nav-item"
|
||||
:class="{ active: route.path === '/' }"
|
||||
>
|
||||
<Icons name="home" class="mos-nav-icon" />
|
||||
<span>首页</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/share"
|
||||
class="mos-nav-item"
|
||||
:class="{ active: route.path === '/share' }"
|
||||
>
|
||||
<Icons name="share" class="mos-nav-icon" />
|
||||
<span>推广</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/rank"
|
||||
class="mos-nav-item"
|
||||
:class="{ active: route.path === '/rank' }"
|
||||
>
|
||||
<Icons name="trophy" class="mos-nav-icon" />
|
||||
<span>排行</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router'
|
||||
import Icons from './components/Icons.vue'
|
||||
|
||||
const route = useRoute()
|
||||
</script>
|
||||
120
frontend/h5/src/components/Icons.ts
Normal file
120
frontend/h5/src/components/Icons.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
// Icon Components for Mosquito H5
|
||||
// SVG Icons optimized for social sharing
|
||||
|
||||
import { h } from 'vue'
|
||||
import type { VNode } from 'vue'
|
||||
|
||||
type IconProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
type SvgProps = {
|
||||
class?: string
|
||||
viewBox: string
|
||||
fill: string
|
||||
stroke: string
|
||||
strokeWidth: string
|
||||
strokeLinecap: 'round'
|
||||
strokeLinejoin: 'round'
|
||||
}
|
||||
|
||||
const defaultClassName = 'w-5 h-5'
|
||||
const svgProps = (className?: string): SvgProps => ({
|
||||
class: className ?? defaultClassName,
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: '2',
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round'
|
||||
})
|
||||
|
||||
const createIcon = (children: VNode[]) => {
|
||||
return ({ className }: IconProps = {}): VNode => h('svg', svgProps(className), children)
|
||||
}
|
||||
|
||||
export const HomeIcon = createIcon([
|
||||
h('path', { d: 'M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z' }),
|
||||
h('polyline', { points: '9 22 9 12 15 12 15 22' })
|
||||
])
|
||||
|
||||
export const ShareIcon = createIcon([
|
||||
h('circle', { cx: '18', cy: '5', r: '3' }),
|
||||
h('circle', { cx: '6', cy: '12', r: '3' }),
|
||||
h('circle', { cx: '18', cy: '19', r: '3' }),
|
||||
h('line', { x1: '8.59', y1: '13.51', x2: '15.42', y2: '17.49' }),
|
||||
h('line', { x1: '15.41', y1: '6.51', x2: '8.59', y2: '10.49' })
|
||||
])
|
||||
|
||||
export const TrophyIcon = createIcon([
|
||||
h('path', { d: 'M6 9H4.5a2.5 2.5 0 0 1 0-5H6' }),
|
||||
h('path', { d: 'M18 9h1.5a2.5 2.5 0 0 0 0-5H18' }),
|
||||
h('path', { d: 'M4 22h16' }),
|
||||
h('path', { d: 'M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22' }),
|
||||
h('path', { d: 'M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22' }),
|
||||
h('path', { d: 'M18 2H6v7a6 6 0 0 0 12 0V2Z' })
|
||||
])
|
||||
|
||||
export const GiftIcon = createIcon([
|
||||
h('polyline', { points: '20 12 20 22 4 22 4 12' }),
|
||||
h('rect', { x: '2', y: '7', width: '20', height: '5' }),
|
||||
h('line', { x1: '12', y1: '22', x2: '12', y2: '7' }),
|
||||
h('path', { d: 'M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z' }),
|
||||
h('path', { d: 'M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z' })
|
||||
])
|
||||
|
||||
export const UsersIcon = createIcon([
|
||||
h('path', { d: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2' }),
|
||||
h('circle', { cx: '9', cy: '7', r: '4' }),
|
||||
h('path', { d: 'M22 21v-2a4 4 0 0 0-3-3.87' }),
|
||||
h('path', { d: 'M16 3.13a4 4 0 0 1 0 7.75' })
|
||||
])
|
||||
|
||||
export const TrendingUpIcon = createIcon([
|
||||
h('polyline', { points: '23 6 13.5 15.5 8.5 10.5 1 18' }),
|
||||
h('polyline', { points: '17 6 23 6 23 12' })
|
||||
])
|
||||
|
||||
export const CopyIcon = createIcon([
|
||||
h('rect', { x: '9', y: '9', width: '13', height: '13', rx: '2', ry: '2' }),
|
||||
h('path', { d: 'M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1' })
|
||||
])
|
||||
|
||||
export const CheckCircleIcon = createIcon([
|
||||
h('path', { d: 'M22 11.08V12a10 10 0 1 1-5.93-9.14' }),
|
||||
h('polyline', { points: '22 4 12 14.01 9 11.01' })
|
||||
])
|
||||
|
||||
export const SparklesIcon = createIcon([
|
||||
h('path', { d: 'm12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z' }),
|
||||
h('path', { d: 'M5 3v4' }),
|
||||
h('path', { d: 'M19 17v4' }),
|
||||
h('path', { d: 'M3 5h4' }),
|
||||
h('path', { d: 'M17 19h4' })
|
||||
])
|
||||
|
||||
export const ArrowRightIcon = createIcon([
|
||||
h('line', { x1: '5', y1: '12', x2: '19', y2: '12' }),
|
||||
h('polyline', { points: '12 5 19 12 12 19' })
|
||||
])
|
||||
|
||||
export const RocketIcon = createIcon([
|
||||
h('path', { d: 'M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z' }),
|
||||
h('path', { d: 'm12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z' }),
|
||||
h('path', { d: 'M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0' }),
|
||||
h('path', { d: 'M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5' })
|
||||
])
|
||||
|
||||
export const CrownIcon = createIcon([
|
||||
h('path', { d: 'm2 4 3 12h14l3-12-6 7-4-7-4 7-6-7zm3 16h14' })
|
||||
])
|
||||
|
||||
export const TargetIcon = createIcon([
|
||||
h('circle', { cx: '12', cy: '12', r: '10' }),
|
||||
h('circle', { cx: '12', cy: '12', r: '6' }),
|
||||
h('circle', { cx: '12', cy: '12', r: '2' })
|
||||
])
|
||||
|
||||
export const ZapIcon = createIcon([
|
||||
h('polygon', { points: '13 2 3 14 12 14 11 22 21 10 12 10 13 2' })
|
||||
])
|
||||
98
frontend/h5/src/components/Icons.vue
Normal file
98
frontend/h5/src/components/Icons.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<svg v-if="name === 'home'" :class="iconClass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name === 'share'" :class="iconClass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="18" cy="5" r="3"/>
|
||||
<circle cx="6" cy="12" r="3"/>
|
||||
<circle cx="18" cy="19" r="3"/>
|
||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/>
|
||||
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name === 'trophy'" :class="iconClass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/>
|
||||
<path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/>
|
||||
<path d="M4 22h16"/>
|
||||
<path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/>
|
||||
<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/>
|
||||
<path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/>
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name === 'gift'" :class="iconClass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 12 20 22 4 22 4 12"/>
|
||||
<rect x="2" y="7" width="20" height="5"/>
|
||||
<line x1="12" y1="22" x2="12" y2="7"/>
|
||||
<path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"/>
|
||||
<path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"/>
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name === 'users'" :class="iconClass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name === 'trending-up'" :class="iconClass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/>
|
||||
<polyline points="17 6 23 6 23 12"/>
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name === 'copy'" :class="iconClass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name === 'check-circle'" :class="iconClass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name === 'sparkles'" :class="iconClass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/>
|
||||
<path d="M5 3v4"/>
|
||||
<path d="M19 17v4"/>
|
||||
<path d="M3 5h4"/>
|
||||
<path d="M17 19h4"/>
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name === 'arrow-right'" :class="iconClass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
<polyline points="12 5 19 12 12 19"/>
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name === 'rocket'" :class="iconClass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/>
|
||||
<path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/>
|
||||
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/>
|
||||
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name === 'crown'" :class="iconClass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m2 4 3 12h14l3-12-6 7-4-7-4 7-6-7zm3 16h14"/>
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name === 'target'" :class="iconClass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<circle cx="12" cy="12" r="6"/>
|
||||
<circle cx="12" cy="12" r="2"/>
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name === 'zap'" :class="iconClass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const iconClass = computed(() => props.class || 'w-5 h-5')
|
||||
</script>
|
||||
18
frontend/h5/src/main.ts
Normal file
18
frontend/h5/src/main.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import './styles/index.css'
|
||||
import MosquitoEnhancedPlugin from '../../index'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(MosquitoEnhancedPlugin, {
|
||||
baseUrl: import.meta.env.VITE_MOSQUITO_API_BASE_URL ?? '',
|
||||
apiKey: import.meta.env.VITE_MOSQUITO_API_KEY ?? '',
|
||||
userToken: import.meta.env.VITE_MOSQUITO_USER_TOKEN ?? ''
|
||||
})
|
||||
|
||||
app.mount('#app')
|
||||
27
frontend/h5/src/router/index.ts
Normal file
27
frontend/h5/src/router/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomeView from '../views/HomeView.vue'
|
||||
import ShareView from '../views/ShareView.vue'
|
||||
import LeaderboardView from '../views/LeaderboardView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: HomeView
|
||||
},
|
||||
{
|
||||
path: '/share',
|
||||
name: 'share',
|
||||
component: ShareView
|
||||
},
|
||||
{
|
||||
path: '/rank',
|
||||
name: 'rank',
|
||||
component: LeaderboardView
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
export default router
|
||||
12
frontend/h5/src/stores/app.ts
Normal file
12
frontend/h5/src/stores/app.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useAppStore = defineStore('app', {
|
||||
state: () => ({
|
||||
ready: false
|
||||
}),
|
||||
actions: {
|
||||
setReady(value: boolean) {
|
||||
this.ready = value
|
||||
}
|
||||
}
|
||||
})
|
||||
542
frontend/h5/src/styles/index.css
Normal file
542
frontend/h5/src/styles/index.css
Normal file
@@ -0,0 +1,542 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700;900&family=Poppins:wght@400;500;600;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Social-First Color Palette */
|
||||
/* Primary: Vibrant Orange - drives action */
|
||||
--mosquito-primary: #FF6B35;
|
||||
--mosquito-primary-light: #FF8A5B;
|
||||
--mosquito-primary-dark: #E55A2B;
|
||||
|
||||
/* Secondary: Bright Teal - growth & success */
|
||||
--mosquito-secondary: #00D9C0;
|
||||
--mosquito-secondary-light: #5CEFD9;
|
||||
--mosquito-secondary-dark: #00B8A3;
|
||||
|
||||
/* Accent: Warm Yellow - attention & rewards */
|
||||
--mosquito-accent: #FFD93D;
|
||||
--mosquito-accent-light: #FFE56D;
|
||||
--mosquito-accent-dark: #F5C700;
|
||||
|
||||
/* Gradient backgrounds */
|
||||
--mosquito-gradient-primary: linear-gradient(135deg, #FF6B35 0%, #FF8A5B 100%);
|
||||
--mosquito-gradient-secondary: linear-gradient(135deg, #00D9C0 0%, #5CEFD9 100%);
|
||||
--mosquito-gradient-accent: linear-gradient(135deg, #FFD93D 0%, #FFE56D 100%);
|
||||
--mosquito-gradient-hero: linear-gradient(135deg, #FF6B35 0%, #FF8A5B 50%, #FFD93D 100%);
|
||||
|
||||
/* Semantic colors */
|
||||
--mosquito-success: #00C781;
|
||||
--mosquito-warning: #FFB800;
|
||||
--mosquito-error: #FF4757;
|
||||
--mosquito-info: #4A90E2;
|
||||
|
||||
/* Background & Surface */
|
||||
--mosquito-bg: #FEF9F6;
|
||||
--mosquito-bg-gradient: linear-gradient(180deg, #FFF8F5 0%, #FEF9F6 100%);
|
||||
--mosquito-surface: #FFFFFF;
|
||||
--mosquito-surface-elevated: #FFFFFF;
|
||||
|
||||
/* Text colors */
|
||||
--mosquito-ink: #1A1A2E;
|
||||
--mosquito-ink-light: #4A4A68;
|
||||
--mosquito-muted: #8B8BA7;
|
||||
--mosquito-white: #FFFFFF;
|
||||
|
||||
/* Borders & Shadows */
|
||||
--mosquito-border: rgba(255, 107, 53, 0.15);
|
||||
--mosquito-line: #FFE8E0;
|
||||
|
||||
/* Shadows - softer, more approachable */
|
||||
--mosquito-shadow-sm: 0 2px 8px rgba(255, 107, 53, 0.08);
|
||||
--mosquito-shadow: 0 8px 24px rgba(255, 107, 53, 0.12);
|
||||
--mosquito-shadow-lg: 0 16px 48px rgba(255, 107, 53, 0.16);
|
||||
--mosquito-shadow-glow: 0 0 40px rgba(255, 107, 53, 0.2);
|
||||
|
||||
/* Card shadows */
|
||||
--mosquito-card-shadow: 0 4px 16px rgba(26, 26, 46, 0.06);
|
||||
--mosquito-card-shadow-hover: 0 8px 24px rgba(26, 26, 46, 0.1);
|
||||
|
||||
/* Typography */
|
||||
--mosquito-font-display: 'Poppins', 'Noto Sans SC', sans-serif;
|
||||
--mosquito-font-body: 'Noto Sans SC', 'Poppins', sans-serif;
|
||||
--mosquito-font-mono: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
|
||||
/* Animation timing */
|
||||
--mosquito-transition-fast: 150ms ease;
|
||||
--mosquito-transition: 250ms ease;
|
||||
--mosquito-transition-slow: 350ms ease;
|
||||
|
||||
/* Touch targets - minimum 44px for accessibility */
|
||||
--mosquito-touch-min: 44px;
|
||||
}
|
||||
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--mosquito-font-body);
|
||||
background: var(--mosquito-bg);
|
||||
color: var(--mosquito-ink);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* App Container */
|
||||
.mosquito-app {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(ellipse at top, rgba(255, 107, 53, 0.08) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at bottom right, rgba(0, 217, 192, 0.06) 0%, transparent 40%),
|
||||
var(--mosquito-bg);
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.mos-card {
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--mosquito-border);
|
||||
background: var(--mosquito-surface);
|
||||
box-shadow: var(--mosquito-card-shadow);
|
||||
transition: transform var(--mosquito-transition), box-shadow var(--mosquito-transition);
|
||||
}
|
||||
|
||||
.mos-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--mosquito-card-shadow-hover);
|
||||
}
|
||||
|
||||
.mos-card-gradient {
|
||||
border-radius: 20px;
|
||||
background: var(--mosquito-gradient-primary);
|
||||
color: var(--mosquito-white);
|
||||
box-shadow: var(--mosquito-shadow);
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
.mos-title {
|
||||
font-family: var(--mosquito-font-display);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.mos-subtitle {
|
||||
font-family: var(--mosquito-font-body);
|
||||
font-weight: 500;
|
||||
color: var(--mosquito-ink-light);
|
||||
}
|
||||
|
||||
.mos-muted {
|
||||
color: var(--mosquito-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.mos-kpi {
|
||||
font-family: var(--mosquito-font-display);
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--mosquito-ink);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.mos-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
transition: all var(--mosquito-transition);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
min-height: var(--mosquito-touch-min);
|
||||
}
|
||||
|
||||
.mos-btn-primary {
|
||||
background: var(--mosquito-gradient-primary);
|
||||
color: var(--mosquito-white);
|
||||
box-shadow: 0 4px 12px rgba(255, 107, 53, 0.3);
|
||||
}
|
||||
|
||||
.mos-btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(255, 107, 53, 0.4);
|
||||
}
|
||||
|
||||
.mos-btn-primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.mos-btn-secondary {
|
||||
background: var(--mosquito-white);
|
||||
color: var(--mosquito-primary);
|
||||
border: 2px solid var(--mosquito-primary);
|
||||
}
|
||||
|
||||
.mos-btn-secondary:hover {
|
||||
background: var(--mosquito-primary);
|
||||
color: var(--mosquito-white);
|
||||
}
|
||||
|
||||
.mos-btn-accent {
|
||||
background: var(--mosquito-gradient-accent);
|
||||
color: var(--mosquito-ink);
|
||||
box-shadow: 0 4px 12px rgba(255, 217, 61, 0.3);
|
||||
}
|
||||
|
||||
.mos-btn-accent:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(255, 217, 61, 0.4);
|
||||
}
|
||||
|
||||
.mos-btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--mosquito-ink-light);
|
||||
border: 1px solid var(--mosquito-line);
|
||||
}
|
||||
|
||||
.mos-btn-ghost:hover {
|
||||
background: var(--mosquito-bg);
|
||||
color: var(--mosquito-ink);
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.mos-nav {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 50;
|
||||
width: min(calc(100% - 32px), 400px);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--mosquito-border);
|
||||
box-shadow: var(--mosquito-shadow-lg);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.mos-nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 16px;
|
||||
color: var(--mosquito-muted);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
transition: all var(--mosquito-transition);
|
||||
min-width: 64px;
|
||||
}
|
||||
|
||||
.mos-nav-item:hover {
|
||||
color: var(--mosquito-primary);
|
||||
background: rgba(255, 107, 53, 0.08);
|
||||
}
|
||||
|
||||
.mos-nav-item.active {
|
||||
color: var(--mosquito-primary);
|
||||
background: rgba(255, 107, 53, 0.12);
|
||||
}
|
||||
|
||||
.mos-nav-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
/* Tags & Pills */
|
||||
.mos-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.mos-pill-primary {
|
||||
background: rgba(255, 107, 53, 0.12);
|
||||
color: var(--mosquito-primary);
|
||||
}
|
||||
|
||||
.mos-pill-secondary {
|
||||
background: rgba(0, 217, 192, 0.12);
|
||||
color: var(--mosquito-secondary-dark);
|
||||
}
|
||||
|
||||
.mos-pill-accent {
|
||||
background: rgba(255, 217, 61, 0.2);
|
||||
color: #B8860B;
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.mos-hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 28px;
|
||||
background: var(--mosquito-gradient-hero);
|
||||
color: var(--mosquito-white);
|
||||
box-shadow: var(--mosquito-shadow-glow);
|
||||
}
|
||||
|
||||
.mos-hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.2) 0%, transparent 60%);
|
||||
animation: shimmer 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(10%, 10%) scale(1.1); }
|
||||
}
|
||||
|
||||
/* KPI Cards */
|
||||
.mos-kpi-card {
|
||||
border-radius: 20px;
|
||||
background: var(--mosquito-surface);
|
||||
border: 1px solid var(--mosquito-border);
|
||||
padding: 20px;
|
||||
transition: all var(--mosquito-transition);
|
||||
}
|
||||
|
||||
.mos-kpi-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--mosquito-shadow);
|
||||
border-color: var(--mosquito-primary-light);
|
||||
}
|
||||
|
||||
.mos-kpi-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mos-kpi-icon-primary {
|
||||
background: linear-gradient(135deg, rgba(255, 107, 53, 0.15) 0%, rgba(255, 107, 53, 0.05) 100%);
|
||||
color: var(--mosquito-primary);
|
||||
}
|
||||
|
||||
.mos-kpi-icon-secondary {
|
||||
background: linear-gradient(135deg, rgba(0, 217, 192, 0.15) 0%, rgba(0, 217, 192, 0.05) 100%);
|
||||
color: var(--mosquito-secondary-dark);
|
||||
}
|
||||
|
||||
.mos-kpi-icon-accent {
|
||||
background: linear-gradient(135deg, rgba(255, 217, 61, 0.2) 0%, rgba(255, 217, 61, 0.05) 100%);
|
||||
color: #D4A017;
|
||||
}
|
||||
|
||||
/* Status Badges */
|
||||
.mos-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mos-status-live {
|
||||
background: rgba(0, 199, 129, 0.12);
|
||||
color: var(--mosquito-success);
|
||||
}
|
||||
|
||||
.mos-status-live::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--mosquito-success);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.7; transform: scale(1.1); }
|
||||
}
|
||||
|
||||
/* Leaderboard */
|
||||
.mos-rank-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
transition: background var(--mosquito-transition);
|
||||
}
|
||||
|
||||
.mos-rank-item:hover {
|
||||
background: rgba(255, 107, 53, 0.04);
|
||||
}
|
||||
|
||||
.mos-rank-number {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.mos-rank-1 {
|
||||
background: linear-gradient(135deg, #FFD93D 0%, #F5C700 100%);
|
||||
color: var(--mosquito-ink);
|
||||
}
|
||||
|
||||
.mos-rank-2 {
|
||||
background: linear-gradient(135deg, #C0C0C0 0%, #A0A0A0 100%);
|
||||
color: var(--mosquito-white);
|
||||
}
|
||||
|
||||
.mos-rank-3 {
|
||||
background: linear-gradient(135deg, #CD7F32 0%, #B87333 100%);
|
||||
color: var(--mosquito-white);
|
||||
}
|
||||
|
||||
.mos-rank-other {
|
||||
background: rgba(26, 26, 46, 0.06);
|
||||
color: var(--mosquito-muted);
|
||||
}
|
||||
|
||||
/* Share Section */
|
||||
.mos-share-card {
|
||||
border-radius: 24px;
|
||||
background: var(--mosquito-surface);
|
||||
border: 2px dashed var(--mosquito-line);
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
transition: all var(--mosquito-transition);
|
||||
}
|
||||
|
||||
.mos-share-card:hover {
|
||||
border-color: var(--mosquito-primary-light);
|
||||
background: rgba(255, 107, 53, 0.02);
|
||||
}
|
||||
|
||||
/* Toast Notifications */
|
||||
.mos-toast {
|
||||
position: fixed;
|
||||
bottom: 100px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 14px 24px;
|
||||
border-radius: 14px;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
box-shadow: var(--mosquito-shadow-lg);
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.mos-toast-success {
|
||||
background: var(--mosquito-success);
|
||||
color: var(--mosquito-white);
|
||||
}
|
||||
|
||||
/* Empty States */
|
||||
.mos-empty {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.mos-empty-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 20px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(135deg, rgba(255, 107, 53, 0.1) 0%, rgba(0, 217, 192, 0.1) 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--mosquito-primary);
|
||||
}
|
||||
|
||||
/* Loading States */
|
||||
.mos-skeleton {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
border-radius: 12px;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
/* Accessibility */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus states for keyboard navigation */
|
||||
.mos-btn:focus-visible,
|
||||
.mos-nav-item:focus-visible {
|
||||
outline: 3px solid var(--mosquito-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-gradient {
|
||||
background: var(--mosquito-gradient-primary);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.bg-gradient-primary {
|
||||
background: var(--mosquito-gradient-primary);
|
||||
}
|
||||
|
||||
.bg-gradient-secondary {
|
||||
background: var(--mosquito-gradient-secondary);
|
||||
}
|
||||
|
||||
.bg-gradient-accent {
|
||||
background: var(--mosquito-gradient-accent);
|
||||
}
|
||||
|
||||
.shadow-glow {
|
||||
box-shadow: var(--mosquito-shadow-glow);
|
||||
}
|
||||
|
||||
.touch-target {
|
||||
min-height: var(--mosquito-touch-min);
|
||||
min-width: var(--mosquito-touch-min);
|
||||
}
|
||||
}
|
||||
625
frontend/h5/src/tests/userOperations.test.js
Normal file
625
frontend/h5/src/tests/userOperations.test.js
Normal file
@@ -0,0 +1,625 @@
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { AuthProvider } from '../contexts/AuthContext';
|
||||
import { NotificationProvider } from '../contexts/NotificationContext';
|
||||
import CouponCard from '../components/CouponCard';
|
||||
import StatsCard from '../components/StatsCard';
|
||||
import InviteModal from '../components/InviteModal';
|
||||
import UserDashboard from '../pages/UserDashboard';
|
||||
|
||||
// Mock API responses
|
||||
const mockResponses = {
|
||||
coupons: [
|
||||
{
|
||||
id: '1',
|
||||
name: '新用户专享优惠券',
|
||||
description: '满100减10元',
|
||||
discount: 10.00,
|
||||
minAmount: 100.00,
|
||||
validUntil: '2026-02-23T23:59:59Z',
|
||||
claimed: false
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '限时特惠券',
|
||||
description: '满50减5元',
|
||||
discount: 5.00,
|
||||
minAmount: 50.00,
|
||||
validUntil: '2026-01-30T23:59:59Z',
|
||||
claimed: false
|
||||
}
|
||||
],
|
||||
stats: {
|
||||
totalClicks: 1250,
|
||||
totalConversions: 89,
|
||||
totalEarnings: 1256.78,
|
||||
todayEarnings: 45.50,
|
||||
inviteCount: 15,
|
||||
teamMembers: {
|
||||
level1: 8,
|
||||
level2: 12,
|
||||
level3: 6
|
||||
}
|
||||
},
|
||||
shortLinks: [
|
||||
{
|
||||
id: '1',
|
||||
shortCode: 'abc123',
|
||||
originalUrl: 'https://example.com/landing',
|
||||
totalClicks: 125,
|
||||
conversionRate: 7.2,
|
||||
createdAt: '2026-01-20T10:00:00Z'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Test wrapper
|
||||
const createTestWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<NotificationProvider>
|
||||
{children}
|
||||
</NotificationProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = jest.fn();
|
||||
global.URLSearchParams = jest.fn();
|
||||
|
||||
describe('用户操作前端测试套件', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = createTestWrapper();
|
||||
fetch.mockClear();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('优惠券功能测试', () => {
|
||||
test('应该正确显示优惠券列表', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: mockResponses.coupons })
|
||||
});
|
||||
|
||||
render(<CouponCard />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('新用户专享优惠券')).toBeInTheDocument();
|
||||
expect(screen.getByText('限时特惠券')).toBeInTheDocument();
|
||||
expect(screen.getByText('满100减10元')).toBeInTheDocument();
|
||||
expect(screen.getByText('满50减5元')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('应该能够领取优惠券', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: mockResponses.coupons })
|
||||
});
|
||||
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, message: '优惠券领取成功' })
|
||||
});
|
||||
|
||||
render(<CouponCard />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('立即领取')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const claimButton = screen.getByText('立即领取');
|
||||
fireEvent.click(claimButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('优惠券领取成功')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('应该显示已领取状态', async () => {
|
||||
const claimedCoupons = mockResponses.coupons.map(c => ({ ...c, claimed: true }));
|
||||
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: claimedCoupons })
|
||||
});
|
||||
|
||||
render(<CouponCard />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('已领取')).toBeInTheDocument();
|
||||
expect(screen.getByText('立即领取')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('统计数据显示测试', () => {
|
||||
test('应该正确显示个人统计数据', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: mockResponses.stats })
|
||||
});
|
||||
|
||||
render(<StatsCard />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('1,250')).toBeInTheDocument(); // totalClicks
|
||||
expect(screen.getByText('89')).toBeInTheDocument(); // totalConversions
|
||||
expect(screen.getByText('¥1,256.78')).toBeInTheDocument(); // totalEarnings
|
||||
expect(screen.getByText('¥45.50')).toBeInTheDocument(); // todayEarnings
|
||||
});
|
||||
});
|
||||
|
||||
test('应该显示团队统计数据', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
level1Count: 8,
|
||||
level2Count: 12,
|
||||
level3Count: 6,
|
||||
totalTeamEarnings: 3456.78
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
render(<StatsCard />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('8')).toBeInTheDocument();
|
||||
expect(screen.getByText('12')).toBeInTheDocument();
|
||||
expect(screen.getByText('6')).toBeInTheDocument();
|
||||
expect(screen.getByText('¥3,456.78')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('应该显示趋势图表', async () => {
|
||||
const trendData = {
|
||||
dailyStats: [
|
||||
{ date: '2026-01-20', clicks: 100, conversions: 8 },
|
||||
{ date: '2026-01-21', clicks: 120, conversions: 10 },
|
||||
{ date: '2026-01-22', clicks: 110, conversions: 9 }
|
||||
]
|
||||
};
|
||||
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: trendData })
|
||||
});
|
||||
|
||||
render(<StatsCard />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
// 验证图表渲染(这里简化为验证数据点存在)
|
||||
expect(screen.getByTestId('trend-chart')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('邀请功能测试', () => {
|
||||
test('应该生成邀请链接', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
inviteCode: 'INVITE123',
|
||||
inviteLink: 'https://mosquito.com/invite/INVITE123'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
render(<InviteModal />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('INVITE123')).toBeInTheDocument();
|
||||
expect(screen.getByText('https://mosquito.com/invite/INVITE123')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('应该能够复制邀请链接', async () => {
|
||||
// Mock clipboard API
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: jest.fn().mockResolvedValue(),
|
||||
},
|
||||
});
|
||||
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
inviteCode: 'INVITE123',
|
||||
inviteLink: 'https://mosquito.com/invite/INVITE123'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
render(<InviteModal />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('复制链接')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const copyButton = screen.getByText('复制链接');
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('https://mosquito.com/invite/INVITE123');
|
||||
expect(screen.getByText('链接已复制')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('应该显示邀请记录', async () => {
|
||||
const inviteRecords = [
|
||||
{
|
||||
id: '1',
|
||||
inviteePhone: '138****8001',
|
||||
level: 1,
|
||||
reward: 10.00,
|
||||
createdAt: '2026-01-20T10:00:00Z'
|
||||
}
|
||||
];
|
||||
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: inviteRecords })
|
||||
});
|
||||
|
||||
render(<InviteModal />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('138****8001')).toBeInTheDocument();
|
||||
expect(screen.getByText('10.00')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('短链功能测试', () => {
|
||||
test('应该生成短链', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
id: '1',
|
||||
shortCode: 'abc123',
|
||||
shortUrl: 'https://mosquito.com/s/abc123',
|
||||
originalUrl: 'https://example.com/landing'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
render(<UserDashboard />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('生成短链')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const generateButton = screen.getByText('生成短链');
|
||||
fireEvent.click(generateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('abc123')).toBeInTheDocument();
|
||||
expect(screen.getByText('https://mosquito.com/s/abc123')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('应该显示短链统计', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: mockResponses.shortLinks })
|
||||
});
|
||||
|
||||
render(<UserDashboard />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('125')).toBeInTheDocument(); // totalClicks
|
||||
expect(screen.getByText('7.2%')).toBeInTheDocument(); // conversionRate
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('用户交互流程测试', () => {
|
||||
test('完整的用户注册到使用流程', async () => {
|
||||
// Mock 注册API
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
token: 'mock-token',
|
||||
user: { id: '1', phone: '13800138001' }
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// Mock 用户信息API
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
id: '1',
|
||||
phone: '13800138001',
|
||||
createdAt: '2026-01-20T10:00:00Z'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// Mock 优惠券API
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: mockResponses.coupons })
|
||||
});
|
||||
|
||||
render(<UserDashboard />, { wrapper });
|
||||
|
||||
// 模拟用户登录
|
||||
act(() => {
|
||||
localStorage.setItem('token', 'mock-token');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('新用户专享优惠券')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 领取优惠券
|
||||
const claimButton = screen.getByText('立即领取');
|
||||
fireEvent.click(claimButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('优惠券领取成功')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('应该处理网络错误', async () => {
|
||||
fetch.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
render(<CouponCard />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('网络错误,请稍后重试')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('应该处理API错误响应', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: async () => ({ success: false, message: '请求参数错误' })
|
||||
});
|
||||
|
||||
render(<CouponCard />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('请求参数错误')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('响应式设计测试', () => {
|
||||
test('应该在移动设备上正确显示', async () => {
|
||||
// Mock mobile viewport
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 375,
|
||||
});
|
||||
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: mockResponses.stats })
|
||||
});
|
||||
|
||||
render(<StatsCard />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mobile-stats-container')).toBeInTheDocument();
|
||||
expect(screen.getByText('1,250')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('应该在桌面设备上正确显示', async () => {
|
||||
// Mock desktop viewport
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 1200,
|
||||
});
|
||||
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: mockResponses.stats })
|
||||
});
|
||||
|
||||
render(<StatsCard />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('desktop-stats-container')).toBeInTheDocument();
|
||||
expect(screen.getByText('1,250')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('性能测试', () => {
|
||||
test('应该在大数据量下保持响应', async () => {
|
||||
// 生成大量优惠券数据
|
||||
const largeCoupons = Array.from({ length: 1000 }, (_, i) => ({
|
||||
id: i.toString(),
|
||||
name: `优惠券 ${i + 1}`,
|
||||
description: `满${(i + 1) * 10}减${i + 1}元`,
|
||||
discount: i + 1,
|
||||
minAmount: (i + 1) * 10,
|
||||
claimed: false
|
||||
}));
|
||||
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: largeCoupons })
|
||||
});
|
||||
|
||||
const startTime = performance.now();
|
||||
render(<CouponCard />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('优惠券 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const renderTime = performance.now() - startTime;
|
||||
expect(renderTime).toBeLessThan(1000); // 渲染时间应小于1秒
|
||||
});
|
||||
});
|
||||
|
||||
describe('可访问性测试', () => {
|
||||
test('应该支持键盘导航', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: mockResponses.coupons })
|
||||
});
|
||||
|
||||
render(<CouponCard />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('立即领取')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const claimButton = screen.getByText('立即领取');
|
||||
|
||||
// 测试Tab键导航
|
||||
claimButton.focus();
|
||||
expect(claimButton).toHaveFocus();
|
||||
|
||||
// 测试Enter键触发
|
||||
fireEvent.keyPress(claimButton, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('优惠券领取成功')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('应该提供正确的ARIA标签', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: mockResponses.stats })
|
||||
});
|
||||
|
||||
render(<StatsCard />, { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
const statsContainer = screen.getByTestId('stats-container');
|
||||
expect(statsContainer).toHaveAttribute('aria-label', '用户统计数据');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 集成测试:用户完整操作流程
|
||||
describe('用户完整操作流程集成测试', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = createTestWrapper();
|
||||
fetch.mockClear();
|
||||
});
|
||||
|
||||
test('从注册到查看数据的完整流程', async () => {
|
||||
// 1. 用户注册
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
token: 'user-token',
|
||||
user: { id: '1', phone: '13800138001' }
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// 2. 获取用户信息
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
id: '1',
|
||||
phone: '13800138001',
|
||||
isNewUser: true
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// 3. 获取优惠券列表
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: mockResponses.coupons })
|
||||
});
|
||||
|
||||
// 4. 领取优惠券
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, message: '领取成功' })
|
||||
});
|
||||
|
||||
// 5. 获取统计数据
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: mockResponses.stats })
|
||||
});
|
||||
|
||||
// 6. 生成邀请码
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
inviteCode: 'INVITE123',
|
||||
inviteLink: 'https://mosquito.com/invite/INVITE123'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
render(<UserDashboard />, { wrapper });
|
||||
|
||||
// 验证完整流程
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('新用户专享优惠券')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 领取优惠券
|
||||
const claimButton = screen.getByText('立即领取');
|
||||
fireEvent.click(claimButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('领取成功')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 查看统计数据
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('1,250')).toBeInTheDocument();
|
||||
expect(screen.getByText('89')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 生成邀请码
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('INVITE123')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
284
frontend/h5/src/views/HomeView.vue
Normal file
284
frontend/h5/src/views/HomeView.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<section class="mx-auto max-w-md px-4 pb-28 pt-6 space-y-6">
|
||||
<!-- Hero Section -->
|
||||
<header class="mos-hero relative overflow-hidden p-6">
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="mos-pill mos-pill-accent">
|
||||
<Icons name="sparkles" class="w-3 h-3" />
|
||||
{{ activityStatus }}
|
||||
</span>
|
||||
<span class="text-xs text-white/70">{{ activityName }}</span>
|
||||
</div>
|
||||
|
||||
<h1 class="mos-title mt-4 text-2xl font-bold text-white">{{ heroTitle }}</h1>
|
||||
<p class="mt-2 text-sm text-white/80">
|
||||
{{ heroSubtitle }}
|
||||
</p>
|
||||
|
||||
<div class="mt-6 flex flex-wrap gap-3">
|
||||
<RouterLink to="/share" class="mos-btn mos-btn-primary">
|
||||
<Icons name="rocket" class="w-4 h-4" />
|
||||
立即分享
|
||||
</RouterLink>
|
||||
<a href="#rules" class="mos-btn mos-btn-ghost !border-white/30 !text-white hover:!bg-white/10">
|
||||
<Icons name="target" class="w-4 h-4" />
|
||||
查看规则
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-xs text-white/60 flex items-center gap-1">
|
||||
<Icons name="crown" class="w-3 h-3" />
|
||||
{{ activityPeriod }}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- KPI Cards -->
|
||||
<section class="grid grid-cols-2 gap-4">
|
||||
<div class="mos-kpi-card">
|
||||
<div class="mos-kpi-icon mos-kpi-icon-primary">
|
||||
<Icons name="trending-up" class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="text-xs font-semibold text-mosquito-muted">本周访问</div>
|
||||
<div class="mos-kpi mt-1 text-2xl">{{ formatNumber(kpis.visits) }}</div>
|
||||
<div class="text-xs text-mosquito-muted mt-1">{{ kpiHints.visits }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mos-kpi-card">
|
||||
<div class="mos-kpi-icon mos-kpi-icon-secondary">
|
||||
<Icons name="share" class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="text-xs font-semibold text-mosquito-muted">分享次数</div>
|
||||
<div class="mos-kpi mt-1 text-2xl">{{ formatNumber(kpis.shares) }}</div>
|
||||
<div class="text-xs text-mosquito-muted mt-1">{{ kpiHints.shares }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mos-kpi-card">
|
||||
<div class="mos-kpi-icon mos-kpi-icon-accent">
|
||||
<Icons name="users" class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="text-xs font-semibold text-mosquito-muted">转化人数</div>
|
||||
<div class="mos-kpi mt-1 text-2xl">{{ formatNumber(kpis.conversions) }}</div>
|
||||
<div class="text-xs text-mosquito-muted mt-1">{{ kpiHints.conversions }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mos-kpi-card">
|
||||
<div class="mos-kpi-icon mos-kpi-icon-primary">
|
||||
<Icons name="gift" class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="text-xs font-semibold text-mosquito-muted">我的奖励</div>
|
||||
<div class="mos-kpi mt-1 text-2xl">{{ formatNumber(kpis.rewards) }}</div>
|
||||
<div class="text-xs text-mosquito-muted mt-1">{{ kpiHints.rewards }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Rules Section -->
|
||||
<section id="rules" class="mos-card space-y-4 p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-mosquito-primary to-mosquito-primary-light flex items-center justify-center text-white">
|
||||
<Icons name="target" class="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="mos-title text-lg font-bold">活动规则</h2>
|
||||
<p class="text-xs text-mosquito-muted">完成关键步骤解锁奖励</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="mos-pill mos-pill-primary">5 条规则</span>
|
||||
</div>
|
||||
|
||||
<ol class="space-y-3">
|
||||
<li v-for="(rule, index) in rules" :key="index" class="flex gap-3 p-3 rounded-xl bg-mosquito-bg/50">
|
||||
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-mosquito-primary text-white text-sm font-bold">
|
||||
{{ index + 1 }}
|
||||
</span>
|
||||
<span class="text-sm text-mosquito-ink-light leading-relaxed">{{ rule }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- Leaderboard Preview -->
|
||||
<section class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-mosquito-accent to-mosquito-accent-light flex items-center justify-center text-mosquito-ink">
|
||||
<Icons name="crown" class="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="mos-title text-lg font-bold">排行榜</h2>
|
||||
<p class="text-xs text-mosquito-muted">实时排名,持续更新</p>
|
||||
</div>
|
||||
</div>
|
||||
<RouterLink to="/rank" class="mos-btn mos-btn-secondary !py-2 !px-4 !text-sm">
|
||||
完整榜单
|
||||
<Icons name="arrow-right" class="w-4 h-4" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div v-if="hasAuth && activeActivityId" class="mos-card p-4">
|
||||
<MosquitoLeaderboard :activity-id="activeActivityId" :top-n="3" :size="6" :current-user-id="userId" />
|
||||
</div>
|
||||
|
||||
<div v-else class="mos-card border-dashed p-6 text-center">
|
||||
<div class="mos-empty-icon">
|
||||
<Icons name="trophy" class="w-8 h-8" />
|
||||
</div>
|
||||
<h3 class="font-semibold text-mosquito-ink">暂无榜单数据</h3>
|
||||
<p class="text-sm text-mosquito-muted mt-1">参与活动后可查看实时排名</p>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import MosquitoLeaderboard from '../../../components/MosquitoLeaderboard.vue'
|
||||
import { MosquitoError, useMosquito } from '../../../index'
|
||||
import { getUserIdFromToken, parseUserId } from '../../../shared/auth'
|
||||
import Icons from '../components/Icons.vue'
|
||||
|
||||
type ActivitySummary = {
|
||||
id: number
|
||||
name: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const apiKey = import.meta.env.VITE_MOSQUITO_API_KEY
|
||||
const userToken = import.meta.env.VITE_MOSQUITO_USER_TOKEN
|
||||
const routeUserId = computed(() => parseUserId(route.query.userId ?? route.params.userId))
|
||||
const userId = computed(() => getUserIdFromToken(userToken) ?? routeUserId.value ?? 0)
|
||||
const hasAuth = computed(() => Boolean(apiKey && userToken && userId.value))
|
||||
const { getActivities, getActivityStats, getShareMetrics, getRewards } = useMosquito()
|
||||
|
||||
const activities = ref<ActivitySummary[]>([])
|
||||
const activeActivity = computed(() => activities.value[0] ?? null)
|
||||
const activeActivityId = computed(() => activeActivity.value?.id ?? 0)
|
||||
|
||||
const kpis = ref({
|
||||
visits: null as number | null,
|
||||
shares: null as number | null,
|
||||
conversions: null as number | null,
|
||||
rewards: null as number | null
|
||||
})
|
||||
|
||||
const kpiHints = ref({
|
||||
visits: '等待首批数据',
|
||||
shares: '绑定 API 后自动更新',
|
||||
conversions: '活动开启后可见',
|
||||
rewards: '分享成功后累计'
|
||||
})
|
||||
|
||||
const rules = [
|
||||
'分享专属链接给好友,好友点击即计入访问',
|
||||
'好友完成注册后计入转化,累计积分自动增加',
|
||||
'每日 24 点结算排名,前 10 名获得额外奖励',
|
||||
'同一好友仅计入一次转化,防止重复计数',
|
||||
'奖励将在活动结束后 7 日内发放'
|
||||
]
|
||||
|
||||
const activityName = computed(() => activeActivity.value?.name ?? '裂变增长计划')
|
||||
const activityStatus = computed(() => resolveStatus(activeActivity.value))
|
||||
const heroTitle = computed(() => (activeActivity.value ? '邀请好友,解锁双倍奖励' : '当前暂无活动'))
|
||||
const heroSubtitle = computed(() =>
|
||||
activeActivity.value
|
||||
? '完成分享任务即可累计积分,前 10 名将额外获得加码奖励'
|
||||
: '请先创建活动后再开启分享推广'
|
||||
)
|
||||
const activityPeriod = computed(() => formatPeriod(activeActivity.value))
|
||||
|
||||
const formatNumber = (value: number | null) => {
|
||||
if (value === null || Number.isNaN(value)) {
|
||||
return '--'
|
||||
}
|
||||
return value.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const formatPeriod = (activity: ActivitySummary | null) => {
|
||||
if (!activity?.startTime || !activity?.endTime) {
|
||||
return '活动时间待配置'
|
||||
}
|
||||
const start = new Date(activity.startTime)
|
||||
const end = new Date(activity.endTime)
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return '活动时间待配置'
|
||||
}
|
||||
return `${start.toLocaleDateString('zh-CN')} - ${end.toLocaleDateString('zh-CN')}`
|
||||
}
|
||||
|
||||
const resolveStatus = (activity: ActivitySummary | null) => {
|
||||
if (!activity?.startTime || !activity?.endTime) {
|
||||
return '活动未配置'
|
||||
}
|
||||
const now = Date.now()
|
||||
const start = new Date(activity.startTime).getTime()
|
||||
const end = new Date(activity.endTime).getTime()
|
||||
if (Number.isNaN(start) || Number.isNaN(end)) {
|
||||
return '活动未配置'
|
||||
}
|
||||
if (now < start) {
|
||||
return '即将开始'
|
||||
}
|
||||
if (now > end) {
|
||||
return '活动已结束'
|
||||
}
|
||||
return '活动进行中'
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
if (!hasAuth.value) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const list = await getActivities()
|
||||
activities.value = list
|
||||
const activity = activeActivity.value
|
||||
if (!activity) {
|
||||
return
|
||||
}
|
||||
const activityId = activity.id
|
||||
|
||||
const [stats, metrics, rewards] = await Promise.all([
|
||||
getActivityStats(activityId),
|
||||
getShareMetrics(activityId),
|
||||
getRewards(activityId, userId.value)
|
||||
])
|
||||
|
||||
kpis.value.visits = metrics?.totalClicks ?? 0
|
||||
kpis.value.shares = stats?.totalShares ?? 0
|
||||
kpis.value.conversions = stats?.totalParticipants ?? 0
|
||||
kpis.value.rewards = Array.isArray(rewards)
|
||||
? rewards.reduce((sum, item) => sum + (item.points || 0), 0)
|
||||
: 0
|
||||
kpiHints.value = {
|
||||
visits: '7 天内访问次数',
|
||||
shares: '累计分享次数',
|
||||
conversions: '累计转化人数',
|
||||
rewards: '累计奖励积分'
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof MosquitoError && error.statusCode === 401) {
|
||||
kpiHints.value = {
|
||||
visits: '未授权访问',
|
||||
shares: '未授权访问',
|
||||
conversions: '未授权访问',
|
||||
rewards: '未授权访问'
|
||||
}
|
||||
return
|
||||
}
|
||||
kpiHints.value = {
|
||||
visits: '数据加载失败',
|
||||
shares: '数据加载失败',
|
||||
conversions: '数据加载失败',
|
||||
rewards: '数据加载失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
91
frontend/h5/src/views/LeaderboardView.vue
Normal file
91
frontend/h5/src/views/LeaderboardView.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<section class="mx-auto max-w-md px-4 pb-28 pt-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<header class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 rounded-2xl bg-gradient-to-br from-mosquito-accent to-mosquito-accent-light flex items-center justify-center text-mosquito-ink shadow-lg">
|
||||
<Icons name="trophy" class="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="mos-title text-2xl font-bold">排行榜</h1>
|
||||
<p class="text-sm text-mosquito-muted">实时排名,竞争上榜</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Live Status -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="mos-status mos-status-live">实时更新</span>
|
||||
<span class="text-xs text-mosquito-muted">每小时自动刷新</span>
|
||||
</div>
|
||||
|
||||
<!-- Leaderboard Content -->
|
||||
<div v-if="hasAuth" class="space-y-4">
|
||||
<div class="mos-card p-4">
|
||||
<MosquitoLeaderboard :activity-id="activityId" :current-user-id="userId" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="mos-card border-2 border-dashed border-mosquito-muted/30 p-8 text-center">
|
||||
<div class="mos-empty-icon !w-20 !h-20 !rounded-3xl">
|
||||
<Icons name="trophy" class="w-10 h-10" />
|
||||
</div>
|
||||
<h3 class="font-bold text-mosquito-ink text-lg mt-4">暂无法加载排行榜</h3>
|
||||
<p class="text-sm text-mosquito-muted mt-2">请配置 API Key 与用户令牌后刷新页面</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="loadError" class="mos-card border-2 border-mosquito-error/30 bg-mosquito-error/5 p-4">
|
||||
<div class="flex items-center gap-2 text-mosquito-error">
|
||||
<Icons name="zap" class="w-4 h-4" />
|
||||
<span class="text-sm font-medium">{{ loadError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import MosquitoLeaderboard from '../../../components/MosquitoLeaderboard.vue'
|
||||
import { MosquitoError, useMosquito } from '../../../index'
|
||||
import { getUserIdFromToken, parseUserId } from '../../../shared/auth'
|
||||
import Icons from '../components/Icons.vue'
|
||||
|
||||
type ActivitySummary = {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
const { getActivities } = useMosquito()
|
||||
const route = useRoute()
|
||||
const apiKey = import.meta.env.VITE_MOSQUITO_API_KEY
|
||||
const userToken = import.meta.env.VITE_MOSQUITO_USER_TOKEN
|
||||
const routeUserId = computed(() => parseUserId(route.query.userId ?? route.params.userId))
|
||||
const userId = computed(() => getUserIdFromToken(userToken) ?? routeUserId.value ?? 0)
|
||||
const activityId = ref(1)
|
||||
const loadError = ref('')
|
||||
const hasAuth = computed(() => Boolean(apiKey && userToken && userId.value))
|
||||
|
||||
const loadActivity = async () => {
|
||||
if (!hasAuth.value) {
|
||||
return
|
||||
}
|
||||
loadError.value = ''
|
||||
try {
|
||||
const list: ActivitySummary[] = await getActivities()
|
||||
if (list.length) {
|
||||
activityId.value = list[0].id
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof MosquitoError && error.statusCode === 401) {
|
||||
loadError.value = '鉴权失败:无法加载活动信息。'
|
||||
return
|
||||
}
|
||||
loadError.value = '活动信息加载失败。'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadActivity()
|
||||
})
|
||||
</script>
|
||||
166
frontend/h5/src/views/ShareView.vue
Normal file
166
frontend/h5/src/views/ShareView.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<section class="mx-auto max-w-md px-4 pb-28 pt-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<header class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 rounded-2xl bg-gradient-to-br from-mosquito-primary to-mosquito-primary-light flex items-center justify-center text-white shadow-lg">
|
||||
<Icons name="share" class="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="mos-title text-2xl font-bold">分享推广</h1>
|
||||
<p class="text-sm text-mosquito-muted">生成专属链接,邀请好友参与</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Auth Warning -->
|
||||
<div v-if="!hasAuth" class="mos-card border-2 border-dashed border-mosquito-warning/30 bg-mosquito-warning/5 p-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-mosquito-warning/20 text-mosquito-warning">
|
||||
<Icons name="zap" class="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-mosquito-ink">请先配置鉴权信息</div>
|
||||
<div class="text-sm text-mosquito-muted mt-1">需要 API Key 与用户令牌才可生成链接与海报</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Share Link Card -->
|
||||
<div class="mos-card-gradient p-6 space-y-4">
|
||||
<div class="flex items-center gap-2 text-white/90">
|
||||
<Icons name="rocket" class="w-4 h-4" />
|
||||
<span class="text-xs font-bold uppercase tracking-wider">默认模板</span>
|
||||
<span class="text-xs opacity-75">· {{ activityLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<template v-if="hasAuth">
|
||||
<MosquitoShareButton :activity-id="activityId" :user-id="userId" />
|
||||
<button class="mos-btn mos-btn-accent !py-2 !px-4">
|
||||
<Icons name="copy" class="w-4 h-4" />
|
||||
复制链接
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="mos-btn mos-btn-accent !py-2 !px-4 opacity-50 cursor-not-allowed" disabled>
|
||||
<Icons name="copy" class="w-4 h-4" />
|
||||
复制链接
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-white/70 flex items-center gap-1">
|
||||
<Icons name="check-circle" class="w-3 h-3" />
|
||||
分享按钮会自动复制链接,方便一键转发
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Poster Card -->
|
||||
<div class="mos-card p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-mosquito-accent to-mosquito-accent-light flex items-center justify-center">
|
||||
<Icons name="gift" class="w-4 h-4 text-mosquito-ink" />
|
||||
</div>
|
||||
<h2 class="mos-title text-base font-bold">分享海报</h2>
|
||||
</div>
|
||||
<span class="mos-pill mos-pill-secondary">点击可重试</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center bg-mosquito-bg rounded-2xl p-4">
|
||||
<MosquitoPosterCard
|
||||
v-if="hasAuth"
|
||||
:activity-id="activityId"
|
||||
:user-id="userId"
|
||||
template="default"
|
||||
width="280px"
|
||||
height="380px"
|
||||
/>
|
||||
<div v-else class="flex h-[380px] w-[280px] items-center justify-center rounded-xl border border-dashed border-mosquito-muted/40 text-sm text-mosquito-muted">
|
||||
配置鉴权后可预览海报
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Guide Card -->
|
||||
<div class="mos-card space-y-4 p-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-lg bg-mosquito-secondary/10 flex items-center justify-center text-mosquito-secondary-dark">
|
||||
<Icons name="target" class="w-4 h-4" />
|
||||
</div>
|
||||
<h3 class="mos-title text-base font-bold">分享指引</h3>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-3">
|
||||
<li v-for="(step, index) in guideSteps" :key="index" class="flex gap-3 items-start">
|
||||
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-mosquito-primary text-white text-xs font-bold">
|
||||
{{ index + 1 }}
|
||||
</span>
|
||||
<span class="text-sm text-mosquito-ink-light">{{ step }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="loadError" class="mos-card border-2 border-mosquito-error/30 bg-mosquito-error/5 p-4">
|
||||
<div class="flex items-center gap-2 text-mosquito-error">
|
||||
<ZapIcon class="w-4 h-4" />
|
||||
<span class="text-sm font-medium">{{ loadError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import MosquitoShareButton from '../../../components/MosquitoShareButton.vue'
|
||||
import MosquitoPosterCard from '../../../components/MosquitoPosterCard.vue'
|
||||
import { MosquitoError, useMosquito } from '../../../index'
|
||||
import { getUserIdFromToken, parseUserId } from '../../../shared/auth'
|
||||
import Icons from '../components/Icons.vue'
|
||||
|
||||
type ActivitySummary = {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const { getActivities } = useMosquito()
|
||||
const apiKey = import.meta.env.VITE_MOSQUITO_API_KEY
|
||||
const userToken = import.meta.env.VITE_MOSQUITO_USER_TOKEN
|
||||
const routeUserId = computed(() => parseUserId(route.query.userId ?? route.params.userId))
|
||||
const userId = computed(() => getUserIdFromToken(userToken) ?? routeUserId.value ?? 0)
|
||||
const activityId = ref(1)
|
||||
const activityLabel = computed(() => `活动 #${activityId.value}`)
|
||||
const loadError = ref('')
|
||||
const hasAuth = computed(() => Boolean(apiKey && userToken && userId.value))
|
||||
|
||||
const guideSteps = [
|
||||
'点击"分享给好友"生成专属链接',
|
||||
'发送给好友,完成注册后即可计入转化',
|
||||
'回到首页查看最新排行和奖励进度'
|
||||
]
|
||||
|
||||
const loadActivity = async () => {
|
||||
if (!hasAuth.value) {
|
||||
return
|
||||
}
|
||||
loadError.value = ''
|
||||
try {
|
||||
const list: ActivitySummary[] = await getActivities()
|
||||
if (list.length) {
|
||||
activityId.value = list[0].id
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof MosquitoError && error.statusCode === 401) {
|
||||
loadError.value = '鉴权失败:无法加载活动信息'
|
||||
return
|
||||
}
|
||||
loadError.value = '活动信息加载失败'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadActivity()
|
||||
})
|
||||
</script>
|
||||
1
frontend/h5/src/vite-env.d.ts
vendored
Normal file
1
frontend/h5/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user