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,111 @@
import { SortItem, TableRequestClient, TableConstants } from '@b2c/lib_baseui';
import { HeaderItem, TableData } from '@b2b/hq_table';
import { StandardPriceTypeHelper } from 'biz_quote';
import { RequestHelper } from '@kernel/lib_communication';
/**
* AI诊断卡片行情请求客户端
* 参考 FirstPageSelfStockRequestClient 实现,支持动态 computemode 和基准价
*/
export class AiDiagnosisRequestClient extends TableRequestClient {
// 合约代码
private code: string = ''
// 市场代码
private market: string = ''
// 扩展字段ID列表(用于基准价计算)
private extFieldIds: number[] = []
constructor(frameId: number, pageId: number, code: string, market: string,
headerConfig: Array<HeaderItem>, sortItem?: SortItem | null,
dataReceiveListener?: (tableData: TableData | string) => void) {
super(frameId, pageId, headerConfig, sortItem, dataReceiveListener, undefined)
this.code = code
this.market = market
// 设置订阅字段 ID(包含扩展字段用于基准价计算)
let ids: number[] = []
headerConfig.forEach((item: HeaderItem) => {
this.addId(ids, item.mobileid)
})
// 添加基准价计算所需的扩展字段
this.extFieldIds = [
TableConstants.DATA_ID_CODE_4,
TableConstants.DATA_ID_PRICE,
TableConstants.DATA_ID_ZF,
TableConstants.DATA_ID_ZD,
TableConstants.DATA_ID_MARKET,
TableConstants.DATA_ID_MARKET_OLD,
StandardPriceTypeHelper.FIELD_ZUO_SHOU,
StandardPriceTypeHelper.FIELD_JIN_KAI,
StandardPriceTypeHelper.FIELD_ZUO_JIE
]
this.extFieldIds.forEach((id: number) => {
this.addId(ids, id)
})
this.setSubscribeIds(ids)
}
private addId(ids: number[], id: number): void {
if (!ids.includes(id)) {
ids.push(id)
}
}
/**
* 重写请求参数构建
* 与 hqHelper.js buildHqParams 保持一致的参数格式
*/
protected createRequestParam(): string {
let param = "\r\nsortid=-1"
param += "\r\nsortorder=0"
param += "\r\npush=1"
param += "\r\n" + this.createHeaderParam()
param += this.createStockListParam() // 合约列表参数(format4106Data 格式)
param += "\r\nscenario=qht_qihuo_sort"
param += "\r\nprecision=1"
param += "\r\npushtime=2.5"
return param
}
protected doRequest() {
let requestParam = this.createRequestParam()
RequestHelper.getHqManager().addRequestStructToBuff(this.frameId, this.pageId, this.getInstanceId(), requestParam)
RequestHelper.build().base(this.frameId, this.pageId, this, requestParam).request();
}
/**
* 构建合约列表参数
* 与 hqHelper.js format4106Data 保持一致格式: market1(code1,code2,);market2(code3,);
*/
private createStockListParam(): string {
if (!this.code || !this.market) {
return ''
}
// 使用 format4106Data 格式
return "\r\ncodelist=" + this.market + "(" + this.code + ",);"
}
/**
* 生成 dataitem 参数
* 与 hqHelper.js dataitem 保持一致格式:字段ID用逗号分隔
*/
private createHeaderParam(): string {
let param = "dataitem="
this.headerConfig.forEach((item: HeaderItem) => {
param += item.mobileid + ","
if (item.extDataIds) {
item.extDataIds.forEach((id: number) => {
param += id + ","
})
}
})
return param
}
release(isDestory: boolean = false): void {
super.release(isDestory)
}
}
@@ -0,0 +1,212 @@
import { TableConstants, TableRequestClient } from '@b2c/lib_baseui';
import { AbsRowData, HeaderItem, RowData, TableData } from '@b2b/hq_table';
import { TableConstants as HXTableConstants } from '@b2c/lib_baseui/src/main/ets/constant/ConstantsTable';
import { HXLog } from 'biz_common';
import { StandardPriceTypeHelper, applyStandardPriceToTableData } from 'biz_quote';
import { RequestHelper, StuffTableStruct } from '@kernel/lib_communication';
/**
* AI看涨跌卡片行情请求客户端
* 参考 FirstPageSelfStockRequestClient 实现,支持动态 computemode 和基准价
*/
export class AiRiseFallHqRequestClient extends TableRequestClient {
// 行情数据回调
private hqDataCallback?: (hqs: ContractHqItem[]) => void
// 合约代码列表
private codeList: string[] = []
// 市场代码列表
private marketList: string[] = []
// 扩展字段ID列表(用于基准价计算)
private extFieldIds: number[] = []
constructor(frameId: number, pageId: number, headerConfig: Array<HeaderItem>,
dataReceiveListener?: (tableData: TableData | string) => void) {
super(frameId, pageId, headerConfig, null, dataReceiveListener, undefined)
this.initSubscribeIds(headerConfig)
}
/**
* 初始化订阅字段ID(包含扩展字段用于基准价计算)
*/
private initSubscribeIds(headerConfig: Array<HeaderItem>): void {
let ids: number[] = []
headerConfig.forEach((item: HeaderItem) => {
this.addId(ids, item.mobileid)
})
// 添加基准价计算所需的扩展字段
this.extFieldIds = [
TableConstants.DATA_ID_CODE_4,
TableConstants.DATA_ID_PRICE,
TableConstants.DATA_ID_ZF,
TableConstants.DATA_ID_ZD,
TableConstants.DATA_ID_MARKET,
TableConstants.DATA_ID_MARKET_OLD,
StandardPriceTypeHelper.FIELD_ZUO_SHOU,
StandardPriceTypeHelper.FIELD_JIN_KAI,
StandardPriceTypeHelper.FIELD_ZUO_JIE
]
this.extFieldIds.forEach((id: number) => {
this.addId(ids, id)
})
this.setSubscribeIds(ids)
}
private addId(ids: number[], id: number): void {
if (!ids.includes(id)) {
ids.push(id)
}
}
/**
* 设置合约列表并请求数据
* @param codes 合约代码数组
* @param markets 市场代码数组
*/
setStockListAndRequest(codes: string[], markets: string[]): void {
if (codes.length !== markets.length) {
HXLog.e('AiRiseFallHqRequestClient', 'codes and markets length mismatch')
return
}
this.codeList = codes
this.marketList = markets
this.request(0, codes.length)
}
/**
* 设置行情数据回调
*/
setHqDataCallback(callback: (hqs: ContractHqItem[]) => void): void {
this.hqDataCallback = callback
}
/**
* 重写 release 方法
*/
release(isDestory: boolean = false): void {
super.release(isDestory)
}
/**
* 重写请求参数构建
* 与 hqHelper.js buildHqParams 保持一致的参数格式
*/
protected createRequestParam(): string {
let param = "\r\nsortid=-1"
param += "\r\nsortorder=0"
param += "\r\npush=1"
param += "\r\n" + this.createHeaderParam()
param += this.createStockListParam() // 合约列表参数(format4106Data 格式)
param += "\r\nscenario=qht_qihuo_sort"
param += "\r\nprecision=1"
param += "\r\npushtime=2.5"
return param
}
protected doRequest() {
let requestParam = this.createRequestParam()
RequestHelper.getHqManager().addRequestStructToBuff(this.frameId, this.pageId, this.getInstanceId(), requestParam)
RequestHelper.build().base(this.frameId, this.pageId, this, requestParam).request();
}
/**
* 构建合约列表参数
* 与 hqHelper.js format4106Data 保持一致格式: market1(code1,code2,);market2(code3,);
*/
private createStockListParam(): string {
if (this.codeList.length === 0 || this.marketList.length === 0) {
return ''
}
// 按市场分组,生成 format4106Data 格式
const marketMap: Map<string, string[]> = new Map()
for (let i = 0; i < this.codeList.length; i++) {
const market = this.marketList[i]
const code = this.codeList[i]
if (marketMap.has(market)) {
marketMap.get(market)!.push(code + ',')
} else {
marketMap.set(market, [code + ','])
}
}
let codelist = ''
marketMap.forEach((codes, market) => {
codelist += market + '(' + codes.join('') + ');'
})
return "\r\ncodelist=" + codelist
}
/**
* 构建表头参数
*/
private createHeaderParam(): string {
let param = "dataitem="
let addedIds: number[] = []
this.headerConfig.forEach((item: HeaderItem) => {
param += item.mobileid + ","
addedIds.push(item.mobileid)
if (item.extDataIds) {
item.extDataIds.forEach((id: number) => {
param += id + ","
addedIds.push(id)
})
}
})
return param
}
/**
* 数据解析完成回调
*/
protected afterParserModel(tableData: TableData, structData: StuffTableStruct): void {
// 先按当前基准价类型补齐 originalData 并重算 ZF/ZD(首次与实时推送都会走到这里),
// 保证下面读取的 priceChg 是按用户所选基准价计算的口径。
applyStandardPriceToTableData(tableData, structData, this.extFieldIds)
// 解析行情数据
const hqs: ContractHqItem[] = []
if (tableData.tableRows) {
tableData.tableRows.forEach((row: AbsRowData) => {
if (row instanceof RowData) {
const data = row.originalData
const code = data.get(HXTableConstants.DATA_ID_CODE_4)
const market = data.get(HXTableConstants.DATA_ID_MARKET)
const name = data.get(HXTableConstants.DATA_ID_NAME)
const price = data.get(HXTableConstants.DATA_ID_PRICE)
const priceChg = data.get(HXTableConstants.DATA_ID_ZF) // 涨跌幅字段
if (typeof code === 'string' && typeof market === 'string') {
let priceChgValue: number | null = null
if (priceChg && typeof priceChg === 'string' && priceChg !== '--') {
priceChgValue = parseFloat(priceChg)
}
hqs.push({
code: code,
market: market,
name: typeof name === 'string' ? name : '',
price: typeof price === 'string' ? price : '--',
priceChg: priceChgValue
})
}
}
})
}
// 回调通知
if (this.hqDataCallback && hqs.length > 0) {
this.hqDataCallback(hqs)
}
}
}
/**
* 合约行情数据项
*/
export interface ContractHqItem {
code: string
market: string
name: string
price: string
priceChg: number | null
}
@@ -0,0 +1,175 @@
import { TableConstants, TableRequestClient } from '@b2c/lib_baseui';
import { AbsRowData, HeaderItem, RowData, TableData } from '@b2b/hq_table';
import { HXLog } from 'biz_common';
import { RequestHelper } from '@kernel/lib_communication';
/**
* 商品期权卡片行情请求客户端
* 参考 AiRiseFallHqRequestClient 实现,使用 codelist 格式
*
* Author: YS
* Date: 2026-06-03
*/
export class CommodityOptionsHqRequestClient extends TableRequestClient {
private codeList: string[] = []
private marketList: string[] = []
private hqDataCallback?: (hqs: CommodityOptionHqItem[]) => void
constructor(frameId: number, pageId: number, headerConfig: Array<HeaderItem>,
dataReceiveListener?: (tableData: TableData | string) => void) {
super(frameId, pageId, headerConfig, null, dataReceiveListener, undefined)
this.initSubscribeIds(headerConfig)
}
private initSubscribeIds(headerConfig: Array<HeaderItem>): void {
let ids: number[] = []
headerConfig.forEach((item: HeaderItem) => {
if (!ids.includes(item.mobileid)) {
ids.push(item.mobileid)
}
})
// 订阅行情所需字段
const extFieldIds = [
TableConstants.DATA_ID_CODE_4,
TableConstants.DATA_ID_PRICE,
TableConstants.DATA_ID_MARKET,
TableConstants.DATA_ID_ZF_YTD,
]
extFieldIds.forEach((id: number) => {
if (!ids.includes(id)) {
ids.push(id)
}
})
this.setSubscribeIds(ids)
}
setStockListAndRequest(codes: string[], markets: string[]): void {
if (codes.length !== markets.length) {
HXLog.e('CommodityOptionsHqRequestClient', 'codes and markets length mismatch')
return
}
this.codeList = codes
this.marketList = markets
this.request(0, codes.length)
}
setHqDataCallback(callback: (hqs: CommodityOptionHqItem[]) => void): void {
this.hqDataCallback = callback
}
/**
* 重写请求参数构建
*/
protected createRequestParam(): string {
let param = "\r\nsortid=" + TableConstants.DATA_ID_ZF_YTD
param += "\r\nsortorder=0"
param += "\r\npush=1"
param += "\r\n" + this.createHeaderParam()
param += this.createStockListParam()
param += "\r\nscenario=qht_qiquan_sort"
param += "\r\nprecision=1"
param += "\r\npushtime=2.5"
return param
}
protected doRequest() {
let requestParam = this.createRequestParam()
RequestHelper.getHqManager().addRequestStructToBuff(this.frameId, this.pageId, this.getInstanceId(), requestParam)
RequestHelper.build().base(this.frameId, this.pageId, this, requestParam).request();
}
/**
* 构建合约列表参数
* 与 hqHelper.js format4106Data 保持一致格式: market1(code1,code2,);market2(code3,);
*/
private createStockListParam(): string {
if (this.codeList.length === 0 || this.marketList.length === 0) {
return ''
}
// 按市场分组,生成 format4106Data 格式
const marketMap: Map<string, string[]> = new Map()
for (let i = 0; i < this.codeList.length; i++) {
const market = this.marketList[i]
const code = this.codeList[i]
if (marketMap.has(market)) {
marketMap.get(market)!.push(code + ',')
} else {
marketMap.set(market, [code + ','])
}
}
let codelist = ''
marketMap.forEach((codes, market) => {
codelist += market + '(' + codes.join('') + ');'
})
return "\r\ncodelist=" + codelist
}
/**
* 生成 dataitem 参数
* 与 hqHelper.js dataitem 保持一致格式:字段ID用逗号分隔
*/
private createHeaderParam(): string {
let param = "dataitem="
let addedIds: number[] = []
this.headerConfig.forEach((item: HeaderItem) => {
param += item.mobileid + ","
addedIds.push(item.mobileid)
if (item.extDataIds) {
item.extDataIds.forEach((id: number) => {
param += id + ","
addedIds.push(id)
})
}
})
return param
}
protected afterParserModel(tableData: TableData): void {
const hqs: CommodityOptionHqItem[] = []
if (tableData.tableRows) {
tableData.tableRows.forEach((row: AbsRowData) => {
if (row instanceof RowData) {
const data = row.originalData
const code = data.get(TableConstants.DATA_ID_CODE_4)
const market = data.get(TableConstants.DATA_ID_MARKET)
const name = data.get(TableConstants.DATA_ID_NAME)
const price = data.get(TableConstants.DATA_ID_PRICE)
const priceChg = data.get(TableConstants.DATA_ID_ZF_YTD)
if (typeof code === 'string' && typeof market === 'string') {
let priceChgValue: number | null = null
if (priceChg && typeof priceChg === 'string' && priceChg !== '--') {
priceChgValue = parseFloat(priceChg)
}
hqs.push({
code: code,
market: market,
name: typeof name === 'string' ? name : '',
price: typeof price === 'string' ? price : '--',
priceChg: priceChgValue,
expiredDate: '' // 到期日稍后从 contractList 中补充
})
}
}
})
}
if (this.hqDataCallback && hqs.length > 0) {
this.hqDataCallback(hqs)
}
}
}
/**
* 商品期权行情数据项
*/
export interface CommodityOptionHqItem {
code: string
market: string
name: string
price: string
priceChg: number | null
expiredDate: string // 到期日(格式:20250628
}
@@ -0,0 +1,98 @@
import { SortItem, TableConstants, TableRequestClient } from '@b2c/lib_baseui';
import { HeaderItem, TableData } from '@b2b/hq_table';
import {
QuoteCustomSettingManager,
StandardPriceTypeHelper,
applyStandardPriceToTableData
} from 'biz_quote';
import { StuffTableStruct } from '@kernel/lib_communication';
const REQUIRED_IDS_FOR_STANDARD_PRICE: number[] = [
TableConstants.DATA_ID_CODE_4,
TableConstants.DATA_ID_PRICE,
TableConstants.DATA_ID_ZF,
TableConstants.DATA_ID_ZD,
TableConstants.DATA_ID_MARKET,
TableConstants.DATA_ID_MARKET_OLD,
StandardPriceTypeHelper.FIELD_ZUO_SHOU,
StandardPriceTypeHelper.FIELD_JIN_KAI,
StandardPriceTypeHelper.FIELD_ZUO_JIE
]
export class FirstPageSelfStockRequestClient extends TableRequestClient {
constructor(frameId: number, pageId: number, headerConfig: Array<HeaderItem>, sortItem?: SortItem | null,
dataReceiveListener?: (tableData: TableData | string) => void, extRequestText?: string) {
super(frameId, pageId, headerConfig, sortItem, dataReceiveListener, extRequestText)
let ids: number[] = []
this.headerConfig.forEach((item: HeaderItem) => {
this.addId(ids, item.mobileid)
})
REQUIRED_IDS_FOR_STANDARD_PRICE.forEach((id: number) => {
this.addId(ids, id)
})
this.setSubscribeIds(ids)
}
/**
* 增加自选股列表请求的定制参数
* @returns
*/
protected createRequestParam(): string {
let param = super.createRequestParam()
param += "\r\n" + this.createHeaderParam()
param += "\r\nupdate=1"
param += "\r\ncomputemode=" + QuoteCustomSettingManager.getInstance().getStandardPriceType()
return param;
}
protected doRequest(){
super.doRequest()
}
protected afterParserModel(tableData: TableData, structData: StuffTableStruct): void {
applyStandardPriceToTableData(tableData, structData, REQUIRED_IDS_FOR_STANDARD_PRICE)
}
release(isDestory:boolean = false): void {
super.release(isDestory)
}
/**
* 目前直接请求全部字段的数据,暂不考虑横向分页
* @returns
*/
private createHeaderParam(): string {
let param = "columnorder="
let addedIds: number[] = []
this.headerConfig.forEach((item: HeaderItem) => {
if (!addedIds.includes(item.mobileid)) {
param += item.mobileid + "|"
addedIds.push(item.mobileid)
}
if (item.extDataIds) {
item.extDataIds.forEach((id: number) => {
if (!addedIds.includes(id)) {
param += id + "|"
addedIds.push(id)
}
})
}
})
REQUIRED_IDS_FOR_STANDARD_PRICE.forEach((id: number) => {
if (!addedIds.includes(id)) {
param += id + "|"
}
})
return param
}
private addId(ids: number[], id: number): void {
if (!ids.includes(id)) {
ids.push(id)
}
}
}
@@ -0,0 +1,200 @@
import { AbsRowData, RowData, TableData } from '@b2b/hq_table';
import { TableRequestClient } from '@b2c/lib_baseui';
import { RequestHelper, StuffTableStruct } from '@kernel/lib_communication';
import { applyStandardPriceToTableData } from 'biz_quote';
import {
CardData,
RankItem,
RecommendFuturesItem,
TabMetric,
} from '../../market-ranking/MarketRankingModels';
import {
PERIOD_RANGES,
TAB_METRICS,
} from '../../market-ranking/MarketRankingConstant';
import { TableConstants } from '../../market-ranking/TableConstants';
const FRAME_ID = 2201;
const PAGE_ID = 4106;
export type MarketRankingCardDataListener = (cardData: CardData) => void;
/**
* 单次市场排名行情请求。
*/
class MarketRankingTableRequestClient extends TableRequestClient {
private requestParams: string;
private fieldIds: number[];
private sortId: number;
private listener: MarketRankingCardDataListener;
constructor(
requestParams: string,
fieldIds: number[],
sortId: number,
listener: MarketRankingCardDataListener,
) {
super(FRAME_ID, PAGE_ID, [], null, undefined);
this.requestParams = requestParams;
this.fieldIds = fieldIds;
this.sortId = sortId;
this.listener = listener;
this.setSubscribeIds(fieldIds);
}
protected doRequest(): void {
RequestHelper.getHqManager()
.addRequestStructToBuff(this.frameId, this.pageId, this.getInstanceId(), this.requestParams);
RequestHelper.build().base(this.frameId, this.pageId, this, this.requestParams).request();
}
protected afterParserModel(tableData: TableData, structData: StuffTableStruct): void {
applyStandardPriceToTableData(tableData, structData, this.fieldIds);
this.listener(this.tableDataToCardData(tableData));
}
private tableDataToCardData(tableData: TableData): CardData {
const tableList: RankItem[] = [];
if (tableData.tableRows) {
tableData.tableRows.forEach((row: AbsRowData): void => {
if (row instanceof RowData) {
const codeValue = row.originalData.get(TableConstants.DATA_ID_CODE_4);
const marketValue = row.originalData.get(TableConstants.DATA_ID_MARKET);
if (codeValue === undefined || marketValue === undefined) {
return;
}
const item: RankItem = {
code: `${codeValue}`,
market: `${marketValue}`,
name: this.getString(row, TableConstants.DATA_ID_NAME),
};
item.price = this.getNumber(row, TableConstants.DATA_ID_PRICE);
item.price_chg = this.getNumber(row, TableConstants.DATA_ID_ZF);
if (this.sortId === TableConstants.DATA_ID_TURNOVER) {
item.turnover = this.getNumber(row, TableConstants.DATA_ID_TURNOVER);
} else if (this.sortId === TableConstants.DATA_ID_INCRE_POSIT) {
item.incre_posit = this.getNumber(row, TableConstants.DATA_ID_INCRE_POSIT);
} else if (this.sortId !== TableConstants.DATA_ID_ZF) {
item.chg_speed = this.getNumber(row, this.sortId);
}
tableList.push(item);
}
});
}
return { tableList: tableList };
}
private getString(row: RowData, fieldId: number): string {
const value = row.originalData.get(fieldId);
return value === undefined ? '' : `${value}`;
}
private getNumber(row: RowData, fieldId: number): number | undefined {
const value = row.originalData.get(fieldId);
if (typeof value === 'number') {
return value;
}
if (typeof value === 'string' && value !== '' && value !== '--') {
const result: number = Number(value);
return Number.isNaN(result) ? undefined : result;
}
return undefined;
}
}
/**
* 市场排名行情入口:根据当前 Tab 生成字段和请求参数,并管理真实行情订阅。
*/
export class MarketRankingHqRequestClient {
private static readonly instance: MarketRankingHqRequestClient =
new MarketRankingHqRequestClient();
private constructor() {
}
static getInstance(): MarketRankingHqRequestClient {
return MarketRankingHqRequestClient.instance;
}
subscribeHq(
contracts: RecommendFuturesItem[],
primaryIdx: number,
secondaryIdx: number,
listener: MarketRankingCardDataListener,
): () => void {
if (contracts.length === 0) {
return (): void => {
};
}
const sortId: number = this.getSortId(primaryIdx, secondaryIdx);
const fieldIds: number[] = [
TableConstants.DATA_ID_NAME,
TableConstants.DATA_ID_PRICE,
TableConstants.DATA_ID_CODE_4,
TableConstants.DATA_ID_MARKET,
TableConstants.DATA_ID_ZF,
TableConstants.DATA_ID_PRE_PRICE,
TableConstants.DATA_ID_OPEN_PRICE,
TableConstants.DATA_ID_JSJ,
];
if (!fieldIds.includes(sortId)) {
fieldIds.push(sortId);
}
const requestParams: string =
this.buildRequestParams(contracts, primaryIdx, sortId, fieldIds);
const requestClient = new MarketRankingTableRequestClient(
requestParams,
fieldIds,
sortId,
listener,
);
requestClient.request(0, contracts.length);
return (): void => {
requestClient.release(true);
};
}
private buildRequestParams(
contracts: RecommendFuturesItem[],
primaryIdx: number,
sortId: number,
fieldIds: number[],
): string {
const metric: TabMetric = TAB_METRICS[primaryIdx];
const sortOrder: string = metric.sortOrder;
return `\r\nsortid=${sortId}\r\n` +
`sortorder=${sortOrder}\r\n` +
`push=1\r\n` +
`dataitem=${fieldIds.map((fieldId: number): string => `${fieldId},`).join('')}\r\n` +
`codelist=${this.formatCodeList(contracts)}\r\n` +
`scenario=qht_qihuo_sort\r\n` +
`precision=1\r\n` +
`pushtime=2.5\r\n`;
}
private getSortId(primaryIdx: number, secondaryIdx: number): number {
const metric: TabMetric = TAB_METRICS[primaryIdx];
return metric.hasChildren ? PERIOD_RANGES[secondaryIdx].hqId : metric.hqId;
}
private formatCodeList(contracts: RecommendFuturesItem[]): string {
const markets: string[] = [];
const codeGroups: string[][] = [];
contracts.forEach((contract: RecommendFuturesItem): void => {
const marketIndex: number = markets.indexOf(contract.market);
if (marketIndex >= 0) {
codeGroups[marketIndex].push(contract.contract_code);
} else {
markets.push(contract.market);
codeGroups.push([contract.contract_code]);
}
});
return markets.map((market: string, index: number): string =>
`${market}(${codeGroups[index].map((code: string): string => `${code},`).join('')});`
).join('');
}
}
@@ -0,0 +1,115 @@
import { http } from '@kit.NetworkKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { buildHeader } from '../../util/Header';
import { AiDiagnosisData, AiDiagnosisResponse, AllCardsCard } from '../model/AiDiagnosisNodeModel';
import { HXLog } from 'biz_common';
import { FirstPageCardsConfigLoader } from './FirstPageCardsConfigLoader';
/**
* AI 诊断数据获取器
* 职责:
* 1. 从本地 first_page_cards_config.json 读取卡片配置
* 2. 调用 mod_data[0].url 获取卡片数据
*/
export class AiDiagnosisDataFetcher {
private static instance: AiDiagnosisDataFetcher
// 卡片配置回调
onCardConfigChange?: (config: AllCardsCard | null) => void
// 卡片数据回调
onCardDataChange?: (data: AiDiagnosisData | null) => void
// 错误回调
onError?: (errMsg: string) => void
// 当前卡片配置
private cardConfig: AllCardsCard | null = null
// 当前卡片数据
private cardData: AiDiagnosisData | null = null
static getInstance(): AiDiagnosisDataFetcher {
if (!AiDiagnosisDataFetcher.instance) {
AiDiagnosisDataFetcher.instance = new AiDiagnosisDataFetcher()
}
return AiDiagnosisDataFetcher.instance
}
getCardConfig(): AllCardsCard | null {
return this.cardConfig
}
getCardData(): AiDiagnosisData | null {
return this.cardData
}
/**
* 请求卡片配置(从本地 first_page_cards_config.json 读取)
*/
fetchCardConfig(): void {
const aiCardsConfig = FirstPageCardsConfigLoader.getConfig()
if (aiCardsConfig?.aiDiagnosis) {
this.cardConfig = aiCardsConfig.aiDiagnosis
if (this.onCardConfigChange) {
this.onCardConfigChange(this.cardConfig)
}
this.fetchCardData()
} else {
if (this.onCardConfigChange) {
this.onCardConfigChange(null)
}
HXLog.e('AiDiagnosisDataFetcher', 'fetchCardConfig: aiDiagnosis config not found')
}
}
/**
* 请求卡片数据(调用 mod_data[0].url
*/
fetchCardData(): void {
if (!this.cardConfig?.mod_data?.[0]?.url) {
return
}
let httpRequest = http.createHttp()
const url = this.cardConfig.mod_data[0].url
httpRequest.request(url, {
method: http.RequestMethod.GET,
header: buildHeader(),
}, (err: BusinessError, data: http.HttpResponse) => {
if (err) {
HXLog.e('AiDiagnosisDataFetcher', 'fetchCardData error: ' + err.message)
if (this.onError) {
this.onError(err.message)
}
httpRequest.destroy()
return
}
if (data.responseCode === http.ResponseCode.OK) {
try {
const resultStr = data.result as string
const result = JSON.parse(resultStr) as AiDiagnosisResponse
if (result.status_code === 0 && result.data) {
this.cardData = result.data
}
if (this.onCardDataChange) {
this.onCardDataChange(this.cardData)
}
} catch (e) {
const error = e as BusinessError
HXLog.e('AiDiagnosisDataFetcher', `parse data error ${error.code} ${error.message}`)
if (this.onError) {
this.onError('数据解析失败')
}
}
} else {
HXLog.e('AiDiagnosisDataFetcher', 'fetchCardData responseCode: ' + data.responseCode)
if (this.onError) {
this.onError('请求失败: ' + data.responseCode)
}
}
httpRequest.destroy()
})
}
}
@@ -0,0 +1,116 @@
import { http } from '@kit.NetworkKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { buildHeader } from '../../util/Header';
import { AiRiseFallData, AiRiseFallResponse } from '../model/AiRiseFallNodeModel';
import { AllCardsCard } from '../model/AiDiagnosisNodeModel';
import { HXLog } from 'biz_common';
import { FirstPageCardsConfigLoader } from './FirstPageCardsConfigLoader';
/**
* AI 看涨跌数据获取器
* 职责:
* 1. 从本地 first_page_cards_config.json 读取卡片配置
* 2. 调用 mod_data[0].url 获取卡片数据
*/
export class AiRiseFallDataFetcher {
private static instance: AiRiseFallDataFetcher
// 卡片配置回调
onCardConfigChange?: (config: AllCardsCard | null) => void
// 卡片数据回调
onCardDataChange?: (data: AiRiseFallData | null) => void
// 错误回调
onError?: (errMsg: string) => void
// 当前卡片配置
private cardConfig: AllCardsCard | null = null
// 当前卡片数据
private cardData: AiRiseFallData | null = null
static getInstance(): AiRiseFallDataFetcher {
if (!AiRiseFallDataFetcher.instance) {
AiRiseFallDataFetcher.instance = new AiRiseFallDataFetcher()
}
return AiRiseFallDataFetcher.instance
}
getCardConfig(): AllCardsCard | null {
return this.cardConfig
}
getCardData(): AiRiseFallData | null {
return this.cardData
}
/**
* 请求卡片配置(从本地 first_page_cards_config.json 读取)
*/
fetchCardConfig(): void {
const aiCardsConfig = FirstPageCardsConfigLoader.getConfig()
if (aiCardsConfig?.aiRiseFall) {
this.cardConfig = aiCardsConfig.aiRiseFall
if (this.onCardConfigChange) {
this.onCardConfigChange(this.cardConfig)
}
this.fetchCardData()
} else {
if (this.onCardConfigChange) {
this.onCardConfigChange(null)
}
HXLog.e('AiRiseFallDataFetcher', 'fetchCardConfig: aiRiseFall config not found')
}
}
/**
* 请求卡片数据
*/
fetchCardData(): void {
if (!this.cardConfig?.mod_data?.[0]?.url) {
return
}
let httpRequest = http.createHttp()
const url = this.cardConfig.mod_data[0].url
httpRequest.request(url, {
method: http.RequestMethod.GET,
header: buildHeader(),
}, (err: BusinessError, data: http.HttpResponse) => {
if (err) {
HXLog.e('AiRiseFallDataFetcher', 'fetchCardData error: ' + err.message)
if (this.onError) {
this.onError(err.message)
}
httpRequest.destroy()
return
}
if (data.responseCode === http.ResponseCode.OK) {
try {
const resultStr = data.result as string
const result = JSON.parse(resultStr) as AiRiseFallResponse
if (result.code === 0 && result.data) {
this.cardData = result.data
}
if (this.onCardDataChange) {
this.onCardDataChange(this.cardData)
}
} catch (e) {
const error = e as BusinessError
HXLog.e('AiRiseFallDataFetcher', `parse data error ${error.code} ${error.message}`)
if (this.onError) {
this.onError('数据解析失败')
}
}
} else {
HXLog.e('AiRiseFallDataFetcher', 'fetchCardData responseCode: ' + data.responseCode)
if (this.onError) {
this.onError('请求失败: ' + data.responseCode)
}
}
httpRequest.destroy()
})
}
}
@@ -0,0 +1,176 @@
import { http } from '@kit.NetworkKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { buildHeader } from '../../util/Header';
import { CommodityOptionsContract, CommodityOptionsResponse } from '../model/CommodityOptionsNodeModel';
import { COMMODITY_OPTIONS_TABS, CommodityOptionsTab } from '../model/CommodityOptionsNodeModel';
import { HXLog } from 'biz_common';
import { FirstPageCardsConfigLoader, CommodityOptionsCardConfig } from './FirstPageCardsConfigLoader';
import { AppUtil } from '@pura/harmony-utils';
/**
* 商品期权数据获取器
* 职责:
* 1. 请求推荐期权数据(热门/临期期权)
* 2. 支持 Tab 切换
*
* Author: YS
* Date: 2026-06-03
*/
export class CommodityOptionsDataFetcher {
private static instance: CommodityOptionsDataFetcher
// 数据回调
onCardDataChange?: (data: CommodityOptionsContract[], tabIndex: number) => void
// 卡片配置回调
onCardConfigChange?: (config: CommodityOptionsCardConfig | null) => void
// 卡片配置
private cardConfig: CommodityOptionsCardConfig | null = null
// 当前选中的 Tab 索引
private currentTabIndex: number = 0
// 缓存的数据(按 Tab 索引存储)
private cachedData: Map<number, CommodityOptionsContract[]> = new Map()
static getInstance(): CommodityOptionsDataFetcher {
if (!CommodityOptionsDataFetcher.instance) {
CommodityOptionsDataFetcher.instance = new CommodityOptionsDataFetcher()
}
return CommodityOptionsDataFetcher.instance
}
/**
* 获取卡片配置
*/
getCardConfig(): CommodityOptionsCardConfig | null {
return this.cardConfig
}
/**
* 请求卡片配置(从 FirstPageCardsConfigLoader 读取)
*/
fetchCardConfig(): void {
const config = FirstPageCardsConfigLoader.getConfig()
if (config?.commodityOptions) {
this.cardConfig = config.commodityOptions
if (this.onCardConfigChange) {
this.onCardConfigChange(this.cardConfig)
}
// 同时请求所有tab的数据
this.fetchAllTabData()
} else {
if (this.onCardConfigChange) {
this.onCardConfigChange(null)
}
HXLog.e('CommodityOptionsDataFetcher', 'fetchCardConfig: commodityOptions config not found')
}
}
/**
* 同时请求所有Tab的数据并缓存
*/
fetchAllTabData(): void {
COMMODITY_OPTIONS_TABS.forEach((tab, index) => {
this.requestOptions(index)
})
}
/**
* 获取当前 Tab
*/
getCurrentTab(): CommodityOptionsTab {
return COMMODITY_OPTIONS_TABS[this.currentTabIndex]
}
/**
* 切换 Tab
* @param index Tab 索引
*/
switchTab(index: number): void {
if (index < 0 || index >= COMMODITY_OPTIONS_TABS.length) {
return
}
this.currentTabIndex = index
// 触发数据获取
this.fetchCardData(index)
}
/**
* 获取指定Tab的数据
*/
getDataByTab(tabIndex: number): CommodityOptionsContract[] {
return this.cachedData.get(tabIndex) || []
}
/**
* 请求数据
*/
fetchCardData(index: number): void {
this.requestOptions(index)
}
/**
* 清理资源
*/
destroy(): void {
this.cachedData.clear()
}
/**
* 请求期权数据
* @param quoteType API类型
* @param tabIndex Tab索引(可选,用于同时请求多个tab)
*/
private requestOptions(tabIndex: number): void {
if (tabIndex >= COMMODITY_OPTIONS_TABS.length) {
return
}
const tabConfig = COMMODITY_OPTIONS_TABS[tabIndex]
const url = this.buildRequestUrl(tabConfig.api)
let httpRequest = http.createHttp()
httpRequest.request(url, {
method: http.RequestMethod.GET,
header: buildHeader(),
}, (err: BusinessError, data: http.HttpResponse) => {
if (err) {
HXLog.e('CommodityOptionsDataFetcher', 'requestOptions error: ' + err.message)
httpRequest.destroy()
return
}
if (data.responseCode === http.ResponseCode.OK) {
try {
const resultStr = data.result as string
const result = JSON.parse(resultStr) as CommodityOptionsResponse
if (result.code === 0 && result.data && result.data.length > 0) {
// 缓存数据到指定tab
this.cachedData.set(tabIndex, result.data)
// 如果是当前tab,触发回调
if (this.onCardDataChange) {
this.onCardDataChange(result.data, tabIndex)
}
} else {
HXLog.e('CommodityOptionsDataFetcher', 'requestOptions: data is empty')
}
} catch (e) {
const error = e as BusinessError
HXLog.e('CommodityOptionsDataFetcher', 'parse data error: ' + error.message)
}
} else {
HXLog.e('CommodityOptionsDataFetcher', 'requestOptions responseCode: ' + data.responseCode)
}
httpRequest.destroy()
})
}
/**
* 构建请求 URL
*/
private buildRequestUrl(quoteType: string): string {
const urlTemplate: string = AppUtil.getContext().resourceManager.getStringSync($r('app.string.commodity_options_recommend_url'))
return urlTemplate.replace('%s', quoteType)
}
}
@@ -0,0 +1,94 @@
import { BusinessError } from '@kit.BasicServicesKit';
import { GlobalContext, HXLog } from 'biz_common';
import util from '@ohos.util';
import { AllCardsCard, CardTitle, CardUrl, ModDataItem } from '../model/AiDiagnosisNodeModel';
/**
* 首页卡片配置加载器
* 职责:
* 1. 从本地 first_page_cards_config.json 读取卡片配置
* 2. 提供公共方法供各 Fetcher 使用
*
* Author: yansong@myhexin.com
* Date: 2026/06/04
*/
// 配置文件名
const FIRST_PAGE_CARDS_CONFIG_FILE = 'first_page_cards_config.json'
/**
* 热点资讯卡片配置
*/
export interface HotListCardConfig {
card_id: number
card_key: string
card_title: CardTitle
card_url: CardUrl
mod_data: ModDataItem[]
}
/**
* 商品期权卡片配置
*/
export interface CommodityOptionsCardConfig {
card_id: number
card_key: string
card_title: CardTitle
card_url: CardUrl
mod_data: ModDataItem[]
explain_title: string | null
explain_message: string | null
}
// AI卡片配置接口
export interface FirstPageCardsConfig {
hotList: HotListCardConfig
aiDiagnosis: AllCardsCard
aiRiseFall: AllCardsCard
commodityOptions: CommodityOptionsCardConfig
marketRanking: AllCardsCard
}
export class FirstPageCardsConfigLoader {
private static cardsConfig: FirstPageCardsConfig | null = null
static getConfig(): FirstPageCardsConfig | null {
if (FirstPageCardsConfigLoader.cardsConfig) {
return FirstPageCardsConfigLoader.cardsConfig
}
const rawData = FirstPageCardsConfigLoader.readRawFile(FIRST_PAGE_CARDS_CONFIG_FILE)
if (!rawData) {
HXLog.e('AiCardsConfigLoader', 'read first_page_cards_config.json failed')
return null
}
try {
FirstPageCardsConfigLoader.cardsConfig = JSON.parse(rawData) as FirstPageCardsConfig
HXLog.d('AiCardsConfigLoader', 'load success')
} catch (e) {
const error = e as BusinessError
HXLog.e('AiCardsConfigLoader', 'parse error ' + error.message)
}
return FirstPageCardsConfigLoader.cardsConfig
}
/**
* 从 rawfile 读取配置文件
* @param fileName rawfile 文件名
* @returns 读取的字符串内容,失败返回空字符串
*/
private static readRawFile(fileName: string): string {
try {
const context = GlobalContext.get()
const mgr = context.resourceManager
const rawFile = mgr.getRawFileContentSync(fileName)
let textDecoder = util.TextDecoder.create('utf-8', { ignoreBOM: true })
return textDecoder.decodeToString(rawFile, { stream: false })
} catch (e) {
const error = e as BusinessError
HXLog.e('AiCardsConfigLoader', `readRawFile ${fileName} error: ${error.code} ${error.message}`)
return ''
}
}
}
@@ -0,0 +1,128 @@
import { http } from '@kit.NetworkKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { HotListItemModel, HotListResultModel } from '../model/HotListNodeModel';
import { FileManager } from '../../util/FileManager';
import { buildHeader } from '../../util/Header';
import { HXLog } from 'biz_common';
import { FirstPageCardsConfigLoader } from './FirstPageCardsConfigLoader';
/**
* 热点排行数据获取器
* @author YS
* @date 2026-06-03
*/
export class HotListDataFetcher {
// 单例实例
private static instance: HotListDataFetcher
// 数据变更回调
hotListChange?: (data: HotListItemModel[] | null) => void
// 配置变更回调
cardConfigChange?: (cardUrl: string) => void
// API 地址
private apiUrl: string = ''
// 跳转列表页 URL
private cardUrl: string = ''
private constructor() {}
static getInstance(): HotListDataFetcher {
if (!HotListDataFetcher.instance) {
HotListDataFetcher.instance = new HotListDataFetcher()
}
return HotListDataFetcher.instance
}
/**
* 获取跳转列表页 URL
*/
getCardUrl(): string {
return this.cardUrl
}
/**
* 加载配置(供外部调用)
*/
loadConfig(): void {
const config = FirstPageCardsConfigLoader.getConfig()
if (config?.hotList) {
// 获取 API 地址
if (config.hotList.mod_data?.[0]?.url) {
this.apiUrl = config.hotList.mod_data[0].url
}
// 获取跳转 URL(使用 android 平台的 URL,鸿蒙类似 Android
if (config.hotList.card_url) {
this.cardUrl = config.hotList.card_url.android || config.hotList.card_url.ios || ''
}
HXLog.d('HotListDataFetcher', `loadConfig: success, apiUrl=${this.apiUrl}, cardUrl=${this.cardUrl}`)
} else {
HXLog.e('HotListDataFetcher', 'loadConfig: hotList config not found')
}
}
/**
* 远程获取数据
*/
remoteFetchData(): void {
// 加载配置
this.loadConfig()
if (!this.apiUrl) {
HXLog.e('HotListDataFetcher', 'remoteFetchData: apiUrl is empty')
if (this.hotListChange) {
this.hotListChange(null)
}
return
}
let httpRequest = http.createHttp()
httpRequest.request(this.apiUrl, {
method: http.RequestMethod.GET,
header: buildHeader()
}, (err: BusinessError, data: http.HttpResponse) => {
if (err) {
HXLog.e('HotListDataFetcher', `remoteFetchData: request error ${err.code} ${err.message}`)
if (this.hotListChange) {
this.hotListChange(null)
}
return
}
if (data.responseCode === http.ResponseCode.OK) {
let dataStr = data.result as string
const dataObj = this.parseData(dataStr)
if (dataObj) {
if (this.hotListChange) {
this.hotListChange(dataObj)
}
} else {
// 数据解析失败
if (this.hotListChange) {
this.hotListChange(null)
}
}
} else {
// 非 200 响应
if (this.hotListChange) {
this.hotListChange(null)
}
}
})
}
/**
* 解析数据
*/
private parseData(dataStr: string): HotListItemModel[] | null {
try {
const result = JSON.parse(dataStr) as HotListResultModel
if (result && result.code === 0 && result.data && result.data.length > 0) {
return result.data
}
return null
} catch (e) {
HXLog.e('HotListDataFetcher', `parseData: ${e}`)
return null
}
}
}
@@ -0,0 +1,66 @@
import { parseInt } from '@wolfx/lodash';
import { GlobalContext, HXLog } from 'biz_common';
import { StringUtils } from 'hxutil';
import { DateUtils } from '../../util/DateUtils';
import { FileManager } from '../../util/FileManager';
import { HotNewsNodeModel, HotNewsNodeModelResult } from '../model/HotNewsNodeModel';
export class NewsDataExecutor {
private static ONE_SECOND = 1000; //1秒
static ONE_MINUTE = 60 * 1000; //1分
static ONE_HOUR = 60 * 60 * 1000; // 1小时
static ONE_DAY = 24 * 60 * 60 * 1000; //1天
static TWO_DAY = 2 * 24 * 60 * 60 * 1000; //2天
static MINUTE_BEFORE = "分钟前";
static HOUR_BEFORE = "小时前";
static YESTERDAY = "昨天";
static parseData(data : string) : HotNewsNodeModel[] | null {
let resultData : HotNewsNodeModel[] | null = null
if (StringUtils.isNotEmpty(data)) {
try {
const firstPageNodeResult = JSON.parse(data) as HotNewsNodeModelResult
if (firstPageNodeResult) {
resultData = NewsDataExecutor.handlerNewsModel(firstPageNodeResult.data)
}
} catch (e) {
HXLog.e("FirstpageDataExecutor.parseData", "error " + JSON.stringify(e));
}
}
return resultData;
}
static handlerNewsModel(models: HotNewsNodeModel[]): HotNewsNodeModel[] {
models.forEach((item) => {
item.create_time = NewsDataExecutor.transferTime(item.create_time)
if (item.tags.length > 2) {
item.tags = item.tags.slice(0, 2)
}
})
return models
}
static transferTime(time: string): string {
let showTime = ''
let timeTmp = parseInt(time)
if (timeTmp) {
timeTmp = timeTmp * NewsDataExecutor.ONE_SECOND
let currentTime = new Date().getTime()
let timeSpace = currentTime - timeTmp
if (timeSpace < NewsDataExecutor.ONE_MINUTE) {
showTime = '1' + NewsDataExecutor.MINUTE_BEFORE
} else if (timeSpace < NewsDataExecutor.ONE_HOUR) {
showTime = Math.floor(timeSpace / NewsDataExecutor.ONE_MINUTE) + NewsDataExecutor.MINUTE_BEFORE
} else if (timeSpace < NewsDataExecutor.ONE_DAY) {
showTime = Math.floor(timeSpace / NewsDataExecutor.ONE_HOUR) + NewsDataExecutor.HOUR_BEFORE
} else if (timeSpace < NewsDataExecutor.TWO_DAY) {
showTime = NewsDataExecutor.YESTERDAY
} else {
showTime = DateUtils.formatDate(new Date(timeTmp), 'MM月dd日')
}
}
return showTime
}
}
@@ -0,0 +1,54 @@
import { http } from '@kit.NetworkKit';
import { buildHeader } from '../../util/Header';
import { BusinessError } from '@kit.BasicServicesKit';
import { NewsDataExecutor } from './HotNewsDataExecutor';
import { HotNewsNodeModel } from '../model/HotNewsNodeModel';
import { FileManager } from '../../util/FileManager';
export class NewsDataFetcher {
private static CACHE_FILE_NAME = '/firstpage/firstpage_news.txt'
private static instance: NewsDataFetcher
newsChange?: (news : HotNewsNodeModel[] | null) => void
static getInstance() {
if (!NewsDataFetcher.instance) {
NewsDataFetcher.instance = new NewsDataFetcher
}
return NewsDataFetcher.instance
}
fetchDataLocal() {
let cacheData = FileManager.getInstance().getCachePathSync(NewsDataFetcher.CACHE_FILE_NAME)
if (cacheData) {
const dataObj = NewsDataExecutor.parseData(cacheData)
if (dataObj && this.newsChange) {
this.newsChange(dataObj)
}
}
}
remoteFetchData(url: string) {
let httpRequest = http.createHttp();
httpRequest.request(url, {
method: http.RequestMethod.GET,
header: buildHeader(),
}, (err: BusinessError, data: http.HttpResponse) => {
httpRequest.destroy()
if (err) {
return
}
if (data.responseCode === http.ResponseCode.OK) {
let daraStr = data.result as string
const dataObj = NewsDataExecutor.parseData(daraStr)
if (dataObj) {
FileManager.getInstance().saveCacheSync(daraStr, NewsDataFetcher.CACHE_FILE_NAME)
if (this.newsChange) {
this.newsChange(dataObj)
}
}
}
})
}
}
@@ -0,0 +1,420 @@
import { BaseAd, AdsManager, ADS_TYPE_INDEX_POPUP } from "@b2c-f/fuhm-ads"
import { InfDialog, DialogHub } from "@hadss/dialoghub"
import { BusinessError, systemDateTime } from "@kit.BasicServicesKit"
import { AppUtil } from "@pura/harmony-utils"
import { HXLog, FrameId, AdsPageConfig } from "biz_common"
import { DateManager } from "biz_common/src/main/ets/datePicker/DateManager"
import { HXUserService } from "biz_hxservice"
import { getCurrentFrameId } from "biz_quote/src/main/ets/util/FrameId"
import { HexinVersionControl } from "biz_trade/src/main/ets/utils/HexinVersionControl"
import { ELK_LEVEL, sendElk } from "service_monitor"
import {
CBAS_FIRSTPAGE_TANGCHUANGAD_CLOSE,
CBAS_FIRSTPAGE_TANGCHUANGAD_BAOGUANG,
CBAS_FIRSTPAGE_PREFIX,
CBAS_FIRSTPAGE_TANGCHUANGAD_DIANJI
} from "../../constants/FirstPageConstants"
import { AdsPopupHost } from "../../popup/AdsPopupHost"
import { FileManager } from "../../util/FileManager"
import { ImageLoader } from "../../util/ImageLoader"
import { RouterUtil } from "../../util/RouterUtil"
import { PopupAdMode, adapterForIndexPopupActivity, AdFrequency } from "../model/PopupAdMode"
import { FirstPageDialogParam, FirstPageDialogBuilder } from "../view/FirstPageDialog"
import { JSON } from "@kit.ArkTS"
/**
* author : liuqingliang@myhexin.com
* time : created on 2026/5/28
* desc : 首页弹窗广告帮助类
*/
const TAG = 'FirstPagePopupAdHelper'
export class FirstPagePopupAdHelper {
private mPopupAdModel: PopupAdMode | null = null
private static instance: FirstPagePopupAdHelper | null = null
firstPageVisible: boolean = true
/**
* 本次启动是否显示过弹窗广告
*/
isShowedPopupAds = false
private adDialog: InfDialog | null = null
/**
* 是否有弹窗广告未显示(用于投教冲突时缓存)
*/
private isHasTanChuangAdNotShow = false
private popAdRecordFilePath = 'record/popAdRecordNew.txt'
private constructor() {
}
static getInstance(): FirstPagePopupAdHelper {
if (!FirstPagePopupAdHelper.instance) {
FirstPagePopupAdHelper.instance = new FirstPagePopupAdHelper()
}
return FirstPagePopupAdHelper.instance
}
/**
* 首页 OnResume 时调用
* 场景:投教弹窗遮盖期间收到广告数据,返回首页后从此方法恢复
*/
showPopupAdWhenOnResume(): void {
if (!this.isHasTanChuangAdNotShow) {
return
}
this.isHasTanChuangAdNotShow = false
const showResult = this.showFirstPagePopupAd()
const elkMsg =
`showPopupAd showPopupAdModel:${this.mPopupAdModel?.toString()} showFirstPagePopupAd:${showResult}`
this.uploadLog(ELK_LEVEL.ERROR, elkMsg)
}
/**
* 广告数据更新入口
* FirstPage 通过 AdsManager.addAdsDataUpdateListener 收到数据后调用此方法
*/
onAdsDataUpdate(adsType: string, adsDataModels: BaseAd[]): void {
//如果隐藏交易直接不展示
if (!this.isCanShowIndexPopupAd() || HexinVersionControl.hideTrade()) {
return
}
if (this.isShowedPopupAds) {
return
}
let elkMsg = `onAdsDataUpdate adType:${adsType} userId:${this.getNotNullUserId()}`
let popupAdModelList: PopupAdMode[] = adapterForIndexPopupActivity(adsDataModels)
elkMsg += ` popupAdModelList size:${popupAdModelList.length}`
// 找到本地弹窗记录
let popAdRecordMap: Record<string, PopupAdMode> = this.readPopAdRecord()
// 找到需要弹出的广告
this.mPopupAdModel = this.findNeedPopAd(popAdRecordMap, popupAdModelList)
elkMsg += ` findNeedPopupAd:${this.mPopupAdModel?.toString()}`
if (this.mPopupAdModel === null && popupAdModelList.length !== 0) {
// 重置
this.resetRecordFlag(popupAdModelList, popAdRecordMap)
// 找到需要弹出的广告
this.mPopupAdModel = this.findNeedPopAd(popAdRecordMap, popupAdModelList)
elkMsg += ` resetRecordFlag:${this.mPopupAdModel?.toString()}`
}
// show ad
let showResult = this.showFirstPagePopupAd()
elkMsg += ` showResult:${showResult}`
if (!showResult) {
// 首页弹窗显隐确定时,通知广告组件
AdsPopupHost.getInstance().notifyFirstPageMatch(false)
}
this.uploadLog(ELK_LEVEL.ERROR, elkMsg)
// 记录弹窗信息
this.recordAdModel(popAdRecordMap, this.mPopupAdModel)
}
/**
* 记录弹窗广告的弹出记录
*
* @param popAdRecordMap 当前用户的所有弹出记录
* @param showAdModel 当前弹出的弹窗广告
*/
private recordAdModel(popAdRecordMap: Record<string, PopupAdMode>, showAdModel: PopupAdMode | null) {
if (showAdModel == null) {
return
}
let userId = this.getNotNullUserId()
let adId = showAdModel.adid
showAdModel.hasShow = true
showAdModel.showTime = systemDateTime.getTime()
popAdRecordMap[adId] = showAdModel
let allUserRecord = this.readPopAdRecordFromFile()
allUserRecord[userId] = popAdRecordMap
let record = JSON.stringify(allUserRecord)
FileManager.getInstance().saveFilesSync(record, this.popAdRecordFilePath)
}
/**
* 从文件中读取当前用户的广告弹窗的弹出记录,如果没有记录就自动生成一个记录Map
* @return 返回当前用户的广告记录
*/
private readPopAdRecord(): Record<string, PopupAdMode> {
const popAdRecord: Record<string, Record<string, PopupAdMode>> = this.readPopAdRecordFromFile()
const userId = this.getNotNullUserId()
const adModels = popAdRecord[userId]
return adModels ?? {}
}
private getNotNullUserId(): string {
return HXUserService.getInstance().getUserIdFromLocal()
}
/**
* 找到本地的弹窗纪录文件并取出所有的弹窗记录
* @return 返回本机器所有用户的记录内容
*/
private readPopAdRecordFromFile(): Record<string, Record<string, PopupAdMode>> {
let record: Record<string, Record<string, PopupAdMode>> = {}
const recordText = FileManager.getInstance().loadFilesSync(this.popAdRecordFilePath)
try {
record = JSON.parse(recordText) as Record<string, Record<string, PopupAdMode>>
} catch (e) {
HXLog.e(TAG, `readPopAdRecordFromFile parse failed. recordText:${recordText}`)
}
return record
}
/**
* 从 BaseAd 列表中查找需要弹出的广告
* @param adsDataModels 网络返回的 BaseAd 列表
* @return 需要弹出的 BaseAd,若无则返回 null
*/
private findNeedPopAd(popAdRecord: Record<string, PopupAdMode>, adList: PopupAdMode[]): PopupAdMode | null {
if (adList.length === 0) {
return null
}
for (let index = 0; index < adList.length; index++) {
//先获取adList中索引位置的adModel
const adModel = adList[index]
if (!this.isNeedShowPopupAd(adModel)) {
continue
}
let adId = adModel.adid
//判断本地纪录中是否含有该条广告
if (popAdRecord[adId]) {
let recordModel = popAdRecord[adId]
//判断如果该条adModel是否显示过,true为显示过,false直接返回
if (!this.isHasShowRecord(recordModel)) {
//将adList中的广告弹窗返回
return adModel
} else {
if (index === adList.length - 1) {
//第一遍未查找到则直接返回null,在adlist长度不为0的情况下会有第二次查找
return null
}
}
} else {
//将没有记录过的广告计入map记录
adModel.hasShow = false
popAdRecord[adId] = adModel
return adModel
}
}
return null
}
private isNeedShowPopupAd(popupAdModel: PopupAdMode) {
return !(!this.isAdModelValid(popupAdModel) || this.isShowedPopupAds)
}
/**
* 是否已经显示过
* @param recordModel
* @return
*/
private isHasShowRecord(recordModel: PopupAdMode) {
return recordModel != null && recordModel.hasShow
}
/**
* 重置本地纪录中的弹窗广告记录标志为false
* @param adList 本次请求的弹窗广告
* @param popAdRecordMap 本地的弹窗广告记录
*/
private resetRecordFlag(adList: PopupAdMode[], popAdRecordMap: Record<string, PopupAdMode>) {
//先判断每次广告弹窗的显示频率,重置所有每次登录都显示的显示标志为false
//每日一次的广告是否需要重置根据上一次显示时间确定
for (let adIndex = 0; adIndex < adList.length; adIndex++) {
let id = adList[adIndex].adid
if (!popAdRecordMap[id]) {
continue
}
let recordAdModel = popAdRecordMap[id]
if (!recordAdModel) {
continue
}
let frequency = recordAdModel.frequency
//每天只弹一次的弹窗
if (frequency == AdFrequency.FREQUENCY_EVERY_DAY &&
!DateManager.isSameDay(new Date(), new Date(recordAdModel.showTime))
) {
//如果记录的显示时间和今天不是同一天
recordAdModel.hasShow = false
}
//每次都要弹出的弹窗
if (frequency == AdFrequency.FREQUENCY_EVERY_LOGIN) {
recordAdModel.hasShow = false
}
}
}
/**
* 去获取广告图片,展示首页弹窗广告
* @return false:不弹首页弹窗广告
*/
private showFirstPagePopupAd(): boolean {
if (this.mPopupAdModel == null) {
return false
}
if (!this.isSupportDialogAd()) {
return false
}
this.sendMessage(this.mPopupAdModel)
return true
}
private sendMessage(showAdModel: PopupAdMode): void {
let isShow = this.showPopupAdDialog(showAdModel)
let elkMsg =
`handle popup index ad userId:${this.getNotNullUserId()} poPupAdModel:${showAdModel.toString()} showPopupAdDialog:${isShow}`
if (isShow) {
//发送弹窗广告曝光埋点
this.sendPopupAndStat(showAdModel)
elkMsg += ` isShowedPopupAds:${this.isShowedPopupAds}`
this.isShowedPopupAds = true
// 首页弹窗显隐确定时,通知广告组件
AdsPopupHost.getInstance().notifyFirstPageMatch(true)
} else {
// 首页弹窗显隐确定时,通知广告组件
AdsPopupHost.getInstance().notifyFirstPageMatch(false)
}
this.uploadLog(ELK_LEVEL.ERROR, elkMsg)
}
private showPopupAdDialog(showAdModel: PopupAdMode): boolean {
if (!this.isAdModelValid(showAdModel)) {
return false
}
//显示投教就先不显示弹窗广告,并计入有弹窗广告没显示
if (this.isShowToujiaoViewed()) {
this.isHasTanChuangAdNotShow = true
return false
}
try {
let dialogParam: FirstPageDialogParam = new FirstPageDialogParam(showAdModel.toBaseAd(), () => {
// 全域运营位广告的5分钟防抖也被首页弹窗影响
AdsPopupHost.getInstance().notifyFirstPageClose()
// 发送关闭埋点
this.sendPopupAdCbas(CBAS_FIRSTPAGE_TANGCHUANGAD_CLOSE, showAdModel)
HXLog.d(TAG,
`[埋点] sendClickStat page:AD_FIRST_PAGE_DIALOG, adid:${showAdModel.adid}, type:AD_CLICK_TYPE_UNINTEREST`)
this.adDialog?.dismiss()
this.adDialog = null
dialogParam.releaseAdImg()
}, () => {
this.adDialog?.dismiss()
this.adDialog = null
dialogParam.releaseAdImg()
// 发送点击埋点
this.sendPopupAdEnterCbas(showAdModel)
HXLog.d(TAG,
`[埋点] sendClickStat page:AD_FIRST_PAGE_DIALOG, adid:${showAdModel.adid}, type:AD_CLICK_TYPE_INTEREST`)
AdsManager.clickAd(ADS_TYPE_INDEX_POPUP, showAdModel.toBaseAd())
if (showAdModel.isOpenInnerWebView) {
RouterUtil.jumpPage(showAdModel.jumpUrl)
} else {
// 外部浏览器打开
this.openWithExternalWebView(showAdModel.jumpUrl ?? '')
}
AdsPopupHost.getInstance().onPageJump(AdsPageConfig.INVALID)
AdsPopupHost.getInstance().notifyFirstPageMatch(false)
})
this.adDialog = DialogHub.getCustomDialog()
.setContent(wrapBuilder(FirstPageDialogBuilder), dialogParam)
.setStyle({ backgroundColor: Color.Transparent, maskColor: $r('app.color.color_80000000') })
.setConfig({ dialogBehavior: { autoDismiss: false } })
.build()
//加载弹窗图片
ImageLoader.load(showAdModel.imgUrl ?? "", (errMsg) => {
let elkMsg = `onLoadFailed showAdModel:${showAdModel.toString()} Exception:${errMsg}`
this.uploadLog(ELK_LEVEL.ERROR, elkMsg)
}, (img) => {
if (this.firstPageVisible) {
AdsManager.showAd(ADS_TYPE_INDEX_POPUP, showAdModel.toBaseAd())
dialogParam.releaseAdImg()
dialogParam.adImg = img
this.adDialog?.updateContent(dialogParam)
// 首页弹窗显隐确定时,通知广告组件
AdsPopupHost.getInstance().notifyFirstPageMatch(true)
this.adDialog?.show()
return
}
img.release()
let elkMsg = `onResourceReady showAdModel:${showAdModel.toString()} frameId:${getCurrentFrameId()}`
this.uploadLog(ELK_LEVEL.ERROR, elkMsg)
})
return true
} catch (e) {
const err = e as BusinessError
let elkMsg = `BadTokenException showAdModel:${showAdModel.toString()} Exception:${err.message}`
this.uploadLog(ELK_LEVEL.ERROR, elkMsg)
}
return false
}
/**
* 外部浏览器打开
*/
private openWithExternalWebView(url: string): void {
const context = AppUtil.getContext()
context.openLink(url).then(() => {
HXLog.i(TAG, `openWithExternalWebView success`)
}).catch((e: BusinessError) => {
HXLog.e(TAG, `openWithExternalWebView failed. code:${e.code} msg:${e.message}`)
})
}
/**
* 是否支持弹框广告,true,公版默认支持,false,不支持
*/
isSupportDialogAd(): boolean {
return true
}
/**
* 对于首页弹窗可能会与投承广告跳转冲突问题进行特殊解决
* 投承限定于新用户,对于新用户来说在第一次到首页可能会有投承,所以对新用户第二次回到首页才重新进行显示弹窗
*
* @return
*/
isCanShowIndexPopupAd(): boolean {
// !NewUserManager.isNewUser() || isHasJumpOtherPage || isHasQhJump
return true
}
isAdModelValid(popupAdMode: PopupAdMode) {
return !(!popupAdMode.imgUrl || !popupAdMode.jumpUrl)
}
isShowToujiaoViewed() {
return false
}
// ==================== 埋点 ====================
private sendPopupAndStat(popupAdModel: PopupAdMode) {
// 发送曝光埋点
this.sendPopupAdCbas(CBAS_FIRSTPAGE_TANGCHUANGAD_BAOGUANG, popupAdModel)
// 发送 HTTPStat 曝光
HXLog.d(TAG,
`[埋点] sendNonClickStat operationType:AD_OPERATION_SHOW, page:AD_FIRST_PAGE_DIALOG, adid:${popupAdModel.adid}`)
}
/**
* 发送非跳转页面类的埋点
*/
private sendPopupAdCbas(cbas: string, popupAdModel: PopupAdMode) {
const cbasObj = CBAS_FIRSTPAGE_PREFIX + cbas
const formatCbas = cbasObj.replace('%s', popupAdModel.adid)
HXLog.d(TAG, `[埋点] sendPopupAdCbas: ${formatCbas}`)
}
private sendPopupAdEnterCbas(popupAdModel: PopupAdMode) {
const cbasObj = CBAS_FIRSTPAGE_PREFIX + CBAS_FIRSTPAGE_TANGCHUANGAD_DIANJI
const formatCbas = cbasObj.replace('%s', popupAdModel.adid)
HXLog.d(TAG, `[埋点] sendPopupAdEnterCbas: ${formatCbas}`)
}
private uploadLog(biz_level: ELK_LEVEL, msg: string) {
sendElk('biz_firstpage', msg, biz_level, true, TAG)
}
}
@@ -0,0 +1,38 @@
/**
* author : liuqingliang@myhexin.com
* time : created on 2026/6/2
* desc : 广告埋点过滤器 -- 防止同一 adId 在同一轮播周期内重复发送曝光埋点。
*/
export class StatLogFilter {
/** 已发送过的 adId 集合 */
private adIdSet: Set<string> = new Set()
/**
* 判断 adId 是否已在过滤列表中,是则跳过埋点。
* @param adId 广告 id
* @returns true=已存在需过滤,false=新广告需发送
*/
filterAdId(adId: string): boolean {
if (this.adIdSet.has(adId)) {
return true
} else {
this.adIdSet.add(adId)
}
return false
}
/**
* 将 adId 添加到过滤列表。
* @param adId 广告 id
*/
addAdId(adId: string): void {
this.adIdSet.add(adId)
}
/**
* 重置过滤器,清空所有记录。
*/
resetFilter(): void {
this.adIdSet.clear()
}
}
@@ -0,0 +1,109 @@
/**
* getAllCards 接口响应
*/
export interface AllCardsResponse {
code: number
msg: string
data: AllCardsFloor[]
}
/**
* 楼层数据
*/
export interface AllCardsFloor {
id: number
key: string
title: string
card_list: AllCardsCard[]
}
/**
* 卡片配置
*/
export interface AllCardsCard {
card_id: number
card_key: string
render_key: string
card_title: CardTitle
card_url: CardUrl
mod_data: ModDataItem[]
bury_point: string
explain_title?: string
explain_message?: string
}
/**
* 卡片标题
*/
export interface CardTitle {
value: string
type: string
}
/**
* 卡片跳转链接
*/
export interface CardUrl {
ios: string
android: string
}
/**
* 数据请求配置
*/
export interface ModDataItem {
url: string
param_list: string[]
req_desc: string
}
/**
* AI 诊断接口响应
*/
export interface AiDiagnosisResponse {
status_code: number
status_msg: string
data: AiDiagnosisData
}
/**
* AI 诊断数据
*/
export class AiDiagnosisData {
market: string = ''
score: string = ''
code: string = ''
factor_list: FactorItem[] = []
name: string = ''
recommend: RecommendItem[] = []
message: string = ''
}
/**
* 因子项
*/
export class FactorItem {
score: string = ''
judge: string = ''
factor: string = ''
}
/**
* 推荐合约项
*/
export class RecommendItem {
market: string = ''
score: string = ''
code: string = ''
name: string = ''
}
/**
* 推荐合约(用于点击跳转)
*/
export interface RecommendContract {
name: string
score: string
code: string
market: string
}
@@ -0,0 +1,50 @@
/**
* AI 看涨跌接口响应
*/
export interface AiRiseFallResponse {
code: number
msg: string
data: AiRiseFallData
}
/**
* AI 看涨跌数据
*/
export interface AiRiseFallData {
date: string // 日期 "2026-06-01"
last_trade_date_accuracy: string // 昨日正确率 "66.7"
last_twenty_trade_date_accuracy: string // 近20日正确率 "66.4"
rise: RiseFallListData // 看涨列表
fall: RiseFallListData // 看跌列表
}
/**
* 看涨/看跌列表数据
*/
export interface RiseFallListData {
count: number
data: RiseFallItem[]
}
/**
* 看涨/看跌项
*/
export interface RiseFallItem {
variety: string // 品种代码 "SA"
name: string // 合约名称 "纯碱2609"
contract: string // 合约代码 "SA2609"
market: string // 市场代码 "67"
forecast: number // 预测方向 0=看涨, 1=看跌
accuracy: string // 品种正确率 "80.0"
show_quote: number // 是否显示行情 1=显示
}
/**
* 合约行情数据(用于行情订阅)
*/
export interface ContractHqData {
contract: string
market: string
name: string
priceChg: number | null // 涨跌幅,null 表示待开盘
}
@@ -0,0 +1,9 @@
export type BannerNodeModel = {
id: number,
title: string
imgUrl: string
jumpUrl: string
startTime: number
endTime: number
}
@@ -0,0 +1,56 @@
/**
* 商品期权卡片数据模型
*
* Author: YS
* Date: 2026-06-03
*/
/**
* Tab 配置项
*/
export interface CommodityOptionsTab {
label: ResourceStr // Tab 标签
api: string // API 标识
stat: string // 统计标识
defaultMsg: ResourceStr // 空数据提示信息
index: number // Tab 索引
}
/**
* Tab 配置
*/
export const COMMODITY_OPTIONS_TABS: CommodityOptionsTab[] = [
{
label: $r('app.string.tab_hot'),
api: 'rise_percent',
stat: 'hot',
defaultMsg: $r('app.string.commodity_options_hot_default_msg'),
index: 0,
},
{
label: $r('app.string.tab_near_expired'),
api: 'near_expired',
stat: 'impend',
defaultMsg: $r('app.string.commodity_options_near_expired_default_msg'),
index: 1,
},
]
/**
* 推荐期权合约项
*/
export class CommodityOptionsContract {
contract_code: string = '' // 合约代码
contract_name: string = '' // 合约名称
market: string = '' // 市场代码
expired_date: string = '' // 到期日(格式:20250628
}
/**
* API 响应
*/
export interface CommodityOptionsResponse {
code: number
msg: string
data: CommodityOptionsContract[]
}
@@ -0,0 +1,13 @@
export class CustomEntryListNodeModel {
id: number = -1
title: string = ''
imgUrl: string | Resource = ''
jumpUrl: string = ''
position: number = -1
startVersion: number = 0
endVersion: number = 0
buriedPoint: string = ''
buriedPointSource: string = ''
gridTag: string = ''
gridTagEffect: string = ''
}
@@ -0,0 +1,14 @@
export type EntryListNodeModel = {
id: number,
title: string,
imgUrl: string,
jumpUrl: string,
startVersion: string,
endVersion: string,
startTime: number,
endTime: number,
buriedPoint: string,
buriedPointSource: string,
gridTag: string
}
@@ -0,0 +1,55 @@
/**
* 热点排行数据模型
* @author YS
* @date 2026-06-03
*/
/**
* 热点排行数据项
* API 返回字段: title, url, tag, color, sorts, hot_value
*/
export interface HotListItemModel {
url: string // 跳转地址
title: string // 资讯标题
tag: string | null // 标签名称(可能为 null)
color: string | null // 颜色名称("red" | "orange",可能为 null
sorts: number // 排序值
hot_value: string // 热度值
}
/**
* 热点排行接口返回结果
*/
export interface HotListResultModel {
code: number // 接口返回码
msg: string // 返回消息
data: HotListItemModel[] // 热点数据列表
}
/**
* 颜色名称到资源ID的映射
*/
export const COLOR_NAME_MAP: Record<string, Resource> = {
'purple': $r('app.color.hotlist_purple'),
'blue': $r('app.color.hotlist_blue'),
'acidblue': $r('app.color.hotlist_acid_blue'),
'green': $r('app.color.hotlist_green'),
'orange': $r('app.color.hotlist_orange'),
'red': $r('app.color.hotlist_red'),
}
/**
* 默认颜色资源
*/
const DEFAULT_COLOR: Resource = $r('app.color.hotlist_blue')
/**
* 根据颜色名称获取颜色值
*/
export function getColorValue(colorName: string | null): ResourceStr {
if (!colorName) {
return DEFAULT_COLOR
}
const color = COLOR_NAME_MAP[colorName]
return color ?? DEFAULT_COLOR
}
@@ -0,0 +1,27 @@
export type HotNewsNodeModelResult = {
code: string,
mes: string,
data: HotNewsNodeModel[]
}
export type HotNewsNodeModel = {
seq: string, // 资讯唯一标识
title: string, // 资讯标题
source: string, // 来源
url: string, // 资讯跳转地址
img_url: string, // 图片地址
create_time: string, // 资讯发布时间
copyright: string, // 资讯版权
type: string, // 资讯分类
tags: HotNewsTagNodeModel[], // 资讯标签
}
export type HotNewsTagNodeModel = {
seq: string, // 资讯唯一标识
name: string, // 标签名称
weight: string, // 资讯权重
code: string, // 品种代码
market: string, // 市场id
}
+83
View File
@@ -0,0 +1,83 @@
import { BaseAd, FatigueDegree } from "@b2c-f/fuhm-ads"
/**
* author : liuqingliang@myhexin.com
* time : created on 2026/2/27
* desc : 弹窗广告数据模型
*/
export class PopupAdMode {
adid: string = ''
startTime: number = 0
endTime: number = 0
imgUrl: string | null = null
jumpUrl: string | null = null
isOpenInnerWebView: boolean = false
frequency: AdFrequency = AdFrequency.FREQUENCY_EVERY_LOGIN
position: number = 0
/**
* 上次显示时间
*/
showTime: number = 0
/**
* 上次是否显示过
*/
hasShow: boolean = false
fatigueDegree: FatigueDegree | null = null
toString(): string {
return `PopupAdMode (adid='${this.adid}', frequency=${this.frequency}, position=${this.position}, hasShow=${this.hasShow}, fatigueDegree=${this.fatigueDegree})`
}
toBaseAd() {
let newAd = new BaseAd()
newAd.adId = this.adid
newAd.startTime = this.startTime
newAd.endTime = this.endTime
newAd.imgUrl = this.imgUrl ?? ""
newAd.jumpUrl = this.jumpUrl ?? ""
newAd.isOpenInnerWebView = this.isOpenInnerWebView
newAd.fatigueDegree = this.fatigueDegree
newAd.position = this.position
return newAd
}
}
/**
* 首页弹窗广告显示频率
*/
export enum AdFrequency {
/**
* 有效期间内,每次登录都显示
*/
FREQUENCY_EVERY_LOGIN = 0,
/**
* 有效期间内,每天显示一次
*/
FREQUENCY_EVERY_DAY = 1,
/**
* 有效期间内,只显示一次
*/
FREQUENCY_ONLY_ONCE = 2
}
/**
* 首页cpyyy适配器
* */
export function adapterForIndexPopupActivity(newAdModels: BaseAd[]) {
let oldAdModels: PopupAdMode[] = []
newAdModels.forEach((it) => {
let oldPopupAdMode = new PopupAdMode()
oldPopupAdMode.adid = it.adId ?? ""
oldPopupAdMode.startTime = it.startTime
oldPopupAdMode.endTime = it.endTime
oldPopupAdMode.imgUrl = it.imgUrl
oldPopupAdMode.jumpUrl = it.jumpUrl
oldPopupAdMode.isOpenInnerWebView = it.isOpenInnerWebView
oldPopupAdMode.frequency =
it.fatigueDegree?.isDailyShowOnce == 1 ? AdFrequency.FREQUENCY_EVERY_DAY : AdFrequency.FREQUENCY_EVERY_LOGIN
oldPopupAdMode.fatigueDegree = it.fatigueDegree
oldPopupAdMode.position = it.position
oldAdModels.push(oldPopupAdMode)
})
return oldAdModels
}
@@ -0,0 +1,152 @@
import { HXUtils, StockMarketUtil } from "@b2c/lib_baseui";
import { NumberUtils } from "hxutil";
export interface SelfSelectedStockModel {
id: string;
stockCode: string;
stockName: string;
currentPrice: number;
changeAmount: number;
changePercent: number;
updateTime?: number;
}
export interface SelfSelectedStockModelMock {
code: string;
name: string;
price: number;
change: number;
percent: number;
}
export interface StockGroupModel {
id: string;
groupName: string;
stocks: SelfSelectedStockModel[];
isSelected: boolean;
}
const HQ_DATA_INVALID = "--"
@Observed
export class SwiperHqDataItemModel extends Array<GridHqDataItemModel>{}
@Observed
export class GridHqDataItemModel extends Array<HQDataItemModel>{}
/**
* 行情自定义数据结构
*/
@Observed
export class HQDataItemModel{
name:string = HQ_DATA_INVALID
code:string = HQ_DATA_INVALID
showCode:string = HQ_DATA_INVALID
market:string = "0"
price:string = HQ_DATA_INVALID
riseFail:string = HQ_DATA_INVALID //涨跌幅
priceRise:string = HQ_DATA_INVALID
nameColor:ResourceColor = 0
priceColor:ResourceColor = 0
riseFailColor:ResourceColor = 0
priceRiseColor:ResourceColor = 0
showAddImage:boolean = false
/**
* -1:跌,1:涨,0:不变
*/
riseFailFlag:number = 0
priceChange:boolean = false
constructor(stockCode?:string,stockMarket?:string,stockName?:string) {
this.code = stockCode??HQ_DATA_INVALID
this.market = stockMarket??HQ_DATA_INVALID
this.name = stockName??HQ_DATA_INVALID
this.showCode = stockCode??HQ_DATA_INVALID
}
copyFrom(dataItemModel:HQDataItemModel){
this.name = dataItemModel.name
const market = dataItemModel.market
const showCodeTemp = dataItemModel.showCode
if ((StockMarketUtil.isOption(market+"") || StockMarketUtil.isFutureTL(market+"")) && HXUtils.isValidContractData(showCodeTemp+"")) {
this.showCode = showCodeTemp
}else{
this.showCode = this.code
}
this.riseFailFlag = this.getRiseFailFlag(dataItemModel.price,this.price)
this.priceChange = this.riseFailFlag != 0
this.price = dataItemModel.price
this.riseFail = dataItemModel.riseFail
this.priceRise = dataItemModel.priceRise
this.riseFailColor = dataItemModel.riseFailColor
this.riseFailColor = dataItemModel.riseFailColor
}
getMapKey():string{
return this.code+"_"+this.market
}
private getRiseFailFlag(newPrice:string,lastPrice:string){
let ret = 0
if (newPrice == lastPrice){
return ret
}
if (NumberUtils.isNumeric(newPrice) && NumberUtils.isNumeric(lastPrice)) {
const newPriceNumber = parseFloat(newPrice)
const lastPriceNumber = parseFloat(lastPrice)
if (newPriceNumber == lastPriceNumber) {
ret = 0
}else {
ret = newPriceNumber > lastPriceNumber ? 1:-1
}
}
return ret
}
copy():HQDataItemModel{
let copyDataItemModel = new HQDataItemModel(this.code,this.market,this.name)
copyDataItemModel.showCode = this.showCode
copyDataItemModel.price = this.price
copyDataItemModel.riseFail = this.riseFail
copyDataItemModel.priceRise = this.priceRise
copyDataItemModel.nameColor = this.nameColor
copyDataItemModel.riseFailColor = this.riseFailColor
copyDataItemModel.priceRiseColor = this.priceRiseColor
copyDataItemModel.showAddImage = this.showAddImage
copyDataItemModel.priceChange = this.priceChange
copyDataItemModel.riseFailFlag = this.riseFailFlag
return copyDataItemModel
}
}
export class GridDataSource implements IDataSource{
private dataArray:HQDataItemModel[] = []
constructor(data:HQDataItemModel[]) {
this.dataArray = data
}
totalCount(): number {
return this.dataArray.length
}
getData(index: number): HQDataItemModel {
return this.dataArray[index]
}
registerDataChangeListener(listener: DataChangeListener): void {
}
unregisterDataChangeListener(listener: DataChangeListener): void {
}
}
@@ -0,0 +1,782 @@
import { LengthMetrics, router } from '@kit.ArkUI';
import { emitter } from '@kit.BasicServicesKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { AiDiagnosisDataFetcher } from '../datacenter/AiDiagnosisDataFetcher';
import { AiDiagnosisData, AllCardsCard, FactorItem, RecommendItem } from '../model/AiDiagnosisNodeModel';
import { FirstPageNodeModel } from '../../datacenter/model/FirstPageNodeModel';
import { AiDiagnosisRequestClient } from '../clients/AiDiagnosisRequestClient';
import { HeaderItem, RowData, CellArray, TableData } from '@b2b/hq_table';
import { EmitterConstants, GlobalContext, HXLog } from 'biz_common';
import { RouterUtil } from '../../util/RouterUtil';
import { enableDarkMode } from '@kernel/theme_manager';
import { TableConstants } from '@b2c/lib_baseui';
import { QuoteCustomSettingManager, StandardPriceType } from 'biz_quote';
import { TabType } from 'biz_quote/src/main/ets/config/tabConfig';
import util from '@ohos.util';
// 评分背景图片配置文件
const AI_SCORE_BG_IMAGES_FILE = 'ai_score_background_images.json'
// 评分背景图片配置数据结构
interface ScoreBgImagesConfig {
light: Record<string, string>
dark: Record<string, string>
}
/**
* AI 诊断节点组件
* @author yansong@myhexin.com
*/
// AI诊断卡片行情请求 FrameId 和 PageId
const FRAME_ID_AI_DIAGNOSIS = 2201
const PAGE_ID_AI_DIAGNOSIS = 4106
// 因子列表最大显示数量
const FACTOR_LIST_MAX = 3
@Component
export struct AiDiagnosisNodeComponent {
nodeModel: FirstPageNodeModel | null = null
// 刷新触发器(父组件通过 @Link 传入,每次递增触发刷新)
@Link @Watch('onRefreshTriggerChange') refreshTrigger: number
// Tab 可见性(用于控制行情请求的 request/release
@Watch('onTabVisibilityChange') @Consume firstPageVisible: boolean = true
// 深色模式状态
@StorageProp(enableDarkMode) isDarkMode: boolean = false
@State private cardConfig: AllCardsCard | null = null
@State private cardData: AiDiagnosisData | null = null
// 行情数据
@State private priceChg: string = '--' // 涨跌幅
@State private priceChgColor: ResourceStr = $r('app.color.price_even') // 涨跌幅颜色
// 行情订阅客户端
private hqRequestClient: AiDiagnosisRequestClient | null = null
// 评分背景图片配置
private scoreBgImagesLight: Record<string, string> = {}
private scoreBgImagesDark: Record<string, string> = {}
// TCP 重连监听
private mIsEventSubscribed: boolean = false
private netRelinkCallBack = () => {
if (this.firstPageVisible) {
setTimeout(() => {
if (this.cardData && this.cardData.code && this.cardData.market) {
this.subscribeHqData(this.cardData.code, this.cardData.market)
}
})
}
}
// 上一次刷新时使用的基准价类型,用于在变可见时判断是否需要按新基准价重新请求
private lastStandardPriceType: StandardPriceType =
QuoteCustomSettingManager.getInstance().getStandardPriceType()
aboutToAppear(): void {
// 加载评分背景图片配置
this.loadScoreBgImages()
// 初始化数据获取器回调
AiDiagnosisDataFetcher.getInstance().onCardConfigChange = (config) => {
this.cardConfig = config
}
AiDiagnosisDataFetcher.getInstance().onCardDataChange = (data) => {
this.cardData = data
// 当卡片数据更新时,重新订阅行情
if (data && data.code && data.market) {
this.subscribeHqData(data.code, data.market)
}
}
// 初始请求数据
this.refreshCardData()
// 订阅 TCP 重连事件
if (this.firstPageVisible) {
this.subscribeTcpNetStatus()
}
}
/**
* 加载评分背景图片配置
*/
private loadScoreBgImages(): void {
const rawData = this.readRawFile(AI_SCORE_BG_IMAGES_FILE)
if (!rawData) {
HXLog.e('AiDiagnosisNodeComponent', 'loadScoreBgImages: read file failed')
return
}
try {
const config = JSON.parse(rawData) as ScoreBgImagesConfig
this.scoreBgImagesLight = config.light || {}
this.scoreBgImagesDark = config.dark || {}
HXLog.d('AiDiagnosisNodeComponent', 'loadScoreBgImages: success')
} catch (e) {
const error = e as BusinessError
HXLog.e('AiDiagnosisNodeComponent', 'loadScoreBgImages: parse error ' + error.message)
}
}
/**
* 从 rawfile 读取配置文件
*/
private readRawFile(fileName: string): string {
try {
const context = GlobalContext.get()
const mgr = context.resourceManager
const rawFile = mgr.getRawFileContentSync(fileName)
let textDecoder = util.TextDecoder.create('utf-8', { ignoreBOM: true })
return textDecoder.decodeToString(rawFile, { stream: false })
} catch (e) {
const error = e as BusinessError
HXLog.e('AiDiagnosisNodeComponent', `readRawFile ${fileName} error: ${error.code} ${error.message}`)
return ''
}
}
/**
* 刷新触发器变化时的回调
* 首页下拉刷新完成后会触发此方法
*/
onRefreshTriggerChange(): void {
HXLog.d('AiDiagnosisNodeComponent', 'onRefreshTriggerChange triggered, refreshTrigger: ' + this.refreshTrigger)
this.refreshCardData()
}
/**
* Tab 可见性变化时的回调
* 可见时请求行情数据,不可见时释放行情请求
*/
onTabVisibilityChange(): void {
HXLog.d('AiDiagnosisNodeComponent', 'onTabVisibilityChange: ' + this.firstPageVisible)
if (this.firstPageVisible) {
// 可见时重新请求数据
if (this.cardData && this.cardData.code && this.cardData.market) {
this.subscribeHqData(this.cardData.code, this.cardData.market)
}
// 订阅 TCP 重连事件
this.subscribeTcpNetStatus()
// 检测基准价是否切换,切换后重新订阅
const currentStandardPriceType = QuoteCustomSettingManager.getInstance().getStandardPriceType()
if (this.lastStandardPriceType !== currentStandardPriceType) {
HXLog.d('AiDiagnosisNodeComponent', 'Standard price type changed, resubscribe')
this.lastStandardPriceType = currentStandardPriceType
if (this.cardData && this.cardData.code && this.cardData.market) {
this.subscribeHqData(this.cardData.code, this.cardData.market)
}
}
} else {
// 不可见时释放行情订阅
this.releaseHqClient()
// 取消订阅 TCP 重连事件
this.unSubscribeTcpNetStatus()
}
}
/**
* 刷新卡片数据(诊断数据 + 重新订阅行情)
*/
refreshCardData(): void {
AiDiagnosisDataFetcher.getInstance().fetchCardConfig()
}
/**
* 释放行情订阅客户端
*/
private releaseHqClient(): void {
if (this.hqRequestClient) {
this.hqRequestClient.release(true)
this.hqRequestClient = null
}
}
aboutToDisappear(): void {
this.releaseHqClient()
this.unSubscribeTcpNetStatus()
}
/**
* 订阅 TCP 重连事件
*/
private subscribeTcpNetStatus(): void {
if (this.mIsEventSubscribed) {
return
}
this.mIsEventSubscribed = true
emitter.on({ eventId: EmitterConstants.NETWORK_TCP_RECONNECT }, this.netRelinkCallBack)
}
/**
* 取消订阅 TCP 重连事件
*/
private unSubscribeTcpNetStatus(): void {
if (!this.mIsEventSubscribed) {
return
}
this.mIsEventSubscribed = false
emitter.off(EmitterConstants.NETWORK_TCP_RECONNECT, this.netRelinkCallBack)
}
/**
* 订阅行情数据(涨跌幅)
* 参考自选股模块实现,使用 AiDiagnosisRequestClient
*/
private subscribeHqData(code: string, market: string): void {
// 释放之前的订阅
if (this.hqRequestClient) {
this.hqRequestClient.release(true)
this.hqRequestClient = null
}
// 创建行情请求头配置
const headerConfig: HeaderItem[] = [
new HeaderItem(TableConstants.DATA_ID_NAME, ''),
new HeaderItem(TableConstants.DATA_ID_PRICE, ''),
new HeaderItem(TableConstants.DATA_ID_ZF, '')
]
// 创建请求客户端(使用 AiDiagnosisRequestClient,参考自选股实现)
this.hqRequestClient = new AiDiagnosisRequestClient(
FRAME_ID_AI_DIAGNOSIS,
PAGE_ID_AI_DIAGNOSIS,
code,
market,
headerConfig,
null,
(tableData: TableData | string) => {
this.onHqDataReceive(tableData)
}
)
// 发送请求
this.hqRequestClient.request(0, 1)
}
/**
* 处理行情数据回调
*/
private onHqDataReceive(tableData: TableData | string): void {
if (tableData instanceof TableData) {
const rows = tableData?.tableRows
if (rows && rows.length > 0) {
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
if (row instanceof RowData) {
const cols: CellArray = row.scrollableCols
if (cols && cols.length > 0) {
// 解析涨跌幅数据
for (let j = 0; j < cols.length; j++) {
const cellData = cols[j]
if (cellData.dataId === TableConstants.DATA_ID_ZF) {
this.priceChg = cellData.data || '--'
// 根据涨跌幅设置颜色
const numValue = parseFloat(this.priceChg)
if (!isNaN(numValue)) {
if (numValue > 0) {
this.priceChgColor = $r('app.color.price_up_100') // 红色
} else if (numValue < 0) {
this.priceChgColor = $r('app.color.price_down_100') // 绿色
} else {
this.priceChgColor = $r('app.color.price_even') // 灰色
}
} else {
this.priceChgColor = $r('app.color.price_even')
}
return
}
}
}
}
}
}
}
}
/**
* 格式化涨跌幅,与AI看涨跌保持一致
*/
formatPriceChg(rawValue: string): string {
if (!rawValue || rawValue === '--') {
return '--'
}
const numValue = parseFloat(rawValue)
if (isNaN(numValue)) {
return '--'
}
if (numValue > 0) {
return `+${numValue.toFixed(2)}%`
} else if (numValue < 0) {
return `${numValue.toFixed(2)}%`
} else {
return '0.00%'
}
}
/**
* 获取评分文案
*/
getScoreText(score: string | number): ResourceStr {
const numScore = parseInt(String(score))
if (isNaN(numScore)) {
return '--'
}
if (numScore === 0) {
return $r('app.string.score_neutral')
}
if (numScore > 0) {
if (numScore < 40) return $r('app.string.score_weak_bull')
if (numScore < 80) return $r('app.string.score_medium_bull')
return $r('app.string.score_strong_bull')
} else {
const absScore = Math.abs(numScore)
if (absScore < 40) return $r('app.string.score_weak_bear')
if (absScore < 80) return $r('app.string.score_medium_bear')
return $r('app.string.score_strong_bear')
}
}
/**
* 获取评分颜色
*/
getScoreColor(score: string | number): ResourceStr {
const numScore = parseInt(String(score))
if (isNaN(numScore) || numScore === 0) {
return $r('app.color.ai_score_neutral')
}
return numScore > 0 ? $r('app.color.price_up_100') : $r('app.color.price_down_100') // 红涨绿跌
}
/**
* 获取因子方向颜色
*/
getFactorColor(judge: string): ResourceStr {
if (judge.includes('利多') || judge.includes('多')) {
return $r('app.color.price_up_100')
}
if (judge.includes('利空') || judge.includes('空')) {
return $r('app.color.price_down_100')
}
return $r('app.color.ai_score_neutral')
}
handleTitleClick() {
// 如果没有合约信息,则跳转到 WebView 详情页
if (!this.cardConfig?.card_url) {
return
}
const url = this.cardConfig.card_url.ios || this.cardConfig.card_url.android || ''
if (url.length === 0) {
return
}
// 使用 RouterUtil 处理 client.html 协议格式的 URL
RouterUtil.jumpPage(url, true)
}
/**
* 处理卡片点击
*/
handleCardClick(): void {
if (this.cardData && this.cardData.code && this.cardData.market) {
const codeList = this.cardData.code
const marketList = this.cardData.market
const nameList = this.cardData.name
this.jumpToQuote(codeList, marketList, codeList, marketList, nameList)
return
}
}
/**
* 处理推荐合约点击
*/
handleRecommendClick(item: RecommendItem): void {
if (!item.code || !item.market) {
return
}
// 构建推荐合约列表(包括当前推荐合约)
const codeList = this.cardData?.recommend?.map(r => r.code).join(',') || item.code
const marketList = this.cardData?.recommend?.map(r => r.market).join(',') || item.market
const nameList = this.cardData?.recommend?.map(r => r.name).join(',') || item.name
// 跳转到行情详情页(分时页面)
this.jumpToQuote(item.code, item.market, codeList, marketList, nameList)
}
/**
* 跳转到行情详情页
*/
private jumpToQuote(code: string, market: string, codeList: string, marketList: string, nameList: string): void {
router.pushUrl({
url: 'pages/QuoteDetailPage',
params: {
marketId: market,
code: code,
name: nameList,
codeList: codeList,
marketIdList: marketList,
nameList: nameList,
periodIndex: 0, // 默认显示分时
defaultTabType: TabType.F10, // 切换到基本面(F10) Tab,
enableScrollToTargetTab: false // 禁止跳转到目标tab,保持在分时页顶部
}
}, router.RouterMode.Standard, (err) => {
if (err) {
HXLog.e('AiDiagnosisNodeComponent', `jumpToQuote failed: ${err.code}, ${err.message}`)
}
})
}
build() {
Column() {
// 标题栏
this.buildTitleBar()
// 卡片内容
if (this.cardData) {
this.buildCardContent()
}
}
.width('100%')
.backgroundColor($r('app.color.surface_layer1_foreground'))
.borderRadius(4)
.padding({ left: 10, right: 10, bottom: 10 })
}
@Builder
buildTitleBar() {
Row() {
Row() {
Text(this.cardConfig?.card_title?.value || $r('app.string.ai_diagnosis_title'))
.fontColor($r('app.color.elements_text_primary_02'))
.fontSize(18)
.fontWeight(500)
Image($r('app.media.icon_arrow_forward_20'))
.width(20)
.height(20)
.fillColor($r('app.color.elements_icon_primary_02'))
}
.alignItems(VerticalAlign.Center)
.height('100%')
.onClick(() => this.handleTitleClick())
Blank()
}
.width('100%')
.height(48)
}
@Builder
buildCardContent() {
Column() {
Column({ space: 12 }) {
// 合约信息
this.buildContractInfo()
// 评分区域
this.buildScoreArea()
}
.borderRadius(4)
.padding(8)
.linearGradient(this.getScoreAreaGradientConfig())
.onClick(() => this.handleCardClick())
// AI诊断说明
if (this.cardData && this.cardData.message && this.cardData.message.length > 0) {
this.buildDiagnosisText()
}
// 推荐合约
if (this.cardData && this.cardData.recommend && this.cardData.recommend.length > 0) {
this.buildRecommendContracts()
}
}
.width('100%')
.borderRadius(8)
}
/**
* 获取评分区域渐变配置
* 从上到下渐变:从不透明到透明
*/
getScoreAreaGradientConfig(): LinearGradientOptions {
const score = this.cardData ? parseInt(this.cardData.score) : 0
if (isNaN(score) || score === 0) {
// 橙色渐变
return {
direction: GradientDirection.Top,
colors: [[$r('app.color.ai_score_gradient_neutral_start'), 0.0], [$r('app.color.ai_score_gradient_neutral_end'), 1.0]]
}
}
if (score > 0) {
// 红色渐变
return {
direction: GradientDirection.Top,
colors: [[$r('app.color.ai_score_gradient_bull_start'), 0.0], [$r('app.color.ai_score_gradient_bull_end'), 1.0]]
}
}
// 绿色渐变
return {
direction: GradientDirection.Top,
colors: [[$r('app.color.ai_score_gradient_bear_start'), 0.0], [$r('app.color.ai_score_gradient_bear_end'), 1.0]]
}
}
@Builder
buildContractInfo() {
Row() {
Text(this.cardData ? this.cardData.name : '--')
.fontColor($r('app.color.elements_text_primary_02'))
.fontSize(16)
.textAlign(TextAlign.Center)
.height(20)
.fontWeight(500)
Blank().width(10)
// 涨跌幅
Text(this.formatPriceChg(this.priceChg))
.fontColor(this.priceChgColor)
.fontSize(14)
.fontWeight(500)
}
.width('100%')
}
@Builder
buildScoreArea() {
Row() {
// 评分圆盘
this.buildScoreDial()
// 因子表格
this.buildFactorTable()
}
.alignItems(VerticalAlign.Center)
.width('100%')
}
@Builder
buildScoreDial() {
Stack({ alignContent: Alignment.Bottom }) {
// 背景图片
Image(this.getScoreBackgroundImage(this.cardData ? this.cardData.score : '0'))
.borderRadius(12)
.objectFit(ImageFit.Contain)
// 文字叠加层
Column() {
Text(this.cardData ? this.cardData.score : '--')
.fontColor(this.getScoreTextColor())
.fontSize(20)
.fontWeight(FontWeight.Bold)
Text(this.getScoreText(this.cardData ? this.cardData.score : 0))
.fontColor($r('app.color.elements_text_tertiary'))
.fontSize(14)
}
.width('100%')
.margin({ bottom: 10 })
.justifyContent(FlexAlign.End)
.alignItems(HorizontalAlign.Center)
}
.layoutWeight(1)
.margin({ right: 12 })
}
/**
* 获取评分文字颜色
* 根据评分正负返回:红涨绿跌
*/
getScoreTextColor(): ResourceStr {
const score = this.cardData ? parseInt(this.cardData.score) : 0
if (isNaN(score) || score === 0) {
// 0或无效值:深色(黑色)
return $r('app.color.price_even')
}
// 正数红色,负数绿色
return score > 0 ? $r('app.color.price_up_100') : $r('app.color.price_down_100')
}
/**
* 根据评分和主题获取背景图片 URL
*/
getScoreBackgroundImage(score: string): string {
const images = this.isDarkMode ? this.scoreBgImagesDark : this.scoreBgImagesLight
const numScore = parseInt(score)
if (isNaN(numScore)) {
return images['neutral']
}
// 根据评分区间返回对应图片
if (numScore <= -60) {
return images['strong_bear']
} else if (numScore <= -40) {
return images['medium_bear']
} else if (numScore <= -20) {
return images['weak_bear']
} else if (numScore < 0) {
return images['slight_bear']
} else if (numScore === 0) {
return images['neutral']
} else if (numScore < 20) {
return images['slight_bull']
} else if (numScore < 40) {
return images['weak_bull']
} else if (numScore < 60) {
return images['medium_bull']
} else {
return images['strong_bull']
}
}
@Builder
buildFactorTable() {
Column() {
// 表头
Row() {
// 因子列 - 有右边框
Column() {
Text($r('app.string.table_header_factor'))
.fontColor($r('app.color.elements_text_secondary'))
.fontSize(12)
.width('100%')
}
.layoutWeight(1)
.padding({ left: 8, right: 8, top: 6, bottom: 6 })
.alignItems(HorizontalAlign.Start)
.border({
width: { right: 1 },
color: $r('app.color.elements_others_divider_primary')
})
// 方向列
Column() {
Text($r('app.string.table_header_direction'))
.fontColor($r('app.color.elements_text_secondary'))
.fontSize(12)
.width('100%')
.textAlign(TextAlign.End)
}
.width(50)
.padding({ left: 8, right: 8, top: 6, bottom: 6 })
.alignItems(HorizontalAlign.End)
}
.width('100%')
// 因子列表
ForEach((this.cardData ? this.cardData.factor_list.slice(0, FACTOR_LIST_MAX) : []), (item: FactorItem, index: number) => {
Row() {
// 因子列 - 有右边框
Column() {
Text(item.factor)
.fontColor($r('app.color.elements_text_primary_02'))
.fontSize(12)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
.layoutWeight(1)
.padding({ left: 8, right: 8, top: 6, bottom: 6 })
.alignItems(HorizontalAlign.Start)
.border({
width: { right: 1 },
color: $r('app.color.elements_others_divider_primary')
})
// 方向列
Column() {
Text(item.judge)
.fontColor(this.getFactorColor(item.judge))
.fontSize(12)
.width('100%')
.textAlign(TextAlign.End)
.textVerticalAlign(TextVerticalAlign.CENTER)
}
.width(50)
.padding({ left: 8, right: 8, top: 6, bottom: 6 })
.alignItems(HorizontalAlign.End)
}
.border({
width: { top: 1 },
color: $r('app.color.elements_others_divider_primary')
})
.width('100%')
}, (item: FactorItem) => item.factor)
}
.layoutWeight(1)
.borderRadius(8)
.border({
width: 1,
color: $r('app.color.elements_others_divider_primary'),
radius: 4
})
}
@Builder
buildDiagnosisText() {
Column() {
Text() {
ImageSpan($r('app.media.ai_diagnosis_txt_icon'))
.width(41)
.height(18)
.objectFit(ImageFit.Contain)
.baselineOffset(LengthMetrics.vp(0))
.margin({right: 8})
Span(this.cardData ? this.cardData.message : '')
.fontColor($r('app.color.elements_text_secondary'))
.fontSize(14)
.lineHeight(20)
}
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
.padding(8)
.borderRadius(4)
.width('100%')
.backgroundColor($r('app.color.elements_others_bg_layer1'))
.onClick(() => this.handleCardClick())
}
@Builder
buildRecommendContracts() {
Row({ space: 8 }) {
Text($r('app.string.recommend_contracts'))
.fontColor($r('app.color.elements_text_secondary'))
.fontSize(12)
ForEach(this.cardData ? this.cardData.recommend : [], (item: RecommendItem, index: number) => {
Row({ space: 8 }) {
Text(item.name)
.fontColor($r('app.color.elements_text_primary_02'))
.fontSize(11)
.fontWeight(FontWeight.Medium)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.getScoreText(item.score))
.fontColor(this.getScoreColor(item.score))
.fontSize(11)
.fontWeight(FontWeight.Medium)
}
.padding({ left: 8, right: 8, top: 6, bottom: 6 })
.backgroundColor($r('app.color.elements_others_bg_layer1'))
.borderRadius(4)
.margin({ right: index < (this.cardData ? this.cardData.recommend.length : 0) - 1 ? 8 : 0 })
.onClick(() => this.handleRecommendClick(item))
}, (item: RecommendItem) => item.code + item.name)
}
.alignItems(VerticalAlign.Center)
.width('100%')
.margin({ top: 12 })
}
}
@@ -0,0 +1,738 @@
import { router } from '@kit.ArkUI';
import { emitter } from '@kit.BasicServicesKit';
import { AiRiseFallDataFetcher } from '../datacenter/AiRiseFallDataFetcher';
import { AiRiseFallData, RiseFallListData, RiseFallItem } from '../model/AiRiseFallNodeModel';
import { AllCardsCard } from '../model/AiDiagnosisNodeModel';
import { FirstPageNodeModel } from '../../datacenter/model/FirstPageNodeModel';
import { AiRiseFallHqRequestClient, ContractHqItem } from '../clients/AiRiseFallHqRequestClient';
import { HeaderItem, TableData } from '@b2b/hq_table';
import { EmitterConstants, GlobalContext, HXLog } from 'biz_common';
import { RouterUtil } from '../../util/RouterUtil';
import { enableDarkMode } from '@kernel/theme_manager';
import { TableConstants } from '@b2c/lib_baseui';
import { getResourceString } from '../../util/ResourceUtil';
import { QuoteCustomSettingManager, StandardPriceType } from 'biz_quote';
// FrameId 和 PageId
const FRAME_ID_AI_RISEFALL = 2201
const PAGE_ID_AI_RISEFALL = 4106
// 显示数量限制
const MAX_CONTRACT_COUNT = 3
@Component
export struct AiRiseFallNodeComponent {
nodeModel: FirstPageNodeModel | null = null
@Link @Watch('onRefreshTriggerChange') refreshTrigger: number
// Tab 可见性(用于控制行情请求的 request/release
@Watch('onTabVisibilityChange') @Consume firstPageVisible: boolean = true
@StorageProp(enableDarkMode) isDarkMode: boolean = false
@State private cardConfig: AllCardsCard | null = null
@State private cardData: AiRiseFallData | null = null
// Tab 状态
@State private currentTabIndex: number = 0
// 合约行情数据 Mapkey: contract_market
@State private contractHqMap: Map<string, ContractHqItem> = new Map()
// 行情请求客户端
private hqRequestClient: AiRiseFallHqRequestClient | null = null
// TCP 重连监听
private mIsEventSubscribed: boolean = false
private netRelinkCallBack = () => {
if (this.firstPageVisible) {
setTimeout(() => {
if (this.cardData) {
this.subscribeAllContracts(this.cardData)
}
})
}
}
// 上一次刷新时使用的基准价类型,用于在变可见时判断是否需要按新基准价重新请求
private lastStandardPriceType: StandardPriceType =
QuoteCustomSettingManager.getInstance().getStandardPriceType()
// 表头配置
private headerConfig: HeaderItem[] = [
new HeaderItem(TableConstants.DATA_ID_NAME, ''),
new HeaderItem(TableConstants.DATA_ID_CODE_4, ''),
new HeaderItem(TableConstants.DATA_ID_MARKET, ''),
new HeaderItem(TableConstants.DATA_ID_PRICE, ''),
new HeaderItem(TableConstants.DATA_ID_ZF, '')
]
aboutToAppear(): void {
AiRiseFallDataFetcher.getInstance().onCardConfigChange = (config) => {
this.cardConfig = config
}
AiRiseFallDataFetcher.getInstance().onCardDataChange = (data) => {
this.cardData = data
if (data) {
// 订阅所有合约的行情
this.subscribeAllContracts(data)
}
}
this.initHqRequestClient()
this.refreshCardData()
// 订阅 TCP 重连事件
if (this.firstPageVisible) {
this.subscribeTcpNetStatus()
}
}
onRefreshTriggerChange(): void {
this.refreshCardData()
}
/**
* Tab 可见性变化时的回调
* 可见时请求行情数据,不可见时释放行情请求
*/
onTabVisibilityChange(): void {
HXLog.d('AiRiseFallNodeComponent', 'onTabVisibilityChange: ' + this.firstPageVisible)
if (this.firstPageVisible) {
// 可见时重新请求数据
if (this.cardData) {
this.subscribeAllContracts(this.cardData)
}
// 订阅 TCP 重连事件
this.subscribeTcpNetStatus()
// 检测基准价是否切换,切换后重新订阅
const currentStandardPriceType = QuoteCustomSettingManager.getInstance().getStandardPriceType()
if (this.lastStandardPriceType !== currentStandardPriceType) {
HXLog.d('AiRiseFallNodeComponent', 'Standard price type changed, resubscribe')
this.lastStandardPriceType = currentStandardPriceType
if (this.cardData) {
this.subscribeAllContracts(this.cardData)
}
}
} else {
// 不可见时释放行情订阅
this.releaseHqClient()
// 取消订阅 TCP 重连事件
this.unSubscribeTcpNetStatus()
}
}
/**
* 初始化行情请求客户端
*/
private initHqRequestClient(): void {
this.hqRequestClient = new AiRiseFallHqRequestClient(
FRAME_ID_AI_RISEFALL,
PAGE_ID_AI_RISEFALL,
this.headerConfig,
(tableData: TableData | string) => {}
)
// 设置行情数据回调
this.hqRequestClient.setHqDataCallback((hqs: ContractHqItem[]) => {
this.onHqDataReceive(hqs)
})
}
refreshCardData(): void {
AiRiseFallDataFetcher.getInstance().fetchCardConfig()
}
aboutToDisappear(): void {
this.releaseHqClient()
this.unSubscribeTcpNetStatus()
}
/**
* 订阅 TCP 重连事件
*/
private subscribeTcpNetStatus(): void {
if (this.mIsEventSubscribed) {
return
}
this.mIsEventSubscribed = true
emitter.on({ eventId: EmitterConstants.NETWORK_TCP_RECONNECT }, this.netRelinkCallBack)
}
/**
* 取消订阅 TCP 重连事件
*/
private unSubscribeTcpNetStatus(): void {
if (!this.mIsEventSubscribed) {
return
}
this.mIsEventSubscribed = false
emitter.off(EmitterConstants.NETWORK_TCP_RECONNECT, this.netRelinkCallBack)
}
/**
* 订阅所有合约的行情数据
*/
private subscribeAllContracts(data: AiRiseFallData): void {
const codes: string[] = []
const markets: string[] = []
const collectContracts = (items: RiseFallItem[]) => {
if (items) {
items.slice(0, MAX_CONTRACT_COUNT).forEach((item: RiseFallItem) => {
if (item.show_quote === 1 && item.contract && item.market) {
codes.push(item.contract)
markets.push(item.market)
}
})
}
}
// 收集看涨和看跌列表中的合约
collectContracts(data.rise?.data || [])
collectContracts(data.fall?.data || [])
if (codes.length > 0) {
// 先清理上次的订阅,避免重复
this.releaseHqClient()
// 重新初始化客户端
this.initHqRequestClient()
this.hqRequestClient?.setStockListAndRequest(codes, markets)
}
}
/**
* 处理行情数据接收
*/
private onHqDataReceive(hqs: ContractHqItem[]): void {
hqs.forEach((hq: ContractHqItem) => {
const key = `${hq.code}_${hq.market}`
this.contractHqMap.set(key, hq)
})
}
private releaseHqClient(): void {
if (this.hqRequestClient) {
this.hqRequestClient.release(true)
this.hqRequestClient = null
}
}
/**
* 格式化涨跌幅
*/
formatPriceChg(priceChg: number | null): ResourceStr {
if (priceChg === null) {
return $r('app.string.wait_open')
}
const sign = priceChg > 0 ? '+' : ''
return `${sign}${priceChg.toFixed(2)}%`
}
/**
* 获取涨跌幅颜色
*/
getPriceChgColor(priceChg: number | null): ResourceStr {
if (priceChg === null) {
return $r('app.color.price_even')
}
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.price_even')
}
/**
* 获取方向文案
*/
getDirectionText(forecast: number): ResourceStr {
return forecast === 0 ? $r('app.string.tab_fall') : $r('app.string.tab_rise')
}
/**
* 处理标题点击
*/
handleTitleClick() {
if (!this.cardConfig?.card_url) {
return
}
const url = this.cardConfig.card_url.ios || this.cardConfig.card_url.android || ''
if (url.length === 0) {
return
}
RouterUtil.jumpPage(url, true)
}
/**
* 处理正确率点击(跳转历史页面)
*/
handleAccuracyClick(label: string) {
if (label === '昨日正确率') {
const url = getResourceString(getContext(this), $r('app.string.ai_rise_fall_history_url'))
RouterUtil.jumpPage(url, true)
}
}
/**
* 处理合约点击
*/
handleContractClick(item: RiseFallItem) {
if (item.contract && item.market) {
router.pushUrl({
url: 'pages/QuoteDetailPage',
params: {
marketId: item.market,
code: item.contract,
name: item.name,
codeList: item.contract,
marketIdList: item.market,
nameList: item.name,
periodIndex: 0,
defaultTabType: 0
}
})
}
}
/**
* 获取当前 Tab 数据
*/
getCurrentTabData(): RiseFallListData | null {
if (!this.cardData) {
return null
}
return this.currentTabIndex === 0 ? this.cardData.rise : this.cardData.fall
}
build() {
Column() {
this.buildTitleBar()
if (this.cardData) {
this.buildCardContent()
}
}
.padding({ left: 10, right: 10, bottom: 10 })
.width('100%')
.backgroundColor($r('app.color.surface_layer1_foreground'))
.borderRadius(4)
.bindSheet($$this.isSheetShow, this.buildExplainSheetContent, {
height: SheetSize.FIT_CONTENT, // 固定高度
dragBar: true,
showClose: false,
maskColor: $r('app.color.mask_level2'),
onDisappear: () => {
// 关闭时重置状态
},
radius: {
topLeft: 10,
topRight: 10
}
})
}
@Builder
buildTitleBar() {
Row() {
Row() {
Text(this.cardConfig?.card_title?.value || 'AI看涨跌')
.fontColor($r('app.color.elements_text_primary_02'))
.fontSize(18)
.fontWeight(500)
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.showExplainDialog())
}
}
.alignItems(VerticalAlign.Center)
.height('100%')
.onClick(() => this.handleTitleClick())
Blank()
}
.width('100%')
.height(48)
}
/**
* 显示功能说明弹窗(底部弹出,占屏幕80%高度,宽度撑满)
*/
showExplainDialog(): void {
const title = this.cardConfig?.explain_title ?? $r('app.string.ai_rise_fall_explain_title')
const message = this.cardConfig?.explain_message ?? ''
// 使用 bindSheet 实现底部弹出效果
this.sheetTitle = title
this.sheetMessage = message
this.isSheetShow = true
}
@State private sheetTitle: ResourceStr = ''
@State private sheetMessage: string = ''
@State private isSheetShow: boolean = false
@Builder
buildExplainSheetContent() {
Column() {
Row() {
Blank().layoutWeight(1)
Text(this.sheetTitle)
.fontSize(18)
.fontWeight(500)
.layoutWeight(1)
Row() {
Image($r('app.media.icon_close_24'))
.width(24)
.height(24)
.fillColor($r('app.color.elements_icon_primary_02'))
.onClick(() => {
this.isSheetShow = false
})
}
.height('100%')
.justifyContent(FlexAlign.End)
.layoutWeight(1)
}
.justifyContent(FlexAlign.Center)
.height(64)
.width('100%')
Text(this.sheetMessage.replace(/\\n/g, '\n'))
.fontSize(14)
.lineHeight(22)
.fontColor($r('app.color.elements_text_primary_02'))
.width('100%')
Button($r('app.string.confirm_button'))
.type(ButtonType.Normal)
.backgroundColor($r('app.color.elements_button_bg_primary'))
.width('100%')
.height(44)
.borderRadius(4)
.onClick(() => {
this.isSheetShow = false
})
}
.width('100%')
.backgroundColor($r('app.color.surface_layer3_background'))
.padding({
bottom: px2vp(GlobalContext.navigationBarHeight),
left: 16,
right: 16
})
}
@Builder
buildCardContent() {
Column({ space: 10 }) {
Column({ space: 10 }) {
// 正确率展示
this.buildDataDisplay()
// 风险提示(在正确率下方)
this.buildRiskTip()
}
.padding({
top: 8,
bottom: 8,
left: 4,
right: 4
})
.width('100%')
.backgroundColor($r('app.color.elements_others_bg_layer2'))
.borderRadius(4)
// Tab 切换
this.buildTabBar()
// 合约表格
this.buildContractTable()
// 显示更多
this.buildSeeMore()
}
.width('100%')
}
@Builder
buildDataDisplay() {
Row() {
// 近20日正确率
Column() {
Text($r('app.string.accuracy_recent_twenty'))
.fontColor($r('app.color.elements_text_tertiary'))
.fontSize(13)
.margin({ bottom: 4 })
Text(this.cardData ? this.cardData.last_twenty_trade_date_accuracy + '%' : '--')
.fontColor(this.getAccuracyColor(this.cardData?.last_twenty_trade_date_accuracy))
.fontSize(16)
.fontWeight(500)
}
.layoutWeight(1)
// 分割线
Column()
.width(0.5)
.height(32)
.backgroundColor($r('app.color.elements_others_divider_primary'))
// 昨日正确率(可点击)
Column() {
Row() {
Text($r('app.string.accuracy_yesterday'))
.fontColor($r('app.color.elements_text_tertiary'))
.fontSize(13)
.margin({ bottom: 4 })
Image($r('app.media.icon_arrow_forward_20'))
.width(16)
.height(16)
.fillColor($r('app.color.elements_icon_primary_02'))
.margin({ left: 4, bottom: 4 })
}
Text(this.cardData ? this.cardData.last_trade_date_accuracy + '%' : '--')
.fontColor(this.getAccuracyColor(this.cardData?.last_trade_date_accuracy))
.fontSize(16)
.fontWeight(500)
}
.layoutWeight(1)
.onClick(() => this.handleAccuracyClick('昨日正确率'))
}
.width('100%')
.borderRadius(8)
}
getAccuracyColor(value: string | undefined): ResourceStr {
if (!value) {
return $r('app.color.price_even')
}
const num = parseFloat(value)
if (num > 0) {
return $r('app.color.price_up_100')
} else if (num < 0) {
return $r('app.color.price_down_100')
}
return $r('app.color.price_even')
}
@Builder
buildTabBar() {
Row() {
// 看涨 Tab
Row({ space: 8 }) {
Text($r('app.string.tab_rise'))
.fontColor(this.currentTabIndex === 0 ? $r('app.color.elements_text_primary_01') : $r('app.color.elements_text_secondary'))
.fontSize(14)
.fontWeight(this.currentTabIndex === 0 ? FontWeight.Medium : FontWeight.Normal)
Text(`(${this.cardData?.rise?.count || 0}个)`)
.fontColor(this.currentTabIndex === 0 ? $r('app.color.elements_text_primary_01') : $r('app.color.elements_text_secondary'))
.fontSize(14)
.fontWeight(this.currentTabIndex === 0 ? FontWeight.Medium : FontWeight.Normal)
}
.height(30)
.constraintSize({
minWidth: 66
})
.padding({ left: 8, right: 8, top: 8, bottom: 8 })
.backgroundColor(this.currentTabIndex === 0 ? $r('app.color.elements_others_bg_primary') : $r('app.color.elements_button_bg_tertiary'))
.borderRadius(4)
.onClick(() => {
if (this.currentTabIndex !== 0) {
this.currentTabIndex = 0
}
})
Blank().width(12)
// 看跌 Tab
Row({ space: 8 }) {
Text($r('app.string.tab_fall'))
.fontColor(this.currentTabIndex === 1 ? $r('app.color.elements_text_primary_01') : $r('app.color.elements_text_secondary'))
.fontSize(14)
.fontWeight(this.currentTabIndex === 1 ? FontWeight.Medium : FontWeight.Normal)
Text(`(${this.cardData?.fall?.count || 0}个)`)
.fontColor(this.currentTabIndex === 1 ? $r('app.color.elements_text_primary_01') : $r('app.color.elements_text_secondary'))
.fontSize(14)
.fontWeight(this.currentTabIndex === 1 ? FontWeight.Medium : FontWeight.Normal)
}
.height(30)
.constraintSize({
minWidth: 66
})
.padding({ left: 8, right: 8, top: 8, bottom: 8 })
.backgroundColor(this.currentTabIndex === 1 ? $r('app.color.elements_others_bg_primary') : $r('app.color.elements_button_bg_tertiary'))
.borderRadius(4)
.onClick(() => {
if (this.currentTabIndex !== 1) {
this.currentTabIndex = 1
}
})
Blank()
// 更新提示
Text($r('app.string.update_time_tip'))
.fontColor($r('app.color.elements_text_tertiary'))
.fontSize(12)
}
.width('100%')
}
@Builder
buildContractTable() {
Column() {
// 表头
Row({ space: 8 }) {
Text($r('app.string.table_header_main_contract'))
.fontColor($r('app.color.elements_text_tertiary'))
.fontSize(14)
.layoutWeight(1)
Text($r('app.string.table_header_direction'))
.fontColor($r('app.color.elements_text_tertiary'))
.fontSize(14)
.width(72)
.textAlign(TextAlign.End)
Text($r('app.string.table_header_actual_change'))
.fontColor($r('app.color.elements_text_tertiary'))
.fontSize(14)
.width(72)
.textAlign(TextAlign.End)
Text($r('app.string.table_header_variety_accuracy'))
.fontColor($r('app.color.elements_text_tertiary'))
.fontSize(14)
.width(72)
.textAlign(TextAlign.End)
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.border({
width: { bottom: 1 },
color: $r('app.color.elements_others_divider_primary'),
})
// 数据行
ForEach(this.getCurrentTabData()?.data?.slice(0, MAX_CONTRACT_COUNT) || [], (item: RiseFallItem, index: number) => {
this.buildContractRow(item, index)
}, (item: RiseFallItem) => `${item.contract}_${item.market}`)
}
.width('100%')
}
@Builder
buildContractRow(item: RiseFallItem, index: number) {
Row({space: 8}) {
// 主力合约
Row() {
Text(item.name || item.contract)
.fontColor($r('app.color.elements_text_primary_02'))
.fontSize(16)
.layoutWeight(1)
// 方向
Text(this.getDirectionText(item.forecast))
.fontColor(this.currentTabIndex === 0 ? $r('app.color.price_up_100') : $r('app.color.price_down_100'))
.fontSize(16)
.width(40)
.fontWeight(500)
.textAlign(TextAlign.End)
}
.height(20)
.layoutWeight(1)
// 实际涨幅
Text(this.formatPriceChg(this.getContractHqPriceChg(item)))
.fontColor(this.getPriceChgColor(this.getContractHqPriceChg(item)))
.fontSize(16)
.width(72)
.height(20)
.textAlign(TextAlign.End)
.fontWeight(500)
// 正确率
Text(item.accuracy + '%')
.fontColor($r('app.color.elements_text_primary_02'))
.fontSize(16)
.width(72)
.height(20)
.textAlign(TextAlign.End)
.fontWeight(500)
}
.width('100%')
.padding({ top: 12, bottom: 8 })
.border({
width: { bottom: 1 },
color: $r('app.color.elements_others_divider_primary')
})
.onClick(() => this.handleContractClick(item))
}
/**
* 获取合约的行情涨跌幅
*/
private getContractHqPriceChg(item: RiseFallItem): number | null {
const key = `${item.contract}_${item.market}`
return this.contractHqMap.get(key)?.priceChg ?? null
}
@Builder
buildRiskTip() {
Text($r('app.string.risk_tip_content'))
.fontColor($r('app.color.elements_text_quaternary'))
.maxFontSize(10)
.minFontSize(9)
.textAlign(TextAlign.Center)
.width('100%')
}
@Builder
buildSeeMore() {
Row() {
Text($r('app.string.see_more'))
.fontColor($r('app.color.elements_text_tertiary'))
.fontSize(16)
.height(20)
}
.width('100%')
.justifyContent(FlexAlign.Center)
.alignItems(VerticalAlign.Center)
.padding({ top: 2, bottom: 6 })
.onClick(() => this.handleSeeMore())
}
/**
* 处理显示更多点击
*/
handleSeeMore() {
// 跳转到 AI 看涨跌详情页面
if (this.cardConfig?.card_url) {
const url = this.cardConfig.card_url.ios || this.cardConfig.card_url.android || ''
RouterUtil.jumpPage(url, true)
}
}
}
@@ -0,0 +1,224 @@
import { OnAdsDataUpdateListener, BaseAd, AdsManager, ADS_TYPE_INDEX_BANAER } from '@b2c-f/fuhm-ads'
import { FirstPageNodeModel } from '../../datacenter/model/FirstPageNodeModel'
import { RouterUtil } from '../../util/RouterUtil'
import { HXLog } from 'biz_common'
import { systemDateTime } from '@kit.BasicServicesKit'
import { LengthMetrics } from '@kit.ArkUI'
import {
AD_CLICK_TYPE_INTEREST,
AD_CLICK_TYPE_INVALIDE,
AD_LOCATION_YUNYING_SHOUYE,
AD_OPERATION_CLICK,
AD_OPERATION_SHOW,
AD_OPERATION_SWITCH,
AD_TYPE_INVALID
} from '../../constants/FirstPageConstants'
import { StatLogFilter } from '../helper/StatLogFilter'
import { Action, HxCBASAgentProvider, logMapValue } from '@b2c-spi/hx_cbas_api'
import { FirstPageFiveCBASConstants } from '../../constants/FirstPageFiveCBASConstants'
const TAG = 'BannerNodeComponent'
/**
* 首页轮播图节点
* @author wubingsong@myhexin.com
*/
@Component
export struct BannerNodeComponent {
nodeModel: FirstPageNodeModel | null = null
@State bannerHeight: number = 135
@State private isNodeShow: boolean = false
@State private bannerNodeModelArr: BaseAd[] = []
@State private isNeedIndicator: boolean = false
@State private currentIndex: number = 0
@Consume @Watch('onShowStatusChange') firstPageVisible: boolean = true;
private CYCLE_GAP = 3000
/** 上一次手动触摸时间,用于区分手动切换和自动轮播 */
private lastTouchTime: number = 0
/** 广告数据监听器 */
private adsListener: OnAdsDataUpdateListener = {
onAdsDataUpdate: (adsType: string, adsDataModels: BaseAd[]) => {
if (this.firstPageVisible) {
this.handleAdsDataUpdate(adsDataModels)
}
}
}
/** 广告埋点过滤器 */
private statLogFilter: StatLogFilter = new StatLogFilter()
/** 当前轮播真实广告数量 */
private realCount: number = 0
aboutToAppear(): void {
// 注册广告数据监听
AdsManager.addAdsDataUpdateListener(ADS_TYPE_INDEX_BANAER, this.adsListener)
if (this.firstPageVisible) {
// 请求广告数据
AdsManager.refreshNotifyAds(ADS_TYPE_INDEX_BANAER)
}
}
aboutToDisappear(): void {
// 移除广告数据监听
AdsManager.removeAdsDataUpdateListener(ADS_TYPE_INDEX_BANAER, this.adsListener)
this.statLogFilter.resetFilter()
}
onShowStatusChange() {
if (this.firstPageVisible) {
this.statLogFilter.resetFilter()
AdsManager.refreshNotifyAds(ADS_TYPE_INDEX_BANAER)
}
}
/**
* 处理广告数据更新
*/
private handleAdsDataUpdate(adsDataModels: BaseAd[]): void {
HXLog.i(TAG, `${TAG} onAdsDataUpdate, size = ${adsDataModels.length}`)
// 按 position 排序
adsDataModels.sort((a, b) => a.position - b.position)
this.bannerNodeModelArr = adsDataModels
this.isNeedIndicator = adsDataModels.length > 1
this.isNodeShow = adsDataModels.length > 0
this.realCount = adsDataModels.length
// 数据到达且当前可见时,发曝光 stat
if (adsDataModels.length > 0 && this.firstPageVisible) {
this.sendAdStat(AD_OPERATION_SHOW, AD_CLICK_TYPE_INVALIDE, true)
}
}
/**
* 发送广告统计日志
*
* @param operationType 操作类型
* @param clickType 点击类型
* @param isNeedFilter 是否过滤
*/
private sendAdStat(operationType: number, clickType: number, isNeedFilter: boolean): void {
const adItem = this.bannerNodeModelArr[this.currentIndex]
const adId = adItem?.adId
if (!adId) {
return
}
// 需要过滤且 adId 已存在则跳过
if (isNeedFilter && this.statLogFilter.filterAdId(adId)) {
return
}
const adModule: string = AD_LOCATION_YUNYING_SHOUYE
if (operationType === AD_OPERATION_SWITCH && !this.statLogFilter.filterAdId(adId)) {
// 手动切换到广告且未在过滤列表时,先发一次曝光埋点,再发切换埋点
this.doSendNonClickStat(AD_OPERATION_SHOW, adModule, adId, AD_TYPE_INVALID)
}
if (operationType === AD_OPERATION_CLICK) {
this.doSendClickStat(adModule, adId, AD_TYPE_INVALID, clickType)
AdsManager.clickAd(ADS_TYPE_INDEX_BANAER, adItem)
} else {
this.sendCbas(Action.SHOW, adItem)
this.doSendNonClickStat(operationType, adModule, adId, AD_TYPE_INVALID)
AdsManager.showAd(ADS_TYPE_INDEX_BANAER, adItem)
}
}
// 发送带广告类型的非点击STAT日志
private doSendNonClickStat(operationType: number, adModule?: string, adId?: string, adType?: string) {
HXLog.i(TAG, `doSendNonClickStat operationType=${operationType} adModule=${adModule} adId=${adId} adType=${adType}`)
}
// 发送带广告类型的点击STAT日志
private doSendClickStat(adModule?: string, adId?: string, adType?: string, clickType?: number) {
HXLog.i(TAG, `doSendClickStat adModule=${adModule} adId=${adId} adType=${adType} clickType=${clickType}`)
}
private sendCbas(action: Action, item: BaseAd) {
const oid = FirstPageFiveCBASConstants.CBAS_SHOUYE_BANNER
let logMap: Map<string, logMapValue> = new Map<string, logMapValue>()
logMap.set(FirstPageFiveCBASConstants.CBAS_BANNER_LOGMAP_ADID, item.adId)
HxCBASAgentProvider.getInstance().doEvent(oid, action, logMap)
HXLog.i(TAG, `sendCbas action:${action} adId=${item.adId}`)
}
/**
* 广告点击处理
*/
private handleAdClick(model: BaseAd): void {
// 发送点击埋点
this.sendCbas(Action.CLICK, model)
// 处理网页埋点的初始化
let jumUrl = model.jumpUrl
if (jumUrl && jumUrl.length !== 0) {
const adModule: string = AD_LOCATION_YUNYING_SHOUYE
this.doSendClickStat(adModule, model.adId, AD_TYPE_INVALID, AD_CLICK_TYPE_INTEREST)
AdsManager.clickAd(ADS_TYPE_INDEX_BANAER, model)
RouterUtil.jumpPage(model.jumpUrl)
}
}
/**
* 记录用户触摸时间,用于区分手动切换和自动轮播
*/
private handleTouch(): void {
this.lastTouchTime = systemDateTime.getTime(false)
}
/**
* Swiper 页面切换回调
* 3 秒内切换视为手动切换,发 SWITCH 埋点;否则视为自动轮播,发 SHOW 埋点
*/
private handlePageSelected(index: number): void {
this.currentIndex = index
const currentTime = systemDateTime.getTime(false)
if (currentTime - this.lastTouchTime < this.CYCLE_GAP) {
this.lastTouchTime = 0
this.sendAdStat(AD_OPERATION_SWITCH, AD_CLICK_TYPE_INVALIDE, false)
} else {
this.sendAdStat(AD_OPERATION_SHOW, AD_CLICK_TYPE_INVALIDE, true)
}
}
build() {
Swiper() {
ForEach(this.bannerNodeModelArr, (model: BaseAd) => {
Image(model.imgUrl)
.width('100%')
.height('100%')
.objectFit(ImageFit.Fill)
.onClick(() => {
this.handleAdClick(model)
})
})
}
.width('100%')
.height(this.bannerHeight)
.loop(true)
.autoPlay(this.isNeedIndicator ? true : false)
.interval(this.CYCLE_GAP)
.indicator(this.isNeedIndicator ? Indicator.dot()
.itemWidth(6)
.itemHeight(3)
.selectedItemHeight(3)
.selectedItemWidth(8)
.space(LengthMetrics.vp(4))
.bottom(LengthMetrics.vp(5), true)
.color($r('app.color.color_ffffff'))
.selectedColor($r('app.color.color_3d3d42')) : false
)
.effectMode(EdgeEffect.None)
.onSizeChange((oldValue: SizeOptions, newValue: SizeOptions) => {
if (newValue.width) {
let width = newValue.width as number
this.bannerHeight = width * 0.25
}
})
.borderRadius(6)
.visibility(this.isNodeShow ? Visibility.Visible : Visibility.None)
.onChange((index: number) => {
this.handlePageSelected(index)
})
.onTouch((event: TouchEvent) => {
if (event.type === TouchType.Down) {
this.handleTouch()
}
})
}
}
@@ -0,0 +1,535 @@
import { router } from '@kit.ArkUI';
import { emitter } from '@kit.BasicServicesKit';
import { CommodityOptionsDataFetcher } from '../datacenter/CommodityOptionsDataFetcher';
import { CommodityOptionsContract, COMMODITY_OPTIONS_TABS, CommodityOptionsTab } from '../model/CommodityOptionsNodeModel';
import { CommodityOptionsHqRequestClient, CommodityOptionHqItem } from '../clients/CommodityOptionsHqRequestClient';
import { HeaderItem, TableData } from '@b2b/hq_table';
import { EmitterConstants, GlobalContext, HXLog } from 'biz_common';
import { TableConstants } from '@b2c/lib_baseui';
// FrameId 和 PageId
const FRAME_ID_COMMODITY_OPTIONS = 2201
const PAGE_ID_COMMODITY_OPTIONS = 4106
// 显示数量限制
const MAX_SHOW_COUNT = 5
@Component
export struct CommodityOptionsNodeComponent {
// 刷新触发器(首页下拉刷新时会递增)
@Link @Watch('onRefreshTriggerChange') refreshTrigger: number
// Tab 可见性
@Watch('onTabVisibilityChange') @Consume firstPageVisible: boolean = true
// 当前 Tab 索引
@State private currentTabIndex: number = 0
// 合约数据(50条,后端按涨跌幅降序排列)
@State private contractList: CommodityOptionsContract[] = []
// 合约列表(5条)
@State private displayHqList: CommodityOptionHqItem[] = []
// 行情数据缓存(按 tabIndex 存储)
private displayHqCache: Map<number, CommodityOptionHqItem[]> = new Map()
// 功能提示弹窗状态
@State private isSheetShow: boolean = false
@State private explainTitle: ResourceStr = $r('app.string.commodity_options_explain_title')
@State private explainMessage: string = ''
// 行情请求客户端
private hqRequestClient: CommodityOptionsHqRequestClient | null = null
// TCP 重连监听
private mIsEventSubscribed: boolean = false
private netRelinkCallBack = () => {
if (this.firstPageVisible && this.contractList.length > 0) {
setTimeout(() => {
this.subscribeContracts()
})
}
}
// 表头配置
private headerConfig: HeaderItem[] = [
new HeaderItem(TableConstants.DATA_ID_NAME, ''),
new HeaderItem(TableConstants.DATA_ID_CODE_4, ''),
new HeaderItem(TableConstants.DATA_ID_MARKET, ''),
new HeaderItem(TableConstants.DATA_ID_PRICE, ''),
new HeaderItem(TableConstants.DATA_ID_ZF_YTD, '')
]
aboutToAppear(): void {
// 设置卡片配置回调
CommodityOptionsDataFetcher.getInstance().onCardConfigChange = (config) => {
if (config) {
// 从配置中读取功能说明
if (config.explain_message) {
this.explainMessage = config.explain_message
}
}
}
// 设置数据回调
CommodityOptionsDataFetcher.getInstance().onCardDataChange = (data, tabIndex) => {
if (tabIndex !== this.currentTabIndex) {
return
}
this.contractList = data
if (data.length > 0) {
this.subscribeContracts()
}
}
// 初始请求配置和数据
CommodityOptionsDataFetcher.getInstance().fetchCardConfig()
// 初始请求数据
this.initHqRequestClient()
this.refreshCardData()
// 订阅 TCP 重连事件
if (this.firstPageVisible) {
this.subscribeTcpNetStatus()
}
}
aboutToDisappear(): void {
this.releaseHqClient()
this.unSubscribeTcpNetStatus()
CommodityOptionsDataFetcher.getInstance().destroy()
}
onRefreshTriggerChange(): void {
this.refreshCardData()
}
onTabVisibilityChange(): void {
if (this.firstPageVisible) {
if (this.contractList.length > 0) {
this.subscribeContracts()
}
this.subscribeTcpNetStatus()
} else {
this.releaseHqClient()
this.unSubscribeTcpNetStatus()
}
}
private initHqRequestClient(): void {
this.hqRequestClient = new CommodityOptionsHqRequestClient(
FRAME_ID_COMMODITY_OPTIONS,
PAGE_ID_COMMODITY_OPTIONS,
this.headerConfig,
(tableData: TableData | string) => {}
)
this.hqRequestClient.setHqDataCallback((hqs: CommodityOptionHqItem[]) => {
this.onHqDataReceive(hqs)
})
}
refreshCardData(): void {
CommodityOptionsDataFetcher.getInstance().fetchCardData(this.currentTabIndex)
}
private subscribeTcpNetStatus(): void {
if (this.mIsEventSubscribed) {
return
}
this.mIsEventSubscribed = true
emitter.on({ eventId: EmitterConstants.NETWORK_TCP_RECONNECT }, this.netRelinkCallBack)
}
private unSubscribeTcpNetStatus(): void {
if (!this.mIsEventSubscribed) {
return
}
this.mIsEventSubscribed = false
emitter.off(EmitterConstants.NETWORK_TCP_RECONNECT, this.netRelinkCallBack)
}
private subscribeContracts(): void {
const codes: string[] = []
const markets: string[] = []
// 传入全部合约请求行情(与Web端一致)
this.contractList.forEach((item: CommodityOptionsContract) => {
if (item.contract_code && item.market) {
codes.push(item.contract_code)
markets.push(item.market)
}
})
if (codes.length > 0) {
this.releaseHqClient()
this.initHqRequestClient()
this.hqRequestClient?.setStockListAndRequest(codes, markets)
}
}
private onHqDataReceive(hqs: CommodityOptionHqItem[]): void {
// 行情接口返回的数据已按涨跌幅降序排列,直接取前5条(无需再次排序)
const top5Hqs = hqs.slice(0, MAX_SHOW_COUNT)
// 为每个合约设置到期日(从 contractList 中获取)
top5Hqs.forEach((hq: CommodityOptionHqItem) => {
const contract = this.contractList.find(c =>
`${c.contract_code}_${c.market}` === `${hq.code}_${hq.market}`
)
if (contract) {
hq.expiredDate = String(contract.expired_date)
}
})
// 更新当前tab的显示列表
this.displayHqList = top5Hqs
// 同时缓存到对应tab
this.displayHqCache.set(this.currentTabIndex, top5Hqs)
}
private releaseHqClient(): void {
if (this.hqRequestClient) {
this.hqRequestClient.release(true)
this.hqRequestClient = null
}
}
formatPriceChg(priceChg: number | null): string {
if (priceChg === null) {
return '--'
}
const sign = priceChg > 0 ? '+' : ''
return `${sign}${priceChg.toFixed(2)}%`
}
getDueDays(expiredDate: string | number): number {
if (expiredDate === null || expiredDate === undefined || expiredDate === '') {
return -1
}
// 转换为字符串处理
const dateStr = String(expiredDate)
if (dateStr.length !== 8) {
return -1
}
const year = parseInt(dateStr.substring(0, 4))
const month = parseInt(dateStr.substring(4, 6)) - 1
const day = parseInt(dateStr.substring(6, 8))
const expiredTime = new Date(year, month, day).getTime()
const now = Date.now()
const diffDays = Math.ceil((expiredTime - now) / (24 * 60 * 60 * 1000))
return diffDays >= 0 ? diffDays : -1
}
handleContractClick(item: CommodityOptionsContract): void {
if (item.contract_code && item.market) {
// 构建合约列表
const codeList = this.contractList.map(c => c.contract_code).join(',')
const marketList = this.contractList.map(c => c.market).join(',')
const nameList = this.contractList.map(c => c.contract_name).join(',')
router.pushUrl({
url: 'pages/QuoteDetailPage',
params: {
marketId: item.market,
code: item.contract_code,
name: item.contract_name,
codeList: codeList,
marketIdList: marketList,
nameList: nameList,
periodIndex: 0 // 分时
}
}, router.RouterMode.Standard, (err) => {
if (err) {
HXLog.e('CommodityOptionsNodeComponent', `jumpToQuote failed: ${err.code}, ${err.message}`)
}
})
}
}
handleTabClick(index: number): void {
if (this.currentTabIndex !== index) {
// 先从缓存切换到对应tab的行情数据(解决切换tab显示旧数据问题)
this.displayHqList = this.displayHqCache.get(index) ?? []
this.contractList = CommodityOptionsDataFetcher.getInstance().getDataByTab(index)
if (this.contractList.length > 0) {
this.subscribeContracts()
}
this.currentTabIndex = index
// 切换到新tab,触发数据更新
CommodityOptionsDataFetcher.getInstance().switchTab(index)
}
}
/**
* 显示功能说明弹窗
*/
showExplainDialog(): void {
this.isSheetShow = true
}
build() {
Column() {
this.buildTitleBar()
this.buildTabBar()
this.buildContent()
}
.width('100%')
.backgroundColor($r('app.color.surface_layer1_foreground'))
.borderRadius(4)
.padding({ left: 10, right: 10, bottom: 10 })
.bindSheet($$this.isSheetShow, this.buildExplainSheetContent, {
height: SheetSize.FIT_CONTENT,
dragBar: true,
showClose: false,
maskColor: $r('app.color.mask_level2'),
radius: {
topLeft: 10,
topRight: 10
}
})
}
@Builder
buildTitleBar() {
Row() {
Row({ space: 8 }) {
Text($r('app.string.commodity_options_title'))
.fontColor($r('app.color.elements_text_primary_02'))
.fontSize(18)
.height(22)
.fontWeight(500)
// 功能提醒图标
Image($r('app.media.icon_info_24'))
.width(20)
.height(20)
.fillColor($r('app.color.elements_icon_tertiary'))
.onClick(() => this.showExplainDialog())
}
.alignItems(VerticalAlign.Center)
.height('100%')
Blank()
}
.width('100%')
.height(48)
}
@Builder
buildExplainSheetContent() {
Column() {
Stack({ alignContent: Alignment.End }) {
Text(this.explainTitle)
.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.isSheetShow = false
})
}
.height('100%')
.justifyContent(FlexAlign.End)
}
.height(64)
.width('100%')
Text(this.explainMessage.replace(/\\n/g, '\n'))
.fontSize(14)
.lineHeight(22)
.fontColor($r('app.color.elements_text_primary_02'))
.width('100%')
Button($r('app.string.commodity_options_confirm'))
.type(ButtonType.Normal)
.backgroundColor($r('app.color.elements_button_bg_primary'))
.width('100%')
.height(44)
.borderRadius(4)
.onClick(() => {
this.isSheetShow = false
})
}
.width('100%')
.backgroundColor($r('app.color.surface_layer3_background'))
.padding({
bottom: px2vp(GlobalContext.navigationBarHeight),
left: 16,
right: 16
})
}
@Builder
buildTabBar() {
Row({ space: 8 }) {
ForEach(COMMODITY_OPTIONS_TABS, (tab: CommodityOptionsTab, index: number) => {
Text(tab.label)
.fontColor(this.currentTabIndex === index ? $r('app.color.elements_text_primary_01') : $r('app.color.elements_text_secondary'))
.fontSize(14)
.fontWeight(this.currentTabIndex === index ? FontWeight.Medium : FontWeight.Normal)
.padding({ left: 8, right: 8 })
.height(30)
.constraintSize({
minWidth: 66
})
.textAlign(TextAlign.Center)
.backgroundColor(this.currentTabIndex === index ? $r('app.color.elements_others_bg_primary') : $r('app.color.elements_button_bg_tertiary'))
.borderRadius(4)
.onClick(() => this.handleTabClick(index))
})
}
.height(46)
.width('100%')
}
@Builder
buildContent() {
Column() {
// 数据列表(与Web端一致:表头包含在列表组件内,数据为空时整个组件不显示)
if (this.displayHqList.length > 0) {
// 表头
Row({ space: 8 }) {
Text($r('app.string.commodity_options_table_header_name'))
.fontColor($r('app.color.elements_text_tertiary'))
.fontSize(14)
.layoutWeight(1)
Text($r('app.string.commodity_options_table_header_price'))
.fontColor($r('app.color.elements_text_tertiary'))
.fontSize(14)
.width(96)
.textAlign(TextAlign.End)
Text($r('app.string.commodity_options_table_header_change'))
.fontColor($r('app.color.elements_text_tertiary'))
.fontSize(14)
.width(96)
.textAlign(TextAlign.End)
}
.height(40)
.width('100%')
// 列表数据
ForEach(this.displayHqList, (item: CommodityOptionHqItem, index: number) => {
this.buildContractRow(item, index)
})
} else {
// 空数据占位(与Web端一致:数据为空时整个区域不显示表头)
this.buildEmptyView()
}
}
.width('100%')
}
@Builder
buildEmptyView() {
Column() {
Text(this.getDefaultMsg())
.fontColor($r('app.color.elements_text_tertiary'))
.fontSize(14)
}
.width('100%')
.height(120)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
private getDefaultMsg(): ResourceStr {
if (this.currentTabIndex >= COMMODITY_OPTIONS_TABS.length) {
return $r('app.string.commodity_options_no_data')
}
return COMMODITY_OPTIONS_TABS[this.currentTabIndex].defaultMsg
}
@Builder
buildContractRow(item: CommodityOptionHqItem, index: number) {
Row({ space: 8 }) {
Column({ space: 2 }) {
// 合约名称
Text(item.name)
.fontColor($r('app.color.elements_text_primary_02'))
.fontSize(16)
.height(20)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
// 临期期权 Tab 显示"x天到期"标签
if (this.currentTabIndex === 1 && this.getDueDays(item.expiredDate) >= 0) {
Row() {
Text(String(this.getDueDays(item.expiredDate)))
.fontColor($r('app.color.commodity_options_expire_label_color'))
.fontSize(9)
Text($r('app.string.commodity_options_expire_suffix'))
.fontColor($r('app.color.commodity_options_expire_label_color'))
.fontSize(9)
}
.height(10)
.padding({ left: 1, right: 1 })
.border({
radius: 1,
width: 0.33,
color: $r('app.color.commodity_options_expire_label_color')
})
.alignSelf(ItemAlign.Start)
}
}
.margin({ top: 12, bottom: 8 })
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
// 最新价
Text(item.price)
.fontColor(this.getPriceColor(item.priceChg))
.fontSize(16)
.fontWeight(500)
.textAlign(TextAlign.End)
// 涨跌幅(昨收)
Text(this.formatPriceChg(item.priceChg))
.fontColor(this.getPriceChgColor(item.priceChg))
.fontSize(16)
.fontWeight(500)
.width(96)
.textAlign(TextAlign.End)
}
.width('100%')
.border({
width: { top: 1 },
color: $r('app.color.elements_others_divider_primary')
})
.onClick(() => this.handleContractClickByHq(item))
}
private getPriceColor(priceChg: number | null): ResourceStr {
if (priceChg === null) {
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')
}
private getPriceChgColor(priceChg: number | null): ResourceStr {
return this.getPriceColor(priceChg)
}
private handleContractClickByHq(item: CommodityOptionHqItem): void {
// 从 hq 数据中获取对应的原始合约信息进行跳转
const contract = this.contractList.find(c =>
`${c.contract_code}_${c.market}` === `${item.code}_${item.market}`
)
if (contract) {
this.handleContractClick(contract)
}
}
}
@@ -0,0 +1,89 @@
import { StringUtils } from 'hxutil';
import { FirstPageNodeModel } from '../../datacenter/model/FirstPageNodeModel';
import { CustomEntryListNodeModel } from '../model/CustomEntryListNodeModel';
import { router } from '@kit.ArkUI';
import { RouterUtil } from '../../util/RouterUtil';
/**
* 首页自定义宫格节点
* @author wubingsong@myhexin.com
*/
@Component
export struct CustomEntryListNodeComponent {
nodeModel: FirstPageNodeModel | null = null
@State
private isNodeShow: boolean = false
@State
private customEntryListNodeModelArr: CustomEntryListNodeModel[] | null = null;
private static MAX_SHOW_ITEM_COUNT = 10
aboutToAppear(): void {
if (this.nodeModel != null) {
let extraData = this.nodeModel.extraData
if (StringUtils.isNotEmpty(extraData)) {
let customEntryListNodes = JSON.parse(extraData) as CustomEntryListNodeModel[]
if (customEntryListNodes && customEntryListNodes.length > 0) {
let moreModel: CustomEntryListNodeModel = new CustomEntryListNodeModel()
moreModel.title = '更多'
moreModel.imgUrl = $r('app.media.firstpage_grid_item_all')
customEntryListNodes.push(moreModel)
if (customEntryListNodes.length > CustomEntryListNodeComponent.MAX_SHOW_ITEM_COUNT) {
this.customEntryListNodeModelArr = customEntryListNodes.slice(0, CustomEntryListNodeComponent.MAX_SHOW_ITEM_COUNT)
} else {
this.customEntryListNodeModelArr = customEntryListNodes
}
this.isNodeShow = true
}
}
}
}
build() {
List({ initialIndex: 0 }) {
ForEach(this.customEntryListNodeModelArr, (item: CustomEntryListNodeModel) => {
ListItem() {
this.createItem(item)
}
}, (item: CustomEntryListNodeModel) => item.id.toString())
}
.width("100%")
.height("auto")
.lanes(5)
.alignListItem(ListItemAlign.Center)
.scrollBar(BarState.Off)
.backgroundColor($r('app.color.surface_layer1_foreground'))
.borderRadius(10)
.visibility(this.isNodeShow ? Visibility.Visible : Visibility.None)
}
@Builder
createItem(item: CustomEntryListNodeModel) {
Column() {
Image(item.imgUrl)
.width(40)
.height(40)
Text(item.title)
.margin( { top : 4 })
.fontColor($r('app.color.elements_text_primary_02'))
.maxLines(1)
.minFontSize(9)
.maxFontSize(12)
.fontSize(12)
}
.padding( { top : 16, bottom: 16 })
.onClick(() => {
if (item.title == '更多') {
router.pushNamedRoute({
name: 'GridNodeAllPage'
})
} else {
let fullscreen = false
if (item.title.includes("智能客服")) {
fullscreen = true
}
RouterUtil.jumpPage(item.jumpUrl, fullscreen)
}
})
}
}
@@ -0,0 +1,63 @@
import { BaseAd } from '@b2c-f/fuhm-ads'
import { image } from '@kit.ImageKit'
/**
* author : liuqingliang@myhexin.com
* time : created on 2026/2/27
* desc : 首页弹窗广告组件
*/
export class FirstPageDialogParam {
constructor(showAd: BaseAd, onClose: () => void, onClick: () => void) {
this.showAd = showAd
this.onClose = onClose
this.onClick = onClick
}
/** 广告数据(从外部传入) */
showAd: BaseAd
/** 关闭按钮回调 */
onClose: () => void
/** 广告点击回调 */
onClick: () => void
adImg?: image.PixelMap
releaseAdImg(): void {
if (!this.adImg) {
return
}
this.adImg.release()
this.adImg = undefined
}
}
@Builder
export function FirstPageDialogBuilder(param: FirstPageDialogParam) {
// 广告内容
Column() {
// 关闭按钮
Row() {
Blank()
Image($r('app.media.firstpage_dialog_tanchuang_close'))
.width(28)
.height(28)
.onClick(() => {
param.onClose()
})
}
.width('100%')
Image(param.adImg ?? param.showAd.imgUrl)
.width('100%')
.layoutWeight(1)
.objectFit(ImageFit.Fill)
.borderRadius(12)
.onClick(() => {
param.onClick()
})
.margin({ top: 10 })
}
.width(255)
.height(378)
.backgroundColor(Color.Transparent)
}
@@ -0,0 +1,82 @@
import { StringUtils } from 'hxutil';
import { FirstPageNodeModel } from '../../datacenter/model/FirstPageNodeModel';
import { EntryListNodeModel } from '../model/EntryListNodeModel';
import { RouterUtil } from '../../util/RouterUtil';
/**
* 首页四宫格节点
* @author wubingsong@myhexin.com
*/
@Component
export struct FourEntryListNodeComponent {
nodeModel: FirstPageNodeModel | null = null
@State
private isNodeShow: boolean = false
@State
private entryListNodeModelArr: EntryListNodeModel[] | null = null
private static COLUMN_COUNT = 4
aboutToAppear(): void {
if (this.nodeModel != null) {
let extraData = this.nodeModel.extraData
if (StringUtils.isNotEmpty(extraData)) {
let entryListNodes = JSON.parse(extraData) as EntryListNodeModel[]
if (entryListNodes) {
if (entryListNodes.length > FourEntryListNodeComponent.COLUMN_COUNT) {
this.entryListNodeModelArr = entryListNodes.slice(0, FourEntryListNodeComponent.COLUMN_COUNT)
} else {
this.entryListNodeModelArr = entryListNodes
}
if (this.entryListNodeModelArr.length > 0) {
this.isNodeShow = true
}
}
}
}
}
build() {
Column() {
List({ initialIndex: 0 }) {
ForEach(this.entryListNodeModelArr, (item: EntryListNodeModel) => {
ListItem() {
this.createItem(item)
}
}, (item: EntryListNodeModel) => item.id.toString())
}
.width("100%")
.height('auto')
.lanes(FourEntryListNodeComponent.COLUMN_COUNT)
.alignListItem(ListItemAlign.Center)
.scrollBar(BarState.Off)
.visibility(this.isNodeShow ? Visibility.Visible : Visibility.None)
}
.width("100%")
.height(80)
.backgroundImageSize(ImageSize.FILL)
}
@Builder
createItem(item: EntryListNodeModel) {
Column() {
Image(item.imgUrl)
.width(40)
.height(40)
Text(item.title)
.margin( { top : 4 })
.fontColor($r('app.color.color_333333'))
.maxLines(1)
.minFontSize(9)
.maxFontSize(14)
.fontSize(14)
}
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.height(80)
.onClick(() => {
RouterUtil.jumpPage(item.jumpUrl)
})
}
}
@@ -0,0 +1,224 @@
import { HotListDataFetcher } from '../datacenter/HotListDataFetcher';
import { HotListItemModel, getColorValue } from '../model/HotListNodeModel';
import { FirstPageNodeModel } from '../../datacenter/model/FirstPageNodeModel';
import { RouterUtil } from '../../util/RouterUtil';
/**
* 热点排行卡片组件
* @author YS
* @date 2026-06-03
*/
@Component
export struct HotListNodeComponent {
nodeModel: FirstPageNodeModel | null = null
// 刷新触发器(首页下拉刷新时会递增)
@Link @Watch('onRefreshTriggerChange') refreshTrigger: number
@State private newsList: HotListItemModel[] | null = null
@State private isUnfold: boolean = false
@State private dataReady: boolean = false // 数据加载状态(区分加载中/暂无数据)
// 展示数量:折叠3条,展开5条
private readonly NORMAL_SIZE: number = 3
private readonly MAX_SIZE: number = 5
// 排名背景色
private readonly bgColors: ResourceColor[] = [
$r('app.color.hotlist_rank_bg_1'),
$r('app.color.hotlist_rank_bg_2'),
$r('app.color.hotlist_rank_bg_3')
]
aboutToAppear(): void {
if (this.nodeModel != null) {
HotListDataFetcher.getInstance().hotListChange = (data) => {
this.newsList = data
this.dataReady = true // 标记数据已加载
}
this.refreshCardData()
}
}
onRefreshTriggerChange(): void {
this.refreshCardData()
}
refreshCardData(): void {
// 初始状态为加载中
this.dataReady = false
// 远程获取最新数据(从配置文件读取 API 地址)
HotListDataFetcher.getInstance().remoteFetchData()
}
/**
* 切换展开/折叠状态
*/
toggleUnfold(): void {
this.isUnfold = !this.isUnfold
}
/**
* 跳转到列表页(点击标题栏)
*/
jumpToListPage(): void {
const cardUrl = HotListDataFetcher.getInstance().getCardUrl()
if (cardUrl) {
RouterUtil.jumpPage(cardUrl, true)
}
}
/**
* 跳转到资讯详情(点击列表项)
*/
jumpToDetail(item: HotListItemModel): void {
RouterUtil.jumpPage(item.url, true)
}
/**
* 获取显示的数据
*/
private getDisplayList(): HotListItemModel[] {
if (!this.newsList || this.newsList.length === 0) {
return []
}
const size = this.isUnfold ? this.MAX_SIZE : this.NORMAL_SIZE
return this.newsList.slice(0, size)
}
build() {
Column() {
// 标题栏:标题 + 展开/收起 按钮
this.buildTitleBar()
if (this.newsList && this.newsList.length > 0) {
this.buildContentList()
} else {
// 空数据占位(与Web端一致:dataReady为false显示"加载中",否则显示"暂无数据"
this.buildEmptyView()
}
}
.width('100%')
.backgroundColor($r('app.color.surface_layer1_foreground'))
.borderRadius(4)
.padding({ left: 10, right: 10, bottom: 10 })
}
@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(80)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
@Builder
buildTitleBar() {
Row() {
Row() {
Text($r('app.string.hotlist_title'))
.fontColor($r('app.color.elements_text_primary_02'))
.fontSize(18)
.fontWeight(500)
Image($r('app.media.icon_arrow_forward_20'))
.width(20)
.height(20)
.fillColor($r('app.color.elements_icon_primary_02'))
}
.alignItems(VerticalAlign.Center)
.height('100%')
.onClick(() => {
this.jumpToListPage()
})
// 展开/收起 按钮
if (this.newsList && this.newsList.length > this.NORMAL_SIZE) {
Row() {
Text(this.isUnfold ? $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.toggleUnfold()
})
}
Blank()
}
.width('100%')
.height(48)
}
@Builder
buildContentList() {
Column({ space: 12 }) {
ForEach(this.getDisplayList(), (item: HotListItemModel, index: number) => {
this.buildNewsItem(item, index)
})
}
.width('100%')
.padding({ top: 8, bottom: 8 })
}
@Builder
buildNewsItem(item: HotListItemModel, index: number) {
Row() {
// 排名序号
Row() {
Text((index + 1).toString())
.fontSize(14)
.fontWeight(700)
.fontColor(index < 3 ? $r('app.color.elements_text_on_color') : $r('app.color.elements_text_tertiary'))
}
.width(16)
.height(16)
.borderRadius(2)
.backgroundColor(index < this.bgColors.length ? this.bgColors[index] : undefined)
.justifyContent(FlexAlign.Center)
.alignItems(VerticalAlign.Center)
// 标题
Text(item.title)
.fontSize(16)
.fontColor($r('app.color.elements_text_primary_02'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ left: 8 })
.flexShrink(1)
// 标签(tag 存在时显示)
if (item.tag) {
Text(item.tag)
.fontSize(10)
.fontWeight(500)
.fontColor($r('app.color.elements_text_on_color'))
.height(14)
.borderRadius(2)
.padding(2)
.backgroundColor(getColorValue(item.color))
.margin({ left: 4 })
.flexShrink(0)
}
}
.width('100%')
.height(22)
.alignItems(VerticalAlign.Center)
.onClick(() => {
this.jumpToDetail(item)
})
}
}
@@ -0,0 +1,593 @@
import { GroupInfo } from 'service_watchlist';
import { FirstPageSelfCodeGroupView } from '../../views/FirstPageSelfCodeGroupView';
import {
GridDataSource,
GridHqDataItemModel,
HQDataItemModel,
SwiperHqDataItemModel,
} from '../model/SelfSelectedStockModel';
import { router } from '@kit.ArkUI';
import { StrUtil } from '@pura/harmony-utils';
import { CellArray, HeaderItem, RowData, TableData } from '@b2b/hq_table';
import { FirstPageSelfStockRequestClient } from '../clients/FirstPageSelfStockRequestClient';
import { EmitterConstants, HQPageConstants, jumpToQuote, PreferenceService } from 'biz_common';
import { emitter } from '@kit.BasicServicesKit';
import { NumberUtils } from 'hxutil';
import { FistPageSelfCodeGridItemView } from '../../views/FistPageSelfCodeGridItemView';
import { HXUtils, TableConstants } from '@b2c/lib_baseui';
import { QuoteCustomSettingManager, StandardPriceType } from 'biz_quote';
//TableRowBGView TableConstants
/**
* 每页显示item数量3
*/
const ITEM_COUNT_PER_PAGE_3 = 3
/**
* 每页显示item数量5
*/
const ITEM_COUNT_PER_PAGE_5 = 5
const DATA_ID_ZUO_SHOU = 6 //左收
const DATA_ID_JIN_KAI = 7 //左收
const STR_DEFAULT = "--"
const SELF_CODE_DATA_IDS: number[] = [
TableConstants.DATA_ID_NAME, //股票名称
TableConstants.DATA_ID_PRICE, //最新价
TableConstants.DATA_ID_ZF, //涨跌幅
TableConstants.DATA_ID_ZD, //涨跌
TableConstants.DATA_ID_SHOW_CODE, //showCode
TableConstants.DATA_ID_CODE_4, //股票代码
DATA_ID_ZUO_SHOU, //左收
DATA_ID_JIN_KAI, //今开
TableConstants.DATA_ID_JSJ, //昨结
TableConstants.DATA_ID_MARKET,//市场id
]
/**
* 此处frameId修改要慎重,在启动的时候执行FutureMarketDataManager.init() 发送socket请求(frameId从FrameIdStore.get()),不一致可能会导致启动的时候行情刷新失败
*/
const FRAME_ID_SELF_CODE = 2201 //自选股行情页面frameId
const PAGE_ID_SELF_CODE = 1264 //自选股行情pageId
const SP_KEY_FIRST_PAGE_SELF_CODE_EXPAND = "sp_key_first_page_self_code_expand"
@Component
export struct SelfSelectedStockNodeComponent {
@Consume @Watch("onVisibleChanged") firstPageVisible: boolean;
//一多断点
@StorageLink('displayBreakPoint') @Watch("displayChanged") curBp: string = 'sm'
@StorageLink('windowWidthVp') windowWidthVp: number = 0;
@State isExtend: boolean = false;
@State mSwiperPagesItems: SwiperHqDataItemModel = new SwiperHqDataItemModel()
private mDataItemModels: HQDataItemModel[] = []
@State private swiperIndex: number = 0;
private swiperController: SwiperController = new SwiperController();
private mRequestClient?: FirstPageSelfStockRequestClient
private mHeaderConfig: Array<HeaderItem> = []
private mPageItemCount = ITEM_COUNT_PER_PAGE_3
private mMaxTotalItemCount = 3 * ITEM_COUNT_PER_PAGE_3
private mFrameId = FRAME_ID_SELF_CODE
private mPageId = PAGE_ID_SELF_CODE
private mGroupInfo?: GroupInfo
private mIsNeedRealData = true
private mIsHasReceivedData = false
private mIsEventSubscribed = false
// 上一次刷新时使用的基准价类型,用于在变可见时判断是否需要按新基准价重新请求
private lastStandardPriceType: StandardPriceType =
QuoteCustomSettingManager.getInstance().getStandardPriceType();
aboutToAppear(): void {
this.initHeadConfig();
this.initPageConfig()
if (this.firstPageVisible) {
this.subscribeTcpNetStatus()
}
}
aboutToDisappear(): void {
this.unSubscribeTcpNetStatus()
this.mRequestClient?.release(true)
this.mRequestClient = undefined
}
initPageConfig() {
this.isExtend =
PreferenceService.getBooleanValueSync(FirstPageSelfCodeGroupView.SP_FILE_NAME_FIRST_PAGE_SELF_CODE_FILE_NAME,
SP_KEY_FIRST_PAGE_SELF_CODE_EXPAND, false)
this.mPageItemCount = this.getPageItemCount()
this.mMaxTotalItemCount = this.getTotalItemCount()
}
displayChanged() {
this.mPageItemCount = this.getPageItemCount()
this.mMaxTotalItemCount = this.getTotalItemCount()
this.updateSwiperPagesItemsWidthDataItemModels()
}
build() {
Column() {
this.buildCardHeader()
FirstPageSelfCodeGroupView({
onForeground: this.firstPageVisible,
onSelectGroupInfo: (index: number, groupInfo: GroupInfo) => {
this.onSelectGroupInfo(index, groupInfo)
}
}).margin({ left: 11 })
this.buildStockGrid()
}
.width('100%')
.backgroundColor($r('app.color.surface_layer1_foreground'))
.borderRadius(4)
}
onSelectGroupInfo(_index: number, groupInfo: GroupInfo) {
if (this.mGroupInfo && JSON.stringify(this.mGroupInfo) == JSON.stringify(groupInfo)) {
return
}
this.mGroupInfo = groupInfo
this.initPageStocks()
this.requestData()
}
@Builder
buildCardHeader() {
Row() {
Text('自选盯盘')
.fontColor($r('app.color.elements_text_primary_02'))
.fontSize(16)
.margin({ left: 16, right: 10 })
this.buildExpandButton()
Blank()
Row() {
Text('更多')
.fontColor($r('app.color.elements_text_tertiary'))
.fontSize(14)
.margin({ right: 2 })
Image($r('app.media.icon_arrow_forward_24'))
.fillColor($r('app.color.elements_icon_tertiary'))
.width(17)
.height(17)
.margin({ right: 8 })
}.onClick(() => {
emitter.emit({eventId:EmitterConstants.TAB_UI_MANAGER_CHANGE_INDEX},{
data: {
tabUIManagerTabIndex:1,
hqPageSubTabIndex: HQPageConstants.HQ_PAGE_INDEX_SELF_CODE_PAGE
}
})
})
}
.justifyContent(FlexAlign.Start)
.alignItems(VerticalAlign.Center)
.width('100%')
.height(44)
}
@Builder
buildStockGrid() {
Column() {
Swiper(this.swiperController) {
ForEach(this.mSwiperPagesItems, (pageStocks: GridHqDataItemModel, pageIndex: number) => {
Grid() {
ForEach(pageStocks, (stock: HQDataItemModel) => {
GridItem() {
FistPageSelfCodeGridItemView({ hqItemData: stock,jumpToQuoteCallBack:(stockModel: HQDataItemModel)=>{
this.jumpToQuote(stockModel)
},jumpToMoreCallBack:()=>{
this.jumpToMore()
} })
}
})
}
.columnsTemplate('1fr 1fr 1fr')
.rowsTemplate(this.isExtend ? '1fr 1fr' : '1fr')
.rowsGap(8)
.padding({ bottom: 30 })
.width('100%')
.height(this.isExtend ? 170 : 90)
})
}
.index(this.swiperIndex)
.autoPlay(false)
.loop(false)
.duration(0)
.itemSpace(0)
.displayCount(1)
.backgroundColor($r('app.color.surface_layer1_foreground'))
.cachedCount(1)
.indicatorInteractive(true)
.indicator(new DotIndicator().bottom(0)
.selectedItemWidth(8)
.itemWidth(5)
.itemHeight(5)
.selectedItemHeight(5))
.onChange((index: number) => {
this.swiperIndex = index;
})
.onTouch((event) => {
switch (event.type) {
case TouchType.Down:
this.mIsNeedRealData = false
break;
case TouchType.Up:
case TouchType.Cancel:
setTimeout(()=>{
this.mIsNeedRealData = true
},500)
break;
default:
break;
}
})
.width('100%')
}
.width('100%')
}
@Builder
buildExpandButton() {
Row() {
Image( $r('app.media.icon_arrow_forward_24'))
.fillColor($r('app.color.elements_icon_primary_02'))
.width(24)
.height(16)
.padding({ right: 8 })
.onClick((event)=>{
emitter.emit({eventId:EmitterConstants.TAB_UI_MANAGER_CHANGE_INDEX},{
data: {
tabUIManagerTabIndex:1,
hqPageSubTabIndex: HQPageConstants.HQ_PAGE_INDEX_SELF_CODE_PAGE
}
})
})
Text(this.isExtend ? '收起' : '展开')
.fontSize(12)
.fontColor($r('app.color.elements_text_tertiary'))
.padding({top:3,bottom:3,left:10,right:10})
.backgroundColor($r('app.color.elements_others_bg_layer1'))
.borderRadius(10)
}
.onClick(() => this.toggleExpand())
}
onVisibleChanged() {
if (this.firstPageVisible) {
this.onForeground()
} else {
this.onBackground()
}
}
onForeground() {
this.requestData()
this.subscribeTcpNetStatus()
// 切换基准价后回到前台:computemode 由 FirstPageSelfStockRequestClient 实时拼装,requestData 已会按新基准价重发,这里仅同步快照
this.lastStandardPriceType = QuoteCustomSettingManager.getInstance().getStandardPriceType();
}
onBackground() {
this.unSubscribeTcpNetStatus()
this.mRequestClient?.release()
this.mRequestClient = undefined
}
onReceiveData(tableData: string | TableData) {
if (tableData instanceof TableData) {
if (!this.mIsNeedRealData && this.mIsHasReceivedData) {
return
}
const dataItems: HQDataItemModel[] = []
tableData?.tableRows?.forEach((rowData, index) => {
if (rowData instanceof RowData) {
const dataItem = this.parseRowCellData2HQDataItemModel(rowData.scrollableCols)
dataItems.push(dataItem)
}
})
this.updatePageDataItemModels(dataItems)
this.mIsHasReceivedData = true
} else {
console.log("firstPageSelfCodeData:" + JSON.stringify(tableData))
}
}
//socket连接成功
private netRelinkCallBack = () => {
if (this.firstPageVisible) {
setTimeout(() => {
this.requestData()
})
}
}
private subscribeTcpNetStatus() {
if (this.mIsEventSubscribed) {
return
}
this.mIsEventSubscribed = true
emitter.on({ eventId: EmitterConstants.NETWORK_TCP_RECONNECT }, this.netRelinkCallBack)
}
private unSubscribeTcpNetStatus() {
if (!this.mIsEventSubscribed) {
return
}
this.mIsEventSubscribed = false
emitter.off(EmitterConstants.NETWORK_TCP_RECONNECT, this.netRelinkCallBack)
}
private mockData() {
let dataItems: HQDataItemModel[] = []
for (let index = 0; index < this.mDataItemModels.length; index++) {
const element = this.mDataItemModels[index];
if (element.showAddImage) {
const moreDataHqDataItemModel = new HQDataItemModel(STR_DEFAULT, STR_DEFAULT, STR_DEFAULT)
moreDataHqDataItemModel.showAddImage = true;
dataItems.push(moreDataHqDataItemModel)
} else {
let haDataItemModel = new HQDataItemModel(element.code, element.market, element.name)
haDataItemModel.showCode = element.showCode
haDataItemModel.price = this.updatePrice(element.price, index)
haDataItemModel.riseFail = element.riseFail
haDataItemModel.priceRise = element.priceRise
haDataItemModel.nameColor = element.nameColor
haDataItemModel.riseFail = element.riseFail
haDataItemModel.priceColor = element.priceColor
haDataItemModel.riseFailColor = element.riseFailColor
haDataItemModel.priceRiseColor = element.priceRiseColor
dataItems.push(haDataItemModel)
}
}
this.updatePageDataItemModels(dataItems)
}
private updatePrice(oldPrice: string, index: number) {
if (NumberUtils.isNumeric(oldPrice)) {
let newPrice = index % 2 == 0 ? (parseFloat(oldPrice) + 1).toString() : (parseFloat(oldPrice) - 1).toString()
return newPrice
}
return oldPrice
}
private getPageItemCount(): number {
if (this.isFoldExpand()) {
return this.isExtend ? 2 * ITEM_COUNT_PER_PAGE_5 : ITEM_COUNT_PER_PAGE_5
} else {
return this.isExtend ? 2 * ITEM_COUNT_PER_PAGE_3 : ITEM_COUNT_PER_PAGE_3
}
}
private getTotalItemCount(): number {
if (this.isFoldExpand()) {
return this.isExtend ? 2 * 2 * ITEM_COUNT_PER_PAGE_5 : 2 * ITEM_COUNT_PER_PAGE_5
} else {
return this.isExtend ? 3 * 2 * ITEM_COUNT_PER_PAGE_3 : 3 * ITEM_COUNT_PER_PAGE_3
}
}
private isFoldExpand(): boolean {
return this.curBp === "lg"
}
private initHeadConfig() {
const headerConfig: HeaderItem[] = []
SELF_CODE_DATA_IDS.forEach((idValue, index) => {
headerConfig.push(new HeaderItem(idValue, ""))
})
this.mHeaderConfig = headerConfig
}
private initPageStocks() {
if (!this.mGroupInfo) {
this.mDataItemModels = []
this.mSwiperPagesItems = []
this.swiperIndex = 0
return
}
const stocks = this.mGroupInfo.stocks
let hqDataItems: HQDataItemModel[] = []
stocks.forEach((security, index) => {
if (StrUtil.isNotEmpty(security.code) && StrUtil.isNotEmpty(security.market)) {
hqDataItems.push(new HQDataItemModel(security.code, security.market, security.name))
}
})
let isNeedAddMoreItem = false
if (hqDataItems.length < this.mMaxTotalItemCount) {
// 数据没满,增加更多
isNeedAddMoreItem = true;
} else {
// 数据满了,只返回前mMaxTotalItemCount个数据
hqDataItems = hqDataItems.slice(0, this.mMaxTotalItemCount);
}
if (isNeedAddMoreItem) {
const moreDataHqDataItemModel = new HQDataItemModel(STR_DEFAULT, STR_DEFAULT, STR_DEFAULT)
moreDataHqDataItemModel.showAddImage = true
hqDataItems.push(moreDataHqDataItemModel)
}
this.mDataItemModels = hqDataItems
this.swiperIndex = 0
this.updateSwiperPagesItemsWidthDataItemModels()
}
private updatePageDataItemModels(hqDataItems: HQDataItemModel[]) {
if (this.mDataItemModels.length === 0) {
this.mSwiperPagesItems.length = 0
return;
}
if (hqDataItems.length > 0) {
for (let currIndex = 0; currIndex < hqDataItems.length; currIndex++) {
const currentDataItem = hqDataItems[currIndex]
const stockCode = currentDataItem.code
const market = currentDataItem.market
if (!HXUtils.isValidContractData(stockCode) || !HXUtils.isValidContractData(market)) {
continue;
}
for (let oldIndex = this.mDataItemModels.length - 1; oldIndex >= 0; oldIndex--) {
const lastDataItemModel = this.mDataItemModels[oldIndex]
if (lastDataItemModel && !lastDataItemModel.showAddImage && stockCode == lastDataItemModel.code) {
lastDataItemModel.copyFrom(currentDataItem)
break;
}
}
}
}
this.updateSwiperPagesItemsWidthDataItemModels()
}
/**
* 根据数据模型构建Swiper
*/
private updateSwiperPagesItemsWidthDataItemModels(): void {
const itemsPerPage = this.mPageItemCount;
const pages: SwiperHqDataItemModel = new SwiperHqDataItemModel();
for (let i = 0; i < this.mDataItemModels.length; i += itemsPerPage) {
let gridHqDataItemModel = new GridHqDataItemModel()
gridHqDataItemModel.push(...this.mDataItemModels.slice(i,
Math.min(i + itemsPerPage, this.mDataItemModels.length)))
pages.push(gridHqDataItemModel);
}
this.mSwiperPagesItems = pages
}
private requestData() {
this.mIsNeedRealData = true
this.mIsHasReceivedData = false
if (!this.firstPageVisible) {
return
}
const stockListRequestText = this.buildExtRequestText();
if (stockListRequestText.length < 0) {
return
}
if (!this.mRequestClient) {
this.mRequestClient = new FirstPageSelfStockRequestClient(this.mFrameId, this.mPageId, this.mHeaderConfig,
null, (tableData: TableData | string) => {
this.onReceiveData(tableData)
}, stockListRequestText)
}
this.mRequestClient?.setExtRequestText(stockListRequestText)
this.mRequestClient.request(0, 25)
}
private buildExtRequestText(): string {
if (this.mDataItemModels.length > 0) {
let stockListText = "stocklist="
let marketListText = "marketlist="
let isValidStock = false;
this.mDataItemModels.forEach((value, index) => {
if (!value.showAddImage && HXUtils.isValidContractData(value.code) &&
HXUtils.isValidContractData(value.market)) {
isValidStock = true
stockListText = stockListText + value.code + "|"
marketListText = marketListText + value.market + "|"
}
})
if (isValidStock) {
return "selfstockcustom=1\r\n" + stockListText + "\r\n" + marketListText
} else {
return ""
}
}
return ""
}
private parseRowCellData2HQDataItemModel(cellArrayData: CellArray): HQDataItemModel {
const dataItem = new HQDataItemModel()
cellArrayData.forEach((cellData, index) => {
switch (cellData.dataId) {
case TableConstants.DATA_ID_NAME:
dataItem.name = cellData.data
dataItem.nameColor = cellData.color
break;
case TableConstants.DATA_ID_MARKET:
dataItem.market = cellData.data
break;
case TableConstants.DATA_ID_CODE_4:
dataItem.code = cellData.data
break;
case TableConstants.DATA_ID_SHOW_CODE:
dataItem.showCode = cellData.data
break;
case TableConstants.DATA_ID_PRICE:
dataItem.price = cellData.data
dataItem.priceColor = cellData.color
break;
case TableConstants.DATA_ID_ZF:
dataItem.riseFail = cellData.data
dataItem.riseFailColor = cellData.color
break;
case TableConstants.DATA_ID_ZD:
dataItem.priceRise = cellData.data
dataItem.priceRiseColor = cellData.color
break;
default:
break;
}
})
return dataItem
}
private toggleExpand(): void {
animateTo({
duration: 300,
curve: Curve.EaseInOut
}, () => {
this.isExtend = !this.isExtend;
PreferenceService.putVal(FirstPageSelfCodeGroupView.SP_FILE_NAME_FIRST_PAGE_SELF_CODE_FILE_NAME,
SP_KEY_FIRST_PAGE_SELF_CODE_EXPAND, this.isExtend, true)
this.swiperIndex = 0;
this.mPageItemCount = this.getPageItemCount()
this.mMaxTotalItemCount = this.getTotalItemCount()
this.initPageStocks()
this.requestData()
});
}
private jumpToMore() {
router.pushUrl({
url: 'pages/SearchPage' // 目标url
}, router.RouterMode.Standard, (err) => {
if (err) {
console.error(`Invoke pushUrl failed, code is ${err.code}, message is ${err.message}`);
return;
}
console.info('Invoke pushUrl succeeded.');
});
}
private jumpToQuote(dataModel: HQDataItemModel) {
let code = dataModel.code
let market = dataModel.market
let codeList: Array<string> = []
let marketList: Array<string> = []
let nameList: Array<string> = []
this.mDataItemModels.forEach((dataModel, _index) => {
if (!dataModel.showAddImage) {
codeList.push(dataModel.code ?? '')
marketList.push(dataModel.market ?? '')
nameList.push(dataModel.name ?? '')
}
})
try {
jumpToQuote(code, market, codeList.join(","), marketList.join(","), nameList.join(","));
} catch (error) {
console.log("Invoke pushUrl from selfCode to QuoteDetailPage Failed")
}
}
}
@@ -0,0 +1,64 @@
import { StringUtils } from 'hxutil'
import { FirstPageNodeModel } from '../../datacenter/model/FirstPageNodeModel'
import { display } from '@kit.ArkUI'
import { RouterUtil } from '../../util/RouterUtil'
/**
* 首页顶部运营图节点
* @author wubingsong@myhexin.com
*/
@Component
export struct TopImageNodeComponent {
nodeModel: FirstPageNodeModel | null = null
@State
private isNodeShow: boolean = false
@State bgUrl: string = ''
private jumpUrl: string = ''
@State nodeHeight: number = 70
aboutToAppear(): void {
if (this.nodeModel != null) {
this.bgUrl = this.nodeModel.iconUrl
this.jumpUrl = this.nodeModel.url
if (StringUtils.isNotEmpty(this.bgUrl)) {
this.isNodeShow = true
this.calculateHeight()
}
}
}
private calculateHeight() {
let dis = display.getDefaultDisplaySync()
if (dis.densityPixels > 0) {
let screenWidth = dis.width / dis.densityPixels
if (StringUtils.isNotEmpty(this.bgUrl)) {
this.nodeHeight = screenWidth * 0.61
} else {
this.nodeHeight = screenWidth * 0.3
}
}
}
build() {
Image(this.bgUrl)
.width('100%')
.height(this.nodeHeight)
.objectFit(ImageFit.Fill)
.onSizeChange((oldValue: SizeOptions, newValue: SizeOptions) => {
if (newValue.width) {
let width = newValue.width as number
if (StringUtils.isNotEmpty(this.bgUrl)) {
this.nodeHeight = width * 0.61
} else {
this.nodeHeight = width * 0.3
}
}
})
.onClick(() => {
RouterUtil.jumpPage(this.jumpUrl)
})
.visibility(this.isNodeShow ? Visibility.Visible : Visibility.None)
}
}