diff --git a/AGENTS.md b/AGENTS.md index 6a1d85b..af1c43d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,7 @@ Git 历史采用 Conventional Commits,且摘要多为中文,例如 `feat: # 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 diff --git a/assets/main.scene b/assets/main.scene index 6ba43c2..fcdaca8 100644 --- a/assets/main.scene +++ b/assets/main.scene @@ -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" diff --git a/assets/main.ts b/assets/main.ts index 5ddd29a..4ca9f02 100644 --- a/assets/main.ts +++ b/assets/main.ts @@ -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, diff --git a/assets/prefabs/PagePKData.ts b/assets/prefabs/PagePKData.ts index 5fef921..451ff7b 100644 --- a/assets/prefabs/PagePKData.ts +++ b/assets/prefabs/PagePKData.ts @@ -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; } diff --git a/assets/prefabs/PagePKDetail.ts b/assets/prefabs/PagePKDetail.ts index 6dfdef2..7ae514a 100644 --- a/assets/prefabs/PagePKDetail.ts +++ b/assets/prefabs/PagePKDetail.ts @@ -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 { + 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(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; + } +} diff --git a/assets/scripts/config/ApiConfig.ts b/assets/scripts/config/ApiConfig.ts index d9e24a4..bcafb3b 100644 --- a/assets/scripts/config/ApiConfig.ts +++ b/assets/scripts/config/ApiConfig.ts @@ -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`; } diff --git a/assets/scripts/types/ApiTypes.ts b/assets/scripts/types/ApiTypes.ts index 6235ef4..6114b4b 100644 --- a/assets/scripts/types/ApiTypes.ts +++ b/assets/scripts/types/ApiTypes.ts @@ -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 */ diff --git a/assets/scripts/utils/ShareManager.ts b/assets/scripts/utils/ShareManager.ts index 778b8c6..98e7ff7 100644 --- a/assets/scripts/utils/ShareManager.ts +++ b/assets/scripts/utils/ShareManager.ts @@ -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 { + try { + const response = await HttpUtil.get>( + 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 { if (!this._shareLevels || index < 0 || index >= this._shareLevels.length) { return null;