功能:新增财富榜
This commit is contained in:
1 parent
b4b6cb7b9f
commit
e3f31a2a86
11 files changed
+5030
-8
No files matched your search
@@ -0,0 +1 @@
|
||||
CREATE INDEX "cultivators_status_spirit_stones_idx" ON "wanjiedaoyou_cultivators" USING btree ("status","spirit_stones");
|
||||
File diff suppressed because it is too large.
Load diff
@@ -57,6 +57,13 @@
|
||||
"when": 1783050312021,
|
||||
"tag": "0007_cultured_ultragirl",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1783062829480,
|
||||
"tag": "0008_fantastic_ronan",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
import { InkBadge, InkButton, type Tier } from '@app/components/ui';
|
||||
import { cn } from '@shared/lib/cn';
|
||||
import { isPillSpec } from '@shared/lib/consumables';
|
||||
import {
|
||||
formatCompactGameNumber,
|
||||
formatFullGameNumber,
|
||||
} from '@shared/lib/numberFormat';
|
||||
import {
|
||||
CONSUMABLE_TYPE_DISPLAY_MAP,
|
||||
getEquipmentSlotInfo,
|
||||
@@ -16,6 +20,7 @@ import type {
|
||||
BattleRankingItem,
|
||||
ItemRankingEntry,
|
||||
RankingsDisplayItem,
|
||||
WealthRankingEntry,
|
||||
} from '@shared/types/rankings';
|
||||
import { memo } from 'react';
|
||||
|
||||
@@ -38,6 +43,12 @@ interface ItemRankingCardProps {
|
||||
variant?: 'list' | 'podium';
|
||||
}
|
||||
|
||||
interface WealthRankingCardProps {
|
||||
item: WealthRankingEntry;
|
||||
isSelf?: boolean;
|
||||
variant?: 'list' | 'podium';
|
||||
}
|
||||
|
||||
interface LegacyRankingListItemProps {
|
||||
item: RankingsDisplayItem;
|
||||
isSelf: boolean;
|
||||
@@ -301,8 +312,68 @@ function ItemRankingCardComponent({
|
||||
);
|
||||
}
|
||||
|
||||
function WealthRankingCardComponent({
|
||||
item,
|
||||
isSelf,
|
||||
variant = 'list',
|
||||
}: WealthRankingCardProps) {
|
||||
const rankTone = resolveBattleRankTone(item.rank);
|
||||
const origin = item.origin ?? '散修';
|
||||
const realmLabel = [item.realm, item.realm_stage].filter(Boolean).join(' · ');
|
||||
const fullSpiritStones = `${formatFullGameNumber(item.spiritStones)} 灵石`;
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
'group border-ink/25 hover:border-crimson/30 relative min-w-0 overflow-hidden border border-dashed bg-white/30 transition-colors',
|
||||
isSelf && 'border-crimson/30 bg-amber-200/30',
|
||||
variant === 'podium' && 'border-gold/30 bg-gold/15',
|
||||
)}
|
||||
>
|
||||
<div className="grid min-w-0 gap-2 px-3 py-3 lg:grid-cols-[4.75rem_minmax(0,1fr)_8.5rem] lg:items-start lg:gap-4 lg:px-4 lg:py-3">
|
||||
<div className="absolute top-3 right-3 flex items-start lg:static">
|
||||
<RankSeal label={rankTone.label} className={rankTone.className} />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<h3 className="text-ink text-[1.05rem] leading-6 font-semibold wrap-break-word sm:text-lg sm:leading-7">
|
||||
{item.name}
|
||||
</h3>
|
||||
{item.title ? (
|
||||
<span className="text-ink-secondary text-sm leading-6 wrap-break-word">
|
||||
「{item.title}」
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
{realmLabel ? (
|
||||
<MetaChip>
|
||||
<span className="text-crimson font-semibold">{realmLabel}</span>
|
||||
</MetaChip>
|
||||
) : null}
|
||||
<MetaChip>{item.age} 岁</MetaChip>
|
||||
<MetaChip>{origin}</MetaChip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-right lg:mt-0" title={fullSpiritStones}>
|
||||
<div className="text-battle-muted text-xs leading-5">灵石</div>
|
||||
<div className="text-wood text-lg leading-7 font-semibold">
|
||||
{formatCompactGameNumber(item.spiritStones)}
|
||||
</div>
|
||||
<div className="text-battle-muted text-xs leading-5">
|
||||
{fullSpiritStones}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export const BattleRankingCard = memo(BattleRankingCardComponent);
|
||||
export const ItemRankingCard = memo(ItemRankingCardComponent);
|
||||
export const WealthRankingCard = memo(WealthRankingCardComponent);
|
||||
|
||||
function RankingListItemComponent({
|
||||
item,
|
||||
@@ -326,6 +397,10 @@ function RankingListItemComponent({
|
||||
);
|
||||
}
|
||||
|
||||
if ('rankingType' in item && item.rankingType === 'wealth') {
|
||||
return <WealthRankingCard item={item as WealthRankingEntry} isSelf={isSelf} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BattleRankingCard
|
||||
item={item as BattleRankingItem}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { CultivatorInspectionModal } from '@app/components/feature/cultivator-in
|
||||
import {
|
||||
BattleRankingCard,
|
||||
ItemRankingCard,
|
||||
WealthRankingCard,
|
||||
} from '@app/components/feature/ranking/RankingListItem';
|
||||
import {
|
||||
GameSceneAsideSection,
|
||||
@@ -35,6 +36,7 @@ import type {
|
||||
BattleRankingItem,
|
||||
ItemRankingEntry,
|
||||
RankingsDisplayItem,
|
||||
WealthRankingEntry,
|
||||
} from '@shared/types/rankings';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toRankingDetailItem } from './rankingDetailItem';
|
||||
@@ -47,7 +49,13 @@ type MyRankInfo = {
|
||||
|
||||
type LoadingState = 'idle' | 'loading' | 'loaded';
|
||||
|
||||
type RankingTab = 'battle' | 'artifact' | 'technique' | 'skill' | 'elixir';
|
||||
type RankingTab =
|
||||
| 'battle'
|
||||
| 'artifact'
|
||||
| 'technique'
|
||||
| 'skill'
|
||||
| 'elixir'
|
||||
| 'wealth';
|
||||
const REPUTATION_INFO = getGameConceptInfo('reputation');
|
||||
const REPUTATION_LABEL = `${REPUTATION_INFO.icon} ${REPUTATION_INFO.label}`;
|
||||
|
||||
@@ -196,6 +204,8 @@ function RankingEmptyState({
|
||||
<InkNotice>
|
||||
{activeTab === 'battle'
|
||||
? `${activeRealm}天骄榜暂无记录。越境榜单不可直接上榜,需等待本境修士留名后方可切磋。`
|
||||
: activeTab === 'wealth'
|
||||
? '财富榜暂无记录,静待灵石入库。'
|
||||
: '此榜单暂无记录,静待宝物出世。'}
|
||||
</InkNotice>
|
||||
);
|
||||
@@ -227,7 +237,9 @@ export default function RankingsPage() {
|
||||
setError('');
|
||||
try {
|
||||
let url = `/api/rankings?realm=${encodeURIComponent(realm)}`;
|
||||
if (tab !== 'battle') {
|
||||
if (tab === 'wealth') {
|
||||
url = '/api/rankings/wealth';
|
||||
} else if (tab !== 'battle') {
|
||||
url = `/api/rankings/items?type=${tab}`;
|
||||
}
|
||||
|
||||
@@ -279,7 +291,9 @@ export default function RankingsPage() {
|
||||
const loadInitialRankings = async () => {
|
||||
try {
|
||||
let url = `/api/rankings?realm=${encodeURIComponent(activeRealm)}`;
|
||||
if (activeTab !== 'battle') {
|
||||
if (activeTab === 'wealth') {
|
||||
url = '/api/rankings/wealth';
|
||||
} else if (activeTab !== 'battle') {
|
||||
url = `/api/rankings/items?type=${activeTab}`;
|
||||
}
|
||||
|
||||
@@ -510,6 +524,7 @@ export default function RankingsPage() {
|
||||
const ownRealm = cultivator?.realm ? resolveRealm(cultivator.realm) : undefined;
|
||||
const rankingTabs = [
|
||||
{ label: '天骄榜', value: 'battle' },
|
||||
{ label: '财富榜', value: 'wealth' },
|
||||
{ label: '法宝榜', value: 'artifact' },
|
||||
{ label: '功法榜', value: 'technique' },
|
||||
{ label: '神通榜', value: 'skill' },
|
||||
@@ -544,6 +559,8 @@ export default function RankingsPage() {
|
||||
description={
|
||||
activeTab === 'battle'
|
||||
? '择敌、查探、挑战,一切夺位都从榜前决断。'
|
||||
: activeTab === 'wealth'
|
||||
? '灵石聚散自有痕迹,榜上只看当前身家。'
|
||||
: '诸般名器留影于榜,观其品阶、评分与持有者。'
|
||||
}
|
||||
headerMeta={
|
||||
@@ -577,7 +594,12 @@ export default function RankingsPage() {
|
||||
{REPUTATION_LABEL}:{cultivator?.reputation ?? 0}
|
||||
</p>
|
||||
<p>
|
||||
当前收录:{rankings.length} {activeTab === 'battle' ? '条' : '件'}
|
||||
当前收录:{rankings.length}{' '}
|
||||
{activeTab === 'battle'
|
||||
? '条'
|
||||
: activeTab === 'wealth'
|
||||
? '人'
|
||||
: '件'}
|
||||
</p>
|
||||
{activeTab === 'battle' ? (
|
||||
<p>
|
||||
@@ -731,6 +753,37 @@ export default function RankingsPage() {
|
||||
</GameSceneSection>
|
||||
) : null}
|
||||
</>
|
||||
) : activeTab === 'wealth' ? (
|
||||
<>
|
||||
{podiumEntries.length > 0 ? (
|
||||
<GameSceneSection title="富甲三席">
|
||||
<div className="grid gap-3">
|
||||
{(podiumEntries as WealthRankingEntry[]).map((item) => (
|
||||
<WealthRankingCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
isSelf={item.id === cultivator.id}
|
||||
variant="podium"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</GameSceneSection>
|
||||
) : null}
|
||||
|
||||
{listEntries.length > 0 ? (
|
||||
<GameSceneSection title="财富名录">
|
||||
<div className="grid gap-3">
|
||||
{(listEntries as WealthRankingEntry[]).map((item) => (
|
||||
<WealthRankingCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
isSelf={item.id === cultivator.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</GameSceneSection>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{podiumEntries.length > 0 ? (
|
||||
|
||||
@@ -88,6 +88,10 @@ export const cultivators = pgTable(
|
||||
table.updatedAt,
|
||||
),
|
||||
index('cultivators_status_created_idx').on(table.status, table.createdAt),
|
||||
index('cultivators_status_spirit_stones_idx').on(
|
||||
table.status,
|
||||
table.spirit_stones,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ const {
|
||||
getRemainingChallengesMock,
|
||||
isLockedMock,
|
||||
createMessageMock,
|
||||
getExecutorMock,
|
||||
} = vi.hoisted(() => ({
|
||||
checkDailyChallengesMock: vi.fn(),
|
||||
isRankingEmptyMock: vi.fn(),
|
||||
@@ -60,6 +61,7 @@ const {
|
||||
getRemainingChallengesMock: vi.fn(),
|
||||
isLockedMock: vi.fn(() => false),
|
||||
createMessageMock: vi.fn(),
|
||||
getExecutorMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@server/lib/hono/middleware', () => ({
|
||||
@@ -90,14 +92,36 @@ vi.mock('@shared/engine/battle-v5/adapters/CultivatorDisplayAdapter', () => ({
|
||||
getCultivatorDisplayAttributes: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
|
||||
desc: vi.fn((column: unknown) => ({ type: 'desc', column })),
|
||||
eq: vi.fn((left: unknown, right: unknown) => ({ type: 'eq', left, right })),
|
||||
inArray: vi.fn((column: unknown, values: unknown[]) => ({
|
||||
type: 'inArray',
|
||||
column,
|
||||
values,
|
||||
})),
|
||||
isNotNull: vi.fn((column: unknown) => ({ type: 'isNotNull', column })),
|
||||
}));
|
||||
|
||||
vi.mock('@server/lib/drizzle/db', () => ({
|
||||
getExecutor: vi.fn(),
|
||||
getExecutor: getExecutorMock,
|
||||
}));
|
||||
|
||||
vi.mock('@server/lib/drizzle/schema', () => ({
|
||||
consumables: {},
|
||||
creationProducts: {},
|
||||
cultivators: {},
|
||||
cultivators: {
|
||||
id: 'cultivators.id',
|
||||
name: 'cultivators.name',
|
||||
title: 'cultivators.title',
|
||||
realm: 'cultivators.realm',
|
||||
realm_stage: 'cultivators.realm_stage',
|
||||
age: 'cultivators.age',
|
||||
origin: 'cultivators.origin',
|
||||
status: 'cultivators.status',
|
||||
spirit_stones: 'cultivators.spirit_stones',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@server/lib/redis/rankings', () => ({
|
||||
@@ -198,6 +222,7 @@ describe('rankings router', () => {
|
||||
addToRankingMock.mockResolvedValue(undefined);
|
||||
getRankingListMock.mockResolvedValue([]);
|
||||
createMessageMock.mockResolvedValue({ id: 'chat-1' });
|
||||
getExecutorMock.mockReset();
|
||||
});
|
||||
|
||||
it('loads battle ranking and my rank from the requested realm bucket', async () => {
|
||||
@@ -229,6 +254,47 @@ describe('rankings router', () => {
|
||||
expect(getCultivatorRankMock).toHaveBeenCalledWith('金丹', 'cultivator-1');
|
||||
});
|
||||
|
||||
it('loads wealth ranking ordered by spirit stones', async () => {
|
||||
const query = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'rich-1',
|
||||
name: '铁算盘',
|
||||
title: '富甲一方',
|
||||
realm: '金丹',
|
||||
realmStage: '后期',
|
||||
age: 88,
|
||||
origin: '商会',
|
||||
spiritStones: 120000,
|
||||
},
|
||||
]),
|
||||
};
|
||||
getExecutorMock.mockReturnValue(query);
|
||||
|
||||
const response = await createApp().request('/api/rankings/wealth');
|
||||
const json = (await response.json()) as {
|
||||
success: boolean;
|
||||
data: Array<{
|
||||
rank: number;
|
||||
rankingType: string;
|
||||
spiritStones: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.success).toBe(true);
|
||||
expect(query.limit).toHaveBeenCalledWith(100);
|
||||
expect(json.data[0]).toMatchObject({
|
||||
rank: 1,
|
||||
rankingType: 'wealth',
|
||||
spiritStones: 120000,
|
||||
});
|
||||
});
|
||||
|
||||
it('records the daily ranking task only after a challenge battle completes', async () => {
|
||||
const response = await createApp().request(
|
||||
'/api/rankings/challenge-battle/v5',
|
||||
|
||||
@@ -46,7 +46,10 @@ import {
|
||||
type ElementType,
|
||||
type RealmType,
|
||||
} from '@shared/types/constants';
|
||||
import type { ItemRankingEntry } from '@shared/types/rankings';
|
||||
import type {
|
||||
ItemRankingEntry,
|
||||
WealthRankingEntry,
|
||||
} from '@shared/types/rankings';
|
||||
import { and, desc, eq, inArray, isNotNull } from 'drizzle-orm';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
@@ -361,6 +364,48 @@ publicRouter.get('/items', async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
publicRouter.get('/wealth', async (c) => {
|
||||
try {
|
||||
const limit = 100;
|
||||
const rows = await getExecutor()
|
||||
.select({
|
||||
id: cultivators.id,
|
||||
name: cultivators.name,
|
||||
title: cultivators.title,
|
||||
realm: cultivators.realm,
|
||||
realmStage: cultivators.realm_stage,
|
||||
age: cultivators.age,
|
||||
origin: cultivators.origin,
|
||||
spiritStones: cultivators.spirit_stones,
|
||||
})
|
||||
.from(cultivators)
|
||||
.where(eq(cultivators.status, 'active'))
|
||||
.orderBy(desc(cultivators.spirit_stones))
|
||||
.limit(limit);
|
||||
|
||||
const items: WealthRankingEntry[] = rows.map((row, index) => ({
|
||||
id: row.id,
|
||||
rank: index + 1,
|
||||
rankingType: 'wealth',
|
||||
name: row.name,
|
||||
title: row.title,
|
||||
realm: row.realm,
|
||||
realm_stage: row.realmStage,
|
||||
age: row.age,
|
||||
origin: row.origin,
|
||||
spiritStones: row.spiritStones,
|
||||
}));
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
data: items,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取财富榜失败:', error);
|
||||
return c.json({ success: false, error: '获取财富榜失败' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
challengeRouter.get('/my-rank', requireActiveCultivator(), async (c) => {
|
||||
const cultivator = c.get('cultivator');
|
||||
if (!cultivator) {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatCompactGameNumber, formatFullGameNumber } from './numberFormat';
|
||||
|
||||
describe('game number formatting', () => {
|
||||
it('keeps small numbers exact with grouping', () => {
|
||||
expect(formatCompactGameNumber(0)).toBe('0');
|
||||
expect(formatCompactGameNumber(1234)).toBe('1,234');
|
||||
expect(formatCompactGameNumber(9999)).toBe('9,999');
|
||||
});
|
||||
|
||||
it('uses Chinese compact units for large numbers', () => {
|
||||
expect(formatCompactGameNumber(12_345)).toBe('1.23万');
|
||||
expect(formatCompactGameNumber(123_456)).toBe('12.3万');
|
||||
expect(formatCompactGameNumber(1_234_567)).toBe('123万');
|
||||
expect(formatCompactGameNumber(123_456_789)).toBe('1.23亿');
|
||||
});
|
||||
|
||||
it('formats negative and non-finite numbers predictably', () => {
|
||||
expect(formatCompactGameNumber(-12_345)).toBe('-1.23万');
|
||||
expect(formatCompactGameNumber(Number.NaN)).toBe('0');
|
||||
});
|
||||
|
||||
it('keeps full numbers grouped for exact values', () => {
|
||||
expect(formatFullGameNumber(123_456_789)).toBe('123,456,789');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
const zhNumberFormatter = new Intl.NumberFormat('zh-CN');
|
||||
|
||||
function trimTrailingZeros(value: string): string {
|
||||
return value.replace(/\.?0+$/, '');
|
||||
}
|
||||
|
||||
function formatScaledNumber(value: number): string {
|
||||
if (value >= 100) return trimTrailingZeros(value.toFixed(0));
|
||||
if (value >= 10) return trimTrailingZeros(value.toFixed(1));
|
||||
return trimTrailingZeros(value.toFixed(2));
|
||||
}
|
||||
|
||||
export function formatCompactGameNumber(value: number): string {
|
||||
if (!Number.isFinite(value)) return '0';
|
||||
|
||||
const normalized = Math.trunc(value);
|
||||
const abs = Math.abs(normalized);
|
||||
const sign = normalized < 0 ? '-' : '';
|
||||
|
||||
if (abs < 10_000) {
|
||||
return zhNumberFormatter.format(normalized);
|
||||
}
|
||||
|
||||
if (abs >= 100_000_000) {
|
||||
return `${sign}${formatScaledNumber(abs / 100_000_000)}亿`;
|
||||
}
|
||||
|
||||
return `${sign}${formatScaledNumber(abs / 10_000)}万`;
|
||||
}
|
||||
|
||||
export function formatFullGameNumber(value: number): string {
|
||||
if (!Number.isFinite(value)) return '0';
|
||||
return zhNumberFormatter.format(Math.trunc(value));
|
||||
}
|
||||
@@ -32,4 +32,20 @@ export interface ItemRankingEntry {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export type RankingsDisplayItem = BattleRankingItem | ItemRankingEntry;
|
||||
export interface WealthRankingEntry {
|
||||
id: string;
|
||||
rank: number;
|
||||
rankingType: 'wealth';
|
||||
name: string;
|
||||
title?: string | null;
|
||||
realm: string;
|
||||
realm_stage: string;
|
||||
age: number;
|
||||
origin?: string | null;
|
||||
spiritStones: number;
|
||||
}
|
||||
|
||||
export type RankingsDisplayItem =
|
||||
| BattleRankingItem
|
||||
| ItemRankingEntry
|
||||
| WealthRankingEntry;
|
||||
Reference in new issue
Block a user