功能:新增财富榜

This commit is contained in:
taojiacheng committed 2026-07-03 15:16:41 +08:00
1 parent b4b6cb7b9f
commit e3f31a2a86
11 files changed
+5030 -8

No files matched your search

+1
View File
@@ -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
+7
View File
@@ -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}
+57 -4
View File
@@ -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 ? (
+4
View File
@@ -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,
),
],
);
+68 -2
View File
@@ -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 -1
View File
@@ -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) {
+26
View File
@@ -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');
});
});
+34
View File
@@ -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));
}
+17 -1
View File
@@ -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;