feat: 添加挑战详情页面预制体,优化分享功能与数据展示

This commit is contained in:
richarjiang
2026-05-13 09:04:59 +08:00
parent 394b8d2faf
commit dcbd32b0cd
8 changed files with 464 additions and 13 deletions

View File

@@ -40,7 +40,7 @@ Git 历史采用 Conventional Commits且摘要多为中文例如 `feat:
<claude-mem-context>
# Memory Context
# $CMEM mp-xieyingeng 2026-05-13 8:12am GMT+8
# $CMEM mp-xieyingeng 2026-05-13 9:03am GMT+8
Legend: 🎯session 🔴bugfix 🟣feature 🔄refactor ✅change 🔵discovery ⚖decision
Format: ID TIME TYPE TITLE

View File

@@ -503,6 +503,10 @@
"__uuid__": "1b94d42b-a4db-4c0a-8281-4275786810af",
"__expectedType__": "cc.Prefab"
},
"pagePKDetailPrefab": {
"__uuid__": "335e525c-17ae-4608-8fe0-23b3fc8a5608",
"__expectedType__": "cc.Prefab"
},
"pagePKEndPrefab": {
"__uuid__": "4fc485cf-8c22-47f2-80d0-ae366a380cb6",
"__expectedType__": "cc.Prefab"

View File

@@ -24,6 +24,9 @@ export class main extends Component {
@property({ type: Prefab, tooltip: '挑战数据页面预制体' })
pagePKDataPrefab: Prefab | null = null;
@property({ type: Prefab, tooltip: '挑战详情页面预制体' })
pagePKDetailPrefab: Prefab | null = null;
@property({ type: Prefab, tooltip: '挑战结算页面预制体' })
pagePKEndPrefab: Prefab | null = null;
@@ -86,6 +89,14 @@ export class main extends Component {
});
}
if (this.pagePKDetailPrefab) {
ViewManager.instance.register('PagePKDetail', {
prefab: this.pagePKDetailPrefab,
cache: true,
zIndex: 4
});
}
if (this.pagePKEndPrefab) {
ViewManager.instance.register('PagePKEnd', {
prefab: this.pagePKEndPrefab,

View File

@@ -131,6 +131,13 @@ export class PagePKData extends BaseView {
this._setLabel(this._findLabelIn(item, 'RankNumberLabel'), firstParticipant ? `${rankNumber}` : '暂无排名');
this._loadAvatar(firstParticipant?.avatarUrl ?? '', this._findAvatarSprite(item), version);
const viewButton = this._findChild(item, 'ViewButton');
if (viewButton) {
const handler = () => this._openShareDetail(share);
viewButton.on(Button.EventType.CLICK, handler, this);
this._createdButtonBindings.push({ node: viewButton, handler });
}
const shareButton = this._findChild(item, 'ShareButton');
if (shareButton) {
const handler = () => ShareManager.instance.triggerWxShare(share.title, share.shareCode);
@@ -139,6 +146,24 @@ export class PagePKData extends BaseView {
}
}
private _openShareDetail(share: CreatedShareItem): void {
if (!share.shareCode) {
ToastManager.instance.show('挑战数据异常,请稍后重试');
return;
}
ViewManager.instance.open('PagePKDetail', {
params: {
share,
shareCode: share.shareCode,
},
onError: (err) => {
console.error('[PagePKData] 打开挑战详情失败:', err);
ToastManager.instance.show('打开挑战详情失败,请稍后重试');
},
});
}
private _getFirstParticipant(share: CreatedShareItem): ShareParticipantRankSummary | null {
return share.firstPlaceUser ?? share.topParticipant ?? share.firstParticipant ?? share.champion ?? null;
}

View File

@@ -1,14 +1,387 @@
import { _decorator, Component, Node } from 'cc';
const { ccclass, property } = _decorator;
import { _decorator, assetManager, Button, ImageAsset, instantiate, Label, Node, ScrollView, Sprite, SpriteFrame, Texture2D, UITransform } from 'cc';
import { BaseView } from 'db://assets/scripts/core/BaseView';
import { ViewManager } from 'db://assets/scripts/core/ViewManager';
import { CreatedShareItem, ShareDetailData, ShareParticipantRankSummary } from 'db://assets/scripts/types/ApiTypes';
import { ShareManager } from 'db://assets/scripts/utils/ShareManager';
import { ToastManager } from 'db://assets/scripts/utils/ToastManager';
const { ccclass } = _decorator;
@ccclass('PagePKDetail')
export class PagePKDetail extends Component {
start() {
}
update(deltaTime: number) {
}
interface PagePKDetailParams {
share?: CreatedShareItem | null;
shareCode?: string | null;
detail?: ShareDetailData | null;
}
@ccclass('PagePKDetail')
export class PagePKDetail extends BaseView {
private static readonly RANK_ITEM_TOP_PADDING = 16;
private static readonly RANK_ITEM_BOTTOM_PADDING = 16;
private static readonly RANK_ITEM_SPACING = 16;
private _backButton: Node | null = null;
private _titleLabel: Label | null = null;
private _championPanel: Node | null = null;
private _rankListContent: Node | null = null;
private _rankListItemTemplate: Node | null = null;
private _rankItemNodes: Node[] = [];
private _renderVersion: number = 0;
onViewLoad(): void {
this._resolveNodes();
this._bindEvents();
this._hideRankItemTemplate();
}
onViewShow(): void {
this._resolveNodes();
void this._loadAndRenderDetail();
}
onViewHide(): void {
this._renderVersion++;
}
onViewDestroy(): void {
this._unbindEvents();
this._clearRankItems();
}
private _resolveNodes(): void {
if (!this._backButton || !this._backButton.isValid) {
this._backButton = this.node.getChildByName('ButtonBack');
}
this._titleLabel = this._titleLabel
?? this.node.getChildByName('Title')?.getChildByName('Label')?.getComponent(Label)
?? null;
this._championPanel = this._championPanel ?? this.node.getChildByName('ChampionPanel');
const rankList = this.node.getChildByName('RankList');
const view = rankList?.getChildByName('view');
this._rankListContent = this._rankListContent ?? view?.getChildByName('content') ?? null;
this._rankListItemTemplate = this._rankListItemTemplate
?? this._rankListContent?.getChildByName('RankListItem')
?? null;
if (!this._backButton) {
console.warn('[PagePKDetail] 未找到 ButtonBack 节点');
}
if (!this._rankListContent) {
console.warn('[PagePKDetail] 未找到 RankList/content 节点');
}
if (!this._rankListItemTemplate) {
console.warn('[PagePKDetail] 未找到 RankListItem 模板节点');
}
}
private _bindEvents(): void {
if (this._backButton) {
this._backButton.on(Button.EventType.CLICK, this._onBackClick, this);
}
}
private _unbindEvents(): void {
if (this._backButton?.isValid) {
this._backButton.off(Button.EventType.CLICK, this._onBackClick, this);
}
}
private _onBackClick(): void {
ViewManager.instance.back();
}
private async _loadAndRenderDetail(): Promise<void> {
const params = this.getParams() as PagePKDetailParams | null;
const share = this._resolveShare(params);
const passedDetail = params?.detail ?? null;
const shareCode = passedDetail?.shareCode ?? params?.shareCode ?? share?.shareCode ?? null;
const version = ++this._renderVersion;
if (passedDetail) {
this._renderShareDetail(passedDetail, version);
return;
}
this._renderShareSummary(share, version);
if (!shareCode) {
ToastManager.instance.show('挑战数据异常,请稍后重试');
return;
}
const detail = await ShareManager.instance.fetchShareDetail(shareCode);
if (version !== this._renderVersion || !this.isShowing) {
return;
}
if (!detail) {
ToastManager.instance.show('获取挑战详情失败,请稍后重试');
this._renderShareSummary(share, version);
return;
}
this._renderShareDetail(detail, version);
}
private _resolveShare(params: PagePKDetailParams | null): CreatedShareItem | null {
if (params?.share) {
return params.share;
}
const shareCode = params?.shareCode ?? params?.detail?.shareCode;
if (!shareCode) {
return null;
}
return ShareManager.instance.createdShares.find((share) => share.shareCode === shareCode) ?? null;
}
private _renderShareSummary(share: CreatedShareItem | null, version: number): void {
this._clearRankItems();
this._setLabel(this._titleLabel, share?.title || '挑战详情');
this._renderChampion(share ? this._getFirstParticipant(share) : null, share, version);
this._layoutRankContent(0);
this._scrollRankListToTop();
}
private _renderShareDetail(detail: ShareDetailData, version: number): void {
this._clearRankItems();
const rankings = this._normalizeRankings(detail.rankings ?? []);
const champion = rankings.find((participant) => participant.rank === 1) ?? rankings[0] ?? null;
const restRankings = rankings.filter((participant) => participant !== champion);
this._setLabel(this._titleLabel, detail.title || '挑战详情');
this._renderChampion(champion, detail, version);
this._renderRankList(restRankings, version);
}
private _normalizeRankings(rankings: ShareParticipantRankSummary[]): ShareParticipantRankSummary[] {
return rankings
.map((participant, index) => ({
...participant,
rank: participant.rank ?? index + 1,
}))
.sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0));
}
private _getFirstParticipant(share: CreatedShareItem): ShareParticipantRankSummary | null {
return share.firstPlaceUser ?? share.topParticipant ?? share.firstParticipant ?? share.champion ?? null;
}
private _renderChampion(
champion: ShareParticipantRankSummary | null,
shareInfo: CreatedShareItem | ShareDetailData | null,
version: number,
): void {
const panel = this._championPanel;
if (!panel) {
return;
}
this._setLabel(this._findLabelIn(panel, 'UserName'), this._getParticipantName(champion) || '暂无参与');
this._setLabel(this._findLabelIn(panel, 'RightInfo'), this._formatCorrectText(champion, shareInfo));
this._setLabel(this._findLabelIn(panel, 'UsedTime'), this._formatTimeText(champion, shareInfo));
this._loadAvatar(champion?.avatarUrl ?? '', this._findAvatarSprite(panel), version);
}
private _renderRankList(participants: ShareParticipantRankSummary[], version: number): void {
if (!this._rankListContent || !this._rankListItemTemplate) {
return;
}
this._hideRankItemTemplate();
this._layoutRankContent(participants.length);
participants.forEach((participant, index) => {
const item = instantiate(this._rankListItemTemplate!);
item.name = `RankListItem_${index + 1}`;
item.active = true;
this._rankListContent!.addChild(item);
this._rankItemNodes.push(item);
this._positionRankItem(item, index);
this._applyRankItem(item, participant, version);
});
this._scrollRankListToTop();
}
private _applyRankItem(item: Node, participant: ShareParticipantRankSummary, version: number): void {
const rank = participant.rank ?? 0;
const rank2Badge = this._findChild(item, 'rank2badge');
const rank3Badge = this._findChild(item, 'rank3badge');
const rankNumberNode = this._findChild(item, 'RankNumber');
if (rank2Badge) {
rank2Badge.active = rank === 2;
}
if (rank3Badge) {
rank3Badge.active = rank === 3;
}
if (rankNumberNode) {
rankNumberNode.active = rank !== 2 && rank !== 3;
this._setLabel(rankNumberNode.getComponent(Label), rank > 0 ? `${rank}` : '-');
}
this._setLabel(this._findLabelIn(item, 'UserName'), this._getParticipantName(participant) || '微信用户');
this._setLabel(this._findLabelIn(item, 'RightInfo'), this._formatCorrectText(participant, null));
this._setLabel(this._findLabelIn(item, 'UsedTime'), this._formatTimeText(participant));
this._loadAvatar(participant.avatarUrl ?? '', this._findAvatarSprite(item), version);
}
private _layoutRankContent(itemCount: number): void {
if (!this._rankListContent || !this._rankListItemTemplate) {
return;
}
const contentTransform = this._rankListContent.getComponent(UITransform);
const viewTransform = this._rankListContent.parent?.getComponent(UITransform) ?? null;
const itemTransform = this._rankListItemTemplate.getComponent(UITransform);
if (!contentTransform || !viewTransform || !itemTransform) {
return;
}
const contentHeight = Math.max(
viewTransform.height,
PagePKDetail.RANK_ITEM_TOP_PADDING
+ PagePKDetail.RANK_ITEM_BOTTOM_PADDING
+ itemCount * itemTransform.height
+ Math.max(0, itemCount - 1) * PagePKDetail.RANK_ITEM_SPACING,
);
contentTransform.setContentSize(contentTransform.width, contentHeight);
this._rankListContent.setPosition(
this._rankListContent.position.x,
viewTransform.height / 2,
this._rankListContent.position.z,
);
}
private _positionRankItem(item: Node, index: number): void {
const itemTransform = item.getComponent(UITransform);
if (!itemTransform) {
return;
}
const y = -PagePKDetail.RANK_ITEM_TOP_PADDING
- itemTransform.height / 2
- index * (itemTransform.height + PagePKDetail.RANK_ITEM_SPACING);
item.setPosition(0, y, item.position.z);
}
private _scrollRankListToTop(): void {
this.node.getChildByName('RankList')?.getComponent(ScrollView)?.scrollToTop(0);
}
private _formatCorrectText(
participant: ShareParticipantRankSummary | null,
shareInfo: CreatedShareItem | ShareDetailData | null,
): string {
if (participant?.correctCount !== undefined && participant.correctCount !== null) {
return `答对${participant.correctCount}`;
}
if (shareInfo && (shareInfo.participantCount ?? 0) <= 0) {
return '答对0道';
}
if (shareInfo) {
return `${shareInfo.participantCount ?? 0}人参与`;
}
return '暂无成绩';
}
private _formatTimeText(
participant: ShareParticipantRankSummary | null,
shareInfo: CreatedShareItem | ShareDetailData | null = null,
): string {
if (participant?.totalTimeSpent === undefined || participant.totalTimeSpent === null) {
if (shareInfo && (shareInfo.participantCount ?? 0) <= 0) {
return '用时0秒';
}
return '暂无用时';
}
const totalSeconds = Math.max(0, Math.round(participant.totalTimeSpent));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `用时${minutes}:${seconds.toString().padStart(2, '0')}`;
}
private _getParticipantName(participant: ShareParticipantRankSummary | null): string {
return participant?.nickname || participant?.nickName || '';
}
private _loadAvatar(url: string, sprite: Sprite | null, version: number): void {
if (!sprite) {
return;
}
if (!url) {
sprite.spriteFrame = null;
return;
}
assetManager.loadRemote<ImageAsset>(url, (err, imageAsset) => {
if (err || !imageAsset || version !== this._renderVersion || !sprite.node.isValid) {
if (err) {
console.error('[PagePKDetail] 加载头像失败:', url, err);
}
return;
}
const texture = new Texture2D();
texture.image = imageAsset;
const spriteFrame = new SpriteFrame();
spriteFrame.texture = texture;
sprite.spriteFrame = spriteFrame;
});
}
private _findAvatarSprite(root: Node): Sprite | null {
const avatarNode = this._findChild(root, 'Avatar');
return avatarNode?.getComponent(Sprite) ?? null;
}
private _clearRankItems(): void {
for (const item of this._rankItemNodes) {
if (item.isValid) {
item.removeFromParent();
item.destroy();
}
}
this._rankItemNodes = [];
this._hideRankItemTemplate();
}
private _hideRankItemTemplate(): void {
if (this._rankListItemTemplate?.isValid) {
this._rankListItemTemplate.active = false;
}
}
private _setLabel(label: Label | null, text: string): void {
if (label) {
label.string = text;
}
}
private _findLabelIn(root: Node, nodeName: string): Label | null {
return this._findChild(root, nodeName)?.getComponent(Label) ?? null;
}
private _findChild(root: Node, nodeName: string): Node | null {
if (root.name === nodeName) {
return root;
}
for (const child of root.children) {
const found = this._findChild(child, nodeName);
if (found) {
return found;
}
}
return null;
}
}

View File

@@ -37,6 +37,10 @@ export function getShareJoinUrl(code: string): string {
return `${API_BASE}/share/${code}/join`;
}
export function getShareDetailUrl(code: string): string {
return `${API_BASE}/share/${code}`;
}
export function getShareSubmitUrl(code: string): string {
return `${API_BASE}/share/${code}/submit`;
}

View File

@@ -165,6 +165,7 @@ export interface SubmitShareData {
/** 分享挑战参与者排行摘要 */
export interface ShareParticipantRankSummary {
userId?: string | null;
participantId?: string | null;
nickname?: string | null;
nickName?: string | null;
avatarUrl?: string | null;
@@ -193,6 +194,18 @@ export interface CreatedShareListData {
items: CreatedShareItem[];
}
/** 分享挑战详情响应 */
export interface ShareDetailData {
id: string;
shareCode: string;
title: string;
levelCount: number;
participantCount: number;
userRank: number | null;
createdAt: string;
rankings: ShareParticipantRankSummary[];
}
/** 已通关关卡数据(成就墙 / 关卡回看场景) */
export interface CompletedLevel {
/** 关卡 ID */

View File

@@ -1,7 +1,7 @@
import { SpriteFrame, Texture2D, ImageAsset, assetManager } from 'cc';
import { HttpUtil } from './HttpUtil';
import { WxSDK } from './WxSDK';
import { API_ENDPOINTS, getShareJoinUrl, getShareSubmitUrl, API_TIMEOUT } from '../config/ApiConfig';
import { API_ENDPOINTS, getShareDetailUrl, getShareJoinUrl, getShareSubmitUrl, API_TIMEOUT } from '../config/ApiConfig';
import {
ApiEnvelope,
CreateShareData,
@@ -9,6 +9,7 @@ import {
ShareLevelData,
CreatedShareItem,
CreatedShareListData,
ShareDetailData,
SubmitShareData,
SubmitShareLevel,
} from '../types/ApiTypes';
@@ -157,6 +158,26 @@ export class ShareManager {
}
}
async fetchShareDetail(code: string): Promise<ShareDetailData | null> {
try {
const response = await HttpUtil.get<ApiEnvelope<ShareDetailData>>(
getShareDetailUrl(code),
API_TIMEOUT.DEFAULT,
);
if (!response.success || !response.data) {
console.error('[ShareManager] 获取分享挑战详情失败:', response.message);
return null;
}
console.log(`[ShareManager] 获取分享挑战详情成功: ${response.data.title}, ${response.data.rankings?.length ?? 0} 条排行`);
return response.data;
} catch (err) {
console.error('[ShareManager] 获取分享挑战详情异常:', err);
return null;
}
}
async ensureShareLevelReady(index: number): Promise<RuntimeLevelConfig | null> {
if (!this._shareLevels || index < 0 || index >= this._shareLevels.length) {
return null;