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,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 })
}
}