feat(leaderboard): add toggle control for mobile-app ranking page
- Add public /leaderboard/status endpoint (no auth required) - Add LeaderboardService in mobile-app to fetch board status - Update RankingPage to show "待开启" when board is disabled - Connect admin-web leaderboard page to real API - Board toggle now takes effect immediately 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
52afe72f17
commit
dacefa2b51
|
|
@ -0,0 +1,37 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { LeaderboardApplicationService } from '../../application/services/leaderboard-application.service';
|
||||
import { LeaderboardStatusResponseDto } from '../dto/leaderboard-config.dto';
|
||||
|
||||
/**
|
||||
* 龙虎榜公开接口(无需认证)
|
||||
* 供移动端查询榜单开关状态
|
||||
*/
|
||||
@ApiTags('龙虎榜-公开')
|
||||
@Controller('leaderboard')
|
||||
export class LeaderboardPublicController {
|
||||
constructor(
|
||||
private readonly leaderboardService: LeaderboardApplicationService,
|
||||
) {}
|
||||
|
||||
@Get('status')
|
||||
@ApiOperation({ summary: '获取榜单开关状态(公开接口)' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '榜单开关状态',
|
||||
type: LeaderboardStatusResponseDto,
|
||||
})
|
||||
async getStatus() {
|
||||
const config = await this.leaderboardService.getConfig();
|
||||
|
||||
return {
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
dailyEnabled: config.dailyEnabled,
|
||||
weeklyEnabled: config.weeklyEnabled,
|
||||
monthlyEnabled: config.monthlyEnabled,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,20 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|||
import { IsBoolean, IsInt, IsOptional, Min, Max } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
/**
|
||||
* 公开榜单状态响应 DTO(供移动端使用)
|
||||
*/
|
||||
export class LeaderboardStatusResponseDto {
|
||||
@ApiProperty({ description: '日榜开关' })
|
||||
dailyEnabled: boolean;
|
||||
|
||||
@ApiProperty({ description: '周榜开关' })
|
||||
weeklyEnabled: boolean;
|
||||
|
||||
@ApiProperty({ description: '月榜开关' })
|
||||
monthlyEnabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 榜单配置响应 DTO
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { JwtModule } from '@nestjs/jwt';
|
|||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { HealthController } from '../api/controllers/health.controller';
|
||||
import { LeaderboardController } from '../api/controllers/leaderboard.controller';
|
||||
import { LeaderboardPublicController } from '../api/controllers/leaderboard-public.controller';
|
||||
import { LeaderboardConfigController } from '../api/controllers/leaderboard-config.controller';
|
||||
import { VirtualAccountController } from '../api/controllers/virtual-account.controller';
|
||||
import { JwtStrategy } from '../api/strategies/jwt.strategy';
|
||||
|
|
@ -31,6 +32,7 @@ import { InfrastructureModule } from './infrastructure.module';
|
|||
controllers: [
|
||||
HealthController,
|
||||
LeaderboardController,
|
||||
LeaderboardPublicController,
|
||||
LeaderboardConfigController,
|
||||
VirtualAccountController,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { toast } from '@/components/common';
|
||||
import { PageContainer } from '@/components/layout';
|
||||
import { cn } from '@/utils/helpers';
|
||||
import { useLeaderboardStore } from '@/store/zustand/useLeaderboardStore';
|
||||
import { leaderboardService, LeaderboardConfig } from '@/services/leaderboardService';
|
||||
import styles from './leaderboard.module.scss';
|
||||
|
||||
/**
|
||||
|
|
@ -59,31 +59,103 @@ const getRankStyle = (rank: number) => {
|
|||
* 基于 UIPro Figma 设计实现
|
||||
*/
|
||||
export default function LeaderboardPage() {
|
||||
const { virtualSettings, updateVirtualSettings, displaySettings, updateDisplaySettings } = useLeaderboardStore();
|
||||
// 配置状态
|
||||
const [config, setConfig] = useState<LeaderboardConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 本地编辑状态
|
||||
const [boardEnabled, setBoardEnabled] = useState({
|
||||
daily: true,
|
||||
daily: false,
|
||||
weekly: false,
|
||||
monthly: false,
|
||||
});
|
||||
|
||||
const [virtualSettings, setVirtualSettings] = useState({
|
||||
enabled: false,
|
||||
virtualAccountCount: 0,
|
||||
});
|
||||
const [displayLimit, setDisplayLimit] = useState(20);
|
||||
const [showRules, setShowRules] = useState(false);
|
||||
|
||||
// 加载配置
|
||||
const loadConfig = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await leaderboardService.getConfig();
|
||||
setConfig(data);
|
||||
setBoardEnabled({
|
||||
daily: data.dailyEnabled,
|
||||
weekly: data.weeklyEnabled,
|
||||
monthly: data.monthlyEnabled,
|
||||
});
|
||||
setVirtualSettings({
|
||||
enabled: data.virtualRankingEnabled,
|
||||
virtualAccountCount: data.virtualAccountCount,
|
||||
});
|
||||
setDisplayLimit(data.displayLimit);
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error);
|
||||
toast.error('加载配置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, [loadConfig]);
|
||||
|
||||
// 切换榜单开关
|
||||
const handleToggleBoard = async (type: 'daily' | 'weekly' | 'monthly', enabled: boolean) => {
|
||||
const previousState = boardEnabled[type];
|
||||
setBoardEnabled((prev) => ({ ...prev, [type]: enabled }));
|
||||
|
||||
try {
|
||||
await leaderboardService.updateSwitch({ type, enabled });
|
||||
toast.success(`${type === 'daily' ? '日榜' : type === 'weekly' ? '周榜' : '月榜'}已${enabled ? '开启' : '关闭'}`);
|
||||
} catch (error) {
|
||||
console.error('切换榜单失败:', error);
|
||||
toast.error('操作失败,请重试');
|
||||
setBoardEnabled((prev) => ({ ...prev, [type]: previousState }));
|
||||
}
|
||||
};
|
||||
|
||||
// 导出数据
|
||||
const handleExport = () => {
|
||||
toast.success('导出功能开发中');
|
||||
};
|
||||
|
||||
// 保存设置
|
||||
const handleSave = () => {
|
||||
toast.success('设置已保存');
|
||||
// 保存虚拟排名和显示设置
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
await Promise.all([
|
||||
leaderboardService.updateVirtualRanking({
|
||||
enabled: virtualSettings.enabled,
|
||||
accountCount: virtualSettings.virtualAccountCount,
|
||||
}),
|
||||
leaderboardService.updateDisplaySettings({
|
||||
displayLimit,
|
||||
}),
|
||||
]);
|
||||
toast.success('设置已保存');
|
||||
} catch (error) {
|
||||
console.error('保存设置失败:', error);
|
||||
toast.error('保存失败,请重试');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染切换开关
|
||||
const renderToggle = (checked: boolean, onChange: (checked: boolean) => void) => (
|
||||
const renderToggle = (checked: boolean, onChange: (checked: boolean) => void, disabled?: boolean) => (
|
||||
<div
|
||||
className={cn(styles.leaderboard__toggle, checked ? styles['leaderboard__toggle--on'] : styles['leaderboard__toggle--off'])}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
styles.leaderboard__toggle,
|
||||
checked ? styles['leaderboard__toggle--on'] : styles['leaderboard__toggle--off'],
|
||||
disabled && styles['leaderboard__toggle--disabled']
|
||||
)}
|
||||
onClick={() => !disabled && onChange(!checked)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -134,6 +206,16 @@ export default function LeaderboardPage() {
|
|||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageContainer title="龙虎榜管理">
|
||||
<div className={styles.leaderboard}>
|
||||
<div className={styles.leaderboard__loading}>加载中...</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer title="龙虎榜管理">
|
||||
<div className={styles.leaderboard}>
|
||||
|
|
@ -160,7 +242,7 @@ export default function LeaderboardPage() {
|
|||
<div className={styles.leaderboard__boardToggle}>
|
||||
<span className={styles.leaderboard__toggleLabel}>关闭</span>
|
||||
{renderToggle(boardEnabled.daily, (checked) =>
|
||||
setBoardEnabled({ ...boardEnabled, daily: checked })
|
||||
handleToggleBoard('daily', checked)
|
||||
)}
|
||||
<span className={styles.leaderboard__toggleLabel}>开启</span>
|
||||
</div>
|
||||
|
|
@ -210,19 +292,44 @@ export default function LeaderboardPage() {
|
|||
<div className={styles.leaderboard__boardToggle}>
|
||||
<span className={styles.leaderboard__toggleLabel}>关闭</span>
|
||||
{renderToggle(boardEnabled.weekly, (checked) =>
|
||||
setBoardEnabled({ ...boardEnabled, weekly: checked })
|
||||
handleToggleBoard('weekly', checked)
|
||||
)}
|
||||
<span className={styles.leaderboard__toggleLabel}>开启</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.leaderboard__empty}>
|
||||
<div className={styles.leaderboard__emptyIcon}>
|
||||
<Image src="/images/Background.svg" width={64} height={64} alt="空状态" />
|
||||
{boardEnabled.weekly ? (
|
||||
<div className={styles.leaderboard__table}>
|
||||
<div className={styles.leaderboard__tableHeader}>
|
||||
<div className={cn(styles.leaderboard__tableHeaderCell, styles['leaderboard__tableHeaderCell--rank'])}>
|
||||
排名
|
||||
</div>
|
||||
<div className={cn(styles.leaderboard__tableHeaderCell, styles['leaderboard__tableHeaderCell--avatar'])}>
|
||||
头像
|
||||
</div>
|
||||
<div className={cn(styles.leaderboard__tableHeaderCell, styles['leaderboard__tableHeaderCell--nickname'])}>
|
||||
昵称
|
||||
</div>
|
||||
<div className={cn(styles.leaderboard__tableHeaderCell, styles['leaderboard__tableHeaderCell--count'])}>
|
||||
认种数量
|
||||
</div>
|
||||
<div className={cn(styles.leaderboard__tableHeaderCell, styles['leaderboard__tableHeaderCell--team'])}>
|
||||
团队数据
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.leaderboard__tableBody}>
|
||||
{dailyRankings.map(renderRankingRow)}
|
||||
</div>
|
||||
</div>
|
||||
<h4 className={styles.leaderboard__emptyTitle}>榜单未开启</h4>
|
||||
<span className={styles.leaderboard__emptyStatus}>待激活</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.leaderboard__empty}>
|
||||
<div className={styles.leaderboard__emptyIcon}>
|
||||
<Image src="/images/Background.svg" width={64} height={64} alt="空状态" />
|
||||
</div>
|
||||
<h4 className={styles.leaderboard__emptyTitle}>榜单未开启</h4>
|
||||
<span className={styles.leaderboard__emptyStatus}>待激活</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 月榜 */}
|
||||
|
|
@ -235,19 +342,44 @@ export default function LeaderboardPage() {
|
|||
<div className={styles.leaderboard__boardToggle}>
|
||||
<span className={styles.leaderboard__toggleLabel}>关闭</span>
|
||||
{renderToggle(boardEnabled.monthly, (checked) =>
|
||||
setBoardEnabled({ ...boardEnabled, monthly: checked })
|
||||
handleToggleBoard('monthly', checked)
|
||||
)}
|
||||
<span className={styles.leaderboard__toggleLabel}>开启</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.leaderboard__empty}>
|
||||
<div className={styles.leaderboard__emptyIcon}>
|
||||
<Image src="/images/Background1.svg" width={64} height={64} alt="空状态" />
|
||||
{boardEnabled.monthly ? (
|
||||
<div className={styles.leaderboard__table}>
|
||||
<div className={styles.leaderboard__tableHeader}>
|
||||
<div className={cn(styles.leaderboard__tableHeaderCell, styles['leaderboard__tableHeaderCell--rank'])}>
|
||||
排名
|
||||
</div>
|
||||
<div className={cn(styles.leaderboard__tableHeaderCell, styles['leaderboard__tableHeaderCell--avatar'])}>
|
||||
头像
|
||||
</div>
|
||||
<div className={cn(styles.leaderboard__tableHeaderCell, styles['leaderboard__tableHeaderCell--nickname'])}>
|
||||
昵称
|
||||
</div>
|
||||
<div className={cn(styles.leaderboard__tableHeaderCell, styles['leaderboard__tableHeaderCell--count'])}>
|
||||
认种数量
|
||||
</div>
|
||||
<div className={cn(styles.leaderboard__tableHeaderCell, styles['leaderboard__tableHeaderCell--team'])}>
|
||||
团队数据
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.leaderboard__tableBody}>
|
||||
{dailyRankings.map(renderRankingRow)}
|
||||
</div>
|
||||
</div>
|
||||
<h4 className={styles.leaderboard__emptyTitle}>榜单未开启</h4>
|
||||
<span className={styles.leaderboard__emptyStatus}>待激活</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.leaderboard__empty}>
|
||||
<div className={styles.leaderboard__emptyIcon}>
|
||||
<Image src="/images/Background1.svg" width={64} height={64} alt="空状态" />
|
||||
</div>
|
||||
<h4 className={styles.leaderboard__emptyTitle}>榜单未开启</h4>
|
||||
<span className={styles.leaderboard__emptyStatus}>待激活</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -263,7 +395,7 @@ export default function LeaderboardPage() {
|
|||
<div className={styles.leaderboard__settingsItem}>
|
||||
<span className={styles.leaderboard__settingsLabel}>启用虚拟排名</span>
|
||||
{renderToggle(virtualSettings.enabled, (checked) =>
|
||||
updateVirtualSettings({ enabled: checked })
|
||||
setVirtualSettings((prev) => ({ ...prev, enabled: checked }))
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -275,7 +407,10 @@ export default function LeaderboardPage() {
|
|||
className={styles.leaderboard__numberInput}
|
||||
value={virtualSettings.virtualAccountCount}
|
||||
onChange={(e) =>
|
||||
updateVirtualSettings({ virtualAccountCount: parseInt(e.target.value) || 0 })
|
||||
setVirtualSettings((prev) => ({
|
||||
...prev,
|
||||
virtualAccountCount: parseInt(e.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<input
|
||||
|
|
@ -285,7 +420,10 @@ export default function LeaderboardPage() {
|
|||
max={10}
|
||||
value={virtualSettings.virtualAccountCount}
|
||||
onChange={(e) =>
|
||||
updateVirtualSettings({ virtualAccountCount: parseInt(e.target.value) })
|
||||
setVirtualSettings((prev) => ({
|
||||
...prev,
|
||||
virtualAccountCount: parseInt(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -332,10 +470,8 @@ export default function LeaderboardPage() {
|
|||
</div>
|
||||
<select
|
||||
className={styles.leaderboard__select}
|
||||
value={displaySettings.frontendDisplayCount}
|
||||
onChange={(e) =>
|
||||
updateDisplaySettings({ frontendDisplayCount: parseInt(e.target.value) as 10 | 20 | 50 })
|
||||
}
|
||||
value={displayLimit}
|
||||
onChange={(e) => setDisplayLimit(parseInt(e.target.value))}
|
||||
>
|
||||
<option value={10}>前10名</option>
|
||||
<option value={20}>前20名</option>
|
||||
|
|
@ -346,8 +482,12 @@ export default function LeaderboardPage() {
|
|||
|
||||
{/* 保存按钮 */}
|
||||
<div className={styles.leaderboard__settingsFooter}>
|
||||
<button className={styles.leaderboard__saveBtn} onClick={handleSave}>
|
||||
保存设置
|
||||
<button
|
||||
className={styles.leaderboard__saveBtn}
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? '保存中...' : '保存设置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,11 @@ export const API_ENDPOINTS = {
|
|||
MONTHLY: '/v1/leaderboard/monthly',
|
||||
SETTINGS: '/v1/leaderboard/settings',
|
||||
EXPORT: '/v1/leaderboard/export',
|
||||
// 配置管理
|
||||
CONFIG: '/v1/leaderboard/config',
|
||||
CONFIG_SWITCH: '/v1/leaderboard/config/switch',
|
||||
CONFIG_VIRTUAL: '/v1/leaderboard/config/virtual',
|
||||
CONFIG_DISPLAY: '/v1/leaderboard/config/display',
|
||||
},
|
||||
|
||||
// 授权管理 (authorization-service)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* 龙虎榜配置服务
|
||||
* 负责龙虎榜配置的API调用
|
||||
*/
|
||||
|
||||
import apiClient from '@/infrastructure/api/client';
|
||||
import { API_ENDPOINTS } from '@/infrastructure/api/endpoints';
|
||||
|
||||
/** 龙虎榜配置 */
|
||||
export interface LeaderboardConfig {
|
||||
id: string;
|
||||
configKey: string;
|
||||
dailyEnabled: boolean;
|
||||
weeklyEnabled: boolean;
|
||||
monthlyEnabled: boolean;
|
||||
virtualRankingEnabled: boolean;
|
||||
virtualAccountCount: number;
|
||||
displayLimit: number;
|
||||
refreshIntervalMinutes: number;
|
||||
}
|
||||
|
||||
/** 更新榜单开关请求 */
|
||||
export interface UpdateLeaderboardSwitchRequest {
|
||||
type: 'daily' | 'weekly' | 'monthly';
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** 更新虚拟排名设置请求 */
|
||||
export interface UpdateVirtualRankingRequest {
|
||||
enabled: boolean;
|
||||
accountCount: number;
|
||||
}
|
||||
|
||||
/** 更新显示设置请求 */
|
||||
export interface UpdateDisplaySettingsRequest {
|
||||
displayLimit: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 龙虎榜配置服务
|
||||
*/
|
||||
export const leaderboardService = {
|
||||
/**
|
||||
* 获取榜单配置
|
||||
*/
|
||||
async getConfig(): Promise<LeaderboardConfig> {
|
||||
return apiClient.get(API_ENDPOINTS.LEADERBOARD.CONFIG);
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新榜单开关
|
||||
*/
|
||||
async updateSwitch(request: UpdateLeaderboardSwitchRequest): Promise<void> {
|
||||
return apiClient.put(API_ENDPOINTS.LEADERBOARD.CONFIG_SWITCH, request);
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新虚拟排名设置
|
||||
*/
|
||||
async updateVirtualRanking(request: UpdateVirtualRankingRequest): Promise<void> {
|
||||
return apiClient.put(API_ENDPOINTS.LEADERBOARD.CONFIG_VIRTUAL, request);
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新显示设置
|
||||
*/
|
||||
async updateDisplaySettings(request: UpdateDisplaySettingsRequest): Promise<void> {
|
||||
return apiClient.put(API_ENDPOINTS.LEADERBOARD.CONFIG_DISPLAY, request);
|
||||
},
|
||||
};
|
||||
|
||||
export default leaderboardService;
|
||||
|
|
@ -12,6 +12,7 @@ import '../services/planting_service.dart';
|
|||
import '../services/reward_service.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import '../services/system_config_service.dart';
|
||||
import '../services/leaderboard_service.dart';
|
||||
import '../services/contract_signing_service.dart';
|
||||
import '../services/contract_check_service.dart';
|
||||
import '../services/pending_action_service.dart';
|
||||
|
|
@ -109,6 +110,12 @@ final systemConfigServiceProvider = Provider<SystemConfigService>((ref) {
|
|||
return SystemConfigService(apiClient: apiClient);
|
||||
});
|
||||
|
||||
// Leaderboard Service Provider (调用 leaderboard-service)
|
||||
final leaderboardServiceProvider = Provider<LeaderboardService>((ref) {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
return LeaderboardService(apiClient: apiClient);
|
||||
});
|
||||
|
||||
// Contract Signing Service Provider (调用 planting-service)
|
||||
final contractSigningServiceProvider = Provider<ContractSigningService>((ref) {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import '../network/api_client.dart';
|
||||
|
||||
/// 龙虎榜开关状态
|
||||
class LeaderboardStatus {
|
||||
/// 日榜是否开启
|
||||
final bool dailyEnabled;
|
||||
|
||||
/// 周榜是否开启
|
||||
final bool weeklyEnabled;
|
||||
|
||||
/// 月榜是否开启
|
||||
final bool monthlyEnabled;
|
||||
|
||||
LeaderboardStatus({
|
||||
required this.dailyEnabled,
|
||||
required this.weeklyEnabled,
|
||||
required this.monthlyEnabled,
|
||||
});
|
||||
|
||||
factory LeaderboardStatus.fromJson(Map<String, dynamic> json) {
|
||||
return LeaderboardStatus(
|
||||
dailyEnabled: json['dailyEnabled'] ?? false,
|
||||
weeklyEnabled: json['weeklyEnabled'] ?? false,
|
||||
monthlyEnabled: json['monthlyEnabled'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
/// 检查是否有任何榜单开启
|
||||
bool get hasAnyEnabled => dailyEnabled || weeklyEnabled || monthlyEnabled;
|
||||
|
||||
/// 默认状态(全部关闭)
|
||||
factory LeaderboardStatus.allDisabled() {
|
||||
return LeaderboardStatus(
|
||||
dailyEnabled: false,
|
||||
weeklyEnabled: false,
|
||||
monthlyEnabled: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 龙虎榜服务
|
||||
class LeaderboardService {
|
||||
final ApiClient _apiClient;
|
||||
|
||||
/// 缓存的榜单状态
|
||||
LeaderboardStatus? _cachedStatus;
|
||||
|
||||
/// 上次获取状态的时间
|
||||
DateTime? _lastFetchTime;
|
||||
|
||||
/// 缓存有效期(5分钟)
|
||||
static const Duration _cacheExpiration = Duration(minutes: 5);
|
||||
|
||||
LeaderboardService({required ApiClient apiClient}) : _apiClient = apiClient;
|
||||
|
||||
/// 获取龙虎榜开关状态
|
||||
///
|
||||
/// [forceRefresh] 强制刷新,不使用缓存
|
||||
Future<LeaderboardStatus> getStatus({bool forceRefresh = false}) async {
|
||||
// 检查缓存是否有效
|
||||
if (!forceRefresh && _cachedStatus != null && _lastFetchTime != null) {
|
||||
final cacheAge = DateTime.now().difference(_lastFetchTime!);
|
||||
if (cacheAge < _cacheExpiration) {
|
||||
return _cachedStatus!;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _apiClient.get(
|
||||
'/leaderboard-service/api/v1/leaderboard/status',
|
||||
);
|
||||
|
||||
if (response.data != null && response.data['data'] != null) {
|
||||
_cachedStatus = LeaderboardStatus.fromJson(response.data['data']);
|
||||
} else {
|
||||
_cachedStatus = LeaderboardStatus.allDisabled();
|
||||
}
|
||||
_lastFetchTime = DateTime.now();
|
||||
|
||||
debugPrint(
|
||||
'[LeaderboardService] 获取榜单状态成功: daily=${_cachedStatus!.dailyEnabled}, weekly=${_cachedStatus!.weeklyEnabled}, monthly=${_cachedStatus!.monthlyEnabled}');
|
||||
|
||||
return _cachedStatus!;
|
||||
} catch (e) {
|
||||
debugPrint('[LeaderboardService] 获取榜单状态失败: $e');
|
||||
|
||||
// 如果有缓存则返回缓存,否则返回全部关闭
|
||||
return _cachedStatus ?? LeaderboardStatus.allDisabled();
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除缓存
|
||||
void clearCache() {
|
||||
_cachedStatus = null;
|
||||
_lastFetchTime = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
import '../../../../core/services/leaderboard_service.dart';
|
||||
|
||||
/// 排行榜类型枚举
|
||||
enum RankingType { daily, weekly, monthly }
|
||||
|
|
@ -26,6 +28,12 @@ class RankingItem {
|
|||
});
|
||||
}
|
||||
|
||||
/// 榜单状态 Provider
|
||||
final leaderboardStatusProvider = FutureProvider<LeaderboardStatus>((ref) async {
|
||||
final leaderboardService = ref.watch(leaderboardServiceProvider);
|
||||
return leaderboardService.getStatus();
|
||||
});
|
||||
|
||||
/// 龙虎榜页面 - 显示用户排行榜
|
||||
/// 支持日榜、周榜、月榜切换,以及筛选功能
|
||||
class RankingPage extends ConsumerStatefulWidget {
|
||||
|
|
@ -102,8 +110,22 @@ class _RankingPageState extends ConsumerState<RankingPage> {
|
|||
);
|
||||
}
|
||||
|
||||
/// 检查当前选中的榜单是否开启
|
||||
bool _isCurrentBoardEnabled(LeaderboardStatus status) {
|
||||
switch (_selectedRankingType) {
|
||||
case RankingType.daily:
|
||||
return status.dailyEnabled;
|
||||
case RankingType.weekly:
|
||||
return status.weeklyEnabled;
|
||||
case RankingType.monthly:
|
||||
return status.monthlyEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final statusAsync = ref.watch(leaderboardStatusProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
|
|
@ -116,24 +138,112 @@ class _RankingPageState extends ConsumerState<RankingPage> {
|
|||
],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 顶部标题和Tab栏
|
||||
_buildHeader(),
|
||||
// 筛选栏
|
||||
_buildFilterBar(),
|
||||
// 排行榜列表
|
||||
Expanded(
|
||||
child: _buildRankingList(),
|
||||
),
|
||||
],
|
||||
child: statusAsync.when(
|
||||
data: (status) => _buildContent(status),
|
||||
loading: () => _buildLoadingContent(),
|
||||
error: (error, stack) => _buildContent(LeaderboardStatus.allDisabled()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建顶部标题和Tab栏
|
||||
Widget _buildHeader() {
|
||||
/// 构建加载中内容
|
||||
Widget _buildLoadingContent() {
|
||||
return Column(
|
||||
children: [
|
||||
_buildHeaderWithStatus(null),
|
||||
const Expanded(
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xFFD4AF37),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建主内容
|
||||
Widget _buildContent(LeaderboardStatus status) {
|
||||
final isCurrentEnabled = _isCurrentBoardEnabled(status);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 顶部标题和Tab栏
|
||||
_buildHeaderWithStatus(status),
|
||||
// 如果当前榜单开启,显示筛选栏和列表
|
||||
if (isCurrentEnabled) ...[
|
||||
_buildFilterBar(),
|
||||
Expanded(child: _buildRankingList()),
|
||||
] else ...[
|
||||
// 显示待开启状态
|
||||
Expanded(child: _buildDisabledState()),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建待开启状态
|
||||
Widget _buildDisabledState() {
|
||||
String boardName;
|
||||
switch (_selectedRankingType) {
|
||||
case RankingType.daily:
|
||||
boardName = '日榜';
|
||||
break;
|
||||
case RankingType.weekly:
|
||||
boardName = '周榜';
|
||||
break;
|
||||
case RankingType.monthly:
|
||||
boardName = '月榜';
|
||||
break;
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 图标
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x1A8B5A2B),
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.hourglass_empty,
|
||||
size: 40,
|
||||
color: Color(0xFF8B5A2B),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// 标题
|
||||
Text(
|
||||
'$boardName待开启',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 描述
|
||||
const Text(
|
||||
'该榜单暂未开启,请稍后再来',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
color: Color(0xFF8B5A2B),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建顶部标题和Tab栏(带状态)
|
||||
Widget _buildHeaderWithStatus(LeaderboardStatus? status) {
|
||||
return Container(
|
||||
color: const Color(0xCCFFF5E6),
|
||||
child: SafeArea(
|
||||
|
|
@ -159,15 +269,15 @@ class _RankingPageState extends ConsumerState<RankingPage> {
|
|||
),
|
||||
),
|
||||
// Tab栏
|
||||
_buildTabBar(),
|
||||
_buildTabBarWithStatus(status),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建Tab栏
|
||||
Widget _buildTabBar() {
|
||||
/// 构建Tab栏(带状态)
|
||||
Widget _buildTabBarWithStatus(LeaderboardStatus? status) {
|
||||
return Container(
|
||||
height: 45,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
|
|
@ -183,25 +293,31 @@ class _RankingPageState extends ConsumerState<RankingPage> {
|
|||
children: [
|
||||
// 日榜
|
||||
Expanded(
|
||||
child: _buildTabItem(
|
||||
child: _buildTabItemWithStatus(
|
||||
title: '日榜',
|
||||
type: RankingType.daily,
|
||||
isSelected: _selectedRankingType == RankingType.daily,
|
||||
isEnabled: status?.dailyEnabled ?? false,
|
||||
onTap: () => _selectRankingType(RankingType.daily),
|
||||
),
|
||||
),
|
||||
// 周榜
|
||||
Expanded(
|
||||
child: _buildTabItem(
|
||||
child: _buildTabItemWithStatus(
|
||||
title: '周榜',
|
||||
type: RankingType.weekly,
|
||||
isSelected: _selectedRankingType == RankingType.weekly,
|
||||
isEnabled: status?.weeklyEnabled ?? false,
|
||||
onTap: () => _selectRankingType(RankingType.weekly),
|
||||
),
|
||||
),
|
||||
// 月榜
|
||||
Expanded(
|
||||
child: _buildTabItem(
|
||||
child: _buildTabItemWithStatus(
|
||||
title: '月榜',
|
||||
type: RankingType.monthly,
|
||||
isSelected: _selectedRankingType == RankingType.monthly,
|
||||
isEnabled: status?.monthlyEnabled ?? false,
|
||||
onTap: () => _selectRankingType(RankingType.monthly),
|
||||
),
|
||||
),
|
||||
|
|
@ -210,12 +326,21 @@ class _RankingPageState extends ConsumerState<RankingPage> {
|
|||
);
|
||||
}
|
||||
|
||||
/// 构建Tab项
|
||||
Widget _buildTabItem({
|
||||
/// 构建Tab项(带状态)
|
||||
Widget _buildTabItemWithStatus({
|
||||
required String title,
|
||||
required RankingType type,
|
||||
required bool isSelected,
|
||||
required bool isEnabled,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
// 如果榜单未开启,显示灰色且带"待开启"标记
|
||||
final textColor = isSelected
|
||||
? const Color(0xFFD4AF37)
|
||||
: isEnabled
|
||||
? const Color(0xFF8B5A2B)
|
||||
: const Color(0xFFB0A090);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
|
|
@ -229,16 +354,39 @@ class _RankingPageState extends ConsumerState<RankingPage> {
|
|||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.5,
|
||||
letterSpacing: 0.21,
|
||||
color: isSelected ? const Color(0xFFD4AF37) : const Color(0xFF8B5A2B),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.5,
|
||||
letterSpacing: 0.21,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
if (!isEnabled) ...[
|
||||
const SizedBox(width: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x1A8B5A2B),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'待开启',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontFamily: 'Inter',
|
||||
color: Color(0xFF8B5A2B),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
Loading…
Reference in New Issue