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:
506
frontend/h5/cypress/e2e/userOperations.cy.js
Normal file
506
frontend/h5/cypress/e2e/userOperations.cy.js
Normal file
@@ -0,0 +1,506 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
describe('蚊子项目 - 用户操作端到端测试', () => {
|
||||
beforeEach(() => {
|
||||
// 清理存储和缓存
|
||||
cy.clearLocalStorage();
|
||||
cy.clearCookies();
|
||||
|
||||
// 设置viewport
|
||||
cy.viewport(375, 812); // 移动端尺寸
|
||||
|
||||
// Mock API响应
|
||||
cy.intercept('GET', '/api/auth/profile', { fixture: 'user-profile.json' }).as('getUserProfile');
|
||||
cy.intercept('GET', '/api/coupons/available', { fixture: 'coupons.json' }).as('getCoupons');
|
||||
cy.intercept('POST', '/api/coupons/*/claim', { fixture: 'coupon-claim-success.json' }).as('claimCoupon');
|
||||
cy.intercept('GET', '/api/stats/personal', { fixture: 'personal-stats.json' }).as('getPersonalStats');
|
||||
cy.intercept('GET', '/api/stats/team', { fixture: 'team-stats.json' }).as('getTeamStats');
|
||||
cy.intercept('GET', '/api/reward/invite-code', { fixture: 'invite-code.json' }).as('getInviteCode');
|
||||
cy.intercept('POST', '/api/short-links/generate', { fixture: 'short-link.json' }).as('generateShortLink');
|
||||
cy.intercept('POST', '/api/auth/login', { fixture: 'login-success.json' }).as('login');
|
||||
});
|
||||
|
||||
describe('用户注册和登录流程', () => {
|
||||
it('应该能够完成用户注册流程', () => {
|
||||
cy.visit('/');
|
||||
|
||||
// 点击注册按钮
|
||||
cy.get('[data-testid="register-button"]').click();
|
||||
|
||||
// 填写手机号
|
||||
cy.get('[data-testid="phone-input"]').type('13800138001');
|
||||
|
||||
// 点击获取验证码
|
||||
cy.get('[data-testid="get-sms-code"]').click();
|
||||
|
||||
// 等待验证码(模拟)
|
||||
cy.wait(1000);
|
||||
|
||||
// 填写验证码
|
||||
cy.get('[data-testid="sms-code-input"]').type('123456');
|
||||
|
||||
// 设置密码
|
||||
cy.get('[data-testid="password-input"]').type('Test123456');
|
||||
|
||||
// 填写邀请码(可选)
|
||||
cy.get('[data-testid="invite-code-input"]').type('INVITE123');
|
||||
|
||||
// 提交注册
|
||||
cy.get('[data-testid="submit-register"]').click();
|
||||
|
||||
// 验证注册成功
|
||||
cy.url().should('include', '/dashboard');
|
||||
cy.get('[data-testid="welcome-message"]').should('contain', '欢迎');
|
||||
});
|
||||
|
||||
it('应该能够完成用户登录流程', () => {
|
||||
cy.visit('/login');
|
||||
|
||||
// 填写登录信息
|
||||
cy.get('[data-testid="phone-input"]').type('13800138001');
|
||||
cy.get('[data-testid="password-input"]').type('Test123456');
|
||||
|
||||
// 提交登录
|
||||
cy.get('[data-testid="submit-login"]').click();
|
||||
|
||||
// 验证登录成功
|
||||
cy.wait('@login');
|
||||
cy.url().should('include', '/dashboard');
|
||||
cy.get('[data-testid="user-avatar"]').should('be.visible');
|
||||
});
|
||||
|
||||
it('应该能够处理登录错误', () => {
|
||||
cy.intercept('POST', '/api/auth/login', {
|
||||
statusCode: 401,
|
||||
body: { success: false, message: '手机号或密码错误' }
|
||||
}).as('loginError');
|
||||
|
||||
cy.visit('/login');
|
||||
|
||||
// 填写错误信息
|
||||
cy.get('[data-testid="phone-input"]').type('13800138001');
|
||||
cy.get('[data-testid="password-input"]').type('wrongpassword');
|
||||
|
||||
// 提交登录
|
||||
cy.get('[data-testid="submit-login"]').click();
|
||||
|
||||
// 验证错误提示
|
||||
cy.wait('@loginError');
|
||||
cy.get('[data-testid="error-message"]').should('contain', '手机号或密码错误');
|
||||
});
|
||||
});
|
||||
|
||||
describe('优惠券功能测试', () => {
|
||||
beforeEach(() => {
|
||||
// 设置登录状态
|
||||
cy.window().then((win) => {
|
||||
win.localStorage.setItem('token', 'mock-jwt-token');
|
||||
});
|
||||
});
|
||||
|
||||
it('应该显示可用优惠券列表', () => {
|
||||
cy.visit('/dashboard');
|
||||
|
||||
// 等待优惠券加载
|
||||
cy.wait('@getCoupons');
|
||||
|
||||
// 验证优惠券显示
|
||||
cy.get('[data-testid="coupon-card"]').should('have.length', 2);
|
||||
cy.get('[data-testid="coupon-1"]').should('contain', '新用户专享优惠券');
|
||||
cy.get('[data-testid="coupon-2"]').should('contain', '限时特惠券');
|
||||
|
||||
// 验证优惠券信息
|
||||
cy.get('[data-testid="coupon-1"]').should('contain', '满100减10元');
|
||||
cy.get('[data-testid="coupon-2"]').should('contain', '满50减5元');
|
||||
});
|
||||
|
||||
it('应该能够领取优惠券', () => {
|
||||
cy.visit('/coupons');
|
||||
|
||||
cy.wait('@getCoupons');
|
||||
|
||||
// 点击领取第一个优惠券
|
||||
cy.get('[data-testid="coupon-1"]').within(() => {
|
||||
cy.get('[data-testid="claim-button"]').click();
|
||||
});
|
||||
|
||||
// 等待领取响应
|
||||
cy.wait('@claimCoupon');
|
||||
|
||||
// 验证成功提示
|
||||
cy.get('[data-testid="success-toast"]').should('contain', '优惠券领取成功');
|
||||
|
||||
// 验证按钮状态变化
|
||||
cy.get('[data-testid="coupon-1"]').within(() => {
|
||||
cy.get('[data-testid="claim-button"]').should('contain', '已领取');
|
||||
cy.get('[data-testid="claim-button"]').should('be.disabled');
|
||||
});
|
||||
});
|
||||
|
||||
it('应该显示已领取的优惠券', () => {
|
||||
cy.intercept('GET', '/api/coupons/my', { fixture: 'my-coupons.json' }).as('getMyCoupons');
|
||||
|
||||
cy.visit('/coupons/my');
|
||||
|
||||
cy.wait('@getMyCoupons');
|
||||
|
||||
// 验证我的优惠券列表
|
||||
cy.get('[data-testid="my-coupon-list"]').should('be.visible');
|
||||
cy.get('[data-testid="my-coupon-1"]').should('contain', '新用户专享优惠券');
|
||||
cy.get('[data-testid="my-coupon-1"]').should('contain', '已使用:0张');
|
||||
});
|
||||
|
||||
it('应该能够分享优惠券', () => {
|
||||
cy.visit('/coupons');
|
||||
|
||||
cy.wait('@getCoupons');
|
||||
|
||||
// 点击分享按钮
|
||||
cy.get('[data-testid="coupon-1"]').within(() => {
|
||||
cy.get('[data-testid="share-button"]').click();
|
||||
});
|
||||
|
||||
// 验证分享弹窗
|
||||
cy.get('[data-testid="share-modal"]').should('be.visible');
|
||||
cy.get('[data-testid="share-link"]').should('be.visible');
|
||||
cy.get('[data-testid="copy-link-button"]').should('be.visible');
|
||||
|
||||
// 测试复制链接
|
||||
cy.get('[data-testid="copy-link-button"]').click();
|
||||
cy.get('[data-testid="copy-success"]').should('contain', '链接已复制');
|
||||
});
|
||||
});
|
||||
|
||||
describe('数据统计功能测试', () => {
|
||||
beforeEach(() => {
|
||||
cy.window().then((win) => {
|
||||
win.localStorage.setItem('token', 'mock-jwt-token');
|
||||
});
|
||||
});
|
||||
|
||||
it('应该显示个人统计数据', () => {
|
||||
cy.visit('/dashboard');
|
||||
|
||||
cy.wait('@getPersonalStats');
|
||||
|
||||
// 验证统计卡片
|
||||
cy.get('[data-testid="stats-card-total-clicks"]').should('contain', '1,250');
|
||||
cy.get('[data-testid="stats-card-conversions"]').should('contain', '89');
|
||||
cy.get('[data-testid="stats-card-earnings"]').should('contain', '¥1,256.78');
|
||||
cy.get('[data-testid="stats-card-today-earnings"]').should('contain', '¥45.50');
|
||||
});
|
||||
|
||||
it('应该显示团队统计数据', () => {
|
||||
cy.visit('/team');
|
||||
|
||||
cy.wait('@getTeamStats');
|
||||
|
||||
// 验证团队统计
|
||||
cy.get('[data-testid="team-stats-level1"]').should('contain', '8');
|
||||
cy.get('[data-testid="team-stats-level2"]').should('contain', '12');
|
||||
cy.get('[data-testid="team-stats-level3"]').should('contain', '6');
|
||||
cy.get('[data-testid="team-stats-total-earnings"]').should('contain', '¥3,456.78');
|
||||
});
|
||||
|
||||
it('应该显示趋势图表', () => {
|
||||
cy.intercept('GET', '/api/stats/trends', { fixture: 'trends.json' }).as('getTrends');
|
||||
|
||||
cy.visit('/dashboard');
|
||||
|
||||
cy.wait('@getTrends');
|
||||
|
||||
// 验证图表显示
|
||||
cy.get('[data-testid="trend-chart"]').should('be.visible');
|
||||
cy.get('[data-testid="chart-canvas"]').should('be.visible');
|
||||
});
|
||||
|
||||
it('应该能够切换时间范围', () => {
|
||||
cy.visit('/dashboard');
|
||||
|
||||
// 点击时间范围选择器
|
||||
cy.get('[data-testid="time-range-selector"]').click();
|
||||
|
||||
// 选择本周
|
||||
cy.get('[data-testid="range-week"]').click();
|
||||
|
||||
// 验证数据更新
|
||||
cy.get('[data-testid="time-range-display"]').should('contain', '本周');
|
||||
});
|
||||
});
|
||||
|
||||
describe('邀请功能测试', () => {
|
||||
beforeEach(() => {
|
||||
cy.window().then((win) => {
|
||||
win.localStorage.setItem('token', 'mock-jwt-token');
|
||||
});
|
||||
});
|
||||
|
||||
it('应该生成邀请码和邀请链接', () => {
|
||||
cy.visit('/invite');
|
||||
|
||||
cy.wait('@getInviteCode');
|
||||
|
||||
// 验证邀请信息显示
|
||||
cy.get('[data-testid="invite-code"]').should('contain', 'INVITE123');
|
||||
cy.get('[data-testid="invite-link"]').should('contain', 'https://mosquito.com/invite/INVITE123');
|
||||
});
|
||||
|
||||
it('应该能够复制邀请链接', () => {
|
||||
cy.visit('/invite');
|
||||
|
||||
cy.wait('@getInviteCode');
|
||||
|
||||
// 点击复制按钮
|
||||
cy.get('[data-testid="copy-invite-link"]').click();
|
||||
|
||||
// 验证复制成功提示
|
||||
cy.get('[data-testid="copy-success"]').should('contain', '邀请链接已复制');
|
||||
});
|
||||
|
||||
it('应该显示邀请记录', () => {
|
||||
cy.intercept('GET', '/api/reward/invite-records', { fixture: 'invite-records.json' }).as('getInviteRecords');
|
||||
|
||||
cy.visit('/invite');
|
||||
|
||||
cy.wait('@getInviteRecords');
|
||||
|
||||
// 验证邀请记录列表
|
||||
cy.get('[data-testid="invite-records"]').should('be.visible');
|
||||
cy.get('[data-testid="invite-record-1"]').should('contain', '138****8001');
|
||||
cy.get('[data-testid="invite-record-1"]').should('contain', '¥10.00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('短链功能测试', () => {
|
||||
beforeEach(() => {
|
||||
cy.window().then((win) => {
|
||||
win.localStorage.setItem('token', 'mock-jwt-token');
|
||||
});
|
||||
});
|
||||
|
||||
it('应该能够生成短链', () => {
|
||||
cy.visit('/short-links');
|
||||
|
||||
// 填写目标URL
|
||||
cy.get('[data-testid="original-url-input"]').type('https://example.com/landing-page');
|
||||
|
||||
// 填写活动名称
|
||||
cy.get('[data-testid="campaign-input"]').type('test-campaign');
|
||||
|
||||
// 点击生成按钮
|
||||
cy.get('[data-testid="generate-button"]').click();
|
||||
|
||||
// 等待生成响应
|
||||
cy.wait('@generateShortLink');
|
||||
|
||||
// 验证短链生成
|
||||
cy.get('[data-testid="short-code"]').should('contain', 'abc123');
|
||||
cy.get('[data-testid="short-url"]').should('contain', 'https://mosquito.com/s/abc123');
|
||||
});
|
||||
|
||||
it('应该显示短链统计', () => {
|
||||
cy.intercept('GET', '/api/short-links', { fixture: 'short-links.json' }).as('getShortLinks');
|
||||
|
||||
cy.visit('/short-links');
|
||||
|
||||
cy.wait('@getShortLinks');
|
||||
|
||||
// 验证短链列表
|
||||
cy.get('[data-testid="short-link-list"]').should('be.visible');
|
||||
cy.get('[data-testid="short-link-1"]').should('contain', 'abc123');
|
||||
cy.get('[data-testid="short-link-1"]').should('contain', '125');
|
||||
cy.get('[data-testid="short-link-1"]').should('contain', '7.2%');
|
||||
});
|
||||
|
||||
it('应该能够测试短链跳转', () => {
|
||||
cy.visit('/short-links');
|
||||
|
||||
cy.wait('@getShortLinks');
|
||||
|
||||
// 点击测试跳转按钮
|
||||
cy.get('[data-testid="test-redirect-button"]').click();
|
||||
|
||||
// 验证新窗口打开(需要处理弹出窗口)
|
||||
cy.get('@testRedirect').should('be.called');
|
||||
});
|
||||
});
|
||||
|
||||
describe('响应式设计测试', () => {
|
||||
beforeEach(() => {
|
||||
cy.window().then((win) => {
|
||||
win.localStorage.setItem('token', 'mock-jwt-token');
|
||||
});
|
||||
});
|
||||
|
||||
it('在移动端应该正确显示', () => {
|
||||
cy.viewport(375, 812); // iPhone X
|
||||
cy.visit('/dashboard');
|
||||
|
||||
cy.wait('@getPersonalStats');
|
||||
|
||||
// 验证移动端布局
|
||||
cy.get('[data-testid="mobile-layout"]').should('be.visible');
|
||||
cy.get('[data-testid="stats-grid"]').should('have.css', 'grid-template-columns', '1fr');
|
||||
|
||||
// 验证底部导航
|
||||
cy.get('[data-testid="bottom-navigation"]').should('be.visible');
|
||||
});
|
||||
|
||||
it('在平板端应该正确显示', () => {
|
||||
cy.viewport(768, 1024); // iPad
|
||||
cy.visit('/dashboard');
|
||||
|
||||
cy.wait('@getPersonalStats');
|
||||
|
||||
// 验证平板端布局
|
||||
cy.get('[data-testid="tablet-layout"]').should('be.visible');
|
||||
cy.get('[data-testid="stats-grid"]').should('have.css', 'grid-template-columns', 'repeat(2, 1fr)');
|
||||
});
|
||||
|
||||
it('在桌面端应该正确显示', () => {
|
||||
cy.viewport(1200, 800); // Desktop
|
||||
cy.visit('/dashboard');
|
||||
|
||||
cy.wait('@getPersonalStats');
|
||||
|
||||
// 验证桌面端布局
|
||||
cy.get('[data-testid="desktop-layout"]').should('be.visible');
|
||||
cy.get('[data-testid="stats-grid"]').should('have.css', 'grid-template-columns', 'repeat(4, 1fr)');
|
||||
|
||||
// 验证侧边栏
|
||||
cy.get('[data-testid="sidebar"]').should('be.visible');
|
||||
});
|
||||
});
|
||||
|
||||
describe('性能测试', () => {
|
||||
beforeEach(() => {
|
||||
cy.window().then((win) => {
|
||||
win.localStorage.setItem('token', 'mock-jwt-token');
|
||||
});
|
||||
});
|
||||
|
||||
it('页面加载时间应该在合理范围内', () => {
|
||||
const startTime = Date.now();
|
||||
|
||||
cy.visit('/dashboard');
|
||||
cy.wait('@getPersonalStats');
|
||||
|
||||
cy.get('[data-testid="stats-container"]').should('be.visible');
|
||||
|
||||
const loadTime = Date.now() - startTime;
|
||||
expect(loadTime).to.be.lessThan(3000); // 3秒内加载完成
|
||||
});
|
||||
|
||||
it('大量数据渲染不应该影响性能', () => {
|
||||
// Mock大量优惠券数据
|
||||
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
|
||||
}));
|
||||
|
||||
cy.intercept('GET', '/api/coupons/available', {
|
||||
body: { success: true, data: largeCoupons }
|
||||
}).as('getLargeCoupons');
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
cy.visit('/coupons');
|
||||
cy.wait('@getLargeCoupons');
|
||||
|
||||
cy.get('[data-testid="coupon-list"]').should('be.visible');
|
||||
|
||||
const renderTime = Date.now() - startTime;
|
||||
expect(renderTime).to.be.lessThan(2000); // 2秒内渲染完成
|
||||
});
|
||||
});
|
||||
|
||||
describe('错误处理测试', () => {
|
||||
beforeEach(() => {
|
||||
cy.window().then((win) => {
|
||||
win.localStorage.setItem('token', 'mock-jwt-token');
|
||||
});
|
||||
});
|
||||
|
||||
it('应该处理网络错误', () => {
|
||||
cy.intercept('GET', '/api/stats/personal', {
|
||||
statusCode: 0,
|
||||
body: {}
|
||||
}).as('networkError');
|
||||
|
||||
cy.visit('/dashboard');
|
||||
cy.wait('@networkError');
|
||||
|
||||
// 验证错误提示
|
||||
cy.get('[data-testid="error-toast"]').should('contain', '网络连接失败');
|
||||
});
|
||||
|
||||
it('应该处理服务器错误', () => {
|
||||
cy.intercept('GET', '/api/stats/personal', {
|
||||
statusCode: 500,
|
||||
body: { success: false, message: '服务器内部错误' }
|
||||
}).as('serverError');
|
||||
|
||||
cy.visit('/dashboard');
|
||||
cy.wait('@serverError');
|
||||
|
||||
// 验证错误提示
|
||||
cy.get('[data-testid="error-toast"]').should('contain', '服务器内部错误');
|
||||
});
|
||||
|
||||
it('应该处理认证错误', () => {
|
||||
cy.intercept('GET', '/api/stats/personal', {
|
||||
statusCode: 401,
|
||||
body: { success: false, message: '未授权访问' }
|
||||
}).as('authError');
|
||||
|
||||
cy.visit('/dashboard');
|
||||
cy.wait('@authError');
|
||||
|
||||
// 验证跳转到登录页
|
||||
cy.url().should('include', '/login');
|
||||
});
|
||||
});
|
||||
|
||||
describe('可访问性测试', () => {
|
||||
beforeEach(() => {
|
||||
cy.window().then((win) => {
|
||||
win.localStorage.setItem('token', 'mock-jwt-token');
|
||||
});
|
||||
});
|
||||
|
||||
it('应该支持键盘导航', () => {
|
||||
cy.visit('/dashboard');
|
||||
cy.wait('@getPersonalStats');
|
||||
|
||||
// 测试Tab键导航
|
||||
cy.get('body').tab();
|
||||
cy.focused().should('have.attr', 'data-testid', 'skip-to-content');
|
||||
|
||||
// 继续Tab导航
|
||||
cy.focused().tab();
|
||||
cy.focused().should('have.attr', 'data-testid', 'mobile-menu-button');
|
||||
});
|
||||
|
||||
it('应该有正确的ARIA标签', () => {
|
||||
cy.visit('/dashboard');
|
||||
cy.wait('@getPersonalStats');
|
||||
|
||||
// 验证重要元素的ARIA标签
|
||||
cy.get('[data-testid="stats-container"]').should('have.attr', 'aria-label', '用户统计数据');
|
||||
cy.get('[data-testid="total-clicks"]').should('have.attr', 'aria-label', '总点击次数');
|
||||
});
|
||||
|
||||
it('应该支持屏幕阅读器', () => {
|
||||
cy.visit('/dashboard');
|
||||
cy.wait('@getPersonalStats');
|
||||
|
||||
// 验证重要内容有文本描述
|
||||
cy.get('[data-testid="stats-card-total-clicks"]').should('contain', '1,250');
|
||||
cy.get('[data-testid="stats-card-total-clicks"]').should('have.attr', 'aria-describedby');
|
||||
});
|
||||
});
|
||||
});
|
||||
10
frontend/h5/cypress/fixtures/coupon-claim-success.json
Normal file
10
frontend/h5/cypress/fixtures/coupon-claim-success.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"success": true,
|
||||
"message": "优惠券领取成功",
|
||||
"data": {
|
||||
"couponId": "1",
|
||||
"claimedAt": "2026-01-23T10:30:00Z",
|
||||
"expiresAt": "2026-02-23T23:59:59Z",
|
||||
"status": "CLAIMED"
|
||||
}
|
||||
}
|
||||
62
frontend/h5/cypress/fixtures/coupons.json
Normal file
62
frontend/h5/cypress/fixtures/coupons.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "新用户专享优惠券",
|
||||
"description": "满100减10元",
|
||||
"discount": 10.00,
|
||||
"minAmount": 100.00,
|
||||
"type": "DISCOUNT",
|
||||
"validUntil": "2026-02-23T23:59:59Z",
|
||||
"claimed": false,
|
||||
"quantity": 1000,
|
||||
"remaining": 850,
|
||||
"imageUrl": "https://picsum.photos/seed/coupon1/300/200.jpg",
|
||||
"tags": ["新用户", "专享"],
|
||||
"rules": [
|
||||
"仅限新用户领取",
|
||||
"有效期30天",
|
||||
"不可与其他优惠叠加"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "限时特惠券",
|
||||
"description": "满50减5元",
|
||||
"discount": 5.00,
|
||||
"minAmount": 50.00,
|
||||
"type": "DISCOUNT",
|
||||
"validUntil": "2026-01-30T23:59:59Z",
|
||||
"claimed": false,
|
||||
"quantity": 500,
|
||||
"remaining": 320,
|
||||
"imageUrl": "https://picsum.photos/seed/coupon2/300/200.jpg",
|
||||
"tags": ["限时", "特惠"],
|
||||
"rules": [
|
||||
"有效期7天",
|
||||
"每人限领1张",
|
||||
"不可与其他优惠叠加"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"name": "邀请奖励券",
|
||||
"description": "无门槛减3元",
|
||||
"discount": 3.00,
|
||||
"minAmount": 0.00,
|
||||
"type": "DISCOUNT",
|
||||
"validUntil": "2026-02-23T23:59:59Z",
|
||||
"claimed": true,
|
||||
"quantity": 2000,
|
||||
"remaining": 1200,
|
||||
"imageUrl": "https://picsum.photos/seed/coupon3/300/200.jpg",
|
||||
"tags": ["邀请", "奖励"],
|
||||
"rules": [
|
||||
"邀请好友成功后获得",
|
||||
"有效期30天",
|
||||
"全场通用"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
23
frontend/h5/cypress/fixtures/invite-code.json
Normal file
23
frontend/h5/cypress/fixtures/invite-code.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"inviteCode": "INVITE123",
|
||||
"inviteLink": "https://mosquito.com/invite/INVITE123",
|
||||
"qrCode": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
|
||||
"shareText": "我正在使用蚊子项目,邀请你也来加入!点击链接:https://mosquito.com/invite/INVITE123",
|
||||
"campaigns": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "新用户专享活动",
|
||||
"reward": 10.00,
|
||||
"endDate": "2026-02-23T23:59:59Z"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "春节特惠活动",
|
||||
"reward": 15.00,
|
||||
"endDate": "2026-02-28T23:59:59Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
58
frontend/h5/cypress/fixtures/invite-records.json
Normal file
58
frontend/h5/cypress/fixtures/invite-records.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "1",
|
||||
"userId": "10",
|
||||
"inviteePhone": "138****8001",
|
||||
"inviteeNickname": "新用户A",
|
||||
"level": 1,
|
||||
"reward": 10.00,
|
||||
"status": "COMPLETED",
|
||||
"createdAt": "2026-01-20T10:00:00Z",
|
||||
"completedAt": "2026-01-20T10:15:00Z"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"userId": "11",
|
||||
"inviteePhone": "138****8002",
|
||||
"inviteeNickname": "新用户B",
|
||||
"level": 1,
|
||||
"reward": 10.00,
|
||||
"status": "COMPLETED",
|
||||
"createdAt": "2026-01-19T14:30:00Z",
|
||||
"completedAt": "2026-01-19T14:45:00Z"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"userId": "12",
|
||||
"inviteePhone": "138****8003",
|
||||
"inviteeNickname": "新用户C",
|
||||
"level": 2,
|
||||
"reward": 5.00,
|
||||
"status": "COMPLETED",
|
||||
"createdAt": "2026-01-18T09:15:00Z",
|
||||
"completedAt": "2026-01-18T09:30:00Z"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"userId": "13",
|
||||
"inviteePhone": "138****8004",
|
||||
"inviteeNickname": "待激活用户",
|
||||
"level": 1,
|
||||
"reward": 0.00,
|
||||
"status": "PENDING",
|
||||
"createdAt": "2026-01-22T16:20:00Z",
|
||||
"completedAt": null
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"totalInvites": 15,
|
||||
"completedInvites": 12,
|
||||
"pendingInvites": 3,
|
||||
"totalRewards": 125.00,
|
||||
"level1Rewards": 100.00,
|
||||
"level2Rewards": 20.00,
|
||||
"level3Rewards": 5.00
|
||||
}
|
||||
}
|
||||
16
frontend/h5/cypress/fixtures/login-success.json
Normal file
16
frontend/h5/cypress/fixtures/login-success.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMzgwMDEzODAwMSIsInVzZXJJZCI6IjEiLCJpYXQiOjE3Mzc2NzI2MDAsImV4cCI6MTczNzc1OTAwMH0.mock-signature",
|
||||
"refreshToken": "mock-refresh-token",
|
||||
"expiresIn": 3600,
|
||||
"user": {
|
||||
"id": "1",
|
||||
"phone": "13800138001",
|
||||
"nickname": "测试用户",
|
||||
"avatar": "https://api.dicebear.com/7.x/avataaars/svg?seed=test",
|
||||
"isNewUser": false,
|
||||
"level": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
44
frontend/h5/cypress/fixtures/my-coupons.json
Normal file
44
frontend/h5/cypress/fixtures/my-coupons.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "新用户专享优惠券",
|
||||
"description": "满100减10元",
|
||||
"discount": 10.00,
|
||||
"minAmount": 100.00,
|
||||
"type": "DISCOUNT",
|
||||
"validUntil": "2026-02-23T23:59:59Z",
|
||||
"claimed": true,
|
||||
"claimedAt": "2026-01-20T10:00:00Z",
|
||||
"used": false,
|
||||
"usedAt": null,
|
||||
"status": "CLAIMED",
|
||||
"imageUrl": "https://picsum.photos/seed/coupon1/300/200.jpg",
|
||||
"couponCode": "NEWUSER123"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"name": "邀请奖励券",
|
||||
"description": "无门槛减3元",
|
||||
"discount": 3.00,
|
||||
"minAmount": 0.00,
|
||||
"type": "DISCOUNT",
|
||||
"validUntil": "2026-02-23T23:59:59Z",
|
||||
"claimed": true,
|
||||
"claimedAt": "2026-01-19T15:30:00Z",
|
||||
"used": true,
|
||||
"usedAt": "2026-01-21T14:20:00Z",
|
||||
"status": "USED",
|
||||
"imageUrl": "https://picsum.photos/seed/coupon3/300/200.jpg",
|
||||
"couponCode": "INVITE456"
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"total": 2,
|
||||
"claimed": 2,
|
||||
"used": 1,
|
||||
"expired": 0,
|
||||
"available": 1
|
||||
}
|
||||
}
|
||||
87
frontend/h5/cypress/fixtures/personal-stats.json
Normal file
87
frontend/h5/cypress/fixtures/personal-stats.json
Normal file
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"totalClicks": 1250,
|
||||
"totalConversions": 89,
|
||||
"totalEarnings": 1256.78,
|
||||
"todayEarnings": 45.50,
|
||||
"yesterdayEarnings": 32.30,
|
||||
"thisWeekEarnings": 189.60,
|
||||
"thisMonthEarnings": 756.80,
|
||||
"inviteCount": 15,
|
||||
"conversionRate": 7.12,
|
||||
"avgClickValue": 1.01,
|
||||
"activeShortLinks": 5,
|
||||
"rank": 28,
|
||||
"totalUsers": 1250,
|
||||
"growthRate": 15.6,
|
||||
"stats": {
|
||||
"daily": [
|
||||
{
|
||||
"date": "2026-01-20",
|
||||
"clicks": 145,
|
||||
"conversions": 12,
|
||||
"earnings": 65.40
|
||||
},
|
||||
{
|
||||
"date": "2026-01-21",
|
||||
"clicks": 167,
|
||||
"conversions": 14,
|
||||
"earnings": 78.20
|
||||
},
|
||||
{
|
||||
"date": "2026-01-22",
|
||||
"clicks": 189,
|
||||
"conversions": 16,
|
||||
"earnings": 89.50
|
||||
},
|
||||
{
|
||||
"date": "2026-01-23",
|
||||
"clicks": 203,
|
||||
"conversions": 17,
|
||||
"earnings": 96.30
|
||||
}
|
||||
],
|
||||
"weekly": [
|
||||
{
|
||||
"week": "2026-W1",
|
||||
"clicks": 892,
|
||||
"conversions": 67,
|
||||
"earnings": 456.70
|
||||
},
|
||||
{
|
||||
"week": "2026-W2",
|
||||
"clicks": 1034,
|
||||
"conversions": 78,
|
||||
"earnings": 523.40
|
||||
},
|
||||
{
|
||||
"week": "2026-W3",
|
||||
"clicks": 1250,
|
||||
"conversions": 89,
|
||||
"earnings": 656.80
|
||||
}
|
||||
],
|
||||
"monthly": [
|
||||
{
|
||||
"month": "2025-11",
|
||||
"clicks": 2340,
|
||||
"conversions": 156,
|
||||
"earnings": 1234.50
|
||||
},
|
||||
{
|
||||
"month": "2025-12",
|
||||
"clicks": 3456,
|
||||
"conversions": 234,
|
||||
"earnings": 1890.60
|
||||
},
|
||||
{
|
||||
"month": "2026-01",
|
||||
"clicks": 1250,
|
||||
"conversions": 89,
|
||||
"earnings": 656.80
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
42
frontend/h5/cypress/fixtures/short-link.json
Normal file
42
frontend/h5/cypress/fixtures/short-link.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "1",
|
||||
"shortCode": "abc123",
|
||||
"shortUrl": "https://mosquito.com/s/abc123",
|
||||
"originalUrl": "https://example.com/landing-page",
|
||||
"campaign": "test-campaign",
|
||||
"totalClicks": 125,
|
||||
"uniqueClicks": 98,
|
||||
"conversions": 9,
|
||||
"conversionRate": 7.2,
|
||||
"createdAt": "2026-01-20T10:00:00Z",
|
||||
"expiresAt": "2026-04-20T10:00:00Z",
|
||||
"status": "ACTIVE",
|
||||
"qrCode": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
|
||||
"statistics": {
|
||||
"daily": [
|
||||
{
|
||||
"date": "2026-01-20",
|
||||
"clicks": 25,
|
||||
"conversions": 2
|
||||
},
|
||||
{
|
||||
"date": "2026-01-21",
|
||||
"clicks": 32,
|
||||
"conversions": 3
|
||||
},
|
||||
{
|
||||
"date": "2026-22",
|
||||
"clicks": 28,
|
||||
"conversions": 2
|
||||
},
|
||||
{
|
||||
"date": "2026-01-23",
|
||||
"clicks": 40,
|
||||
"conversions": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
44
frontend/h5/cypress/fixtures/short-links.json
Normal file
44
frontend/h5/cypress/fixtures/short-links.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "1",
|
||||
"shortCode": "abc123",
|
||||
"shortUrl": "https://mosquito.com/s/abc123",
|
||||
"originalUrl": "https://example.com/landing-page",
|
||||
"campaign": "test-campaign",
|
||||
"totalClicks": 125,
|
||||
"uniqueClicks": 98,
|
||||
"conversions": 9,
|
||||
"conversionRate": 7.2,
|
||||
"createdAt": "2026-01-20T10:00:00Z",
|
||||
"status": "ACTIVE"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"shortCode": "def456",
|
||||
"shortUrl": "https://mosquito.com/s/def456",
|
||||
"originalUrl": "https://example.com/product-page",
|
||||
"campaign": "product-promo",
|
||||
"totalClicks": 89,
|
||||
"uniqueClicks": 76,
|
||||
"conversions": 5,
|
||||
"conversionRate": 5.6,
|
||||
"createdAt": "2026-01-18T15:30:00Z",
|
||||
"status": "ACTIVE"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"shortCode": "ghi789",
|
||||
"shortUrl": "https://mosquito.com/s/ghi789",
|
||||
"originalUrl": "https://example.com/special-offer",
|
||||
"campaign": "special-offer",
|
||||
"totalClicks": 234,
|
||||
"uniqueClicks": 198,
|
||||
"conversions": 28,
|
||||
"conversionRate": 12.0,
|
||||
"createdAt": "2026-01-15T09:00:00Z",
|
||||
"status": "ACTIVE"
|
||||
}
|
||||
]
|
||||
}
|
||||
63
frontend/h5/cypress/fixtures/team-stats.json
Normal file
63
frontend/h5/cypress/fixtures/team-stats.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"level1Count": 8,
|
||||
"level2Count": 12,
|
||||
"level3Count": 6,
|
||||
"totalTeamMembers": 26,
|
||||
"totalTeamEarnings": 3456.78,
|
||||
"teamConversionRate": 8.9,
|
||||
"teamGrowthRate": 23.5,
|
||||
"topPerformers": [
|
||||
{
|
||||
"userId": "1",
|
||||
"nickname": "小明",
|
||||
"avatar": "https://api.dicebear.com/7.x/avataaars/svg?seed=user1",
|
||||
"level": 1,
|
||||
"earnings": 456.70,
|
||||
"inviteCount": 12
|
||||
},
|
||||
{
|
||||
"userId": "2",
|
||||
"nickname": "小红",
|
||||
"avatar": "https://api.dicebear.com/7.x/avataaars/svg?seed=user2",
|
||||
"level": 2,
|
||||
"earnings": 234.50,
|
||||
"inviteCount": 8
|
||||
}
|
||||
],
|
||||
"recentJoins": [
|
||||
{
|
||||
"userId": "3",
|
||||
"nickname": "小张",
|
||||
"avatar": "https://api.dicebear.com/7.x/avataaars/svg?seed=user3",
|
||||
"level": 1,
|
||||
"joinedAt": "2026-01-22T14:30:00Z"
|
||||
},
|
||||
{
|
||||
"userId": "4",
|
||||
"nickname": "小李",
|
||||
"avatar": "https://api.dicebear.com/7.x/avataaars/svg?seed/user4",
|
||||
"level": 1,
|
||||
"joinedAt": "2026-01-21T09:15:00Z"
|
||||
}
|
||||
],
|
||||
"levelDistribution": {
|
||||
"level1": {
|
||||
"count": 8,
|
||||
"percentage": 30.8,
|
||||
"earnings": 1234.50
|
||||
},
|
||||
"level2": {
|
||||
"count": 12,
|
||||
"percentage": 46.2,
|
||||
"earnings": 1678.20
|
||||
},
|
||||
"level3": {
|
||||
"count": 6,
|
||||
"percentage": 23.0,
|
||||
"earnings": 544.08
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
95
frontend/h5/cypress/fixtures/trends.json
Normal file
95
frontend/h5/cypress/fixtures/trends.json
Normal file
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"dailyStats": [
|
||||
{
|
||||
"date": "2026-01-20",
|
||||
"clicks": 145,
|
||||
"conversions": 12,
|
||||
"earnings": 65.40,
|
||||
"conversionRate": 8.28
|
||||
},
|
||||
{
|
||||
"date": "2026-01-21",
|
||||
"clicks": 167,
|
||||
"conversions": 14,
|
||||
"earnings": 78.20,
|
||||
"conversionRate": 8.38
|
||||
},
|
||||
{
|
||||
"date": "2026-01-22",
|
||||
"clicks": 189,
|
||||
"conversions": 16,
|
||||
"earnings": 89.50,
|
||||
"conversionRate": 8.47
|
||||
},
|
||||
{
|
||||
"date": "2026-01-23",
|
||||
"clicks": 203,
|
||||
"conversions": 17,
|
||||
"earnings": 96.30,
|
||||
"conversionRate": 8.37
|
||||
}
|
||||
],
|
||||
"weeklyStats": [
|
||||
{
|
||||
"week": "2026-W1",
|
||||
"startDate": "2026-01-04",
|
||||
"endDate": "2026-01-10",
|
||||
"clicks": 892,
|
||||
"conversions": 67,
|
||||
"earnings": 456.70,
|
||||
"conversionRate": 7.51
|
||||
},
|
||||
{
|
||||
"week": "2026-W2",
|
||||
"startDate": "2026-01-11",
|
||||
"endDate": "2026-01-17",
|
||||
"clicks": 1034,
|
||||
"conversions": 78,
|
||||
"earnings": 523.40,
|
||||
"conversionRate": 7.54
|
||||
},
|
||||
{
|
||||
"week": "2026-W3",
|
||||
"startDate": "2026-01-18",
|
||||
"endDate": "2026-01-24",
|
||||
"clicks": 1250,
|
||||
"conversions": 89,
|
||||
"earnings": 656.80,
|
||||
"conversionRate": 7.12
|
||||
}
|
||||
],
|
||||
"monthlyStats": [
|
||||
{
|
||||
"month": "2025-11",
|
||||
"clicks": 2340,
|
||||
"conversions": 156,
|
||||
"earnings": 1234.50,
|
||||
"conversionRate": 6.67
|
||||
},
|
||||
{
|
||||
"month": "2025-12",
|
||||
"clicks": 3456,
|
||||
"conversions": 234,
|
||||
"earnings": 1890.60,
|
||||
"conversionRate": 6.77
|
||||
},
|
||||
{
|
||||
"month": "2026-01",
|
||||
"clicks": 1250,
|
||||
"conversions": 89,
|
||||
"earnings": 656.80,
|
||||
"conversionRate": 7.12
|
||||
}
|
||||
],
|
||||
"trends": {
|
||||
"clickGrowth": 15.6,
|
||||
"conversionGrowth": 8.9,
|
||||
"earningsGrowth": 23.4,
|
||||
"forecastClicks": 1450,
|
||||
"forecastConversions": 98,
|
||||
"forecastEarnings": 780.50
|
||||
}
|
||||
}
|
||||
}
|
||||
17
frontend/h5/cypress/fixtures/user-profile.json
Normal file
17
frontend/h5/cypress/fixtures/user-profile.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "1",
|
||||
"phone": "13800138001",
|
||||
"nickname": "测试用户",
|
||||
"avatar": "https://api.dicebear.com/7.x/avataaars/svg?seed=test",
|
||||
"isNewUser": true,
|
||||
"createdAt": "2026-01-20T10:00:00Z",
|
||||
"lastLoginAt": "2026-01-23T09:30:00Z",
|
||||
"verificationStatus": "VERIFIED",
|
||||
"level": 1,
|
||||
"experience": 150,
|
||||
"totalEarnings": 1256.78,
|
||||
"inviteCount": 15
|
||||
}
|
||||
}
|
||||
12
frontend/h5/index.html
Normal file
12
frontend/h5/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Mosquito H5</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
2574
frontend/h5/package-lock.json
generated
Normal file
2574
frontend/h5/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
frontend/h5/package.json
Normal file
30
frontend/h5/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@mosquito/h5",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.3.0",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "~5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vue-tsc": "^1.8.25"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
6
frontend/h5/postcss.config.cjs
Normal file
6
frontend/h5/postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
||||
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" />
|
||||
60
frontend/h5/tailwind.config.cjs
Normal file
60
frontend/h5/tailwind.config.cjs
Normal file
@@ -0,0 +1,60 @@
|
||||
module.exports = {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,ts,tsx}",
|
||||
"../components/**/*.{vue,ts,tsx}",
|
||||
"../index.ts"
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
mosquito: {
|
||||
// Primary: Vibrant Orange
|
||||
primary: '#FF6B35',
|
||||
'primary-light': '#FF8A5B',
|
||||
'primary-dark': '#E55A2B',
|
||||
// Secondary: Bright Teal
|
||||
secondary: '#00D9C0',
|
||||
'secondary-light': '#5CEFD9',
|
||||
'secondary-dark': '#00B8A3',
|
||||
// Accent: Warm Yellow
|
||||
accent: '#FFD93D',
|
||||
'accent-light': '#FFE56D',
|
||||
'accent-dark': '#F5C700',
|
||||
// Semantic colors
|
||||
success: '#00C781',
|
||||
warning: '#FFB800',
|
||||
error: '#FF4757',
|
||||
info: '#4A90E2',
|
||||
// Text & Surface
|
||||
ink: '#1A1A2E',
|
||||
'ink-light': '#4A4A68',
|
||||
muted: '#8B8BA7',
|
||||
surface: '#FFFFFF',
|
||||
bg: '#FEF9F6',
|
||||
// Borders
|
||||
line: '#FFE8E0',
|
||||
border: 'rgba(255, 107, 53, 0.15)',
|
||||
// Legacy support (for components compatibility)
|
||||
accent2: '#6AA7FF',
|
||||
brand: '#0B3A63'
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
display: ['Poppins', 'Noto Sans SC', 'sans-serif'],
|
||||
body: ['Noto Sans SC', 'Poppins', 'sans-serif']
|
||||
},
|
||||
boxShadow: {
|
||||
soft: '0 8px 24px rgba(255, 107, 53, 0.12)',
|
||||
glow: '0 0 40px rgba(255, 107, 53, 0.2)',
|
||||
card: '0 4px 16px rgba(26, 26, 46, 0.06)',
|
||||
'card-hover': '0 8px 24px rgba(26, 26, 46, 0.1)'
|
||||
},
|
||||
borderRadius: {
|
||||
'2xl': '20px',
|
||||
'3xl': '28px'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
}
|
||||
17
frontend/h5/tsconfig.json
Normal file
17
frontend/h5/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "preserve",
|
||||
"useDefineForClassFields": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
|
||||
}
|
||||
27
frontend/h5/vite.config.ts
Normal file
27
frontend/h5/vite.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'node:path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
fs: {
|
||||
allow: ['..']
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/r': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user