feat(market-ranking): sync card UI and state

This commit is contained in:
clz
2026-07-27 15:19:34 +08:00
parent 888f7e316c
commit 7f38efa220
5 changed files with 231 additions and 149 deletions
+1
View File
@@ -32,5 +32,6 @@ HTTP 只负责返回 contracts;最新价、涨跌幅、成交额等动态字
当前 HTTP 和行情连接均为 Mock。接入真实环境时,主要替换 `MarketRankingDataFetcher` 当前 HTTP 和行情连接均为 Mock。接入真实环境时,主要替换 `MarketRankingDataFetcher`
`MarketRankingHqRequestClient` 的内部实现,视图层数据结构和生命周期保持不变。 `MarketRankingHqRequestClient` 的内部实现,视图层数据结构和生命周期保持不变。
真实 HTTP 接入后还应增加请求版本校验或取消机制,避免快速切换 Tab 时较早的响应覆盖当前状态。
具体 HTTP 与 4106 参数见 [市场排名接口文档](market-ranking-interfaces.md)。 具体 HTTP 与 4106 参数见 [市场排名接口文档](market-ranking-interfaces.md)。
@@ -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;
}
@@ -202,7 +202,7 @@ export class MarketRankingHqRequestClient {
return value; return value;
} }
if (typeof value === 'string' && value !== '') { if (typeof value === 'string' && value !== '') {
const result = Number(value); const result = parseFloat(value);
return Number.isNaN(result) ? undefined : result; return Number.isNaN(result) ? undefined : result;
} }
return undefined; return undefined;
@@ -5,6 +5,10 @@ import {
RankItem, RecommendFuturesItem, TabMetric, RankItem, RecommendFuturesItem, TabMetric,
} from './MarketRankingModels'; } from './MarketRankingModels';
import { MarketRankingDataFetcher } from './MarketRankingDataFetcher'; import { MarketRankingDataFetcher } from './MarketRankingDataFetcher';
import {
cardDataToContracts,
contractsToPlaceholderCardData,
} from './MarketRankingDataMapper';
import { MarketRankingHqRequestClient } from './MarketRankingHqRequestClient'; import { MarketRankingHqRequestClient } from './MarketRankingHqRequestClient';
import { formatGreatNumber, formatPercent, riseFallColor } from '../common/NumberFormat'; import { formatGreatNumber, formatPercent, riseFallColor } from '../common/NumberFormat';
import { AllCardsCard } from '../common/AllCardsModels'; import { AllCardsCard } from '../common/AllCardsModels';
@@ -24,15 +28,21 @@ import {
const NORMAL_SIZE = 3; const NORMAL_SIZE = 3;
const MAX_SIZE = 5; const MAX_SIZE = 5;
const UNFOLDED_STORAGE_KEY = 'firstPageMarketRankingUnfolded';
PersistentStorage.persistProp<boolean>(UNFOLDED_STORAGE_KEY, false);
@Component @Component
export struct MarketRankingNodeComponent { export struct MarketRankingNodeComponent {
@State cardConfig: AllCardsCard | undefined = undefined; @State cardConfig: AllCardsCard | undefined = undefined;
@State cardData: CardData = { tableList: [] }; @State cardData: CardData = { tableList: [] };
private contracts: RecommendFuturesItem[] = [];
private tabDataCache: Map<string, CardData> = new Map<string, CardData>();
private dataRequestVersion: number = 0;
// Tab 选中/展开状态是纯 UI 交互状态,留在 View 里 // Tab 选中/展开状态是纯 UI 交互状态,留在 View 里
@State primaryIdx: number = 0; @State primaryIdx: number = 0;
@State secondaryIdx: number = 0; @State secondaryIdx: number = 0;
@State unfolded: boolean = false; @StorageLink(UNFOLDED_STORAGE_KEY) unfolded: boolean = false;
@State tabViewportWidth: number = 0; @State tabViewportWidth: number = 0;
@State tabContentWidth: number = 0; @State tabContentWidth: number = 0;
@State tabScrollOffset: number = 0; @State tabScrollOffset: number = 0;
@@ -58,6 +68,7 @@ export struct MarketRankingNodeComponent {
} }
aboutToDisappear(): void { aboutToDisappear(): void {
this.dataRequestVersion += 1;
emitter.off(EVENT_TCP_RECONNECT, this.tcpReconnectListener); emitter.off(EVENT_TCP_RECONNECT, this.tcpReconnectListener);
this.stopSubscribeHq(); this.stopSubscribeHq();
} }
@@ -100,50 +111,61 @@ export struct MarketRankingNodeComponent {
@Builder @Builder
CardTitle() { CardTitle() {
Row({ space: 4 }) { Row() {
Text(this.cardConfig?.card_title.value || $r('app.string.market_ranking_title')) Row() {
.fontSize(18) Text(this.cardConfig?.card_title.value || $r('app.string.market_ranking_title'))
.fontWeight(FontWeight.Bold) .fontSize(18)
.fontColor($r('app.color.text_primary')) .fontWeight(500)
.maxLines(1) .fontColor($r('app.color.text_primary'))
.maxLines(1)
if ((this.cardConfig?.card_url.android ?? '') !== '' || if ((this.cardConfig?.card_url.android ?? '') !== '' ||
(this.cardConfig?.card_url.ios ?? '') !== '') { (this.cardConfig?.card_url.ios ?? '') !== '') {
Image(this.isDarkMode ? TITLE_ARROW_DARK : TITLE_ARROW_LIGHT) Image(this.isDarkMode ? TITLE_ARROW_DARK : TITLE_ARROW_LIGHT)
.width(12) .width(20)
.height(12) .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 ?? '') !== '') { Row() {
Image(this.isDarkMode ? INFO_IMAGE_DARK : INFO_IMAGE_LIGHT) Text(this.unfolded
.width(16) ? $r('app.string.market_ranking_collapse')
: $r('app.string.market_ranking_expand'))
.fontSize(12)
.fontColor($r('app.color.text_tertiary'))
.height(16) .height(16)
.onClick((event: ClickEvent) => { .width('100%')
this.showExplainSheet = true; .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 Blank()
? $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;
})
} }
.width('100%') .width('100%')
.height(48) .height(48)
.alignItems(VerticalAlign.Center) .alignItems(VerticalAlign.Center)
.onClick(() => {
this.jumpToCardTitle();
})
} }
@Builder @Builder
@@ -197,18 +219,19 @@ export struct MarketRankingNodeComponent {
Row({ space: 8 }) { Row({ space: 8 }) {
ForEach(TAB_METRICS, (metric: TabMetric, index: number) => { ForEach(TAB_METRICS, (metric: TabMetric, index: number) => {
Text(metric.label) Text(metric.label)
.fontSize(14)
.fontWeight(this.primaryIdx === index ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.primaryIdx === index .fontColor(this.primaryIdx === index
? $r('app.color.text_blue') ? $r('app.color.text_blue')
: $r('app.color.text_secondary')) : $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) .textAlign(TextAlign.Center)
.padding({ top: 6, bottom: 6, left: 12, right: 12 })
.borderRadius(4)
.backgroundColor(this.primaryIdx === index .backgroundColor(this.primaryIdx === index
? $r('app.color.pill_active_bg') ? $r('app.color.pill_active_bg')
: $r('app.color.pill_inactive_bg')) : $r('app.color.pill_inactive_bg'))
.borderRadius(4)
.onClick(() => { .onClick(() => {
this.primaryIdx = index; this.primaryIdx = index;
this.secondaryIdx = 0; this.secondaryIdx = 0;
@@ -235,7 +258,7 @@ export struct MarketRankingNodeComponent {
.height(30) .height(30)
.linearGradient({ .linearGradient({
angle: 90, 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) .hitTestBehavior(HitTestMode.None)
} }
@@ -259,7 +282,7 @@ export struct MarketRankingNodeComponent {
} }
Text(period.label) Text(period.label)
.fontSize(12) .fontSize(12)
.fontWeight(this.secondaryIdx === index ? FontWeight.Bold : FontWeight.Normal) .fontWeight(this.secondaryIdx === index ? FontWeight.Medium : FontWeight.Normal)
.fontColor(this.secondaryIdx === index .fontColor(this.secondaryIdx === index
? $r('app.color.text_primary') ? $r('app.color.text_primary')
: $r('app.color.text_tertiary')) : $r('app.color.text_tertiary'))
@@ -272,8 +295,7 @@ export struct MarketRankingNodeComponent {
} }
.width('100%') .width('100%')
.justifyContent(FlexAlign.End) .justifyContent(FlexAlign.End)
.padding({ left: 10, right: 10 }) .margin({ top: 8 })
.margin({ top: 8, left: -10, right: -10 })
} }
if (this.cardData.tableList.length === 0) { if (this.cardData.tableList.length === 0) {
@@ -284,98 +306,89 @@ export struct MarketRankingNodeComponent {
} }
.width('100%') .width('100%')
.height(this.emptyHeight()) .height(this.emptyHeight())
.margin({ top: 10 })
.alignItems(HorizontalAlign.Center) .alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center) .justifyContent(FlexAlign.Center)
} else { } else {
// 表头 Column() {
Row() { // 表头
Text($r('app.string.market_ranking_contract_name')) Row({ space: 8 }) {
.fontSize(12) Text($r('app.string.market_ranking_contract_name'))
.lineHeight(21) .fontSize(14)
.fontColor($r('app.color.text_tertiary')) .fontColor($r('app.color.text_tertiary'))
.width('35%') .width('35%')
Text($r('app.string.market_ranking_latest_price')) Text($r('app.string.market_ranking_latest_price'))
.fontSize(12) .fontSize(14)
.lineHeight(21) .fontColor($r('app.color.text_tertiary'))
.fontColor($r('app.color.text_tertiary')) .layoutWeight(1)
.layoutWeight(1) .textAlign(TextAlign.End)
.margin({ left: 8 }) Text(this.thirdColumnTitle())
.textAlign(TextAlign.End) .fontSize(14)
Text(this.thirdColumnTitle()) .fontColor($r('app.color.text_tertiary'))
.fontSize(12) .layoutWeight(1)
.lineHeight(21) .textAlign(TextAlign.End)
.fontColor($r('app.color.text_tertiary')) }
.layoutWeight(1) .width('100%')
.margin({ left: 8 }) .height(40)
.textAlign(TextAlign.End)
// 表格列表
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%') .width('100%')
.height(23) .height(this.emptyHeight())
.margin({ top: 10 }) .animation({ duration: 180, curve: Curve.EaseInOut })
.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 ?? ''}`,
)
} }
} }
.width('100%') .width('100%')
.backgroundColor($r('app.color.card_bg'))
.borderRadius(4)
.padding({ left: 10, right: 10, bottom: 10 })
.bindSheet(this.showExplainSheet, this.ExplainSheet, { .bindSheet(this.showExplainSheet, this.ExplainSheet, {
height: SheetSize.FIT_CONTENT, height: SheetSize.FIT_CONTENT,
dragBar: true, dragBar: true,
@@ -393,40 +406,61 @@ export struct MarketRankingNodeComponent {
// 先请求合约榜单填充列表,再根据当前 CardData 订阅行情。 // 先请求合约榜单填充列表,再根据当前 CardData 订阅行情。
private async updateCardData(primaryIdx: number, secondaryIdx: number): Promise<void> { private async updateCardData(primaryIdx: number, secondaryIdx: number): Promise<void> {
// 切换 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.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 { try {
const contracts = await MarketRankingDataFetcher const contracts = await MarketRankingDataFetcher
.getInstance() .getInstance()
.fetchRecommendFutures(primaryIdx, secondaryIdx); .fetchRecommendFutures(primaryIdx, secondaryIdx);
this.cardData = { if (!this.isCurrentDataRequest(requestVersion, primaryIdx, secondaryIdx)) {
tableList: contracts.map((contract: RecommendFuturesItem): RankItem => ({ return;
code: contract.contract_code, }
market: contract.market, this.contracts = contracts;
name: contract.contract_name, if (cachedData === undefined) {
})), this.cardData = contractsToPlaceholderCardData(contracts);
}; }
this.tabDataCache.set(cacheKey, this.cardData);
if (this.firstPageVisible) { if (this.firstPageVisible) {
this.subscribeHq(primaryIdx, secondaryIdx); this.subscribeHq(primaryIdx, secondaryIdx);
} }
} catch { } 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 { private subscribeHq(primaryIdx: number, secondaryIdx: number): void {
const contracts = this.cardData.tableList.map((item: RankItem): RecommendFuturesItem => ({ this.stopSubscribeHq();
contract_code: item.code, if (this.contracts.length === 0) {
contract_name: item.name, return;
market: item.market, }
}));
this.hqUnsubscribe = MarketRankingHqRequestClient this.hqUnsubscribe = MarketRankingHqRequestClient
.getInstance() .getInstance()
.subscribeHq(contracts, primaryIdx, secondaryIdx, (cardData: CardData): void => { .subscribeHq(this.contracts, primaryIdx, secondaryIdx, (cardData: CardData): void => {
this.cardData = cardData; 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: 后续跳转为真实的页面 // todo: 后续跳转为真实的页面
private jumpToDetail(item: RankItem): void { private jumpToDetail(item: RankItem): void {
@@ -473,6 +525,10 @@ export struct MarketRankingNodeComponent {
} }
// 以下是UI相关 // 以下是UI相关
private toggleUnfolded(): void {
this.unfolded = !this.unfolded;
}
private showTabGradient(): boolean { private showTabGradient(): boolean {
const maxOffset = this.tabContentWidth - this.tabViewportWidth; const maxOffset = this.tabContentWidth - this.tabViewportWidth;
return maxOffset > 0 && this.tabScrollOffset < maxOffset; return maxOffset > 0 && this.tabScrollOffset < maxOffset;
@@ -480,7 +536,7 @@ export struct MarketRankingNodeComponent {
private emptyHeight(): number { private emptyHeight(): number {
const rowHeight = 45; const rowHeight = 45;
const headerHeight = 24; const headerHeight = 40;
return this.displaySize() * rowHeight + headerHeight; return this.displaySize() * rowHeight + headerHeight;
} }
@@ -15,6 +15,7 @@
{ "name": "color_rise", "value": "#FF2436" }, { "name": "color_rise", "value": "#FF2436" },
{ "name": "color_fall", "value": "#07AB4B" }, { "name": "color_fall", "value": "#07AB4B" },
{ "name": "color_orange", "value": "#FF661A" }, { "name": "color_orange", "value": "#FF661A" },
{ "name": "bg_orange", "value": "#1AFF661A" } { "name": "bg_orange", "value": "#1AFF661A" },
{ "name": "transparent_white", "value": "#00FFFFFF" }
] ]
} }