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 dark: Record } /** * 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 = {} private scoreBgImagesDark: Record = {} // 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 }) } }