add AI pick card

This commit is contained in:
clz
2026-07-27 17:22:29 +08:00
parent 30d9154086
commit 10dc2787df
14 changed files with 1453 additions and 5 deletions
+22
View File
@@ -0,0 +1,22 @@
export const MAX_TABS = 5;
export const MAX_ITEMS = 3;
export const MAX_TAGS = 3;
export const MAX_SHOW_KEYS = 3;
export const MAX_DOUBLE_KEYS = 2;
export const EMPTY_IMAGE_LIGHT =
'https://u.thsi.cn/imgsrc/bbs/8eee9cd4fa7e359032de11a3f7c2a70b_300_230.png';
export const EMPTY_IMAGE_DARK =
'https://u.thsi.cn/imgsrc/bbs/1c4e679244ed87231befad2b04cddfe2_300_230.png';
export const BLACK_LIST: string[] = [
'合约代码',
'合约简称',
'收盘价',
'最新价',
'涨跌幅',
'code',
'market_id',
'market_code',
'最新涨跌幅',
];
+122
View File
@@ -0,0 +1,122 @@
import { http } from '@kit.NetworkKit';
import { GlobalContext, HXLog } from 'biz_common';
import { buildHeader } from '../util/Header';
import { FirstPageCardsConfigLoader } from '../node/datacenter/FirstPageCardsConfigLoader';
import { AllCardsCard, ModDataItem } from '../node/model/AiDiagnosisNodeModel';
import {
PickContractItem,
PickStrategy,
PickTabData,
RawPickResponse,
RawPickTabItem,
RawStrategyDetail,
RawStrategyItem,
} from './AiPickModels';
import { getResData, getTableList, ResData } from './AiPickUtils';
import { MAX_ITEMS, MAX_TABS, MAX_TAGS } from './AiPickConstant';
const EMPTY_RAW_STRATEGY_ITEM: RawStrategyItem = {};
const EMPTY_RAW_STRATEGY_DETAIL: RawStrategyDetail = {
id: '',
strategy_name: '',
strategy_type: '',
};
function parseRawTab(item: RawPickTabItem): PickTabData {
const isSystemPeriod: boolean = item.type === 'system_period';
const strategyItem: RawStrategyItem = (
isSystemPeriod ? item.system_strategy : item.custom_strategy
) ?? EMPTY_RAW_STRATEGY_ITEM;
const strategyDetail: RawStrategyDetail = (
isSystemPeriod ? strategyItem.system_strategy : strategyItem.custom_strategy
) ?? EMPTY_RAW_STRATEGY_DETAIL;
const resData: ResData = getResData(strategyItem);
const allItems: PickContractItem[] =
getTableList(resData.list, resData.resultValueList);
const strategy: PickStrategy = {
id: strategyDetail.id,
name: strategyDetail.strategy_name,
detail: strategyDetail.user_question || strategyDetail.content || '',
type: strategyDetail.strategy_type || 'custom_strategy',
periodType: item.type,
tagList: (strategyDetail.tag_list ??
[GlobalContext.get().resourceManager
.getStringSync($r('app.string.ai_pick_custom_tag'))])
.slice(0, MAX_TAGS),
imgs: isSystemPeriod
? [strategyDetail.white_img ?? '', strategyDetail.black_img ?? '']
: [],
count: strategyItem.detail_count ?? 0,
};
const tabData: PickTabData = {
strategy: strategy,
items: allItems.slice(0, MAX_ITEMS),
keysList: resData.showKeyList,
fieldList: resData.fieldList,
};
return tabData;
}
function parseRawTabs(rawTabs: RawPickTabItem[]): PickTabData[] {
return rawTabs
.slice(0, MAX_TABS)
.map((item: RawPickTabItem): PickTabData => parseRawTab(item));
}
export class AiPickDataFetcher {
private static readonly instance: AiPickDataFetcher = new AiPickDataFetcher();
private constructor() {
}
static getInstance(): AiPickDataFetcher {
return AiPickDataFetcher.instance;
}
fetchCardConfig(): AllCardsCard | undefined {
return FirstPageCardsConfigLoader.getConfig()?.aiPick;
}
async fetchCardData(): Promise<PickTabData[]> {
const cardConfig: AllCardsCard | undefined = this.fetchCardConfig();
const modData: ModDataItem | undefined = cardConfig?.mod_data[0];
const configuredUrl: string = modData?.url ?? '';
if (configuredUrl === '') {
return [];
}
const requestOptions: http.HttpRequestOptions = {
method: http.RequestMethod.GET,
header: buildHeader(),
connectTimeout: 60000,
readTimeout: 60000,
};
const httpRequest: http.HttpRequest = http.createHttp();
try {
const requestUrl: string = this.buildRequestUrl(configuredUrl, Date.now());
const response: http.HttpResponse =
await httpRequest.request(requestUrl, requestOptions);
if (response.responseCode !== http.ResponseCode.OK) {
throw new Error('request failed: ' + response.responseCode);
}
const responseText: string = response.result as string;
const result: RawPickResponse = JSON.parse(responseText) as RawPickResponse;
const responseCode: number = result.code ?? result.status_code ?? 0;
if (responseCode !== 0 || result.data === undefined) {
throw new Error('response code: ' + responseCode);
}
return parseRawTabs(result.data);
} catch (requestError) {
const errorInfo: Error = requestError as Error;
HXLog.e('AiPickDataFetcher', 'fetchCardData error: ' + errorInfo.message);
throw new Error(errorInfo.message);
} finally {
httpRequest.destroy();
}
}
private buildRequestUrl(configuredUrl: string, timestamp: number): string {
const separator: string = configuredUrl.includes('?') ? '&' : '?';
return `${configuredUrl}${separator}_=${timestamp}`;
}
}
+83
View File
@@ -0,0 +1,83 @@
export interface PickContractItem {
contract: string;
market: string;
name: string;
price?: number;
priceChg?: number;
isMain: boolean;
extraFields: Record<string, string>;
}
export interface PickFieldItem {
key: string;
unit: string;
}
export interface PickStrategy {
id: string;
name: string;
detail: string;
type: string;
periodType: string;
tagList: string[];
imgs: string[];
count: number;
}
export interface PickTabData {
strategy: PickStrategy;
items: PickContractItem[];
keysList: string[];
fieldList: PickFieldItem[];
}
export interface CardData {
tabs: PickTabData[];
}
export interface RawStrategyDetail {
id: string;
strategy_name: string;
user_question?: string;
content?: string;
strategy_type: string;
tag_list?: string[];
white_img?: string;
black_img?: string;
}
export interface RawFieldItem {
key: string;
type: string;
unit?: string;
}
export interface RawDetailItem {
contract: string;
market: string;
contract_name: string;
main_contract_type: string | number;
}
export type RawResultItem = Record<string, number | string>;
export interface RawStrategyItem {
custom_strategy?: RawStrategyDetail;
system_strategy?: RawStrategyDetail;
detail_list?: RawDetailItem[];
field_list?: RawFieldItem[];
origin_result_list?: RawResultItem[];
detail_count?: number;
}
export interface RawPickTabItem {
type: string;
system_strategy?: RawStrategyItem;
custom_strategy?: RawStrategyItem;
}
export interface RawPickResponse {
code?: number;
status_code?: number;
data?: RawPickTabItem[];
}
@@ -0,0 +1,746 @@
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,
MAX_ITEMS,
} from './AiPickConstant';
import { AllCardsCard } from '../node/model/AiDiagnosisNodeModel';
import { RouterUtil } from '../util/RouterUtil';
import { getResourceString } from '../util/ResourceUtil';
interface StrategyDescParams {
tabIdx: number;
}
const TAB_BAR_HEIGHT = 30;
const STRATEGY_DESC_HEIGHT = 86;
const TABLE_HEADER_HEIGHT = 40;
const TABLE_ROW_HEIGHT = 45;
const TABLE_HEIGHT = TABLE_HEADER_HEIGHT + TABLE_ROW_HEIGHT * MAX_ITEMS;
const CUSTOM_STRATEGY_HEIGHT = 30;
const MAIN_CONTENT_HEIGHT =
TAB_BAR_HEIGHT + STRATEGY_DESC_HEIGHT + TABLE_HEIGHT + CUSTOM_STRATEGY_HEIGHT;
const NO_CONTRACT_HEIGHT = STRATEGY_DESC_HEIGHT + TABLE_HEIGHT;
@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;
@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 hqSubscriptionVersion: number = 0;
private hqUnsubscribe: (() => void) | undefined;
private isTcpEventSubscribed: boolean = false;
private tcpReconnectListener = (): void => {
if (!this.canUseHq()) {
return;
}
setTimeout((): void => {
if (this.canUseHq()) {
this.restartSubscription();
}
});
};
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();
}
onRefreshTriggerChange(): void {
if (!this.isComponentActive) {
return;
}
this.updateCardConfig();
this.updateCardData();
}
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 updateCardConfig(): void {
this.cardConfig = AiPickDataFetcher.getInstance().fetchCardConfig();
}
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();
}
}
}
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;
});
}
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 restartSubscription(): void {
if (this.cardData.tabs.length > 0) {
this.subscribeHq();
} else if (this.dataReady) {
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,
);
}
private canUseHq(): boolean {
return this.isComponentActive && this.firstPageVisible;
}
private isCurrentDataRequest(requestVersion: number): boolean {
return this.isComponentActive && requestVersion === this.dataRequestVersion;
}
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(MAIN_CONTENT_HEIGHT)
}
@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(TAB_BAR_HEIGHT)
.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(TAB_BAR_HEIGHT)
.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(TAB_BAR_HEIGHT)
.linearGradient({
angle: 90,
colors: [
[$r('app.color.transparent_white'), 0],
[$r('app.color.surface_layer1_foreground'), 1],
],
})
.hitTestBehavior(HitTestMode.None)
}
}
.width('100%')
.height(TAB_BAR_HEIGHT)
.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) => {
Text(tag)
.fontSize(11)
.lineHeight(18)
.fontColor($r('app.color.elements_text_primary_01'))
.maxLines(1)
.height(18)
.padding({ left: 3, right: 3 })
.borderRadius(2)
.border({
width: 0.5,
color: $r('app.color.elements_text_primary_01'),
})
.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(STRATEGY_DESC_HEIGHT)
.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(TABLE_HEADER_HEIGHT)
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(TABLE_HEIGHT)
}
@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'))
.layoutWeight(1)
.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.elements_button_bg_tertiary'))
}
}
.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(TABLE_ROW_HEIGHT)
.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(CUSTOM_STRATEGY_HEIGHT)
.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(MAIN_CONTENT_HEIGHT)
.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(NO_CONTRACT_HEIGHT)
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
}
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);
}
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);
}
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}`;
}
}
+177
View File
@@ -0,0 +1,177 @@
import {
CardData,
PickContractItem,
PickFieldItem,
PickTabData,
RawDetailItem,
RawFieldItem,
RawResultItem,
RawStrategyItem,
} from './AiPickModels';
import { BLACK_LIST, MAX_DOUBLE_KEYS, MAX_SHOW_KEYS } from './AiPickConstant';
import { formatPercent, formatPrice, riseFallColor } from '../common/NumberFormat';
const EMPTY_EXTRA_FIELDS: Record<string, string> = {};
export interface ResData {
showKeyList: string[];
fieldList: PickFieldItem[];
resultValueList: Record<string, string>[];
list: RawDetailItem[];
}
export function filterFieldKey(key: string): boolean {
const extBlackList: string[] =
BLACK_LIST.map((value: string): string => `期货@${value}`);
const inBlack: boolean = BLACK_LIST.includes(key) || extBlackList.includes(key);
const isChangeField: boolean =
key.startsWith('涨跌幅[') || key.startsWith('期货@涨跌幅[');
return !inBlack && !isChangeField;
}
export function formatBigData(value: number | string): string {
const numericValue: number = Number(value);
if (Number.isNaN(numericValue)) {
return `${value}`;
}
const tenThousand = 1e4;
const billion = 1e8;
const trillion = 1e12;
const formattedValue: number = Number(numericValue.toFixed(3));
const absoluteValue: number = Math.abs(formattedValue);
if (absoluteValue >= trillion) {
return `${(formattedValue / trillion).toFixed(2)}万亿`;
}
if (absoluteValue >= billion) {
if (Number((absoluteValue / billion).toFixed(2)) >= tenThousand) {
return `${(formattedValue / trillion).toFixed(2)}万亿`;
}
return `${(formattedValue / billion).toFixed(2)}亿`;
}
if (absoluteValue >= tenThousand) {
if (Number((absoluteValue / tenThousand).toFixed(2)) >= tenThousand) {
return `${(formattedValue / billion).toFixed(2)}亿`;
}
return `${(formattedValue / tenThousand).toFixed(2)}万`;
}
return `${formattedValue}`;
}
export function getShowKeyList(doubleKeys: string[]): string[] {
if (doubleKeys.length === 0) {
return ['合约名称', '最新价', '涨跌幅(结)'];
}
return ['合约名称']
.concat(doubleKeys.concat(['涨跌幅(结)', '最新价']))
.slice(0, MAX_SHOW_KEYS);
}
export function getResultValueList(
resultList: RawResultItem[],
doubleListKeys: string[],
): Record<string, string>[] {
return resultList.map((value: RawResultItem): Record<string, string> => {
const result: Record<string, string> = {};
doubleListKeys.forEach((key: string): void => {
const rawValue: number | string | undefined = value[key];
if (rawValue !== undefined) {
result[key] = formatBigData(rawValue);
}
});
return result;
});
}
export function getResData(strategyItem: RawStrategyItem): ResData {
const rawFieldList: RawFieldItem[] = strategyItem.field_list ?? [];
const resultList: RawResultItem[] = strategyItem.origin_result_list ?? [];
const list: RawDetailItem[] = strategyItem.detail_list ?? [];
const doubleList: RawFieldItem[] = rawFieldList.filter(
(field: RawFieldItem): boolean =>
field.type === 'DOUBLE' && filterFieldKey(field.key),
);
const doubleListKeys: string[] =
doubleList.map((field: RawFieldItem): string => field.key);
const showKeys: string[] = doubleListKeys.slice(0, MAX_DOUBLE_KEYS);
const fieldList: PickFieldItem[] =
doubleList.map((field: RawFieldItem): PickFieldItem => {
const item: PickFieldItem = {
key: field.key,
unit: field.unit ?? '',
};
return item;
});
const result: ResData = {
showKeyList: getShowKeyList(showKeys),
fieldList: fieldList,
resultValueList: getResultValueList(resultList, doubleListKeys),
list: list,
};
return result;
}
export function getTableList(
list: RawDetailItem[],
resultValueList: Record<string, string>[],
): PickContractItem[] {
return list.map((item: RawDetailItem, index: number): PickContractItem => {
const contract: PickContractItem = {
contract: item.contract,
market: item.market,
name: item.contract_name,
isMain: String(item.main_contract_type) === '1',
extraFields: resultValueList[index] ?? EMPTY_EXTRA_FIELDS,
};
return contract;
});
}
export function getUniqueContracts(cardData: CardData): PickContractItem[] {
const contracts: PickContractItem[] = [];
const contractKeys: string[] = [];
cardData.tabs.forEach((tab: PickTabData): void => {
tab.items.forEach((item: PickContractItem): void => {
const contractKey: string = `${item.contract}_${item.market}`;
if (!contractKeys.includes(contractKey)) {
contractKeys.push(contractKey);
contracts.push(item);
}
});
});
return contracts;
}
export function formatColName(name: string): string {
return name
.replace(new RegExp('期货@', 'g'), '')
.replace(new RegExp('\\[.*?\\]', 'g'), '');
}
export function getUnit(key: string, fieldList: PickFieldItem[]): string {
const item: PickFieldItem | undefined =
fieldList.find((field: PickFieldItem): boolean => field.key === key);
return item?.unit ?? '';
}
export function cellValue(
columnName: string,
item: PickContractItem,
fieldList: PickFieldItem[],
): string {
if (columnName === '最新价') {
return formatPrice(item.price);
}
if (columnName === '涨跌幅(结)') {
return formatPercent(item.priceChg);
}
const rawValue: string = item.extraFields[columnName] ?? '--';
const unit: string = getUnit(columnName, fieldList);
return unit === '' ? rawValue : `${rawValue}${unit}`;
}
export function cellColor(columnName: string, item: PickContractItem): Resource {
if (columnName === '最新价' || columnName === '涨跌幅(结)') {
return riseFallColor(item.priceChg);
}
return $r('app.color.elements_text_primary_02');
}
@@ -24,6 +24,7 @@ import { AiRiseFallNodeComponent } from '../../node/view/AiRiseFallNodeComponent
import { HotListNodeComponent } from '../../node/view/HotListNodeComponent'
import { CommodityOptionsNodeComponent } from '../../node/view/CommodityOptionsNodeComponent'
import { MarketRankingNodeComponent } from '../../market-ranking/MarketRankingNodeComponent'
import { AiPickNodeComponent } from '../../ai-pick/AiPickNodeComponent'
/**
@@ -146,11 +147,14 @@ export struct FirstPage {
aiRiseFallModel.targetTypeId = FirstPageConstant.KEY_AI_RISEFALL.toString()
let marketRankingModel = new FirstPageNodeModel()
marketRankingModel.targetTypeId = FirstPageConstant.KEY_MARKET_RANKING.toString()
let aiPickModel = new FirstPageNodeModel()
aiPickModel.targetTypeId = FirstPageConstant.KEY_AI_PICK.toString()
let feedBackModel = new FirstPageNodeModel()
feedBackModel.targetTypeId = FirstPageConstant.KEY_FEED_BACK.toString()
let endTipModel = new FirstPageNodeModel()
endTipModel.targetTypeId = FirstPageConstant.KEY_END_TIP.toString()
response.push(hotListModel, commodityOptionModel, aiRiseFallModel, aiDiagnosisModel, marketRankingModel, feedBackModel, endTipModel)
response.push(hotListModel, commodityOptionModel, aiRiseFallModel, aiDiagnosisModel,
aiPickModel, marketRankingModel, feedBackModel, endTipModel)
this.nodeModelArr = response
}
}
@@ -358,6 +362,10 @@ export struct FirstPage {
MarketRankingNodeComponent({
refreshTrigger: this.refreshTrigger,
}).margin({ top: index > 0 ? 10 : 0, left: 10, right: 10 })
} else if (model.targetTypeId == FirstPageConstant.KEY_AI_PICK.toString()) {
AiPickNodeComponent({
refreshTrigger: this.refreshTrigger,
}).margin({ top: index > 0 ? 10 : 0, left: 10, right: 10 })
} else if (model.targetTypeId == FirstPageConstant.KEY_TOP_BG.toString()) {
TopImageNodeComponent({
nodeModel: model
@@ -14,4 +14,5 @@ export class FirstPageConstant {
static KEY_HOT_LIST = 18 //热点排行
static KEY_COMMODITY_OPTIONS = 19 //商品期权
static KEY_MARKET_RANKING = 20 //市场排名
}
static KEY_AI_PICK = 21 //AI选期
}
@@ -13,7 +13,7 @@ export class TableConstants {
static readonly DATA_ID_CHG_SPEED_1MIN: number = 34874; // 1 分钟涨跌速
static readonly DATA_ID_CHG_SPEED_10MIN: number = 34875; // 10 分钟涨跌速
static readonly DATA_ID_CHG_SPEED_15MIN: number = 34876; // 15 分钟涨跌速
static readonly DATA_ID_ZF_YTD: number = 34877; // 昨收涨跌幅
static readonly DATA_ID_ZF_YTD: number = 34877; // 涨跌幅(昨结算价基准)
static readonly DATA_ID_MARKET: number = 36103; // 市场 ID
private constructor() {
@@ -0,0 +1,178 @@
import { AbsRowData, RowData, TableData } from '@b2b/hq_table';
import { TableRequestClient } from '@b2c/lib_baseui';
import { RequestHelper, StuffTableStruct } from '@kernel/lib_communication';
import {
CardData,
PickContractItem,
PickTabData,
} from '../../ai-pick/AiPickModels';
import { getUniqueContracts } from '../../ai-pick/AiPickUtils';
import { TableConstants } from '../../market-ranking/TableConstants';
const FRAME_ID = 2201;
const PAGE_ID = 4106;
const FIELD_IDS: number[] = [
TableConstants.DATA_ID_CODE_4,
TableConstants.DATA_ID_PRICE,
TableConstants.DATA_ID_MARKET,
TableConstants.DATA_ID_ZF_YTD,
];
interface AiPickQuote {
price?: number;
priceChg?: number;
}
export type AiPickCardDataListener = (cardData: CardData) => void;
class AiPickTableRequestClient extends TableRequestClient {
private requestParams: string;
private sourceCardData: CardData;
private listener: AiPickCardDataListener;
constructor(
requestParams: string,
sourceCardData: CardData,
listener: AiPickCardDataListener,
) {
super(FRAME_ID, PAGE_ID, [], null, undefined);
this.requestParams = requestParams;
this.sourceCardData = sourceCardData;
this.listener = listener;
this.setSubscribeIds(FIELD_IDS);
}
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 {
const quoteMap: Map<string, AiPickQuote> = this.toQuoteMap(tableData);
const nextCardData: CardData = this.mergeQuoteData(quoteMap);
this.sourceCardData = nextCardData;
this.listener(nextCardData);
}
private toQuoteMap(tableData: TableData): Map<string, AiPickQuote> {
const quoteMap: Map<string, AiPickQuote> = new Map<string, AiPickQuote>();
if (!tableData.tableRows) {
return quoteMap;
}
tableData.tableRows.forEach((row: AbsRowData): void => {
if (!(row instanceof RowData)) {
return;
}
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 quote: AiPickQuote = {
price: this.getNumber(row, TableConstants.DATA_ID_PRICE),
priceChg: this.getNumber(row, TableConstants.DATA_ID_ZF_YTD),
};
quoteMap.set(`${codeValue}_${marketValue}`, quote);
});
return quoteMap;
}
private mergeQuoteData(quoteMap: Map<string, AiPickQuote>): CardData {
const tabs: PickTabData[] =
this.sourceCardData.tabs.map((tab: PickTabData): PickTabData => {
const items: PickContractItem[] =
tab.items.map((item: PickContractItem): PickContractItem => {
const quote: AiPickQuote | undefined =
quoteMap.get(`${item.contract}_${item.market}`);
const nextItem: PickContractItem = {
contract: item.contract,
market: item.market,
name: item.name,
price: quote?.price ?? item.price,
priceChg: quote?.priceChg ?? item.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;
});
const nextCardData: CardData = { tabs: tabs };
return nextCardData;
}
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 = parseFloat(value);
return Number.isNaN(result) ? undefined : result;
}
return undefined;
}
}
export class AiPickHqRequestClient {
private static readonly instance: AiPickHqRequestClient =
new AiPickHqRequestClient();
private constructor() {
}
static getInstance(): AiPickHqRequestClient {
return AiPickHqRequestClient.instance;
}
subscribeHq(cardData: CardData, listener: AiPickCardDataListener): () => void {
const contracts: PickContractItem[] = getUniqueContracts(cardData);
if (contracts.length === 0) {
return (): void => {
};
}
const requestParams: string = this.buildRequestParams(contracts);
const requestClient: AiPickTableRequestClient =
new AiPickTableRequestClient(requestParams, cardData, listener);
requestClient.request(0, contracts.length);
return (): void => {
requestClient.release(true);
};
}
private buildRequestParams(contracts: PickContractItem[]): string {
return `\r\nsortid=-1\r\n` +
`sortorder=0\r\n` +
`push=1\r\n` +
`dataitem=${FIELD_IDS.map((fieldId: number): string => `${fieldId},`).join('')}\r\n` +
`codelist=${this.formatCodeList(contracts)}\r\n` +
`update=1\r\n` +
`scenario=qht_qihuo_sort\r\n` +
`precision=1\r\n` +
`pushtime=2.5\r\n`;
}
private formatCodeList(contracts: PickContractItem[]): string {
const markets: string[] = [];
const codeGroups: string[][] = [];
contracts.forEach((contract: PickContractItem): void => {
const marketIndex: number = markets.indexOf(contract.market);
if (marketIndex >= 0) {
codeGroups[marketIndex].push(contract.contract);
} else {
markets.push(contract.market);
codeGroups.push([contract.contract]);
}
});
return markets.map((market: string, index: number): string =>
`${market}(${codeGroups[index].map((code: string): string => `${code},`).join('')});`
).join('');
}
}
@@ -47,6 +47,7 @@ export interface FirstPageCardsConfig {
aiRiseFall: AllCardsCard
commodityOptions: CommodityOptionsCardConfig
marketRanking: AllCardsCard
aiPick: AllCardsCard
}
export class FirstPageCardsConfigLoader {
@@ -279,6 +279,34 @@
{
"name": "market_ranking_15min_fall_speed",
"value": "15分钟跌速"
},
{
"name": "ai_pick_title",
"value": "AI选期"
},
{
"name": "ai_pick_loading",
"value": "数据加载中"
},
{
"name": "ai_pick_no_contract",
"value": "没有符合的合约"
},
{
"name": "ai_pick_main_contract",
"value": "主"
},
{
"name": "ai_pick_custom_strategy",
"value": "一句话定制策略"
},
{
"name": "ai_pick_custom_tag",
"value": "自编"
},
{
"name": "card_explain_confirm",
"value": "我知道了"
}
]
}
+29 -1
View File
@@ -3,6 +3,34 @@
{
"name": "page_show",
"value": "page from package"
},
{
"name": "ai_pick_title",
"value": "AI Picks"
},
{
"name": "ai_pick_loading",
"value": "Loading"
},
{
"name": "ai_pick_no_contract",
"value": "No matching contracts"
},
{
"name": "ai_pick_main_contract",
"value": "M"
},
{
"name": "ai_pick_custom_strategy",
"value": "Create a strategy"
},
{
"name": "ai_pick_custom_tag",
"value": "Custom"
},
{
"name": "card_explain_confirm",
"value": "Got it"
}
]
}
}
@@ -112,5 +112,31 @@
"explain_message": null,
"mod_data": [],
"bury_point": "marketRank"
},
"aiPick": {
"card_id": 5106,
"card_key": "futuresHomepageAIPickCard",
"title_type": 1,
"is_toggle": false,
"render_key": "ai-pick",
"card_size": 4,
"explain_title": "AI选期说明",
"explain_message": "1.合约数据更新说明:合约最新价和涨跌幅信息实时更新,无需手动刷新。\n2.符合策略的合约内容需要手动刷新。\n3.安卓3.96.1版本和苹果3.97.01版本以上已支持自定义选期。\n4.新版首页AI选期将与AI分组同步,默认取AI分组前5条。\n5.新版首页AI选期策略可在AI分组管理中可进行删减。",
"card_title": {
"value": "AI选期",
"type": "text"
},
"card_url": {
"ios": "",
"android": ""
},
"mod_data": [
{
"url": "https://ftapi.10jqka.com.cn/futgwapi/api/ai_diagnosis/home_strategy/v1/user_data",
"param_list": [""],
"req_desc": "http_get"
}
],
"bury_point": "aiPick"
}
}
+29 -1
View File
@@ -3,6 +3,34 @@
{
"name": "page_show",
"value": "page from package"
},
{
"name": "ai_pick_title",
"value": "AI选期"
},
{
"name": "ai_pick_loading",
"value": "数据加载中"
},
{
"name": "ai_pick_no_contract",
"value": "没有符合的合约"
},
{
"name": "ai_pick_main_contract",
"value": "主"
},
{
"name": "ai_pick_custom_strategy",
"value": "一句话定制策略"
},
{
"name": "ai_pick_custom_tag",
"value": "自编"
},
{
"name": "card_explain_confirm",
"value": "我知道了"
}
]
}
}