import { emitter } from '@kit.BasicServicesKit'; import { CardData, MetricId, PeriodRange, RankItem, RecommendFuturesItem, TabMetric, } from './MarketRankingModels'; import { MarketRankingDataFetcher } from './MarketRankingDataFetcher'; import { cardDataToContracts, contractsToPlaceholderCardData, } from './MarketRankingDataMapper'; import { MarketRankingHqRequestClient } from '../node/clients/MarketRankingHqRequestClient'; import { formatGreatNumber, formatPercent, riseFallColor } from '../common/NumberFormat'; import { AllCardsCard } from '../node/model/AiDiagnosisNodeModel'; import { PERIOD_RANGES, TAB_METRICS } from './MarketRankingConstant'; import { enableDarkMode } from '@kernel/theme_manager'; import { EmitterConstants, GlobalContext, jumpToQuote } from 'biz_common'; import { RouterUtil } from '../util/RouterUtil'; const NORMAL_SIZE = 3; const MAX_SIZE = 5; const UNFOLDED_STORAGE_KEY = 'firstPageMarketRankingUnfolded'; PersistentStorage.persistProp(UNFOLDED_STORAGE_KEY, false); @Component export struct MarketRankingNodeComponent { // 外部状态 @Link @Watch('onRefreshTriggerChange') refreshTrigger: number; @Consume('firstPageVisible') @Watch('onFirstPageVisibleChange') firstPageVisible: boolean = true; @StorageProp(enableDarkMode) isDarkMode: boolean = false; // 卡片业务数据 @State cardConfig: AllCardsCard | undefined = undefined; @State cardData: CardData = { tableList: [] }; private contracts: RecommendFuturesItem[] = []; private tabDataCache: Map = new Map(); // 区分加载中和暂无数据,与 HotListNodeComponent 的 dataReady 约定保持一致 @State dataReady: boolean = false; // Tab 与展开状态 @State primaryIdx: number = 0; @State secondaryIdx: number = 0; @StorageLink(UNFOLDED_STORAGE_KEY) unfolded: boolean = false; // Tab 滚动区域状态 @State tabViewportWidth: number = 0; @State tabContentWidth: number = 0; @State tabScrollOffset: number = 0; // 弹层状态 @State showExplainSheet: boolean = false; // 生命周期与异步请求状态 private isComponentActive: boolean = false; private dataRequestVersion: number = 0; // 行情订阅与事件状态 private hqUnsubscribe: (() => void) | undefined; private hqSubscriptionVersion: number = 0; private isTcpEventSubscribed: boolean = false; private tcpReconnectListener = (): void => { if (!this.canUseHq()) { return; } setTimeout((): void => { if (this.canUseHq()) { this.restartCurrentSubscription(); } }); }; // ==================== 组件生命周期 ==================== aboutToAppear(): void { this.isComponentActive = true; this.updateCardConfig(); if (this.firstPageVisible) { this.subscribeTcpNetStatus(); } this.updateCardData(this.primaryIdx, this.secondaryIdx); } aboutToDisappear(): void { this.isComponentActive = false; this.invalidateDataRequest(); this.stopSubscribeHq(); this.unSubscribeTcpNetStatus(); this.contracts = []; } // ==================== 配置与刷新 ==================== // 从首页统一卡片配置中读取市场排名配置。 private updateCardConfig(): void { this.cardConfig = MarketRankingDataFetcher.getInstance().fetchCardConfig(); } // refreshTrigger 变化时重新获取当前 Tab 的合约,并在请求完成后重建行情订阅。 onRefreshTriggerChange(): void { if (!this.isComponentActive) { return; } this.updateCardConfig(); this.updateCardData(this.primaryIdx, this.secondaryIdx); } // ==================== 可见性与 TCP 重连 ==================== // 页面不可见时停止行情;恢复可见后使用已有合约重建订阅。 onFirstPageVisibleChange(): void { if (!this.isComponentActive) { return; } if (!this.firstPageVisible) { this.stopSubscribeHq(); this.unSubscribeTcpNetStatus(); return; } this.subscribeTcpNetStatus(); this.restartCurrentSubscription(); } private subscribeTcpNetStatus(): void { if (this.isTcpEventSubscribed || !this.canUseHq()) { return; } this.isTcpEventSubscribed = true; emitter.on({ eventId: EmitterConstants.NETWORK_TCP_RECONNECT }, this.tcpReconnectListener); } private unSubscribeTcpNetStatus(): void { if (!this.isTcpEventSubscribed) { return; } this.isTcpEventSubscribed = false; emitter.off(EmitterConstants.NETWORK_TCP_RECONNECT, this.tcpReconnectListener); } // 收到 TCP 重连事件后,仅在页面可见时重新订阅行情。 private restartCurrentSubscription(): void { if (!this.canUseHq()) { return; } if (this.contracts.length > 0) { this.subscribeHq(this.primaryIdx, this.secondaryIdx); return; } if (this.dataReady) { this.updateCardData(this.primaryIdx, this.secondaryIdx); } } // ==================== Tab 交互 ==================== private selectPrimaryTab(index: number): void { if (this.primaryIdx === index) { return; } this.primaryIdx = index; this.secondaryIdx = 0; this.updateCardData(index, 0); } private selectSecondaryTab(index: number): void { if (this.secondaryIdx === index) { return; } this.secondaryIdx = index; this.updateCardData(this.primaryIdx, index); } // ==================== 数据请求与行情订阅 ==================== // 先请求合约榜单填充列表,再根据当前 CardData 订阅行情。 private async updateCardData(primaryIdx: number, secondaryIdx: number): Promise { if (!this.isComponentActive) { return; } const requestVersion: number = this.dataRequestVersion + 1; this.dataRequestVersion = requestVersion; const cacheKey: string = this.getTabCacheKey(primaryIdx, secondaryIdx); const cachedData: CardData | undefined = this.tabDataCache.get(cacheKey); this.stopSubscribeHq(); if (cachedData !== undefined) { this.cardData = cachedData; this.contracts = cardDataToContracts(cachedData); this.dataReady = true; if (this.firstPageVisible) { this.subscribeHq(primaryIdx, secondaryIdx); } } else { this.contracts = []; this.cardData = { tableList: [] }; this.dataReady = false; } try { const contracts = await MarketRankingDataFetcher .getInstance() .fetchRecommendFutures(primaryIdx, secondaryIdx); if (!this.isCurrentDataRequest(requestVersion, primaryIdx, secondaryIdx)) { return; } this.contracts = contracts; if (cachedData === undefined) { this.cardData = contractsToPlaceholderCardData(contracts); } this.dataReady = true; this.tabDataCache.set(cacheKey, this.cardData); if (this.firstPageVisible) { this.subscribeHq(primaryIdx, secondaryIdx); } } catch { if (!this.isCurrentDataRequest(requestVersion, primaryIdx, secondaryIdx)) { return; } if (cachedData === undefined) { this.contracts = []; this.cardData = { tableList: [] }; } this.dataReady = true; } } // 使用 HTTP 保存的原始合约列表订阅行情,推送后整体更新 CardData。 private subscribeHq(primaryIdx: number, secondaryIdx: number): void { this.stopSubscribeHq(); if (!this.canUseHq() || this.contracts.length === 0) { return; } const subscriptionVersion: number = this.hqSubscriptionVersion; this.hqUnsubscribe = MarketRankingHqRequestClient .getInstance() .subscribeHq(this.contracts, primaryIdx, secondaryIdx, (cardData: CardData): void => { if (this.canUseHq() && subscriptionVersion === this.hqSubscriptionVersion && primaryIdx === this.primaryIdx && secondaryIdx === this.secondaryIdx) { this.cardData = cardData; this.tabDataCache.set(this.getTabCacheKey(primaryIdx, secondaryIdx), cardData); } }); } private stopSubscribeHq(): void { this.hqSubscriptionVersion += 1; if (this.hqUnsubscribe !== undefined) { this.hqUnsubscribe(); this.hqUnsubscribe = undefined; } } private canUseHq(): boolean { return this.isComponentActive && this.firstPageVisible; } private invalidateDataRequest(): void { this.dataRequestVersion += 1; } private isCurrentDataRequest( requestVersion: number, primaryIdx: number, secondaryIdx: number, ): boolean { return this.isComponentActive && requestVersion === this.dataRequestVersion && primaryIdx === this.primaryIdx && secondaryIdx === this.secondaryIdx; } private getTabCacheKey(primaryIdx: number, secondaryIdx: number): string { const metric: TabMetric = TAB_METRICS[primaryIdx]; if (metric.hasChildren) { return `${metric.id}_${PERIOD_RANGES[secondaryIdx].id}`; } return `${metric.id}`; } // ==================== 页面跳转 ==================== // 对齐 futures-homepage jumpToFenShi,跳转客户端行情分时页。 private jumpToDetail(item: RankItem): void { const codeList: string = this.contracts.map((contract: RecommendFuturesItem): string => contract.contract_code).join(','); const marketIdList: string = this.contracts.map((contract: RecommendFuturesItem): string => contract.market).join(','); const nameList: string = this.contracts.map((contract: RecommendFuturesItem): string => contract.contract_name).join(','); jumpToQuote(item.code, item.market, codeList, marketIdList, nameList); } // 跳转到卡片配置中的标题地址。 private jumpToCardTitle(): void { const jumpUrl: string = this.cardConfig?.card_url.android || this.cardConfig?.card_url.ios || ''; if (jumpUrl === '') { return; } RouterUtil.jumpPage(jumpUrl, true); } // ==================== UI 构建 ==================== build() { Column() { this.buildTitleBar() this.buildTabBar() if (this.cardData.tableList.length > 0) { this.buildContent() } else { this.buildEmptyView() } } .width('100%') .backgroundColor($r('app.color.surface_layer1_foreground')) .borderRadius(4) .padding({ left: 10, right: 10, bottom: 10 }) .bindSheet(this.showExplainSheet, this.buildExplainSheetContent, { height: SheetSize.FIT_CONTENT, dragBar: true, showClose: false, maskColor: $r('app.color.mask_level2'), onDisappear: () => { this.showExplainSheet = false; }, radius: { topLeft: 10, topRight: 10, }, }) } @Builder buildTitleBar() { Row() { Row() { Text(this.cardConfig?.card_title.value || $r('app.string.market_ranking_title')) .fontColor($r('app.color.elements_text_primary_02')) .fontSize(18) .fontWeight(500) .maxLines(1) if ((this.cardConfig?.card_url.android ?? '') !== '' || (this.cardConfig?.card_url.ios ?? '') !== '') { Image($r('app.media.icon_arrow_forward_20')) .width(20) .height(20) .fillColor($r('app.color.elements_icon_primary_02')) } if ((this.cardConfig?.explain_message ?? '') !== '') { Image($r('app.media.icon_info_24')) .width(16) .height(16) .fillColor($r('app.color.elements_icon_tertiary')) .margin({ left: 8 }) .onClick(() => { this.showExplainSheet = true; }) } } .alignItems(VerticalAlign.Center) .height('100%') .onClick(() => { this.jumpToCardTitle(); }) // 展开/收起按钮,样式对齐 HotListNodeComponent 的展开/收起按钮 Row() { Text(this.unfolded ? $r('app.string.hotlist_collapse') : $r('app.string.hotlist_expand')) .fontColor($r('app.color.elements_text_tertiary')) .fontSize(12) .height(16) .width('100%') .textAlign(TextAlign.Center) } .width(40) .height(20) .borderRadius(10) .backgroundColor($r('app.color.elements_button_bg_disable_02')) .margin({ left: 8 }) .onClick(() => { this.toggleUnfolded(); }) Blank() } .width('100%') .height(48) } @Builder buildExplainSheetContent() { Column() { Stack({ alignContent: Alignment.End }) { Text(this.cardConfig?.explain_title || this.cardConfig?.card_title.value || $r('app.string.market_ranking_title')) .fontSize(18) .fontWeight(500) .textAlign(TextAlign.Center) .width('100%') Row() { Image($r('app.media.icon_close_24')) .width(24) .height(24) .fillColor($r('app.color.elements_icon_primary_02')) .onClick(() => { this.showExplainSheet = false; }) } .height('100%') .justifyContent(FlexAlign.End) } .height(64) .width('100%') Scroll() { Text((this.cardConfig?.explain_message ?? '').replace(new RegExp('\\\\n', 'g'), '\n')) .width('100%') .fontSize(14) .lineHeight(22) .fontColor($r('app.color.elements_text_primary_02')) } .width('100%') .scrollBar(BarState.Off) Button($r('app.string.card_explain_confirm')) .type(ButtonType.Normal) .backgroundColor($r('app.color.elements_button_bg_primary')) .width('100%') .height(44) .borderRadius(4) .margin({ top: 16 }) .onClick(() => { this.showExplainSheet = false; }) } .width('100%') .backgroundColor($r('app.color.surface_layer3_background')) .padding({ bottom: px2vp(GlobalContext.navigationBarHeight), left: 16, right: 16, }) } @Builder buildTabBar() { Column() { // 一级 Tab(胶囊按钮,可横向滚动;Tab 数量多于 CommodityOptions 等固定两栏卡片, // 因此保留横向滚动 + 边缘渐隐,属于本卡片必要的交互差异,非样式不一致)。 Stack({ alignContent: Alignment.End }) { Scroll() { Row({ space: 8 }) { ForEach(TAB_METRICS, (metric: TabMetric, index: number) => { Text(metric.label) .fontColor(this.primaryIdx === index ? $r('app.color.elements_text_primary_01') : $r('app.color.elements_text_secondary')) .fontSize(14) .fontWeight(this.primaryIdx === index ? FontWeight.Medium : FontWeight.Normal) .padding({ left: 8, right: 8 }) .height(30) .constraintSize({ minWidth: 66 }) .textAlign(TextAlign.Center) .backgroundColor(this.primaryIdx === index ? $r('app.color.elements_others_bg_primary') : $r('app.color.elements_button_bg_tertiary')) .borderRadius(4) .onClick(() => { this.selectPrimaryTab(index); }) }, (metric: TabMetric) => metric.id) } .onAreaChange((oldValue: Area, newValue: Area) => { this.tabContentWidth = Number(newValue.width); }) } .width('100%') .height(30) .padding({ right: 10 }) .scrollable(ScrollDirection.Horizontal) .scrollBar(BarState.Off) .align(Alignment.Start) .onScroll((xOffset: number, _yOffset: number) => { this.tabScrollOffset = xOffset; }) if (this.showTabGradient()) { Row() .width(24) .height(30) .linearGradient({ angle: 90, colors: [[$r('app.color.tab_gradient_start'), 0], [$r('app.color.surface_layer1_foreground'), 1]], }) .hitTestBehavior(HitTestMode.None) } } .width('100%') .height(30) .onAreaChange((oldValue: Area, newValue: Area) => { this.tabViewportWidth = Number(newValue.width); }) // 二级 Tab(仅涨速/跌速出现,右对齐文字 + 分隔线) if (TAB_METRICS[this.primaryIdx].hasChildren) { Row() { ForEach(PERIOD_RANGES, (period: PeriodRange, index: number) => { if (index !== 0) { Divider() .vertical(true) .height(10) .color($r('app.color.elements_others_divider_primary')) .margin({ left: 8, right: 8 }) } Text(period.label) .fontSize(12) .fontWeight(this.secondaryIdx === index ? FontWeight.Medium : FontWeight.Normal) .fontColor(this.secondaryIdx === index ? $r('app.color.elements_text_primary_02') : $r('app.color.elements_text_tertiary')) .padding({ top: 4, bottom: 4 }) .onClick(() => { this.selectSecondaryTab(index); }) }, (period: PeriodRange) => period.id) } .width('100%') .justifyContent(FlexAlign.End) .margin({ top: 8 }) } } .width('100%') } @Builder buildEmptyView() { Column() { Text(this.dataReady ? $r('app.string.hotlist_no_data') : $r('app.string.hotlist_loading')) .fontColor($r('app.color.elements_text_tertiary')) .fontSize(14) } .width('100%') .height(this.emptyHeight()) .alignItems(HorizontalAlign.Center) .justifyContent(FlexAlign.Center) } @Builder buildContent() { Column() { // 表头 Row({ space: 8 }) { Text($r('app.string.commodity_options_table_header_name')) .fontColor($r('app.color.elements_text_tertiary')) .fontSize(14) .width('35%') Text($r('app.string.commodity_options_table_header_price')) .fontColor($r('app.color.elements_text_tertiary')) .fontSize(14) .layoutWeight(1) .textAlign(TextAlign.End) Text(this.thirdColumnTitle()) .fontColor($r('app.color.elements_text_tertiary')) .fontSize(14) .layoutWeight(1) .textAlign(TextAlign.End) } .height(40) .width('100%') // 表格列表 ForEach( this.cardData.tableList.slice(0, this.displaySize()), (item: RankItem, index: number) => { this.buildRankRow(item) }, // 行情更新时字段参与 key,确保整体替换 CardData 后列表行同步刷新。 (item: RankItem) => `${item.code}_${item.market}_${item.price ?? ''}_${item.price_chg ?? ''}_` + `${item.chg_speed ?? ''}_${item.turnover ?? ''}_${item.incre_posit ?? ''}`, ) } .width('100%') .height(this.emptyHeight()) .animation({ duration: 180, curve: Curve.EaseInOut }) } @Builder buildRankRow(item: RankItem) { Row({ space: 8 }) { Text(item.name) .fontColor($r('app.color.elements_text_primary_02')) .fontSize(16) .maxFontSize(16) .minFontSize(9) .width('35%') .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Text(item.price !== undefined ? item.price.toString() : '--') .fontColor(this.getPriceColor(item.price_chg)) .fontSize(16) .fontWeight(500) .maxFontSize(16) .minFontSize(9) .layoutWeight(1) .textAlign(TextAlign.End) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Text(this.thirdColumnValue(item)) .fontColor(this.thirdColumnColor(item)) .fontSize(16) .fontWeight(500) .maxFontSize(16) .minFontSize(9) .layoutWeight(1) .textAlign(TextAlign.End) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .width('100%') .height(45) .alignItems(VerticalAlign.Center) .border({ width: { top: 1 }, color: $r('app.color.elements_others_divider_primary') }) .onClick(() => { if (item.market !== undefined) { this.jumpToDetail(item); } }) } // ==================== 展示计算 ==================== private toggleUnfolded(): void { this.unfolded = !this.unfolded; } private showTabGradient(): boolean { const maxOffset = this.tabContentWidth - this.tabViewportWidth; return maxOffset > 0 && this.tabScrollOffset < maxOffset; } private emptyHeight(): number { const rowHeight = 45; const headerHeight = 40; return this.displaySize() * rowHeight + headerHeight; } private displaySize(): number { return this.unfolded ? MAX_SIZE : NORMAL_SIZE; } // 第三列标题随 Tab 变化 private thirdColumnTitle(): ResourceStr { const metricId = TAB_METRICS[this.primaryIdx].id; if (metricId === MetricId.RISE || metricId === MetricId.FALL) { return $r('app.string.market_ranking_change_rate'); } if (metricId === MetricId.RISE_SPEED) { switch (this.secondaryIdx) { case 0: return $r('app.string.market_ranking_1min_rise_speed'); case 1: return $r('app.string.market_ranking_5min_rise_speed'); case 2: return $r('app.string.market_ranking_10min_rise_speed'); default: return $r('app.string.market_ranking_15min_rise_speed'); } } if (metricId === MetricId.FALL_SPEED) { switch (this.secondaryIdx) { case 0: return $r('app.string.market_ranking_1min_fall_speed'); case 1: return $r('app.string.market_ranking_5min_fall_speed'); case 2: return $r('app.string.market_ranking_10min_fall_speed'); default: return $r('app.string.market_ranking_15min_fall_speed'); } } return TAB_METRICS[this.primaryIdx].label; } // 成交额/日增仓列用中性色,其余按涨跌上色 private isNeutralColumn(): boolean { const metricId = TAB_METRICS[this.primaryIdx].id; return metricId === MetricId.TURN_OVER || metricId === MetricId.INCRE_POSIT; } // 第三列展示值:按当前一级 Tab 从对应原始字段取值并格式化 private thirdColumnValue(item: RankItem): string { const metricId = TAB_METRICS[this.primaryIdx].id; if (metricId === MetricId.TURN_OVER) { return formatGreatNumber(item.turnover); } if (metricId === MetricId.INCRE_POSIT) { return formatGreatNumber(item.incre_posit); } if (metricId === MetricId.RISE_SPEED || metricId === MetricId.FALL_SPEED) { return formatPercent(item.chg_speed); } return formatPercent(item.price_chg); } // 第三列取色:成交额/日增仓走中性色,其余按对应原始字段的正负取色 private thirdColumnColor(item: RankItem): Resource { if (this.isNeutralColumn()) { return $r('app.color.elements_text_primary_02'); } const metricId = TAB_METRICS[this.primaryIdx].id; const raw = (metricId === MetricId.RISE_SPEED || metricId === MetricId.FALL_SPEED) ? item.chg_speed : item.price_chg; return riseFallColor(raw); } private getPriceColor(priceChg?: number): ResourceStr { if (priceChg === undefined) { return $r('app.color.elements_text_primary_02') } if (priceChg > 0) { return $r('app.color.price_up_100') } else if (priceChg < 0) { return $r('app.color.price_down_100') } return $r('app.color.elements_text_primary_02') } }