Files
biz_firstpage/src/main/ets/ai-pick/AiPickNodeComponent.ets
T

777 lines
23 KiB
Plaintext

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<void> {
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<string, PickContractItem> =
new Map<string, PickContractItem>();
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)
}
}