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 { IsBoolean, IsInt, IsOptional, Min, Max } from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公开榜单状态响应 DTO(供移动端使用)
|
||||||
|
*/
|
||||||
|
export class LeaderboardStatusResponseDto {
|
||||||
|
@ApiProperty({ description: '日榜开关' })
|
||||||
|
dailyEnabled: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '周榜开关' })
|
||||||
|
weeklyEnabled: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '月榜开关' })
|
||||||
|
monthlyEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 榜单配置响应 DTO
|
* 榜单配置响应 DTO
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { JwtModule } from '@nestjs/jwt';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { HealthController } from '../api/controllers/health.controller';
|
import { HealthController } from '../api/controllers/health.controller';
|
||||||
import { LeaderboardController } from '../api/controllers/leaderboard.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 { LeaderboardConfigController } from '../api/controllers/leaderboard-config.controller';
|
||||||
import { VirtualAccountController } from '../api/controllers/virtual-account.controller';
|
import { VirtualAccountController } from '../api/controllers/virtual-account.controller';
|
||||||
import { JwtStrategy } from '../api/strategies/jwt.strategy';
|
import { JwtStrategy } from '../api/strategies/jwt.strategy';
|
||||||
|
|
@ -31,6 +32,7 @@ import { InfrastructureModule } from './infrastructure.module';
|
||||||
controllers: [
|
controllers: [
|
||||||
HealthController,
|
HealthController,
|
||||||
LeaderboardController,
|
LeaderboardController,
|
||||||
|
LeaderboardPublicController,
|
||||||
LeaderboardConfigController,
|
LeaderboardConfigController,
|
||||||
VirtualAccountController,
|
VirtualAccountController,
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { toast } from '@/components/common';
|
import { toast } from '@/components/common';
|
||||||
import { PageContainer } from '@/components/layout';
|
import { PageContainer } from '@/components/layout';
|
||||||
import { cn } from '@/utils/helpers';
|
import { cn } from '@/utils/helpers';
|
||||||
import { useLeaderboardStore } from '@/store/zustand/useLeaderboardStore';
|
import { leaderboardService, LeaderboardConfig } from '@/services/leaderboardService';
|
||||||
import styles from './leaderboard.module.scss';
|
import styles from './leaderboard.module.scss';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -59,31 +59,103 @@ const getRankStyle = (rank: number) => {
|
||||||
* 基于 UIPro Figma 设计实现
|
* 基于 UIPro Figma 设计实现
|
||||||
*/
|
*/
|
||||||
export default function LeaderboardPage() {
|
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({
|
const [boardEnabled, setBoardEnabled] = useState({
|
||||||
daily: true,
|
daily: false,
|
||||||
weekly: false,
|
weekly: false,
|
||||||
monthly: false,
|
monthly: false,
|
||||||
});
|
});
|
||||||
|
const [virtualSettings, setVirtualSettings] = useState({
|
||||||
|
enabled: false,
|
||||||
|
virtualAccountCount: 0,
|
||||||
|
});
|
||||||
|
const [displayLimit, setDisplayLimit] = useState(20);
|
||||||
const [showRules, setShowRules] = useState(false);
|
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 = () => {
|
const handleExport = () => {
|
||||||
toast.success('导出功能开发中');
|
toast.success('导出功能开发中');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 保存设置
|
// 保存虚拟排名和显示设置
|
||||||
const handleSave = () => {
|
const handleSave = async () => {
|
||||||
toast.success('设置已保存');
|
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
|
<div
|
||||||
className={cn(styles.leaderboard__toggle, checked ? styles['leaderboard__toggle--on'] : styles['leaderboard__toggle--off'])}
|
className={cn(
|
||||||
onClick={() => onChange(!checked)}
|
styles.leaderboard__toggle,
|
||||||
|
checked ? styles['leaderboard__toggle--on'] : styles['leaderboard__toggle--off'],
|
||||||
|
disabled && styles['leaderboard__toggle--disabled']
|
||||||
|
)}
|
||||||
|
onClick={() => !disabled && onChange(!checked)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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 (
|
return (
|
||||||
<PageContainer title="龙虎榜管理">
|
<PageContainer title="龙虎榜管理">
|
||||||
<div className={styles.leaderboard}>
|
<div className={styles.leaderboard}>
|
||||||
|
|
@ -160,7 +242,7 @@ export default function LeaderboardPage() {
|
||||||
<div className={styles.leaderboard__boardToggle}>
|
<div className={styles.leaderboard__boardToggle}>
|
||||||
<span className={styles.leaderboard__toggleLabel}>关闭</span>
|
<span className={styles.leaderboard__toggleLabel}>关闭</span>
|
||||||
{renderToggle(boardEnabled.daily, (checked) =>
|
{renderToggle(boardEnabled.daily, (checked) =>
|
||||||
setBoardEnabled({ ...boardEnabled, daily: checked })
|
handleToggleBoard('daily', checked)
|
||||||
)}
|
)}
|
||||||
<span className={styles.leaderboard__toggleLabel}>开启</span>
|
<span className={styles.leaderboard__toggleLabel}>开启</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -210,19 +292,44 @@ export default function LeaderboardPage() {
|
||||||
<div className={styles.leaderboard__boardToggle}>
|
<div className={styles.leaderboard__boardToggle}>
|
||||||
<span className={styles.leaderboard__toggleLabel}>关闭</span>
|
<span className={styles.leaderboard__toggleLabel}>关闭</span>
|
||||||
{renderToggle(boardEnabled.weekly, (checked) =>
|
{renderToggle(boardEnabled.weekly, (checked) =>
|
||||||
setBoardEnabled({ ...boardEnabled, weekly: checked })
|
handleToggleBoard('weekly', checked)
|
||||||
)}
|
)}
|
||||||
<span className={styles.leaderboard__toggleLabel}>开启</span>
|
<span className={styles.leaderboard__toggleLabel}>开启</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.leaderboard__empty}>
|
{boardEnabled.weekly ? (
|
||||||
<div className={styles.leaderboard__emptyIcon}>
|
<div className={styles.leaderboard__table}>
|
||||||
<Image src="/images/Background.svg" width={64} height={64} alt="空状态" />
|
<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>
|
</div>
|
||||||
<h4 className={styles.leaderboard__emptyTitle}>榜单未开启</h4>
|
) : (
|
||||||
<span className={styles.leaderboard__emptyStatus}>待激活</span>
|
<div className={styles.leaderboard__empty}>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* 月榜 */}
|
{/* 月榜 */}
|
||||||
|
|
@ -235,19 +342,44 @@ export default function LeaderboardPage() {
|
||||||
<div className={styles.leaderboard__boardToggle}>
|
<div className={styles.leaderboard__boardToggle}>
|
||||||
<span className={styles.leaderboard__toggleLabel}>关闭</span>
|
<span className={styles.leaderboard__toggleLabel}>关闭</span>
|
||||||
{renderToggle(boardEnabled.monthly, (checked) =>
|
{renderToggle(boardEnabled.monthly, (checked) =>
|
||||||
setBoardEnabled({ ...boardEnabled, monthly: checked })
|
handleToggleBoard('monthly', checked)
|
||||||
)}
|
)}
|
||||||
<span className={styles.leaderboard__toggleLabel}>开启</span>
|
<span className={styles.leaderboard__toggleLabel}>开启</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.leaderboard__empty}>
|
{boardEnabled.monthly ? (
|
||||||
<div className={styles.leaderboard__emptyIcon}>
|
<div className={styles.leaderboard__table}>
|
||||||
<Image src="/images/Background1.svg" width={64} height={64} alt="空状态" />
|
<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>
|
</div>
|
||||||
<h4 className={styles.leaderboard__emptyTitle}>榜单未开启</h4>
|
) : (
|
||||||
<span className={styles.leaderboard__emptyStatus}>待激活</span>
|
<div className={styles.leaderboard__empty}>
|
||||||
</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -263,7 +395,7 @@ export default function LeaderboardPage() {
|
||||||
<div className={styles.leaderboard__settingsItem}>
|
<div className={styles.leaderboard__settingsItem}>
|
||||||
<span className={styles.leaderboard__settingsLabel}>启用虚拟排名</span>
|
<span className={styles.leaderboard__settingsLabel}>启用虚拟排名</span>
|
||||||
{renderToggle(virtualSettings.enabled, (checked) =>
|
{renderToggle(virtualSettings.enabled, (checked) =>
|
||||||
updateVirtualSettings({ enabled: checked })
|
setVirtualSettings((prev) => ({ ...prev, enabled: checked }))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -275,7 +407,10 @@ export default function LeaderboardPage() {
|
||||||
className={styles.leaderboard__numberInput}
|
className={styles.leaderboard__numberInput}
|
||||||
value={virtualSettings.virtualAccountCount}
|
value={virtualSettings.virtualAccountCount}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateVirtualSettings({ virtualAccountCount: parseInt(e.target.value) || 0 })
|
setVirtualSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
virtualAccountCount: parseInt(e.target.value) || 0,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
|
|
@ -285,7 +420,10 @@ export default function LeaderboardPage() {
|
||||||
max={10}
|
max={10}
|
||||||
value={virtualSettings.virtualAccountCount}
|
value={virtualSettings.virtualAccountCount}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateVirtualSettings({ virtualAccountCount: parseInt(e.target.value) })
|
setVirtualSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
virtualAccountCount: parseInt(e.target.value),
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -332,10 +470,8 @@ export default function LeaderboardPage() {
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
className={styles.leaderboard__select}
|
className={styles.leaderboard__select}
|
||||||
value={displaySettings.frontendDisplayCount}
|
value={displayLimit}
|
||||||
onChange={(e) =>
|
onChange={(e) => setDisplayLimit(parseInt(e.target.value))}
|
||||||
updateDisplaySettings({ frontendDisplayCount: parseInt(e.target.value) as 10 | 20 | 50 })
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<option value={10}>前10名</option>
|
<option value={10}>前10名</option>
|
||||||
<option value={20}>前20名</option>
|
<option value={20}>前20名</option>
|
||||||
|
|
@ -346,8 +482,12 @@ export default function LeaderboardPage() {
|
||||||
|
|
||||||
{/* 保存按钮 */}
|
{/* 保存按钮 */}
|
||||||
<div className={styles.leaderboard__settingsFooter}>
|
<div className={styles.leaderboard__settingsFooter}>
|
||||||
<button className={styles.leaderboard__saveBtn} onClick={handleSave}>
|
<button
|
||||||
保存设置
|
className={styles.leaderboard__saveBtn}
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? '保存中...' : '保存设置'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,11 @@ export const API_ENDPOINTS = {
|
||||||
MONTHLY: '/v1/leaderboard/monthly',
|
MONTHLY: '/v1/leaderboard/monthly',
|
||||||
SETTINGS: '/v1/leaderboard/settings',
|
SETTINGS: '/v1/leaderboard/settings',
|
||||||
EXPORT: '/v1/leaderboard/export',
|
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)
|
// 授权管理 (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/reward_service.dart';
|
||||||
import '../services/notification_service.dart';
|
import '../services/notification_service.dart';
|
||||||
import '../services/system_config_service.dart';
|
import '../services/system_config_service.dart';
|
||||||
|
import '../services/leaderboard_service.dart';
|
||||||
import '../services/contract_signing_service.dart';
|
import '../services/contract_signing_service.dart';
|
||||||
import '../services/contract_check_service.dart';
|
import '../services/contract_check_service.dart';
|
||||||
import '../services/pending_action_service.dart';
|
import '../services/pending_action_service.dart';
|
||||||
|
|
@ -109,6 +110,12 @@ final systemConfigServiceProvider = Provider<SystemConfigService>((ref) {
|
||||||
return SystemConfigService(apiClient: apiClient);
|
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)
|
// Contract Signing Service Provider (调用 planting-service)
|
||||||
final contractSigningServiceProvider = Provider<ContractSigningService>((ref) {
|
final contractSigningServiceProvider = Provider<ContractSigningService>((ref) {
|
||||||
final apiClient = ref.watch(apiClientProvider);
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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 }
|
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 {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final statusAsync = ref.watch(leaderboardStatusProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Container(
|
body: Container(
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
|
|
@ -116,24 +138,112 @@ class _RankingPageState extends ConsumerState<RankingPage> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: statusAsync.when(
|
||||||
children: [
|
data: (status) => _buildContent(status),
|
||||||
// 顶部标题和Tab栏
|
loading: () => _buildLoadingContent(),
|
||||||
_buildHeader(),
|
error: (error, stack) => _buildContent(LeaderboardStatus.allDisabled()),
|
||||||
// 筛选栏
|
|
||||||
_buildFilterBar(),
|
|
||||||
// 排行榜列表
|
|
||||||
Expanded(
|
|
||||||
child: _buildRankingList(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 构建顶部标题和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(
|
return Container(
|
||||||
color: const Color(0xCCFFF5E6),
|
color: const Color(0xCCFFF5E6),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
|
|
@ -159,15 +269,15 @@ class _RankingPageState extends ConsumerState<RankingPage> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Tab栏
|
// Tab栏
|
||||||
_buildTabBar(),
|
_buildTabBarWithStatus(status),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 构建Tab栏
|
/// 构建Tab栏(带状态)
|
||||||
Widget _buildTabBar() {
|
Widget _buildTabBarWithStatus(LeaderboardStatus? status) {
|
||||||
return Container(
|
return Container(
|
||||||
height: 45,
|
height: 45,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
|
@ -183,25 +293,31 @@ class _RankingPageState extends ConsumerState<RankingPage> {
|
||||||
children: [
|
children: [
|
||||||
// 日榜
|
// 日榜
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildTabItem(
|
child: _buildTabItemWithStatus(
|
||||||
title: '日榜',
|
title: '日榜',
|
||||||
|
type: RankingType.daily,
|
||||||
isSelected: _selectedRankingType == RankingType.daily,
|
isSelected: _selectedRankingType == RankingType.daily,
|
||||||
|
isEnabled: status?.dailyEnabled ?? false,
|
||||||
onTap: () => _selectRankingType(RankingType.daily),
|
onTap: () => _selectRankingType(RankingType.daily),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 周榜
|
// 周榜
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildTabItem(
|
child: _buildTabItemWithStatus(
|
||||||
title: '周榜',
|
title: '周榜',
|
||||||
|
type: RankingType.weekly,
|
||||||
isSelected: _selectedRankingType == RankingType.weekly,
|
isSelected: _selectedRankingType == RankingType.weekly,
|
||||||
|
isEnabled: status?.weeklyEnabled ?? false,
|
||||||
onTap: () => _selectRankingType(RankingType.weekly),
|
onTap: () => _selectRankingType(RankingType.weekly),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 月榜
|
// 月榜
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildTabItem(
|
child: _buildTabItemWithStatus(
|
||||||
title: '月榜',
|
title: '月榜',
|
||||||
|
type: RankingType.monthly,
|
||||||
isSelected: _selectedRankingType == RankingType.monthly,
|
isSelected: _selectedRankingType == RankingType.monthly,
|
||||||
|
isEnabled: status?.monthlyEnabled ?? false,
|
||||||
onTap: () => _selectRankingType(RankingType.monthly),
|
onTap: () => _selectRankingType(RankingType.monthly),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -210,12 +326,21 @@ class _RankingPageState extends ConsumerState<RankingPage> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 构建Tab项
|
/// 构建Tab项(带状态)
|
||||||
Widget _buildTabItem({
|
Widget _buildTabItemWithStatus({
|
||||||
required String title,
|
required String title,
|
||||||
|
required RankingType type,
|
||||||
required bool isSelected,
|
required bool isSelected,
|
||||||
|
required bool isEnabled,
|
||||||
required VoidCallback onTap,
|
required VoidCallback onTap,
|
||||||
}) {
|
}) {
|
||||||
|
// 如果榜单未开启,显示灰色且带"待开启"标记
|
||||||
|
final textColor = isSelected
|
||||||
|
? const Color(0xFFD4AF37)
|
||||||
|
: isEnabled
|
||||||
|
? const Color(0xFF8B5A2B)
|
||||||
|
: const Color(0xFFB0A090);
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
|
|
@ -229,16 +354,39 @@ class _RankingPageState extends ConsumerState<RankingPage> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Row(
|
||||||
title,
|
mainAxisSize: MainAxisSize.min,
|
||||||
style: TextStyle(
|
children: [
|
||||||
fontSize: 14,
|
Text(
|
||||||
fontFamily: 'Inter',
|
title,
|
||||||
fontWeight: FontWeight.w700,
|
style: TextStyle(
|
||||||
height: 1.5,
|
fontSize: 14,
|
||||||
letterSpacing: 0.21,
|
fontFamily: 'Inter',
|
||||||
color: isSelected ? const Color(0xFFD4AF37) : const Color(0xFF8B5A2B),
|
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