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:
@@ -0,0 +1,20 @@
|
||||
import { MetricId, PeriodId, PeriodRange, SortOrder, TabMetric } from './MarketRankingModels';
|
||||
import { TableConstants } from './TableConstants';
|
||||
|
||||
// 一级 Tab 配置:涨幅/跌幅/涨速/跌速/成交额/日增仓。
|
||||
export const TAB_METRICS: TabMetric[] = [
|
||||
{ id: MetricId.RISE, label: $r('app.string.market_ranking_tab_rise'), api: 'rise_percent', hqId: TableConstants.DATA_ID_ZF, sortOrder: SortOrder.ASC, hasChildren: false },
|
||||
{ id: MetricId.FALL, label: $r('app.string.market_ranking_tab_fall'), api: 'fall_percent', hqId: TableConstants.DATA_ID_ZF, sortOrder: SortOrder.DESC, hasChildren: false },
|
||||
{ id: MetricId.RISE_SPEED, label: $r('app.string.market_ranking_tab_rise_speed'), api: 'rise_percent', hqId: TableConstants.DATA_ID_CHG_SPEED_1MIN, sortOrder: SortOrder.ASC, hasChildren: true },
|
||||
{ id: MetricId.FALL_SPEED, label: $r('app.string.market_ranking_tab_fall_speed'), api: 'fall_percent', hqId: TableConstants.DATA_ID_CHG_SPEED_1MIN, sortOrder: SortOrder.DESC, hasChildren: true },
|
||||
{ id: MetricId.TURN_OVER, label: $r('app.string.market_ranking_tab_turnover'), api: 'turnover', hqId: TableConstants.DATA_ID_TURNOVER, sortOrder: SortOrder.ASC, hasChildren: false },
|
||||
{ id: MetricId.INCRE_POSIT, label: $r('app.string.market_ranking_tab_increase_position'), api: 'increase_position', hqId: TableConstants.DATA_ID_INCRE_POSIT, sortOrder: SortOrder.ASC, hasChildren: false },
|
||||
];
|
||||
|
||||
// 二级 Tab 配置:仅涨速/跌速下出现,按周期筛选
|
||||
export const PERIOD_RANGES: PeriodRange[] = [
|
||||
{ id: PeriodId.ONE_MIN, label: $r('app.string.market_ranking_period_1min'), api: 'one_minute', hqId: TableConstants.DATA_ID_CHG_SPEED_1MIN },
|
||||
{ id: PeriodId.FIVE_MIN, label: $r('app.string.market_ranking_period_5min'), api: 'five_minute', hqId: TableConstants.DATA_ID_CHG_SPEED_5MIN },
|
||||
{ id: PeriodId.TEN_MIN, label: $r('app.string.market_ranking_period_10min'), api: 'ten_minute', hqId: TableConstants.DATA_ID_CHG_SPEED_10MIN },
|
||||
{ id: PeriodId.FIFTEEN_MIN, label: $r('app.string.market_ranking_period_15min'), api: 'fifteen_minute', hqId: TableConstants.DATA_ID_CHG_SPEED_15MIN },
|
||||
];
|
||||
@@ -0,0 +1,68 @@
|
||||
import { http } from '@kit.NetworkKit';
|
||||
import { HXLog } from 'biz_common';
|
||||
import { buildHeader } from '../util/Header';
|
||||
import { FirstPageDebugUtil } from '../util/FirstPageDebugUtil';
|
||||
import { FirstPageCardsConfigLoader } from '../node/datacenter/FirstPageCardsConfigLoader';
|
||||
import { AllCardsCard } from '../node/model/AiDiagnosisNodeModel';
|
||||
import {
|
||||
RecommendFuturesItem,
|
||||
RecommendFuturesResponse,
|
||||
} from './MarketRankingModels';
|
||||
import { PERIOD_RANGES, TAB_METRICS } from './MarketRankingConstant';
|
||||
|
||||
export class MarketRankingDataFetcher {
|
||||
private static readonly instance: MarketRankingDataFetcher = new MarketRankingDataFetcher();
|
||||
|
||||
private constructor() {
|
||||
}
|
||||
|
||||
static getInstance(): MarketRankingDataFetcher {
|
||||
return MarketRankingDataFetcher.instance;
|
||||
}
|
||||
|
||||
fetchCardConfig(): AllCardsCard | undefined {
|
||||
const config = FirstPageCardsConfigLoader.getConfig();
|
||||
return config?.marketRanking;
|
||||
}
|
||||
|
||||
async fetchRecommendFutures(primaryIdx: number, secondaryIdx: number): Promise<RecommendFuturesItem[]> {
|
||||
const quoteType: string = this.getQuoteType(primaryIdx, secondaryIdx);
|
||||
const requestUrl: string = FirstPageDebugUtil
|
||||
.getMarketRankingRecommendUrl(FirstPageDebugUtil.isMarketRankingUseTestUrl)
|
||||
.replace('%s', quoteType);
|
||||
const requestOptions: http.HttpRequestOptions = {
|
||||
method: http.RequestMethod.GET,
|
||||
header: buildHeader(),
|
||||
connectTimeout: 60000,
|
||||
readTimeout: 60000,
|
||||
};
|
||||
|
||||
const httpRequest: http.HttpRequest = http.createHttp();
|
||||
try {
|
||||
const response: http.HttpResponse = await httpRequest.request(requestUrl, requestOptions);
|
||||
if (response.responseCode !== http.ResponseCode.OK) {
|
||||
throw new Error('request failed: ' + response.responseCode);
|
||||
}
|
||||
const responseText: string = response.result as string;
|
||||
const result = JSON.parse(responseText) as RecommendFuturesResponse;
|
||||
if (result.code !== 0 || !result.data) {
|
||||
throw new Error('response code: ' + result.code);
|
||||
}
|
||||
return result.data;
|
||||
} catch (requestError) {
|
||||
const errorInfo = requestError as Error;
|
||||
HXLog.e('MarketRankingDataFetcher', 'fetchRecommendFutures error: ' + errorInfo.message);
|
||||
throw new Error(errorInfo.message);
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private getQuoteType(primaryIdx: number, secondaryIdx: number): string {
|
||||
const metric = TAB_METRICS[primaryIdx];
|
||||
if (metric.hasChildren) {
|
||||
return `${PERIOD_RANGES[secondaryIdx].api}_${metric.api}`;
|
||||
}
|
||||
return metric.api;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
export enum MetricId {
|
||||
RISE = 'rise',
|
||||
FALL = 'fall',
|
||||
RISE_SPEED = 'riseSpeed',
|
||||
FALL_SPEED = 'fallSpeed',
|
||||
TURN_OVER = 'turnOver',
|
||||
INCRE_POSIT = 'increPosit',
|
||||
}
|
||||
|
||||
export enum PeriodId {
|
||||
ONE_MIN = '1min',
|
||||
FIVE_MIN = '5min',
|
||||
TEN_MIN = '10min',
|
||||
FIFTEEN_MIN = '15min',
|
||||
}
|
||||
|
||||
export enum SortOrder {
|
||||
ASC = '0',
|
||||
DESC = '1',
|
||||
}
|
||||
|
||||
// ============ 视图数据类型(合并接口 + 行情后,供 View 消费) ============
|
||||
// 保留原始字段,不在数据层预先格式化/预先判断涨跌色;HTTP 占位行允许行情字段暂缺。
|
||||
export interface RankItem {
|
||||
code: string;
|
||||
market: string;
|
||||
name: string;
|
||||
price?: number;
|
||||
// 涨跌幅(%数值,如 3.21 表示 +3.21%)。行情推送里固定字段,任何 Tab 下都存在,
|
||||
// 用于「最新价」列的取色,也是 涨幅/跌幅 Tab 第三列的数据来源。
|
||||
price_chg?: number;
|
||||
// 涨速/跌速(%数值)。仅 riseSpeed/fallSpeed Tab 下有值。
|
||||
chg_speed?: number;
|
||||
// 成交额(原始数值,未换算单位)。仅 turnOver Tab 下有值。
|
||||
turnover?: number;
|
||||
// 日增仓(原始数值,未换算单位)。仅 increPosit Tab 下有值。
|
||||
incre_posit?: number;
|
||||
}
|
||||
|
||||
// 当前市场排名卡片的完整展示数据;网络/行情更新后整体替换以触发 UI 刷新。
|
||||
export interface CardData {
|
||||
tableList: RankItem[];
|
||||
}
|
||||
|
||||
export interface TabMetric {
|
||||
id: MetricId;
|
||||
label: ResourceStr;
|
||||
api: string; // HTTP 接口 quote_type 参数用
|
||||
hqId: number; // 行情订阅 sortid 用
|
||||
sortOrder: string; // 行情排序方向 '0' 升序 '1' 降序
|
||||
hasChildren: boolean; // Vue 用 children 数组本身判断,这里简化为布尔标记
|
||||
}
|
||||
|
||||
export interface PeriodRange {
|
||||
id: PeriodId;
|
||||
label: ResourceStr;
|
||||
api: string;
|
||||
hqId: number;
|
||||
}
|
||||
|
||||
// ============ 后端接口原始响应类型(recommend_futures) ============
|
||||
// 对应 docs/cards/interfaces.md「一、HTTP 接口 - 1. 市场排名列表」
|
||||
|
||||
export interface RecommendFuturesItem {
|
||||
contract_code: string;
|
||||
contract_name: string;
|
||||
market: string;
|
||||
}
|
||||
|
||||
export interface RecommendFuturesResponse {
|
||||
code: number;
|
||||
data: RecommendFuturesItem[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// 市场排名使用的 4106 行情字段 ID。
|
||||
export class TableConstants {
|
||||
static readonly DATA_ID_CODE_4: number = 4; // 合约代码
|
||||
static readonly DATA_ID_PRE_PRICE: number = 6; // 昨收价
|
||||
static readonly DATA_ID_OPEN_PRICE: number = 7; // 今日开盘价
|
||||
static readonly DATA_ID_PRICE: number = 10; // 最新价
|
||||
static readonly DATA_ID_TURNOVER: number = 19; // 成交额
|
||||
static readonly DATA_ID_NAME: number = 55; // 合约名称
|
||||
static readonly DATA_ID_JSJ: number = 66; // 昨日结算价
|
||||
static readonly DATA_ID_CHG_SPEED_5MIN: number = 34325; // 5 分钟涨跌速
|
||||
static readonly DATA_ID_INCRE_POSIT: number = 34355; // 日增仓
|
||||
static readonly DATA_ID_ZF: number = 34818; // 涨跌幅
|
||||
static readonly DATA_ID_CHG_SPEED_1MIN: number = 34874; // 1 分钟涨跌速
|
||||
static readonly DATA_ID_CHG_SPEED_10MIN: number = 34875; // 10 分钟涨跌速
|
||||
static readonly DATA_ID_CHG_SPEED_15MIN: number = 34876; // 15 分钟涨跌速
|
||||
static readonly DATA_ID_ZF_YTD: number = 34877; // 昨收涨跌幅
|
||||
static readonly DATA_ID_MARKET: number = 36103; // 市场 ID
|
||||
|
||||
private constructor() {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user