From 7f38efa2205fa365d42aa1642524dece89abd99c Mon Sep 17 00:00:00 2001 From: clz Date: Mon, 27 Jul 2026 15:19:34 +0800 Subject: [PATCH] feat(market-ranking): sync card UI and state --- doc/market-ranking-design.md | 1 + .../MarketRankingDataMapper.ets | 24 ++ .../MarketRankingHqRequestClient.ets | 2 +- .../MarketRankingNodeComponent.ets | 350 ++++++++++-------- .../main/resources/base/element/color.json | 3 +- 5 files changed, 231 insertions(+), 149 deletions(-) create mode 100644 entry/src/main/ets/market-ranking/MarketRankingDataMapper.ets diff --git a/doc/market-ranking-design.md b/doc/market-ranking-design.md index de66b68..c6c6f1d 100644 --- a/doc/market-ranking-design.md +++ b/doc/market-ranking-design.md @@ -32,5 +32,6 @@ HTTP 只负责返回 contracts;最新价、涨跌幅、成交额等动态字 当前 HTTP 和行情连接均为 Mock。接入真实环境时,主要替换 `MarketRankingDataFetcher` 和 `MarketRankingHqRequestClient` 的内部实现,视图层数据结构和生命周期保持不变。 +真实 HTTP 接入后还应增加请求版本校验或取消机制,避免快速切换 Tab 时较早的响应覆盖当前状态。 具体 HTTP 与 4106 参数见 [市场排名接口文档](market-ranking-interfaces.md)。 diff --git a/entry/src/main/ets/market-ranking/MarketRankingDataMapper.ets b/entry/src/main/ets/market-ranking/MarketRankingDataMapper.ets new file mode 100644 index 0000000..62c7789 --- /dev/null +++ b/entry/src/main/ets/market-ranking/MarketRankingDataMapper.ets @@ -0,0 +1,24 @@ +import { + CardData, + RankItem, + RecommendFuturesItem, +} from './MarketRankingModels'; + +export function cardDataToContracts(cardData: CardData): RecommendFuturesItem[] { + return cardData.tableList.map((item: RankItem): RecommendFuturesItem => ({ + contract_code: item.code, + contract_name: item.name, + market: item.market, + })); +} + +export function contractsToPlaceholderCardData(contracts: RecommendFuturesItem[]): CardData { + const cardData: CardData = { + tableList: contracts.map((contract: RecommendFuturesItem): RankItem => ({ + code: contract.contract_code, + market: contract.market, + name: contract.contract_name, + })), + }; + return cardData; +} diff --git a/entry/src/main/ets/market-ranking/MarketRankingHqRequestClient.ets b/entry/src/main/ets/market-ranking/MarketRankingHqRequestClient.ets index 2f3bd60..3a066ec 100644 --- a/entry/src/main/ets/market-ranking/MarketRankingHqRequestClient.ets +++ b/entry/src/main/ets/market-ranking/MarketRankingHqRequestClient.ets @@ -202,7 +202,7 @@ export class MarketRankingHqRequestClient { return value; } if (typeof value === 'string' && value !== '') { - const result = Number(value); + const result = parseFloat(value); return Number.isNaN(result) ? undefined : result; } return undefined; diff --git a/entry/src/main/ets/market-ranking/MarketRankingNodeComponent.ets b/entry/src/main/ets/market-ranking/MarketRankingNodeComponent.ets index f8bba1a..859b40d 100644 --- a/entry/src/main/ets/market-ranking/MarketRankingNodeComponent.ets +++ b/entry/src/main/ets/market-ranking/MarketRankingNodeComponent.ets @@ -5,6 +5,10 @@ import { RankItem, RecommendFuturesItem, TabMetric, } from './MarketRankingModels'; import { MarketRankingDataFetcher } from './MarketRankingDataFetcher'; +import { + cardDataToContracts, + contractsToPlaceholderCardData, +} from './MarketRankingDataMapper'; import { MarketRankingHqRequestClient } from './MarketRankingHqRequestClient'; import { formatGreatNumber, formatPercent, riseFallColor } from '../common/NumberFormat'; import { AllCardsCard } from '../common/AllCardsModels'; @@ -24,15 +28,21 @@ import { const NORMAL_SIZE = 3; const MAX_SIZE = 5; +const UNFOLDED_STORAGE_KEY = 'firstPageMarketRankingUnfolded'; + +PersistentStorage.persistProp(UNFOLDED_STORAGE_KEY, false); @Component export struct MarketRankingNodeComponent { @State cardConfig: AllCardsCard | undefined = undefined; @State cardData: CardData = { tableList: [] }; + private contracts: RecommendFuturesItem[] = []; + private tabDataCache: Map = new Map(); + private dataRequestVersion: number = 0; // Tab 选中/展开状态是纯 UI 交互状态,留在 View 里 @State primaryIdx: number = 0; @State secondaryIdx: number = 0; - @State unfolded: boolean = false; + @StorageLink(UNFOLDED_STORAGE_KEY) unfolded: boolean = false; @State tabViewportWidth: number = 0; @State tabContentWidth: number = 0; @State tabScrollOffset: number = 0; @@ -58,6 +68,7 @@ export struct MarketRankingNodeComponent { } aboutToDisappear(): void { + this.dataRequestVersion += 1; emitter.off(EVENT_TCP_RECONNECT, this.tcpReconnectListener); this.stopSubscribeHq(); } @@ -100,50 +111,61 @@ export struct MarketRankingNodeComponent { @Builder CardTitle() { - Row({ space: 4 }) { - Text(this.cardConfig?.card_title.value || $r('app.string.market_ranking_title')) - .fontSize(18) - .fontWeight(FontWeight.Bold) - .fontColor($r('app.color.text_primary')) - .maxLines(1) + Row() { + Row() { + Text(this.cardConfig?.card_title.value || $r('app.string.market_ranking_title')) + .fontSize(18) + .fontWeight(500) + .fontColor($r('app.color.text_primary')) + .maxLines(1) - if ((this.cardConfig?.card_url.android ?? '') !== '' || - (this.cardConfig?.card_url.ios ?? '') !== '') { - Image(this.isDarkMode ? TITLE_ARROW_DARK : TITLE_ARROW_LIGHT) - .width(12) - .height(12) + if ((this.cardConfig?.card_url.android ?? '') !== '' || + (this.cardConfig?.card_url.ios ?? '') !== '') { + Image(this.isDarkMode ? TITLE_ARROW_DARK : TITLE_ARROW_LIGHT) + .width(20) + .height(20) + } + + if ((this.cardConfig?.explain_message ?? '') !== '') { + Image(this.isDarkMode ? INFO_IMAGE_DARK : INFO_IMAGE_LIGHT) + .width(16) + .height(16) + .margin({ left: 8 }) + .onClick(() => { + this.showExplainSheet = true; + }) + } } + .alignItems(VerticalAlign.Center) + .height('100%') + .onClick(() => { + this.jumpToCardTitle(); + }) - if ((this.cardConfig?.explain_message ?? '') !== '') { - Image(this.isDarkMode ? INFO_IMAGE_DARK : INFO_IMAGE_LIGHT) - .width(16) + Row() { + Text(this.unfolded + ? $r('app.string.market_ranking_collapse') + : $r('app.string.market_ranking_expand')) + .fontSize(12) + .fontColor($r('app.color.text_tertiary')) .height(16) - .onClick((event: ClickEvent) => { - this.showExplainSheet = true; - }) + .width('100%') + .textAlign(TextAlign.Center) } + .width(40) + .height(20) + .borderRadius(10) + .backgroundColor($r('app.color.pill_inactive_bg')) + .margin({ left: 8 }) + .onClick(() => { + this.toggleUnfolded(); + }) - Text(this.unfolded - ? $r('app.string.market_ranking_collapse') - : $r('app.string.market_ranking_expand')) - .fontSize(12) - .fontColor($r('app.color.text_tertiary')) - .height(20) - .padding({ left: 8, right: 8 }) - .borderRadius(11) - .backgroundColor($r('app.color.pill_inactive_bg')) - .textAlign(TextAlign.Center) - .margin({ left: 4 }) - .onClick((event: ClickEvent) => { - this.unfolded = !this.unfolded; - }) + Blank() } .width('100%') .height(48) .alignItems(VerticalAlign.Center) - .onClick(() => { - this.jumpToCardTitle(); - }) } @Builder @@ -197,18 +219,19 @@ export struct MarketRankingNodeComponent { Row({ space: 8 }) { ForEach(TAB_METRICS, (metric: TabMetric, index: number) => { Text(metric.label) - .fontSize(14) - .fontWeight(this.primaryIdx === index ? FontWeight.Bold : FontWeight.Normal) .fontColor(this.primaryIdx === index ? $r('app.color.text_blue') : $r('app.color.text_secondary')) - .constraintSize({ minWidth: 63 }) + .fontSize(14) + .fontWeight(this.primaryIdx === index ? FontWeight.Medium : FontWeight.Normal) + .padding({ left: 8, right: 8 }) + .height(30) + .constraintSize({ minWidth: 66 }) .textAlign(TextAlign.Center) - .padding({ top: 6, bottom: 6, left: 12, right: 12 }) - .borderRadius(4) .backgroundColor(this.primaryIdx === index ? $r('app.color.pill_active_bg') : $r('app.color.pill_inactive_bg')) + .borderRadius(4) .onClick(() => { this.primaryIdx = index; this.secondaryIdx = 0; @@ -235,7 +258,7 @@ export struct MarketRankingNodeComponent { .height(30) .linearGradient({ angle: 90, - colors: [['#00FFFFFF', 0], [$r('app.color.card_bg'), 1]], + colors: [[$r('app.color.transparent_white'), 0], [$r('app.color.card_bg'), 1]], }) .hitTestBehavior(HitTestMode.None) } @@ -259,7 +282,7 @@ export struct MarketRankingNodeComponent { } Text(period.label) .fontSize(12) - .fontWeight(this.secondaryIdx === index ? FontWeight.Bold : FontWeight.Normal) + .fontWeight(this.secondaryIdx === index ? FontWeight.Medium : FontWeight.Normal) .fontColor(this.secondaryIdx === index ? $r('app.color.text_primary') : $r('app.color.text_tertiary')) @@ -272,8 +295,7 @@ export struct MarketRankingNodeComponent { } .width('100%') .justifyContent(FlexAlign.End) - .padding({ left: 10, right: 10 }) - .margin({ top: 8, left: -10, right: -10 }) + .margin({ top: 8 }) } if (this.cardData.tableList.length === 0) { @@ -284,98 +306,89 @@ export struct MarketRankingNodeComponent { } .width('100%') .height(this.emptyHeight()) - .margin({ top: 10 }) .alignItems(HorizontalAlign.Center) .justifyContent(FlexAlign.Center) } else { - // 表头 - Row() { - Text($r('app.string.market_ranking_contract_name')) - .fontSize(12) - .lineHeight(21) - .fontColor($r('app.color.text_tertiary')) - .width('35%') - Text($r('app.string.market_ranking_latest_price')) - .fontSize(12) - .lineHeight(21) - .fontColor($r('app.color.text_tertiary')) - .layoutWeight(1) - .margin({ left: 8 }) - .textAlign(TextAlign.End) - Text(this.thirdColumnTitle()) - .fontSize(12) - .lineHeight(21) - .fontColor($r('app.color.text_tertiary')) - .layoutWeight(1) - .margin({ left: 8 }) - .textAlign(TextAlign.End) + Column() { + // 表头 + Row({ space: 8 }) { + Text($r('app.string.market_ranking_contract_name')) + .fontSize(14) + .fontColor($r('app.color.text_tertiary')) + .width('35%') + Text($r('app.string.market_ranking_latest_price')) + .fontSize(14) + .fontColor($r('app.color.text_tertiary')) + .layoutWeight(1) + .textAlign(TextAlign.End) + Text(this.thirdColumnTitle()) + .fontSize(14) + .fontColor($r('app.color.text_tertiary')) + .layoutWeight(1) + .textAlign(TextAlign.End) + } + .width('100%') + .height(40) + + // 表格列表 + ForEach( + this.cardData.tableList.slice(0, this.displaySize()), + (item: RankItem, _index: number) => { + Row({ space: 8 }) { + Text(item.name) + .fontSize(16) + .maxFontSize(16) + .minFontSize(9) + .fontColor($r('app.color.text_primary')) + .width('35%') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(item.price === undefined ? '--' : `${item.price}`) + .fontSize(16) + .fontWeight(500) + .maxFontSize(16) + .minFontSize(9) + .fontColor(this.priceColor(item)) + .layoutWeight(1) + .textAlign(TextAlign.End) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.thirdColumnValue(item)) + .fontSize(16) + .fontWeight(500) + .maxFontSize(16) + .minFontSize(9) + .fontColor(this.thirdColumnColor(item)) + .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.divider_color') }) + .onClick(() => { + if (item.market !== undefined) { + this.jumpToDetail(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(23) - .margin({ top: 10 }) - .padding({ bottom: 2 }) - .alignItems(VerticalAlign.Center) - .border({ width: { bottom: 0.5 }, color: $r('app.color.divider_color') }) - - // 表格列表 - ForEach( - this.cardData.tableList.slice(0, this.displaySize()), - (item: RankItem, index: number) => { - Row() { - Text(item.name) - .fontSize(16) - .minFontSize(10) - .lineHeight(21) - .fontColor($r('app.color.text_primary')) - .width('35%') - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - Text(item.price === undefined ? '--' : `${item.price}`) - .fontSize(16) - .minFontSize(10) - .lineHeight(21) - .fontFamily('monospace') - .fontColor(this.priceColor(item)) - .layoutWeight(1) - .margin({ left: 8 }) - .textAlign(TextAlign.End) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - Text(this.thirdColumnValue(item)) - .fontSize(16) - .minFontSize(10) - .lineHeight(21) - .fontFamily('monospace') - .fontColor(this.thirdColumnColor(item)) - .layoutWeight(1) - .margin({ left: 8 }) - .textAlign(TextAlign.End) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - } - .width('100%') - .height(45) - .alignItems(VerticalAlign.Center) - .border(index === Math.min( - this.displaySize(), - this.cardData.tableList.length, - ) - 1 - ? undefined - : { width: { bottom: 0.5 }, color: $r('app.color.divider_color') }) - .onClick(() => { - if (item.market !== undefined) { - this.jumpToDetail(item); - } - }) - }, - // 行情更新时字段参与 key,确保整体替换 CardData 后列表行同步刷新。 - (item: RankItem) => - `${item.code}_${item.market}_${item.price ?? ''}_${item.price_chg ?? ''}_` + - `${item.chg_speed ?? ''}_${item.turnover ?? ''}_${item.incre_posit ?? ''}`, - ) + .height(this.emptyHeight()) + .animation({ duration: 180, curve: Curve.EaseInOut }) } } .width('100%') + .backgroundColor($r('app.color.card_bg')) + .borderRadius(4) + .padding({ left: 10, right: 10, bottom: 10 }) .bindSheet(this.showExplainSheet, this.ExplainSheet, { height: SheetSize.FIT_CONTENT, dragBar: true, @@ -393,40 +406,61 @@ export struct MarketRankingNodeComponent { // 先请求合约榜单填充列表,再根据当前 CardData 订阅行情。 private async updateCardData(primaryIdx: number, secondaryIdx: number): Promise { - // 切换 Tab 后立即清理旧列表,避免新请求返回前闪现上一 Tab 的数据。 + 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(); - this.cardData = { tableList: [] }; + if (cachedData !== undefined) { + this.cardData = cachedData; + this.contracts = cardDataToContracts(cachedData); + if (this.firstPageVisible) { + this.subscribeHq(primaryIdx, secondaryIdx); + } + } else { + this.contracts = []; + this.cardData = { tableList: [] }; + } try { const contracts = await MarketRankingDataFetcher .getInstance() .fetchRecommendFutures(primaryIdx, secondaryIdx); - this.cardData = { - tableList: contracts.map((contract: RecommendFuturesItem): RankItem => ({ - code: contract.contract_code, - market: contract.market, - name: contract.contract_name, - })), - }; + if (!this.isCurrentDataRequest(requestVersion, primaryIdx, secondaryIdx)) { + return; + } + this.contracts = contracts; + if (cachedData === undefined) { + this.cardData = contractsToPlaceholderCardData(contracts); + } + this.tabDataCache.set(cacheKey, this.cardData); if (this.firstPageVisible) { this.subscribeHq(primaryIdx, secondaryIdx); } } catch { - this.cardData = { tableList: [] }; + if (!this.isCurrentDataRequest(requestVersion, primaryIdx, secondaryIdx)) { + return; + } + if (cachedData === undefined) { + this.contracts = []; + this.cardData = { tableList: [] }; + } } } - // 使用当前 CardData 中的合约列表订阅行情,推送后整体更新 CardData。 + // 使用 HTTP 保存的合约列表订阅行情,推送后整体更新当前 Tab 缓存。 private subscribeHq(primaryIdx: number, secondaryIdx: number): void { - const contracts = this.cardData.tableList.map((item: RankItem): RecommendFuturesItem => ({ - contract_code: item.code, - contract_name: item.name, - market: item.market, - })); + this.stopSubscribeHq(); + if (this.contracts.length === 0) { + return; + } this.hqUnsubscribe = MarketRankingHqRequestClient .getInstance() - .subscribeHq(contracts, primaryIdx, secondaryIdx, (cardData: CardData): void => { - this.cardData = cardData; + .subscribeHq(this.contracts, primaryIdx, secondaryIdx, (cardData: CardData): void => { + if (primaryIdx === this.primaryIdx && secondaryIdx === this.secondaryIdx) { + this.cardData = cardData; + this.tabDataCache.set(this.getTabCacheKey(primaryIdx, secondaryIdx), cardData); + } }); } @@ -437,6 +471,24 @@ export struct MarketRankingNodeComponent { } } + private isCurrentDataRequest( + requestVersion: number, + primaryIdx: number, + secondaryIdx: number, + ): boolean { + return 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; + } + // 跳转到分时详情页 // todo: 后续跳转为真实的页面 private jumpToDetail(item: RankItem): void { @@ -473,6 +525,10 @@ export struct MarketRankingNodeComponent { } // 以下是UI相关 + private toggleUnfolded(): void { + this.unfolded = !this.unfolded; + } + private showTabGradient(): boolean { const maxOffset = this.tabContentWidth - this.tabViewportWidth; return maxOffset > 0 && this.tabScrollOffset < maxOffset; @@ -480,7 +536,7 @@ export struct MarketRankingNodeComponent { private emptyHeight(): number { const rowHeight = 45; - const headerHeight = 24; + const headerHeight = 40; return this.displaySize() * rowHeight + headerHeight; } diff --git a/entry/src/main/resources/base/element/color.json b/entry/src/main/resources/base/element/color.json index 70bf1a3..436f278 100644 --- a/entry/src/main/resources/base/element/color.json +++ b/entry/src/main/resources/base/element/color.json @@ -15,6 +15,7 @@ { "name": "color_rise", "value": "#FF2436" }, { "name": "color_fall", "value": "#07AB4B" }, { "name": "color_orange", "value": "#FF661A" }, - { "name": "bg_orange", "value": "#1AFF661A" } + { "name": "bg_orange", "value": "#1AFF661A" }, + { "name": "transparent_white", "value": "#00FFFFFF" } ] }