Initial commit: @b2c/first_page HarmonyOS stage-mode HAR

## Repository
- HarmonyOS stage-mode HAR module, consumed by host app
- No standalone build — hvigorw, oh_modules, and ../biz_quote absent here

## Architecture
### Feed Cards
- FirstPage.createNodeView dispatches cards by FirstPageConstant.KEY_*
- Card config from first_page_cards_config.json loaded by FirstPageCardsConfigLoader
- Public entrypoint: Index.ets

### Market Ranking (src/main/ets/market-ranking/)
- HTTP: MarketRankingDataFetcher fetches recommend_futures contracts
- HQ: MarketRankingHqRequestClient subscribes via 4106/frame 2201/scenario qht_qihuo_sort
- TCP reconnect rebuilds subscription from original HTTP contract list
- Async callbacks guarded by request/subscription versions

### Other Cards
- HotList, CommodityOptions, AiDiagnosis, AiRiseFall, SelfSelectedStock, CustomEntryList, FourEntryList
- Each has DataFetcher + HQ request client + NodeComponent

## Key Changes (from CardHarmonyOS reference)
- Added MetricId, PeriodId, SortOrder enums (MarketRankingModels.ets)
- Replaced string comparisons with enum switches in thirdColumnTitle/Value/Color
- Removed redundant undefined guards in thirdColumnColor/priceColor, delegated to riseFallColor
- Inlined priceColor(), use riseFallColor directly
- Tab bar Stack.height(30), Alignment.End (reverted from 46/TopEnd)
- Name column fontWeight removed (default regular)
This commit is contained in:
clz
2026-07-26 01:10:20 +08:00
commit af0025ce38
137 changed files with 13358 additions and 0 deletions
@@ -0,0 +1,706 @@
import { emitter } from '@kit.BasicServicesKit';
import {
CardData,
MetricId,
PeriodRange,
RankItem, RecommendFuturesItem, TabMetric,
} from './MarketRankingModels';
import { MarketRankingDataFetcher } from './MarketRankingDataFetcher';
import { MarketRankingHqRequestClient } from '../node/clients/MarketRankingHqRequestClient';
import { formatGreatNumber, formatPercent, formatPrice, 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, HXLog, jumpToQuote } from 'biz_common';
import { QuoteCustomSettingManager, StandardPriceType } from 'biz_quote';
import { RouterUtil } from '../util/RouterUtil';
const NORMAL_SIZE = 3;
const MAX_SIZE = 5;
@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[] = [];
// 区分加载中和暂无数据,与 HotListNodeComponent 的 dataReady 约定保持一致
@State dataReady: boolean = false;
// Tab 与展开状态
@State primaryIdx: number = 0;
@State secondaryIdx: number = 0;
@State 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 lastStandardPriceType: StandardPriceType =
QuoteCustomSettingManager.getInstance().getStandardPriceType();
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.syncStandardPriceType();
this.restartCurrentSubscription();
}
private syncStandardPriceType(): void {
const currentStandardPriceType: StandardPriceType =
QuoteCustomSettingManager.getInstance().getStandardPriceType();
if (this.lastStandardPriceType !== currentStandardPriceType) {
HXLog.d('MarketRankingNodeComponent', 'Standard price type changed, resubscribe');
this.lastStandardPriceType = currentStandardPriceType;
}
}
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<void> {
if (!this.isComponentActive) {
return;
}
const requestVersion: number = this.dataRequestVersion + 1;
this.dataRequestVersion = requestVersion;
// 切换 Tab 后立即清理旧列表,避免新请求返回前闪现上一 Tab 的数据。
this.stopSubscribeHq();
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;
this.cardData = {
tableList: contracts.map((contract: RecommendFuturesItem): RankItem => ({
code: contract.contract_code,
market: contract.market,
name: contract.contract_name,
})),
};
this.dataReady = true;
if (this.firstPageVisible) {
this.subscribeHq(primaryIdx, secondaryIdx);
}
} catch {
if (!this.isCurrentDataRequest(requestVersion, primaryIdx, secondaryIdx)) {
return;
}
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;
}
});
}
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;
}
// ==================== 页面跳转 ====================
// 对齐 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 的展开/收起按钮
if (this.cardData.tableList.length > NORMAL_SIZE) {
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.unfolded = !this.unfolded;
})
}
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: [['#00FFFFFF', 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())
.margin({ top: 10 })
.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%')
}
@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(formatPrice(item.price))
.fontColor(riseFallColor(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(() => {
this.jumpToDetail(item);
})
}
// ==================== 展示计算 ====================
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);
}
}