import { emitter } from '@kit.BasicServicesKit'; import { enableDarkMode } from '@kernel/theme_manager'; import { EmitterConstants, GlobalContext, jumpToQuote } from 'biz_common'; import { AiPickDataFetcher } from './AiPickDataFetcher'; import { AiPickHqRequestClient } from '../node/clients/AiPickHqRequestClient'; import { CardData, PickContractItem, PickStrategy, PickTabData, } from './AiPickModels'; import { cellColor, cellValue, formatColName, getUniqueContracts, } from './AiPickUtils'; import { EMPTY_IMAGE_DARK, EMPTY_IMAGE_LIGHT, } from './AiPickConstant'; import { AllCardsCard } from '../node/model/AiDiagnosisNodeModel'; import { RouterUtil } from '../util/RouterUtil'; import { getResourceString } from '../util/ResourceUtil'; interface StrategyDescParams { tabIdx: number; } @Component export struct AiPickNodeComponent { // 外部状态 @Link @Watch('onRefreshTriggerChange') refreshTrigger: number; @Consume('firstPageVisible') @Watch('onFirstPageVisibleChange') firstPageVisible: boolean = true; @StorageProp(enableDarkMode) isDarkMode: boolean = false; // 卡片业务数据 @State cardConfig: AllCardsCard | undefined = undefined; @State cardData: CardData = { tabs: [] }; // 区分加载中和暂无数据 @State dataReady: boolean = false; // Tab 状态及滚动区域 @State tabIdx: number = 0; @State tabViewportWidth: number = 0; @State tabContentWidth: number = 0; @State tabScrollOffset: number = 0; // 弹层状态 @State showExplainSheet: boolean = false; // 生命周期与异步请求状态 private isComponentActive: boolean = false; private dataRequestVersion: number = 0; // 行情订阅与事件状态 private hqUnsubscribe: (() => void) | undefined; private hqSubscriptionVersion: number = 0; private isTcpEventSubscribed: boolean = false; private tcpReconnectListener = (): void => { if (!this.canUseHq()) { return; } setTimeout((): void => { if (this.canUseHq()) { this.restartCurrentSubscription(); } }); }; // ==================== 组件生命周期 ==================== aboutToAppear(): void { this.isComponentActive = true; this.updateCardConfig(); if (this.firstPageVisible) { this.subscribeTcpNetStatus(); } this.updateCardData(); } aboutToDisappear(): void { this.isComponentActive = false; this.dataRequestVersion += 1; this.stopSubscribeHq(); this.unSubscribeTcpNetStatus(); } // ==================== 配置与刷新 ==================== // 从首页统一卡片配置中读取 AI Pick 配置。 private updateCardConfig(): void { this.cardConfig = AiPickDataFetcher.getInstance().fetchCardConfig(); } // refreshTrigger 变化时重新获取策略和合约,并在请求完成后重建行情订阅。 onRefreshTriggerChange(): void { if (!this.isComponentActive) { return; } this.updateCardConfig(); this.updateCardData(); } // ==================== 可见性与 TCP 重连 ==================== // 页面不可见时停止行情;恢复可见后使用已有数据重建订阅。 onFirstPageVisibleChange(): void { if (!this.isComponentActive) { return; } if (!this.firstPageVisible) { this.stopSubscribeHq(); this.unSubscribeTcpNetStatus(); return; } this.subscribeTcpNetStatus(); if (this.cardData.tabs.length > 0) { this.subscribeHq(); } else { this.updateCardData(); } } private subscribeTcpNetStatus(): void { if (this.isTcpEventSubscribed || !this.canUseHq()) { return; } this.isTcpEventSubscribed = true; emitter.on( { eventId: EmitterConstants.NETWORK_TCP_RECONNECT }, this.tcpReconnectListener, ); } private unSubscribeTcpNetStatus(): void { if (!this.isTcpEventSubscribed) { return; } this.isTcpEventSubscribed = false; emitter.off( EmitterConstants.NETWORK_TCP_RECONNECT, this.tcpReconnectListener, ); } // 收到 TCP 重连事件后,仅在页面可见时重建原合约订阅。 private restartCurrentSubscription(): void { if (!this.canUseHq()) { return; } if (this.cardData.tabs.length > 0) { this.subscribeHq(); } else if (this.dataReady) { this.updateCardData(); } } // ==================== 数据请求与行情订阅 ==================== // 请求所有策略和合约;刷新成功后保留已有行情值,避免列表短暂回退到占位状态。 private async updateCardData(): Promise { if (!this.isComponentActive) { return; } const requestVersion: number = this.dataRequestVersion + 1; this.dataRequestVersion = requestVersion; this.stopSubscribeHq(); if (this.cardData.tabs.length === 0) { this.dataReady = false; } try { const tabs: PickTabData[] = await AiPickDataFetcher.getInstance().fetchCardData(); if (!this.isCurrentDataRequest(requestVersion)) { return; } const nextTabs: PickTabData[] = this.mergeCachedQuotes(tabs); if (this.tabIdx >= nextTabs.length) { this.tabIdx = 0; } this.cardData = { tabs: nextTabs }; this.dataReady = true; if (nextTabs.length > 0 && this.firstPageVisible) { this.subscribeHq(); } } catch { if (!this.isCurrentDataRequest(requestVersion)) { return; } this.dataReady = true; if (this.cardData.tabs.length > 0 && this.firstPageVisible) { this.subscribeHq(); } } } // HTTP 刷新时按“合约 + 市场”复用旧行情,等待新订阅首包覆盖。 private mergeCachedQuotes(tabs: PickTabData[]): PickTabData[] { const cachedContracts: PickContractItem[] = getUniqueContracts(this.cardData); const cachedMap: Map = new Map(); cachedContracts.forEach((item: PickContractItem): void => { cachedMap.set(`${item.contract}_${item.market}`, item); }); return tabs.map((tab: PickTabData): PickTabData => { const items: PickContractItem[] = tab.items.map((item: PickContractItem): PickContractItem => { const cachedItem: PickContractItem | undefined = cachedMap.get(`${item.contract}_${item.market}`); const nextItem: PickContractItem = { contract: item.contract, market: item.market, name: item.name, price: cachedItem?.price, priceChg: cachedItem?.priceChg, isMain: item.isMain, extraFields: item.extraFields, }; return nextItem; }); const nextTab: PickTabData = { strategy: tab.strategy, items: items, keysList: tab.keysList, fieldList: tab.fieldList, }; return nextTab; }); } // AI Pick 一次订阅所有 Tab 的去重合约,推送后合并回完整卡片数据。 private subscribeHq(): void { this.stopSubscribeHq(); if (!this.canUseHq() || this.cardData.tabs.length === 0) { return; } const subscriptionVersion: number = this.hqSubscriptionVersion; this.hqUnsubscribe = AiPickHqRequestClient.getInstance().subscribeHq( this.cardData, (cardData: CardData): void => { if (this.canUseHq() && subscriptionVersion === this.hqSubscriptionVersion) { this.cardData = cardData; } }, ); } private stopSubscribeHq(): void { this.hqSubscriptionVersion += 1; if (this.hqUnsubscribe !== undefined) { this.hqUnsubscribe(); this.hqUnsubscribe = undefined; } } private canUseHq(): boolean { return this.isComponentActive && this.firstPageVisible; } private isCurrentDataRequest(requestVersion: number): boolean { return this.isComponentActive && requestVersion === this.dataRequestVersion; } // ==================== 页面跳转 ==================== // 跳转客户端行情页,并保持合约、市场和名称列表顺序一致。 private jumpToDetail(item: PickContractItem): void { const contracts: PickContractItem[] = getUniqueContracts(this.cardData); const codeList: string = contracts.map((contract: PickContractItem): string => contract.contract).join(','); const marketIdList: string = contracts.map((contract: PickContractItem): string => contract.market).join(','); const nameList: string = contracts.map((contract: PickContractItem): string => contract.name).join(','); jumpToQuote(item.contract, item.market, codeList, marketIdList, nameList); } // 跳转到卡片配置中的标题地址。 private jumpToCardTitle(): void { const jumpUrl: string = this.cardConfig?.card_url.android || this.cardConfig?.card_url.ios || ''; RouterUtil.jumpPage(jumpUrl, true); } // 跳转 AI 策略定制首页。 private jumpToNewHome(): void { const pageUrl: string = 'https://fupage.10jqka.com.cn/ai-web/ai-diagnosis-home.html?sync=1'; RouterUtil.jumpPage(this.buildClientUrl(pageUrl), true); } // 跳转策略详情页。 private jumpToStrategyDetail(strategy: PickStrategy): void { const pageUrl: string = 'https://fupage.10jqka.com.cn/ai-web/ai-diagnosis-detail.html' + `?id=${strategy.id}&type=${strategy.periodType}` + `&name=${encodeURIComponent(strategy.name)}`; RouterUtil.jumpPage(this.buildClientUrl(pageUrl), true); } // 跳转策略标签页。 private jumpToLabel(strategy: PickStrategy, label: string): void { const pageUrl: string = 'https://fupage.10jqka.com.cn/ai-web/ai-diagnosis-label.html' + `?id=${strategy.id}&type=${strategy.periodType}` + `&label=${encodeURIComponent(label)}`; RouterUtil.jumpPage(this.buildClientUrl(pageUrl), true); } // 使用宿主统一的 client.html 协议包装 H5 地址。 private buildClientUrl(pageUrl: string): string { const title: string = this.cardConfig?.card_title.value || getResourceString(getContext(this), $r('app.string.ai_pick_title')); return 'client.html?action=ymtz^ishiddenbar=1^mode=new^webid=2804^' + `url=${pageUrl}^title=${title}`; } // ==================== UI 构建 ==================== private showTabGradient(): boolean { const maxOffset: number = this.tabContentWidth - this.tabViewportWidth; return maxOffset > 0 && this.tabScrollOffset < maxOffset; } build() { Column() { this.buildTitleBar() if (this.cardData.tabs.length > 0) { this.buildMainContent() } else { this.buildEmptyState() } } .width('100%') .backgroundColor($r('app.color.surface_layer1_foreground')) .borderRadius(4) .padding({ left: 10, right: 10, bottom: 10 }) .bindSheet(this.showExplainSheet, this.buildExplainSheetContent, { height: SheetSize.FIT_CONTENT, dragBar: true, showClose: false, maskColor: $r('app.color.mask_level2'), onDisappear: () => { this.showExplainSheet = false; }, radius: { topLeft: 10, topRight: 10, }, }) } @Builder buildTitleBar() { Row() { Row({ space: 4 }) { Text(this.cardConfig?.card_title.value || $r('app.string.ai_pick_title')) .fontSize(18) .fontWeight(500) .fontColor($r('app.color.elements_text_primary_02')) .maxLines(1) if ((this.cardConfig?.card_url.android ?? '') !== '' || (this.cardConfig?.card_url.ios ?? '') !== '') { Image($r('app.media.icon_arrow_forward_20')) .width(20) .height(20) .fillColor($r('app.color.elements_icon_primary_02')) } } .height('100%') .alignItems(VerticalAlign.Center) .onClick(() => { this.jumpToCardTitle(); }) if ((this.cardConfig?.explain_message ?? '') !== '') { Image($r('app.media.icon_info_24')) .width(16) .height(16) .fillColor($r('app.color.elements_icon_tertiary')) .margin({ left: 8 }) .onClick(() => { this.showExplainSheet = true; }) } Blank() } .width('100%') .height(48) .alignItems(VerticalAlign.Center) } @Builder buildExplainSheetContent() { Column() { Stack({ alignContent: Alignment.End }) { Text(this.cardConfig?.explain_title || this.cardConfig?.card_title.value || $r('app.string.ai_pick_title')) .fontSize(18) .fontWeight(500) .textAlign(TextAlign.Center) .width('100%') Image($r('app.media.icon_close_24')) .width(24) .height(24) .fillColor($r('app.color.elements_icon_primary_02')) .onClick(() => { this.showExplainSheet = false; }) } .height(64) .width('100%') Scroll() { Text((this.cardConfig?.explain_message ?? '') .replace(new RegExp('\\\\n', 'g'), '\n')) .width('100%') .fontSize(14) .lineHeight(22) .fontColor($r('app.color.elements_text_primary_02')) } .width('100%') .scrollBar(BarState.Off) Button($r('app.string.card_explain_confirm')) .type(ButtonType.Normal) .backgroundColor($r('app.color.elements_button_bg_primary')) .width('100%') .height(44) .borderRadius(4) .margin({ top: 16 }) .onClick(() => { this.showExplainSheet = false; }) } .width('100%') .backgroundColor($r('app.color.surface_layer3_background')) .padding({ bottom: px2vp(GlobalContext.navigationBarHeight), left: 16, right: 16, }) } @Builder buildMainContent() { Column() { this.buildTabBar() if (this.cardData.tabs[this.tabIdx].items.length > 0) { this.buildStrategyDesc({ tabIdx: this.tabIdx }) this.buildTable() } else { this.buildNoContractState() } this.buildCustomStrategy() } .width('100%') .height(321) } @Builder buildTabBar() { Stack({ alignContent: Alignment.End }) { Scroll() { Row({ space: 8 }) { ForEach(this.cardData.tabs, (tab: PickTabData, index: number) => { Text(`${tab.strategy.name}(${tab.strategy.count})`) .fontSize(14) .fontWeight(this.tabIdx === index ? FontWeight.Medium : FontWeight.Normal) .fontColor(this.tabIdx === index ? $r('app.color.elements_text_primary_01') : $r('app.color.elements_text_secondary')) .constraintSize({ minWidth: 66 }) .maxLines(1) .textAlign(TextAlign.Center) .height(30) .padding({ left: 8, right: 8 }) .borderRadius(4) .backgroundColor(this.tabIdx === index ? $r('app.color.elements_others_bg_primary') : $r('app.color.elements_button_bg_tertiary')) .onClick(() => { this.tabIdx = index; }) }, (tab: PickTabData): string => `${tab.strategy.periodType}_${tab.strategy.id}`) } .onAreaChange((_oldValue: Area, newValue: Area) => { this.tabContentWidth = Number(newValue.width); }) } .width('100%') .height(30) .padding({ right: 10 }) .scrollable(ScrollDirection.Horizontal) .scrollBar(BarState.Off) .align(Alignment.Start) .onScroll((xOffset: number, _yOffset: number) => { this.tabScrollOffset = xOffset; }) if (this.showTabGradient()) { Row() .width(24) .height(30) .linearGradient({ angle: 90, colors: [ [$r('app.color.tab_gradient_start'), 0], [$r('app.color.surface_layer1_foreground'), 1], ], }) .hitTestBehavior(HitTestMode.None) } } .width('100%') .height(30) .onAreaChange((_oldValue: Area, newValue: Area) => { this.tabViewportWidth = Number(newValue.width); }) } @Builder buildStrategyDesc($$: StrategyDescParams) { Row() { Column({ space: 4 }) { if (this.cardData.tabs[$$.tabIdx].strategy.detail !== '') { Text(this.cardData.tabs[$$.tabIdx].strategy.detail) .fontSize(14) .lineHeight(21) .fontColor($r('app.color.elements_text_primary_02')) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) .onClick(() => { this.jumpToStrategyDetail( this.cardData.tabs[$$.tabIdx].strategy, ); }) } if (this.cardData.tabs[$$.tabIdx].strategy.tagList.length > 0) { Row({ space: 4 }) { ForEach( this.cardData.tabs[$$.tabIdx].strategy.tagList, (tag: string) => { Row() { Text(tag) .fontSize(11) .fontColor($r('app.color.elements_text_primary_01')) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .height(18) .padding({ left: 3, right: 3 }) .borderRadius(2) .border({ width: 0.5, color: $r('app.color.elements_text_primary_01'), }) .alignItems(VerticalAlign.Center) .onClick(() => { this.jumpToLabel( this.cardData.tabs[$$.tabIdx].strategy, tag, ); }) }, (tag: string): string => tag, ) } .width('100%') } } .layoutWeight(1) .alignItems(HorizontalAlign.Start) if ((this.cardData.tabs[$$.tabIdx] .strategy.imgs[this.isDarkMode ? 1 : 0] ?? '') !== '') { Image(this.cardData.tabs[$$.tabIdx] .strategy.imgs[this.isDarkMode ? 1 : 0] ?? '') .width(104) .height(66) .objectFit(ImageFit.Contain) .margin({ left: 10 }) .onClick(() => { this.jumpToStrategyDetail( this.cardData.tabs[$$.tabIdx].strategy, ); }) } } .width('100%') .height(86) .padding({ top: 10, bottom: 10 }) .alignItems(VerticalAlign.Top) } @Builder buildTable() { Column() { Row({ space: 8 }) { Text(formatColName(this.cardData.tabs[this.tabIdx].keysList[0])) .fontSize(14) .fontColor($r('app.color.elements_text_tertiary')) .width('35%') Text(formatColName(this.cardData.tabs[this.tabIdx].keysList[1])) .fontSize(14) .fontColor($r('app.color.elements_text_tertiary')) .layoutWeight(1) .textAlign(TextAlign.End) Text(formatColName(this.cardData.tabs[this.tabIdx].keysList[2])) .fontSize(14) .fontColor($r('app.color.elements_text_tertiary')) .layoutWeight(1) .textAlign(TextAlign.End) } .width('100%') .height(40) ForEach( this.cardData.tabs[this.tabIdx].items, (item: PickContractItem) => { this.buildContractRow(item) }, (item: PickContractItem): string => `${item.contract}_${item.market}_${item.price ?? ''}_${item.priceChg ?? ''}`, ) } .width('100%') .height(175) } @Builder buildContractRow(item: PickContractItem) { Row({ space: 8 }) { Row({ space: 5 }) { Text(item.name) .fontSize(16) .maxFontSize(16) .minFontSize(9) .fontColor($r('app.color.elements_text_primary_02')) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) if (item.isMain) { Text($r('app.string.ai_pick_main_contract')) .fontSize(11) .fontColor($r('app.color.hotlist_orange')) .width(16) .height(16) .textAlign(TextAlign.Center) .borderRadius(2) .backgroundColor($r('app.color.bg_hotlist_orange')) } } .width('35%') .alignItems(VerticalAlign.Center) Text(cellValue( this.cardData.tabs[this.tabIdx].keysList[1], item, this.cardData.tabs[this.tabIdx].fieldList, )) .fontSize(16) .maxFontSize(16) .minFontSize(9) .fontWeight(500) .fontColor(cellColor( this.cardData.tabs[this.tabIdx].keysList[1], item, )) .layoutWeight(1) .textAlign(TextAlign.End) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Text(cellValue( this.cardData.tabs[this.tabIdx].keysList[2], item, this.cardData.tabs[this.tabIdx].fieldList, )) .fontSize(16) .maxFontSize(16) .minFontSize(9) .fontWeight(500) .fontColor(cellColor( this.cardData.tabs[this.tabIdx].keysList[2], item, )) .layoutWeight(1) .textAlign(TextAlign.End) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .width('100%') .height(45) .alignItems(VerticalAlign.Center) .border({ width: { top: 1 }, color: $r('app.color.elements_others_divider_primary'), }) .onClick(() => { this.jumpToDetail(item); }) } @Builder buildCustomStrategy() { Row({ space: 2 }) { Text($r('app.string.ai_pick_custom_strategy')) .fontSize(14) .lineHeight(20) .fontColor($r('app.color.elements_text_tertiary')) Image($r('app.media.icon_arrow_forward_20')) .width(14) .height(14) .fillColor($r('app.color.elements_icon_tertiary')) } .width('100%') .height(30) .justifyContent(FlexAlign.Center) .alignItems(VerticalAlign.Center) .onClick(() => { this.jumpToNewHome(); }) } @Builder buildEmptyState() { Column() { if (this.dataReady) { Image(this.isDarkMode ? EMPTY_IMAGE_DARK : EMPTY_IMAGE_LIGHT) .width(120) .height(120) .margin({ bottom: 8 }) Text($r('app.string.ai_pick_no_contract')) .fontSize(14) .fontColor($r('app.color.elements_text_tertiary')) } else { Text($r('app.string.ai_pick_loading')) .fontSize(14) .fontColor($r('app.color.elements_text_tertiary')) } } .width('100%') .height(321) .alignItems(HorizontalAlign.Center) .justifyContent(FlexAlign.Center) } @Builder buildNoContractState() { Column() { Image(this.isDarkMode ? EMPTY_IMAGE_DARK : EMPTY_IMAGE_LIGHT) .width(100) .height(100) Text($r('app.string.ai_pick_no_contract')) .fontSize(14) .fontColor($r('app.color.elements_text_tertiary')) } .width('100%') .height(261) .alignItems(HorizontalAlign.Center) .justifyContent(FlexAlign.Center) } }