From af0025ce380facb4474f636daa27f6d9654bdf3f Mon Sep 17 00:00:00 2001 From: clz Date: Sun, 26 Jul 2026 01:10:20 +0800 Subject: [PATCH] Initial commit: @b2c/first_page HarmonyOS stage-mode HAR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Repository - HarmonyOS stage-mode HAR module, consumed by host app - No standalone build — hvigorw, oh_modules, and ../biz_quote absent here ## Architecture ### Feed Cards - FirstPage.createNodeView dispatches cards by FirstPageConstant.KEY_* - Card config from first_page_cards_config.json loaded by FirstPageCardsConfigLoader - Public entrypoint: Index.ets ### Market Ranking (src/main/ets/market-ranking/) - HTTP: MarketRankingDataFetcher fetches recommend_futures contracts - HQ: MarketRankingHqRequestClient subscribes via 4106/frame 2201/scenario qht_qihuo_sort - TCP reconnect rebuilds subscription from original HTTP contract list - Async callbacks guarded by request/subscription versions ### Other Cards - HotList, CommodityOptions, AiDiagnosis, AiRiseFall, SelfSelectedStock, CustomEntryList, FourEntryList - Each has DataFetcher + HQ request client + NodeComponent ## Key Changes (from CardHarmonyOS reference) - Added MetricId, PeriodId, SortOrder enums (MarketRankingModels.ets) - Replaced string comparisons with enum switches in thirdColumnTitle/Value/Color - Removed redundant undefined guards in thirdColumnColor/priceColor, delegated to riseFallColor - Inlined priceColor(), use riseFallColor directly - Tab bar Stack.height(30), Alignment.End (reverted from 46/TopEnd) - Name column fontWeight removed (default regular) --- .gitignore | 6 + AGENTS.md | 29 + BuildProfile.ets | 21 + Index.ets | 13 + build-profile.json5 | 31 + consumer-rules.txt | 0 doc/market-ranking-known-issues.md | 100 ++ hvigorfile.ts | 6 + obfuscation-rules.txt | 18 + oh-package.json5 | 12 + readme.md | 6 + src/main/ets/common/NumberFormat.ets | 52 + src/main/ets/common/mock/AllCardsMock.json | 1149 +++++++++++++++++ .../ets/components/mainpage/FirstPage.ets | 413 ++++++ src/main/ets/constants/FirstPageConstants.ets | 96 ++ .../constants/FirstPageFiveCBASConstants.ets | 12 + src/main/ets/datacenter/FirstPageConstant.ets | 17 + .../ets/datacenter/FirstPageDataFetcher.ets | 86 ++ .../ets/datacenter/GrideNodeDataFetcher.ets | 77 ++ src/main/ets/datacenter/NewsDataFetcher.ets | 117 ++ .../datacenter/model/FirstPageNodeModel.ts | 76 ++ src/main/ets/datacenter/model/GridModel.ts | 31 + .../ets/datacenter/model/GridModelGroup.ets | 8 + src/main/ets/datacenter/model/NewsModel.ts | 33 + .../market-ranking/MarketRankingConstant.ets | 20 + .../MarketRankingDataFetcher.ets | 68 + .../market-ranking/MarketRankingModels.ets | 73 ++ .../MarketRankingNodeComponent.ets | 706 ++++++++++ .../ets/market-ranking/TableConstants.ets | 21 + .../node/clients/AiDiagnosisRequestClient.ets | 111 ++ .../clients/AiRiseFallHqRequestClient.ets | 212 +++ .../CommodityOptionsHqRequestClient.ets | 175 +++ .../FirstPageSelfStockRequestClient.ets | 98 ++ .../clients/MarketRankingHqRequestClient.ets | 200 +++ .../datacenter/AiDiagnosisDataFetcher.ets | 115 ++ .../node/datacenter/AiRiseFallDataFetcher.ets | 116 ++ .../CommodityOptionsDataFetcher.ets | 176 +++ .../datacenter/FirstPageCardsConfigLoader.ets | 94 ++ .../node/datacenter/HotListDataFetcher.ets | 128 ++ .../node/datacenter/HotNewsDataExecutor.ets | 66 + .../node/datacenter/HotNewsDataFetcher.ets | 54 + .../node/helper/FirstPagePopupAdHelper.ets | 420 ++++++ src/main/ets/node/helper/StatLogFilter.ets | 38 + .../ets/node/model/AiDiagnosisNodeModel.ets | 109 ++ .../ets/node/model/AiRiseFallNodeModel.ets | 50 + src/main/ets/node/model/BannerNodeModel.ts | 9 + .../node/model/CommodityOptionsNodeModel.ets | 56 + .../node/model/CustomEntryListNodeModel.ets | 13 + src/main/ets/node/model/EntryListNodeModel.ts | 14 + src/main/ets/node/model/HotListNodeModel.ets | 55 + src/main/ets/node/model/HotNewsNodeModel.ts | 27 + src/main/ets/node/model/PopupAdMode.ets | 83 ++ .../ets/node/model/SelfSelectedStockModel.ets | 152 +++ .../node/view/AiDiagnosisNodeComponent.ets | 782 +++++++++++ .../ets/node/view/AiRiseFallNodeComponent.ets | 738 +++++++++++ .../ets/node/view/BannerNodeComponent.ets | 224 ++++ .../view/CommodityOptionsNodeComponent.ets | 535 ++++++++ .../view/CustomEntryListNodeComponent.ets | 89 ++ src/main/ets/node/view/FirstPageDialog.ets | 63 + .../node/view/FourEntryListNodeComponent.ets | 82 ++ .../ets/node/view/HotListNodeComponent.ets | 224 ++++ .../view/SelfSelectedStockNodeComponent.ets | 593 +++++++++ .../ets/node/view/TopImageNodeComponent.ets | 64 + src/main/ets/pages/GridNodeAllPage.ets | 115 ++ src/main/ets/pages/HotNewsDetailPage.ets | 45 + src/main/ets/pages/HotNewsPage.ets | 199 +++ src/main/ets/popup/AdFloatState.ets | 11 + src/main/ets/popup/AdsBallManager.ets | 106 ++ src/main/ets/popup/AdsFloatBall.ets | 113 ++ src/main/ets/popup/AdsPopupHost.ets | 653 ++++++++++ src/main/ets/popup/AdsPopupView.ets | 297 +++++ src/main/ets/popup/AdsTextView.ets | 131 ++ src/main/ets/popup/PopupRecord.ets | 17 + src/main/ets/popup/SpAdsComponent.ets | 25 + src/main/ets/route.ets | 26 + src/main/ets/router/NewsRouter.ets | 9 + src/main/ets/util/DateUtils.ets | 37 + src/main/ets/util/FileManager.ets | 137 ++ src/main/ets/util/FirstPageConstant.ets | 2 + src/main/ets/util/FirstPageDebugUtil.ets | 36 + src/main/ets/util/Header.ets | 16 + src/main/ets/util/ImageLoader.ets | 71 + src/main/ets/util/ResourceUtil.ets | 8 + src/main/ets/util/RouterUtil.ets | 36 + src/main/ets/views/FirstPageContentBg.ets | 19 + .../ets/views/FirstPageSelfCodeGroupView.ets | 345 +++++ .../ets/views/FirstPageTableRowBGView.ets | 90 ++ src/main/ets/views/FirstPageTitleBar.ets | 203 +++ .../views/FistPageSelfCodeGridItemView.ets | 127 ++ src/main/ets/views/StatusBar.ets | 14 + src/main/ets/views/TitleBar.ets | 125 ++ src/main/module.json5 | 22 + src/main/resources/base/element/color.json | 123 ++ src/main/resources/base/element/string.json | 284 ++++ src/main/resources/base/element/url.json | 52 + .../base/media/ads_popup_arrow_right.png | Bin 0 -> 2689 bytes .../base/media/ai_diagnosis_txt_icon.svg | 14 + .../resources/base/media/back_black2x_svg.svg | 27 + .../resources/base/media/back_white2x_svg.svg | 27 + src/main/resources/base/media/c1_fallback.png | Bin 0 -> 3693 bytes .../firstpage_dialog_tanchuang_close.png | Bin 0 -> 2745 bytes .../base/media/firstpage_fourgrid_bg.png | Bin 0 -> 11395 bytes .../base/media/firstpage_grid_item_all.png | Bin 0 -> 8674 bytes .../base/media/firstpage_news_arrow_right.png | Bin 0 -> 373 bytes .../base/media/firstpage_news_image.png | Bin 0 -> 45149 bytes .../media/firstpage_pull_status_arrow.png | Bin 0 -> 1983 bytes .../base/media/firstpage_search_icon.png | Bin 0 -> 1580 bytes .../base/media/firstpage_top_default_bg.webp | Bin 0 -> 3356 bytes .../base/media/icon_arrow_forward_24_new.svg | 3 + .../base/media/icon_first_page_message.png | Bin 0 -> 862 bytes .../base/media/theme_icon_ad_close_float.png | Bin 0 -> 523 bytes .../media/theme_icon_ad_close_float_night.png | Bin 0 -> 487 bytes .../theme_icon_first_page_hangqing_vp_add.png | Bin 0 -> 275 bytes ..._icon_first_page_hangqing_vp_add_night.png | Bin 0 -> 280 bytes .../base/media/theme_icon_new_feedback.png | Bin 0 -> 556 bytes .../media/theme_icon_new_feedback_night.png | Bin 0 -> 579 bytes .../base/media/title_bar_refresh.png | 0 .../resources/base/profile/main_pages.json | 7 + src/main/resources/dark/element/color.json | 12 + src/main/resources/en_US/element/string.json | 8 + src/main/resources/light/element/color.json | 56 + .../rawfile/ai_score_background_images.json | 24 + .../rawfile/first_page_cards_config.json | 116 ++ src/main/resources/zh_CN/element/string.json | 8 + src/mock/mock-config.json5 | 2 + src/ohosTest/ets/test/Ability.test.ets | 35 + src/ohosTest/ets/test/List.test.ets | 5 + src/ohosTest/ets/testability/TestAbility.ets | 47 + src/ohosTest/ets/testability/pages/Index.ets | 17 + .../ets/testrunner/OpenHarmonyTestRunner.ets | 90 ++ src/ohosTest/module.json5 | 37 + .../resources/base/element/color.json | 8 + .../resources/base/element/string.json | 16 + src/ohosTest/resources/base/media/icon.png | Bin 0 -> 2777 bytes .../resources/base/profile/test_pages.json | 5 + src/test/List.test.ets | 5 + src/test/LocalUnit.test.ets | 33 + 137 files changed, 13358 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 BuildProfile.ets create mode 100644 Index.ets create mode 100644 build-profile.json5 create mode 100644 consumer-rules.txt create mode 100644 doc/market-ranking-known-issues.md create mode 100644 hvigorfile.ts create mode 100644 obfuscation-rules.txt create mode 100644 oh-package.json5 create mode 100644 readme.md create mode 100644 src/main/ets/common/NumberFormat.ets create mode 100644 src/main/ets/common/mock/AllCardsMock.json create mode 100644 src/main/ets/components/mainpage/FirstPage.ets create mode 100644 src/main/ets/constants/FirstPageConstants.ets create mode 100644 src/main/ets/constants/FirstPageFiveCBASConstants.ets create mode 100644 src/main/ets/datacenter/FirstPageConstant.ets create mode 100644 src/main/ets/datacenter/FirstPageDataFetcher.ets create mode 100644 src/main/ets/datacenter/GrideNodeDataFetcher.ets create mode 100644 src/main/ets/datacenter/NewsDataFetcher.ets create mode 100644 src/main/ets/datacenter/model/FirstPageNodeModel.ts create mode 100644 src/main/ets/datacenter/model/GridModel.ts create mode 100644 src/main/ets/datacenter/model/GridModelGroup.ets create mode 100644 src/main/ets/datacenter/model/NewsModel.ts create mode 100644 src/main/ets/market-ranking/MarketRankingConstant.ets create mode 100644 src/main/ets/market-ranking/MarketRankingDataFetcher.ets create mode 100644 src/main/ets/market-ranking/MarketRankingModels.ets create mode 100644 src/main/ets/market-ranking/MarketRankingNodeComponent.ets create mode 100644 src/main/ets/market-ranking/TableConstants.ets create mode 100644 src/main/ets/node/clients/AiDiagnosisRequestClient.ets create mode 100644 src/main/ets/node/clients/AiRiseFallHqRequestClient.ets create mode 100644 src/main/ets/node/clients/CommodityOptionsHqRequestClient.ets create mode 100644 src/main/ets/node/clients/FirstPageSelfStockRequestClient.ets create mode 100644 src/main/ets/node/clients/MarketRankingHqRequestClient.ets create mode 100644 src/main/ets/node/datacenter/AiDiagnosisDataFetcher.ets create mode 100644 src/main/ets/node/datacenter/AiRiseFallDataFetcher.ets create mode 100644 src/main/ets/node/datacenter/CommodityOptionsDataFetcher.ets create mode 100644 src/main/ets/node/datacenter/FirstPageCardsConfigLoader.ets create mode 100644 src/main/ets/node/datacenter/HotListDataFetcher.ets create mode 100644 src/main/ets/node/datacenter/HotNewsDataExecutor.ets create mode 100644 src/main/ets/node/datacenter/HotNewsDataFetcher.ets create mode 100644 src/main/ets/node/helper/FirstPagePopupAdHelper.ets create mode 100644 src/main/ets/node/helper/StatLogFilter.ets create mode 100644 src/main/ets/node/model/AiDiagnosisNodeModel.ets create mode 100644 src/main/ets/node/model/AiRiseFallNodeModel.ets create mode 100644 src/main/ets/node/model/BannerNodeModel.ts create mode 100644 src/main/ets/node/model/CommodityOptionsNodeModel.ets create mode 100644 src/main/ets/node/model/CustomEntryListNodeModel.ets create mode 100644 src/main/ets/node/model/EntryListNodeModel.ts create mode 100644 src/main/ets/node/model/HotListNodeModel.ets create mode 100644 src/main/ets/node/model/HotNewsNodeModel.ts create mode 100644 src/main/ets/node/model/PopupAdMode.ets create mode 100644 src/main/ets/node/model/SelfSelectedStockModel.ets create mode 100644 src/main/ets/node/view/AiDiagnosisNodeComponent.ets create mode 100644 src/main/ets/node/view/AiRiseFallNodeComponent.ets create mode 100644 src/main/ets/node/view/BannerNodeComponent.ets create mode 100644 src/main/ets/node/view/CommodityOptionsNodeComponent.ets create mode 100644 src/main/ets/node/view/CustomEntryListNodeComponent.ets create mode 100644 src/main/ets/node/view/FirstPageDialog.ets create mode 100644 src/main/ets/node/view/FourEntryListNodeComponent.ets create mode 100644 src/main/ets/node/view/HotListNodeComponent.ets create mode 100644 src/main/ets/node/view/SelfSelectedStockNodeComponent.ets create mode 100644 src/main/ets/node/view/TopImageNodeComponent.ets create mode 100644 src/main/ets/pages/GridNodeAllPage.ets create mode 100644 src/main/ets/pages/HotNewsDetailPage.ets create mode 100644 src/main/ets/pages/HotNewsPage.ets create mode 100644 src/main/ets/popup/AdFloatState.ets create mode 100644 src/main/ets/popup/AdsBallManager.ets create mode 100644 src/main/ets/popup/AdsFloatBall.ets create mode 100644 src/main/ets/popup/AdsPopupHost.ets create mode 100644 src/main/ets/popup/AdsPopupView.ets create mode 100644 src/main/ets/popup/AdsTextView.ets create mode 100644 src/main/ets/popup/PopupRecord.ets create mode 100644 src/main/ets/popup/SpAdsComponent.ets create mode 100644 src/main/ets/route.ets create mode 100644 src/main/ets/router/NewsRouter.ets create mode 100644 src/main/ets/util/DateUtils.ets create mode 100644 src/main/ets/util/FileManager.ets create mode 100644 src/main/ets/util/FirstPageConstant.ets create mode 100644 src/main/ets/util/FirstPageDebugUtil.ets create mode 100644 src/main/ets/util/Header.ets create mode 100644 src/main/ets/util/ImageLoader.ets create mode 100644 src/main/ets/util/ResourceUtil.ets create mode 100644 src/main/ets/util/RouterUtil.ets create mode 100644 src/main/ets/views/FirstPageContentBg.ets create mode 100644 src/main/ets/views/FirstPageSelfCodeGroupView.ets create mode 100644 src/main/ets/views/FirstPageTableRowBGView.ets create mode 100644 src/main/ets/views/FirstPageTitleBar.ets create mode 100644 src/main/ets/views/FistPageSelfCodeGridItemView.ets create mode 100644 src/main/ets/views/StatusBar.ets create mode 100644 src/main/ets/views/TitleBar.ets create mode 100644 src/main/module.json5 create mode 100644 src/main/resources/base/element/color.json create mode 100644 src/main/resources/base/element/string.json create mode 100644 src/main/resources/base/element/url.json create mode 100644 src/main/resources/base/media/ads_popup_arrow_right.png create mode 100644 src/main/resources/base/media/ai_diagnosis_txt_icon.svg create mode 100644 src/main/resources/base/media/back_black2x_svg.svg create mode 100644 src/main/resources/base/media/back_white2x_svg.svg create mode 100644 src/main/resources/base/media/c1_fallback.png create mode 100644 src/main/resources/base/media/firstpage_dialog_tanchuang_close.png create mode 100644 src/main/resources/base/media/firstpage_fourgrid_bg.png create mode 100644 src/main/resources/base/media/firstpage_grid_item_all.png create mode 100644 src/main/resources/base/media/firstpage_news_arrow_right.png create mode 100644 src/main/resources/base/media/firstpage_news_image.png create mode 100644 src/main/resources/base/media/firstpage_pull_status_arrow.png create mode 100644 src/main/resources/base/media/firstpage_search_icon.png create mode 100644 src/main/resources/base/media/firstpage_top_default_bg.webp create mode 100644 src/main/resources/base/media/icon_arrow_forward_24_new.svg create mode 100644 src/main/resources/base/media/icon_first_page_message.png create mode 100644 src/main/resources/base/media/theme_icon_ad_close_float.png create mode 100644 src/main/resources/base/media/theme_icon_ad_close_float_night.png create mode 100644 src/main/resources/base/media/theme_icon_first_page_hangqing_vp_add.png create mode 100644 src/main/resources/base/media/theme_icon_first_page_hangqing_vp_add_night.png create mode 100644 src/main/resources/base/media/theme_icon_new_feedback.png create mode 100644 src/main/resources/base/media/theme_icon_new_feedback_night.png create mode 100644 src/main/resources/base/media/title_bar_refresh.png create mode 100644 src/main/resources/base/profile/main_pages.json create mode 100644 src/main/resources/dark/element/color.json create mode 100644 src/main/resources/en_US/element/string.json create mode 100644 src/main/resources/light/element/color.json create mode 100644 src/main/resources/rawfile/ai_score_background_images.json create mode 100644 src/main/resources/rawfile/first_page_cards_config.json create mode 100644 src/main/resources/zh_CN/element/string.json create mode 100644 src/mock/mock-config.json5 create mode 100644 src/ohosTest/ets/test/Ability.test.ets create mode 100644 src/ohosTest/ets/test/List.test.ets create mode 100644 src/ohosTest/ets/testability/TestAbility.ets create mode 100644 src/ohosTest/ets/testability/pages/Index.ets create mode 100644 src/ohosTest/ets/testrunner/OpenHarmonyTestRunner.ets create mode 100644 src/ohosTest/module.json5 create mode 100644 src/ohosTest/resources/base/element/color.json create mode 100644 src/ohosTest/resources/base/element/string.json create mode 100644 src/ohosTest/resources/base/media/icon.png create mode 100644 src/ohosTest/resources/base/profile/test_pages.json create mode 100644 src/test/List.test.ets create mode 100644 src/test/LocalUnit.test.ets diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e2713a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/node_modules +/oh_modules +/.preview +/build +/.cxx +/.test \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a5df0b7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,29 @@ +# AGENTS.md + +## Repository + +- This is the HarmonyOS stage-mode HAR `@b2c/first_page` (`biz_firstpage`), not a host app. The host supplies several hoisted dependencies and runtime services. +- There is no standalone build: `hvigorw`, `oh_modules`, and the sibling `../biz_quote` dependency are absent here. Do not add undeclared host dependencies or claim build/test success; verify ArkTS changes by inspection and in the host project. +- Load the `arkts-grammar-standards` skill before modifying `.ets` files. + +## Wiring + +- The public package entrypoint is root `Index.ets`; export new host-facing symbols there. +- `FirstPage.createNodeView` dispatches feed cards by `FirstPageConstant.KEY_*`. A new feed card needs its key, component, and dispatch branch; card metadata is normally read from `src/main/resources/rawfile/first_page_cards_config.json`. +- `market-ranking` is a special feed card under `src/main/ets/market-ranking/`, inserted with `KEY_MARKET_RANKING` and rendered directly by `FirstPage`. Its config key is `marketRanking`, loaded by `FirstPageCardsConfigLoader`. + +## Market Ranking + +- `MarketRankingDataFetcher` first fetches the recommended contract list over `@kit.NetworkKit`; `MarketRankingHqRequestClient` then subscribes those contracts through the host `TableRequestClient`/4106 infrastructure. Do not replace either path with mock data or a second quote client. +- `MarketRankingConstant.ets` is currently shared by UI, HTTP, and HQ code: `TAB_METRICS`/`PERIOD_RANGES` contain labels, HTTP parameters, and 4106 field IDs. Changing order, IDs, or child-tab semantics affects both request paths; update them together and consult `doc/market-ranking-known-issues.md` before refactoring. +- HQ requests use 4106, frame `2201`, scenario `qht_qihuo_sort`, and `pushtime=2.5`; subscriptions must be released on page invisibility, Tab changes, refresh, and component destruction. TCP reconnect rebuilds the subscription from the original HTTP contract list. +- The component guards asynchronous HTTP and quote callbacks with request/subscription versions; preserve those guards when changing refresh or Tab behavior. Row clicks use host `jumpToQuote()` and must preserve contract, market, and name list ordering. +- Market-ranking host integration still requires validation of 4106 field IDs, standard-price fields, HTTP environment/auth, lifecycle/reconnect behavior, and quote-page routing; see `doc/market-ranking-known-issues.md`. + +## Conventions + +- Use `$r('app.color.*')` and the light/dark resource qualifiers for colors; do not hardcode theme colors. Put user-facing strings in `src/main/resources/{base,zh_CN,en_US}/element/string.json`. +- `FirstPageDebugUtil.isMarketRankingUseTestUrl` and other test URL switches default to `false`; leave them disabled in commits. +- Two files are named `FirstPageConstant.ets`: `datacenter/FirstPageConstant.ets` holds `KEY_*` card-dispatch constants, while `util/FirstPageConstant.ets` only exports `TITLE_BAR_HEIGHT`. Import the correct one. +- `biz_quote` is imported both from its package root and deep paths; preserve the existing import style when touching quote code. +- Release builds enable ArkGuard through `build-profile.json5`; keep `obfuscation-rules.txt` and `consumer-rules.txt` in mind when adding public or reflective symbols. diff --git a/BuildProfile.ets b/BuildProfile.ets new file mode 100644 index 0000000..9c85bb6 --- /dev/null +++ b/BuildProfile.ets @@ -0,0 +1,21 @@ +/** + * Use these variables when you tailor your ArkTS code. They must be of the const type. + */ +export const HAR_VERSION = '1.0.0'; +export const BUILD_MODE_NAME = 'debug'; +export const DEBUG = true; +export const TARGET_NAME = 'default'; +export const isOfficial = true; +export const InnerVersion = 'FUHM037.08.388'; + +/** + * BuildProfile Class is used only for compatibility purposes. + */ +export default class BuildProfile { + static readonly HAR_VERSION = HAR_VERSION; + static readonly BUILD_MODE_NAME = BUILD_MODE_NAME; + static readonly DEBUG = DEBUG; + static readonly TARGET_NAME = TARGET_NAME; + static readonly isOfficial = isOfficial; + static readonly InnerVersion = InnerVersion; +} \ No newline at end of file diff --git a/Index.ets b/Index.ets new file mode 100644 index 0000000..4c7fc79 --- /dev/null +++ b/Index.ets @@ -0,0 +1,13 @@ +export { FirstPage } from './src/main/ets/components/mainpage/FirstPage' +export { HotNewsPage } from './src/main/ets/pages/HotNewsPage' +export { HotNewsDetailPage } from './src/main/ets/pages/HotNewsDetailPage' +export { GridNodeAllPage } from './src/main/ets/pages/GridNodeAllPage' +export { FirstPagePopupAdHelper } from './src/main/ets/node/helper/FirstPagePopupAdHelper' + +// 广告悬浮球 +export { AdsPopupView } from './src/main/ets/popup/AdsPopupView' +export { AdFloatState } from './src/main/ets/popup/AdFloatState' +export { SpAdsComponent } from './src/main/ets/popup/SpAdsComponent' +export { PopupRecord } from './src/main/ets/popup/PopupRecord' +export { AdsPopupHost } from './src/main/ets/popup/AdsPopupHost' +export { AdsBallManager } from './src/main/ets/popup/AdsBallManager' \ No newline at end of file diff --git a/build-profile.json5 b/build-profile.json5 new file mode 100644 index 0000000..697dff2 --- /dev/null +++ b/build-profile.json5 @@ -0,0 +1,31 @@ +{ + "apiType": "stageMode", + "buildOption": { + }, + "buildOptionSet": [ + { + "name": "release", + "arkOptions": { + "obfuscation": { + "ruleOptions": { + "enable": true, + "files": [ + "./obfuscation-rules.txt" + ] + }, + "consumerFiles": [ + "./consumer-rules.txt" + ] + } + }, + }, + ], + "targets": [ + { + "name": "default" + }, + { + "name": "ohosTest" + } + ] +} diff --git a/consumer-rules.txt b/consumer-rules.txt new file mode 100644 index 0000000..e69de29 diff --git a/doc/market-ranking-known-issues.md b/doc/market-ranking-known-issues.md new file mode 100644 index 0000000..5b1433c --- /dev/null +++ b/doc/market-ranking-known-issues.md @@ -0,0 +1,100 @@ +# market-ranking 当前状态与待确认项 + +`src/main/ets/market-ranking/` 来自 `futures-homepage/CardHarmoyos` 参考工程,现已按首页主代码流程完成以下收口: + +- 卡片配置并入 `rawfile/first_page_cards_config.json`,由 `FirstPageCardsConfigLoader` 统一读取。 +- 推荐合约通过 `@kit.NetworkKit`、`buildHeader()` 和资源 URL 发起真实 HTTP 请求。 +- 行情统一使用 `node/clients/MarketRankingHqRequestClient.ets`,接入 `TableRequestClient`、 + `RequestHelper`、`TableData` 和 `applyStandardPriceToTableData`。 +- TCP 重连改用 `EmitterConstants.NETWORK_TCP_RECONNECT`,监听随首页可见性注册和注销。 +- 页面隐藏、组件销毁、Tab 切换和下拉刷新都会释放旧行情订阅。 +- 异步榜单请求和行情回调均有版本校验,避免旧 Tab 数据覆盖当前页面。 +- HTTP 原始合约列表与 `@State cardData` 分开保存,重连不会因行情漏行丢失订阅标的。 +- 行点击通过 `jumpToQuote()` 对齐 `futures-homepage` 的 `jumpToFenShi`。 +- 已移除 Mock 行情客户端、Mock 榜单、自定义 TableData、自建事件总线和重复卡片配置模型。 + +## 后续优化设计:拆分 UI 与业务配置 + +当前 `MarketRankingConstant.ets` 的 `TAB_METRICS` / `PERIOD_RANGES` 同时包含 `$r()` 文案、HTTP +`quote_type` 和行情 `sortid`。因此 `MarketRankingDataFetcher` 与 `MarketRankingHqRequestClient` +间接依赖 UI 资源。 + +计划拆为两层: + +1. `MarketRankingConfig.ets`:纯业务配置,不包含 `$r()` / `ResourceStr`。定义一级指标 ID、HTTP API + 参数、行情排序字段、排序方向、是否包含周期,以及二级周期的 API 参数和行情字段。 +2. `MarketRankingUiConfig.ets`:只负责指标和周期 ID 到资源文案的映射,仅由 + `MarketRankingNodeComponent` 引用。 + +一级指标顺序必须只在 `MarketRankingConfig.ets` 维护一份。View 直接遍历该配置,并通过 +`getMetricLabel(id)` / `getPeriodLabel(id)` 获取文案,避免 UI 数组和业务数组下标错位。 + +迁移时同步完成: + +- 用枚举替代散落的 `'rise'`、`'fallSpeed'`、`'turnOver'` 等字符串判断。 +- 将 `sortOrder` 写入指标配置,删除行情客户端对跌幅/跌速的硬编码判断。 +- DataFetcher 仅使用 `api` / `hasPeriods`;行情客户端仅使用 `sortId` / `sortOrder`。 +- 删除旧 `TabMetric.label`、`PeriodRange.label` 字段及相关 `ResourceStr` 依赖。 + +## 仍需在宿主工程确认 + +### 1. 行情字段 + +`market-ranking/TableConstants.ets` 按主代码命名维护 4106 字段。基础字段名称已经对齐,但成交额、日增仓、 +1/5/10/15 分钟涨跌速、昨收和今开等字段无法在当前 HAR 的依赖源码中核验。需要在宿主实际使用的 +`@b2c/lib_baseui` 和行情协议版本中确认字段 ID。 + +逐项验证涨幅、跌幅、1/5/10/15 分钟涨速和跌速、成交额、日增仓 Tab。预期每个 Tab 的 `sortid`、 +排序方向、第三列字段和值均正确,不出现整列 `--` 或排序方向相反。 + +### 2. 基准价计算 + +`applyStandardPriceToTableData` 已接入,但需要确认传入的 `fieldIds` 是否包含宿主实现要求的全部字段, +特别是 `DATA_ID_ZD`、`DATA_ID_MARKET_OLD` 以及昨收、今开、昨结字段。 + +分别选择昨收、今开、昨结作为基准价,验证: + +- 最新价不变,涨跌幅按当前基准价重新计算。 +- 页面隐藏后修改基准价,再返回首页会重新订阅并更新。 +- 首包和后续实时推送采用相同计算口径。 + +### 3. HTTP 推荐合约接口 + +在测试和生产环境验证 `recommend_futures`: + +- UA、Cookie 和登录状态满足接口要求。 +- 返回结构为 `{ code, data }`,`contract_code`、`contract_name`、`market` 类型正确。 +- 各 `quote_type` 返回对应榜单,空数据、非 200、业务错误码和 JSON 异常进入空状态且不崩溃。 +- 快速切换 Tab 时,旧请求不会覆盖当前 Tab。 + +### 4. 4106 行情请求 + +确认请求参数首行格式、`\r\n` 分隔、`dataitem`、`codelist`、`scenario` 和 `pushtime` 能被宿主行情服务识别。 +验证首包、2.5 秒推送、排序和释放订阅均正常;页面隐藏后不再收到有效 UI 更新。 + +### 5. 生命周期与重连 + +验证以下操作不存在重复订阅、旧数据覆盖或资源泄漏: + +- 首页前后台切换、Tab 页切换、组件销毁和重新创建。 +- 下拉刷新过程中快速切换一级、二级 Tab。 +- HTTP 请求未完成时隐藏或销毁页面。 +- TCP 断开后重连;重连应使用完整的原始合约列表,而不是行情首包中的部分行。 + +### 6. 跳转与配置 + +- 行点击应与 `futures-homepage` 的 `jumpToFenShi` 一致,进入对应合约的分时页。 +- 传入 `jumpToQuote()` 的代码、市场、名称列表顺序必须一致。 +- 标题 `card_url` 在当前本地配置中为空;真实配置提供 Android URL 后再验证标题跳转和箭头显示。 + +### 7. UI 与性能 + +- 加载中、空数据和正常数据状态切换正确。 +- 展开/收起只展示 3/5 行,行情更新后行内容及时刷新。 +- 深浅色、说明弹层、长合约名和大数字格式正确。 +- 连续行情推送下观察 `ForEach` 动态 Key 重建行的性能,确认无明显掉帧。 + +### 构建验证 + +本仓库缺少 `hvigorw`、`oh_modules` 和相邻 `biz_quote`,无法独立编译。最终需在宿主应用中验证 HTTP、 +4106 首包/推送、TCP 重连、基准价切换、快速切换 Tab 和页面前后台切换。 diff --git a/hvigorfile.ts b/hvigorfile.ts new file mode 100644 index 0000000..4218707 --- /dev/null +++ b/hvigorfile.ts @@ -0,0 +1,6 @@ +import { harTasks } from '@ohos/hvigor-ohos-plugin'; + +export default { + system: harTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ + plugins:[] /* Custom plugin to extend the functionality of Hvigor. */ +} diff --git a/obfuscation-rules.txt b/obfuscation-rules.txt new file mode 100644 index 0000000..985b2ae --- /dev/null +++ b/obfuscation-rules.txt @@ -0,0 +1,18 @@ +# Define project specific obfuscation rules here. +# You can include the obfuscation configuration files in the current module's build-profile.json5. +# +# For more details, see +# https://gitee.com/openharmony/arkcompiler_ets_frontend/blob/master/arkguard/README.md + +# Obfuscation options: +# -disable-obfuscation: disable all obfuscations +# -enable-property-obfuscation: obfuscate the property names +# -enable-toplevel-obfuscation: obfuscate the names in the global scope +# -compact: remove unnecessary blank spaces and all line feeds +# -remove-log: remove all console.* statements +# -print-namecache: print the name cache that contains the mapping from the old names to new names +# -apply-namecache: reuse the given cache file + +# Keep options: +# -keep-property-name: specifies property names that you want to keep +# -keep-global-name: specifies names that you want to keep in the global scope \ No newline at end of file diff --git a/oh-package.json5 b/oh-package.json5 new file mode 100644 index 0000000..42ebbdd --- /dev/null +++ b/oh-package.json5 @@ -0,0 +1,12 @@ +{ + "name": "@b2c/first_page", + "version": "1.0.0", + "description": "Please describe the basic information.", + "main": "Index.ets", + "author": "", + "license": "Apache-2.0", + "dependencies": { + "biz_quote": "file:../biz_quote", + "@kernel/theme_manager": "1.0.2-beta.17" + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..83f6b3d --- /dev/null +++ b/readme.md @@ -0,0 +1,6 @@ +# 模块介绍 + +## 模块功能 +手炒首页相关,宫格模块与首页卡片 + +## \ No newline at end of file diff --git a/src/main/ets/common/NumberFormat.ets b/src/main/ets/common/NumberFormat.ets new file mode 100644 index 0000000..691d97d --- /dev/null +++ b/src/main/ets/common/NumberFormat.ets @@ -0,0 +1,52 @@ +// 通用数值格式化/取色工具(格式化和取色都是纯函数,不掺业务判断)。 +// 目前由 market-ranking 使用,其它卡片如果有同样的涨跌色/百分比/大数格式化需求也可以直接复用。 + +// 两位小数,无正负号(用于最新价等普通数值展示,避免直接 toString() 暴露浮点误差) +export function formatPrice(value?: number): string { + if (value === undefined) { + return '--'; + } + return value.toFixed(2); +} + +// 两位小数 + 正负号 + % +export function formatPercent(value?: number): string { + if (value === undefined) { + return '--'; + } + const fixed = value.toFixed(2); + return value > 0 ? `+${fixed}%` : `${fixed}%`; +} + +// 超过万/亿/万亿换算单位,不额外加后缀 +export function formatGreatNumber(value?: number): string { + if (value === undefined) { + return '--'; + } + const abs = Math.abs(value); + if (abs >= 1e12) { + return `${(value / 1e12).toFixed(2)}万亿`; + } + if (abs >= 1e8) { + return `${(value / 1e8).toFixed(2)}亿`; + } + if (abs >= 1e4) { + return `${(value / 1e4).toFixed(2)}万`; + } + return `${value}`; +} + +// 涨红跌绿:按数值正负取色,未处理/为零时统一使用中性色,与 AiDiagnosisNodeComponent 的 +// price_up_100/price_down_100/price_even 三色约定保持一致。 +export function riseFallColor(value?: number): Resource { + if (value === undefined) { + return $r('app.color.price_even'); + } + if (value > 0) { + return $r('app.color.price_up_100'); + } + if (value < 0) { + return $r('app.color.price_down_100'); + } + return $r('app.color.price_even'); +} diff --git a/src/main/ets/common/mock/AllCardsMock.json b/src/main/ets/common/mock/AllCardsMock.json new file mode 100644 index 0000000..4f39beb --- /dev/null +++ b/src/main/ets/common/mock/AllCardsMock.json @@ -0,0 +1,1149 @@ +{ + "code": 0, + "msg": "success", + "data": [ + { + "id": 5395, + "key": "futuresHomepageHotMarketFloor", + "title": "热点行情", + "introduce": "期货市场热点行情监测", + "preview": { + "light": "https://s.thsi.cn/js/m/upload/yyzt/1738916878_9913.png", + "dark": "https://s.thsi.cn/js/m/upload/yyzt/1738916885_7954.png" + }, + "layout_type": 2, + "bury_point": "hotMarket", + "show_title": 1, + "is_new": 0, + "check_block": 1, + "rec_info": null, + "card_list": [ + { + "card_id": 5380, + "card_key": "futuresHomepageHotMarketMainCardCn", + "title_type": 1, + "is_toggle": null, + "render_key": "vertical-content", + "card_size": 3, + "explain_title": null, + "explain_message": null, + "card_title": { + "value": "热点行情", + "type": "text" + }, + "card_url": { + "ios": "", + "android": "" + }, + "background_gradient": null, + "data_completed": true, + "mod_data": [ + { + "contract": { + "name": "原油2609", + "code": "sc2609", + "market": "65" + }, + "message": { + "message": "海峡关闭!据央视新闻报道,伊朗武装部队哈塔姆安比亚中央总部当地时间22日发布声明称,美方再次威胁要打击伊朗的国家基础设施。继昨晚发出警告后,现再次宣布,霍尔木兹海峡仍处于关闭状态。若有船只确需通过该海峡,必须按照此前公布的指定航线和程序通行。声明说,如果美国将其威胁付诸行动,伊朗武装力量将不允许本地区出口一滴石油,地区内的石油、天然气、电力及经济基础设施都将成为打击目标。声明表示,美国反复发出威胁,只会导致战争在本地区进一步扩大,甚至蔓延至地区之外。", + "url": "https://fupage.10jqka.com.cn/ai-web/anomaly-analysis.html?code=sc2609&marketId=65&varietyName=原油2609&changeRate=--", + "color": "gray" + }, + "contract_tag": { + "message": "热门", + "color": "red" + }, + "message_tag": { + "message": "AI异动分析", + "color": "blue" + } + } + ], + "config": [ + { + "type": "a-rich-text", + "list": [ + { + "type": "contract", + "data_key": "contract" + }, + { + "type": "tag", + "data_key": "contract_tag" + } + ], + "props": null, + "class_name": null + }, + { + "type": "a-rich-text", + "list": [ + { + "type": "tag", + "data_key": "message_tag" + }, + { + "type": "text", + "data_key": "message" + } + ], + "props": { + "lines": 2, + "gap": 4, + "font_size": 14 + }, + "class_name": "mt-20" + } + ], + "bury_point": "hotMarket", + "check_block": 0 + }, + { + "card_id": 5385, + "card_key": "futuresHomepageHotMarketRelateCardRelateOption", + "title_type": 2, + "is_toggle": null, + "render_key": "relative-option", + "card_size": 2, + "explain_title": null, + "explain_message": null, + "card_title": { + "value": "关联期权", + "type": "text" + }, + "card_url": { + "ios": "", + "android": "" + }, + "background_gradient": { + "light": "", + "dark": "" + }, + "data_completed": true, + "mod_data": [ + { + "contract": { + "name": "原油2609购650", + "code": "mNz2BB", + "market": "70" + }, + "expire_day": 21 + } + ], + "config": [], + "bury_point": "relateOption", + "check_block": 0 + }, + { + "card_id": 5392, + "card_key": "futuresHomepageHotMarketRelateCardAccountOpen", + "title_type": 2, + "is_toggle": null, + "render_key": "vertical-content", + "card_size": 2, + "explain_title": null, + "explain_message": null, + "card_title": { + "value": "期货开户", + "type": "text" + }, + "card_url": { + "ios": "", + "android": "" + }, + "background_gradient": { + "light": "linear-gradient(180deg #ff24360f #ff243605)", + "dark": "linear-gradient(180deg #ff24360f #ff243605)" + }, + "data_completed": true, + "mod_data": [ + { + "main_message": { + "message": "参与模拟,即领188现金红包>>", + "url": "https://mams.10jqka.com.cn/new/server/html/109224.html", + "color": null + }, + "sub_message": { + "message": "去参与→", + "url": "https://mams.10jqka.com.cn/new/server/html/109224.html", + "color": "red" + } + } + ], + "config": [ + { + "type": "a-rich-text", + "list": [ + { + "type": "text", + "data_key": "main_message" + } + ], + "props": { + "lines": 2, + "gap": null, + "font_size": 16 + }, + "class_name": "mb-12" + }, + { + "type": "a-footer-info", + "list": [ + { + "type": "text", + "data_key": "sub_message" + } + ], + "props": null, + "class_name": "mt-auto" + } + ], + "bury_point": "accountOpen", + "check_block": 0 + } + ] + }, + { + "id": 3955, + "key": "futuresHomepageSelfQuoteFloor", + "title": "自选盯盘", + "introduce": "快速掌握您自选的行情变化", + "preview": { + "light": "https://s.thsi.cn/js/m/upload/yyzt/1721649888_4387.png", + "dark": "https://s.thsi.cn/js/m/upload/yyzt/1721649895_9949.png" + }, + "layout_type": 1, + "bury_point": "marketTrac", + "show_title": 0, + "is_new": 1, + "check_block": 0, + "rec_info": null, + "card_list": [ + { + "card_id": 3951, + "card_key": "futuresHomepageSelfQuoteSingleCard", + "title_type": 1, + "is_toggle": 1, + "render_key": "self-quote", + "card_size": 4, + "explain_title": null, + "explain_message": null, + "card_title": { + "value": "自选盯盘", + "type": "text" + }, + "card_url": { + "ios": "client.html?action=ymtz^webid=2201", + "android": "client.html?action=ymtz^webid=2201" + }, + "background_gradient": null, + "data_completed": null, + "mod_data": [], + "config": null, + "bury_point": "marketTrac", + "check_block": 0 + } + ] + }, + { + "id": 4939, + "key": "futuresHomepageAIViewFloor", + "title": "AI看涨跌", + "introduce": "AI预测合约次日涨跌方向", + "preview": { + "light": "https://s.thsi.cn/js/m/upload/yyzt/1730364478_5138.png", + "dark": "https://s.thsi.cn/js/m/upload/yyzt/1730364493_7994.png" + }, + "layout_type": 1, + "bury_point": "aiView", + "show_title": 0, + "is_new": 1, + "check_block": 0, + "rec_info": null, + "card_list": [ + { + "card_id": 4938, + "card_key": "futuresHomepageAIViewSingleCard", + "title_type": 1, + "is_toggle": 0, + "render_key": "ai-view", + "card_size": 4, + "explain_title": "AI看涨跌说明", + "explain_message": "1、【风险揭示及免责声明】\n 本功能看涨、看跌的观点由AI推理,仅供参考。在任何情况下,本功能中的信息或所表述的意见,不构成对任何人的期货交易咨询建议。过去正确率不代表未来正确率,同花顺不对未来结果做承诺,交易者需自主决策,自行承担交易风险。\n 2、【功能逻辑】\n 通过AI能力,分析活跃的主力合约,AI会推理出具有看涨、看跌方向的合约进行展示。\n 3、【是否正确的判别规则】\n 方向看涨时,实际涨幅>=0%为正确;方向看跌时,实际涨幅<=0%为正确;其余均为错误。\n 4、【实际涨幅】\n 是固定以昨结算价为基准价计算的实时涨跌幅,并非预测的上涨幅度,也不随“涨跌计算基准价设置”功能而变化。\n 5、【正确率】\n (1)【近20日正确率】:有最新涨跌方向的交易日的前20个交易日,“方向正确的合约”占“有方向的合约”的占比。\n (2)【昨日正确率】:有最新涨跌方向的交易日的前一个交易日,“方向正确的合约”占“有方向的合约”的占比。\n (3)【品种正确率】:同一品种,近30次出现涨跌方向的正确率。\n", + "card_title": { + "value": "AI看涨跌", + "type": "text" + }, + "card_url": { + "ios": "client.html?action=ymtz^webid=2804^url=https://fupage.10jqka.com.cn/futures-frontend-kamis-renderer/index.0.3.6.html?token=KF9MTExODcB5^title=AI看涨跌", + "android": "client.html?action=ymtz^webid=2804^url=https://fupage.10jqka.com.cn/futures-frontend-kamis-renderer/index.0.3.6.html?token=KF9MTExODcB5^title=AI看涨跌" + }, + "background_gradient": null, + "data_completed": null, + "mod_data": [ + { + "url": "https://ftapi.10jqka.com.cn/futgwapi/api/om/homepage/v2/component/trend_forecast", + "param_list": [ + "" + ], + "req_desc": "http_get" + } + ], + "config": null, + "bury_point": "aiView", + "check_block": 0 + } + ] + }, + { + "id": 3956, + "key": "futuresHomepageHotNewsFloor", + "title": "热点资讯", + "introduce": "期货热门消息实时速递", + "preview": { + "light": "https://s.thsi.cn/js/m/upload/yyzt/1721649875_4642.png", + "dark": "https://s.thsi.cn/js/m/upload/yyzt/1721649868_4635.png" + }, + "layout_type": 1, + "bury_point": "hotNews", + "show_title": 0, + "is_new": 1, + "check_block": 0, + "rec_info": null, + "card_list": [ + { + "card_id": 3948, + "card_key": "futuresHomepageHotNewsSingleCard", + "title_type": 1, + "is_toggle": 1, + "render_key": "hot-list", + "card_size": 4, + "explain_title": null, + "explain_message": null, + "card_title": { + "value": "热点资讯", + "type": "text" + }, + "card_url": { + "ios": "client.html?action=ymtz^webid=2804^url=https://fupage.10jqka.com.cn/find-page/hot.html^ishiddenbar=1^mode=new", + "android": "client.html?action=ymtz^webid=2804^url=https://fupage.10jqka.com.cn/find-page/hot.html^ishiddenbar=1^mode=new" + }, + "background_gradient": null, + "data_completed": null, + "mod_data": [ + { + "url": "https://ftapi.10jqka.com.cn/futgwapi/domain/news/content/hot_list/v1/list", + "param_list": [ + "" + ], + "req_desc": "http_get" + } + ], + "config": null, + "bury_point": "hotNews", + "check_block": 0 + } + ] + }, + { + "id": 4271, + "key": "futuresHomepageWarehouseFloor", + "title": "仓单异动", + "introduce": "期货品种的仓单异动变化", + "preview": { + "light": "https://s.thsi.cn/js/m/upload/yyzt/1723195171_2158.png", + "dark": "https://s.thsi.cn/js/m/upload/yyzt/1723195292_2085.png" + }, + "layout_type": 1, + "bury_point": "warehouse", + "show_title": 0, + "is_new": 1, + "check_block": 0, + "rec_info": null, + "card_list": [ + { + "card_id": 4270, + "card_key": "futuresHomepageWarehouseSingleCard", + "title_type": 1, + "is_toggle": 1, + "render_key": "market-data", + "card_size": 4, + "explain_title": null, + "explain_message": null, + "card_title": { + "value": "仓单异动", + "type": "text" + }, + "card_url": { + "ios": "client.html?webid=2804^url=https://fupage.10jqka.com.cn/projects/m/inventory/list.html", + "android": "client.html?webid=2804^url=https://fupage.10jqka.com.cn/projects/m/inventory/list.html" + }, + "background_gradient": null, + "data_completed": null, + "mod_data": [ + { + "url": "https://ftapi.10jqka.com.cn/futgwapi/api/om/homepage/v2/component/market_data?type=warehouse", + "param_list": [ + "" + ], + "req_desc": "http_get" + } + ], + "config": null, + "bury_point": "warehouse", + "check_block": 0 + } + ] + }, + { + "id": 4269, + "key": "futuresHomepageBasisFloor", + "title": "期现基差", + "introduce": "期货品种的基差数据异动", + "preview": { + "light": "https://s.thsi.cn/js/m/upload/yyzt/1723194418_7442.png", + "dark": "https://s.thsi.cn/js/m/upload/yyzt/1723194422_8558.png" + }, + "layout_type": 1, + "bury_point": "basis", + "show_title": 0, + "is_new": 1, + "check_block": 0, + "rec_info": null, + "card_list": [ + { + "card_id": 4268, + "card_key": "futuresHomepageBasisSingleCard", + "title_type": 1, + "is_toggle": 1, + "render_key": "market-data", + "card_size": 4, + "explain_title": null, + "explain_message": null, + "card_title": { + "value": "期现基差", + "type": "text" + }, + "card_url": { + "ios": "client.html?webid=2804^url=https://fupage.10jqka.com.cn/projects/m/basisanalysis/index.html^title=基差数据", + "android": "client.html?webid=2804^url=https://fupage.10jqka.com.cn/projects/m/basisanalysis/index.html^title=基差数据" + }, + "background_gradient": null, + "data_completed": null, + "mod_data": [ + { + "url": "https://ftapi.10jqka.com.cn/futgwapi/api/om/homepage/v2/component/market_data?type=basis", + "param_list": [ + "" + ], + "req_desc": "http_get" + } + ], + "config": null, + "bury_point": "basis", + "check_block": 0 + } + ] + }, + { + "id": 5521, + "key": "futuresHomepageImportantEventFloor", + "title": "重要事件", + "introduce": "重要事件", + "preview": { + "light": "https://s.thsi.cn/js/m/upload/yyzt/1741166334_9108.png", + "dark": "https://s.thsi.cn/js/m/upload/yyzt/1741166339_2946.png" + }, + "layout_type": 3, + "bury_point": "importantEvent", + "show_title": 1, + "is_new": 0, + "check_block": 0, + "rec_info": null, + "card_list": [ + { + "card_id": 5501, + "card_key": "futuresHomepageImportantEventMainCardCalendar", + "title_type": 1, + "is_toggle": null, + "render_key": "financial-calendar", + "card_size": 4, + "explain_title": null, + "explain_message": null, + "card_title": { + "value": "财经日历", + "type": "text" + }, + "card_url": { + "ios": "client.html?webid=2804^ishiddenbar=1^url=https://fupage.10jqka.com.cn/financialcalendar/index.html", + "android": "client.html?webid=2804^ishiddenbar=1^url=https://fupage.10jqka.com.cn/financialcalendar/index.html" + }, + "background_gradient": null, + "data_completed": true, + "mod_data": [ + [ + { + "id": 60099146, + "data_id": 1171953, + "title": "中国至5月29日全国重点地区豆油商业库存(万吨)", + "country": "中国", + "weightiness": 3, + "previous": "103.22", + "public_time": 1780300800, + "type": 1 + }, + { + "id": 60099147, + "data_id": 1171967, + "title": "中国至5月29日全国重点地区棕榈油商业库存(万吨)", + "country": "中国", + "weightiness": 3, + "previous": "76.066", + "public_time": 1780300800, + "type": 1 + }, + { + "id": 60099148, + "data_id": 1171981, + "title": "中国至6月1日进口大豆港口库存(万吨)", + "country": "中国", + "weightiness": 3, + "previous": "860.178", + "public_time": 1780300800, + "type": 1 + } + ] + ], + "config": [], + "bury_point": "calendar", + "check_block": 0 + } + ] + }, + { + "id": 3950, + "key": "futuresHomepageMarketRankFloor", + "title": "市场排名", + "introduce": "关注国内商品市场行情异动机会", + "preview": { + "light": "https://s.thsi.cn/js/m/upload/yyzt/1721649934_6963.png", + "dark": "https://s.thsi.cn/js/m/upload/yyzt/1721649928_4151.png" + }, + "layout_type": 1, + "bury_point": "marketRank", + "show_title": 0, + "is_new": 1, + "check_block": 0, + "rec_info": null, + "card_list": [ + { + "card_id": 3952, + "card_key": "futuresHomepageMarketRankSingleCard", + "title_type": 1, + "is_toggle": 1, + "render_key": "market-ranking", + "card_size": 4, + "explain_title": null, + "explain_message": null, + "card_title": { + "value": "市场排名", + "type": "text" + }, + "card_url": { + "ios": "", + "android": "" + }, + "background_gradient": null, + "data_completed": null, + "mod_data": [], + "config": null, + "bury_point": "marketRank", + "check_block": 0 + } + ] + }, + { + "id": 3949, + "key": "futuresHomepageCommodityOptionsFloor", + "title": "商品期权", + "introduce": "以小博大捕捉临期期权波动行情", + "preview": { + "light": "https://s.thsi.cn/js/m/upload/yyzt/1721649948_1690.png", + "dark": "https://s.thsi.cn/js/m/upload/yyzt/1721649959_9986.png" + }, + "layout_type": 1, + "bury_point": "options", + "show_title": 0, + "is_new": 1, + "check_block": 0, + "rec_info": null, + "card_list": [ + { + "card_id": 3954, + "card_key": "futuresHomepageCommodityOptionsSingleCard", + "title_type": 1, + "is_toggle": 0, + "render_key": "commodity-options", + "card_size": 4, + "explain_title": "商品期权说明", + "explain_message": "热门:成交量最大的期权合约涨跌幅排行\n 临期期权:到期日10天内的成交量最大的期权合约涨跌幅排行\n 涨跌幅(昨收):以昨收价为基准价计算涨跌幅\n", + "card_title": { + "value": "商品期权", + "type": "text" + }, + "card_url": { + "ios": "", + "android": "" + }, + "background_gradient": null, + "data_completed": null, + "mod_data": [], + "config": null, + "bury_point": "options", + "check_block": 0 + } + ] + }, + { + "id": 4520, + "key": "futuresHomepageFedChanceFloor", + "title": "美联储降息机会", + "introduce": "历次降息周期中的资产表现", + "preview": { + "light": "https://s.thsi.cn/js/m/upload/yyzt/1726662193_2814.png", + "dark": "https://s.thsi.cn/js/m/upload/yyzt/1726662197_6627.png" + }, + "layout_type": 1, + "bury_point": "fedInvestOpp", + "show_title": 0, + "is_new": 1, + "check_block": 1, + "rec_info": null, + "card_list": [ + { + "card_id": 4518, + "card_key": "futuresHomepageFedChanceSingleCard", + "title_type": 1, + "is_toggle": 1, + "render_key": "market-data", + "card_size": 4, + "explain_title": null, + "explain_message": null, + "card_title": { + "value": "美联储降息机会", + "type": "text" + }, + "card_url": { + "ios": "client.html?action=ymtz^webid=2804^mode=new^ishiddenbar=1^url=https://fupage.10jqka.com.cn/futures-frontend-kamis-renderer/index.0.3.0.html?token=KCENjQwNAB5", + "android": "client.html?action=ymtz^webid=2804^mode=new^ishiddenbar=1^url=https://fupage.10jqka.com.cn/futures-frontend-kamis-renderer/index.0.3.0.html?token=KCENjQwNAB5" + }, + "background_gradient": null, + "data_completed": null, + "mod_data": [ + { + "url": "https://ftapi.10jqka.com.cn/fedapi/fed_chance/v1/futures_home_page", + "param_list": [ + "" + ], + "req_desc": "http_get" + } + ], + "config": null, + "bury_point": "fedInvestOpp", + "check_block": 0 + } + ] + }, + { + "id": 4291, + "key": "futuresHomepageInventoryFloor", + "title": "商品库存", + "introduce": "品种现货的库存变化", + "preview": { + "light": "https://s.thsi.cn/js/m/upload/yyzt/1723703986_3139.png", + "dark": "https://s.thsi.cn/js/m/upload/yyzt/1723703996_3840.png" + }, + "layout_type": 1, + "bury_point": "inventory", + "show_title": 0, + "is_new": 1, + "check_block": 0, + "rec_info": null, + "card_list": [ + { + "card_id": 4290, + "card_key": "futuresHomepageInventorySingleCard", + "title_type": 1, + "is_toggle": 1, + "render_key": "market-data", + "card_size": 4, + "explain_title": null, + "explain_message": null, + "card_title": { + "value": "商品库存", + "type": "text" + }, + "card_url": { + "ios": "", + "android": "" + }, + "background_gradient": null, + "data_completed": null, + "mod_data": [ + { + "url": "https://ftapi.10jqka.com.cn/futgwapi/api/om/homepage/v2/component/market_data?type=inventory", + "param_list": [ + "" + ], + "req_desc": "http_get" + } + ], + "config": null, + "bury_point": "inventory", + "check_block": 0 + } + ] + }, + { + "id": 6459, + "key": "futuresHomepageOptionAnomalyFloor", + "title": "期权异动", + "introduce": "快人一步精准捕捉异动机会", + "preview": { + "light": "https://i.thsi.cn/staticS3/mobileweb-upload-static-server.img/appid_18/yyztpic/302bd8c6-4980-4d01-8080-80712b7b9483.png", + "dark": "https://i.thsi.cn/staticS3/mobileweb-upload-static-server.img/appid_18/yyztpic/f0e44c0c-9384-43f4-9187-bbb20e10dfd4.png" + }, + "layout_type": 1, + "bury_point": "optionAnomaly", + "show_title": 0, + "is_new": 1, + "check_block": 0, + "rec_info": null, + "card_list": [ + { + "card_id": 6457, + "card_key": "futuresHomepageOptionAnomalySingleCard", + "title_type": 1, + "is_toggle": 0, + "render_key": "options-radar", + "card_size": 4, + "explain_title": null, + "explain_message": null, + "card_title": { + "value": "期权异动", + "type": "text" + }, + "card_url": { + "ios": "", + "android": "" + }, + "background_gradient": null, + "data_completed": null, + "mod_data": [ + { + "url": "https://ftapi.10jqka.com.cn/futgwapi/api/om/homepage/v2/component/option_anomaly", + "param_list": [ + "" + ], + "req_desc": "http_get" + } + ], + "config": null, + "bury_point": "optionAnomaly", + "check_block": 0 + } + ] + }, + { + "id": 6458, + "key": "futuresHomepageOptionRankFloor", + "title": "期权榜单", + "introduce": "动态筛选榜单实时追踪行情", + "preview": { + "light": "https://i.thsi.cn/staticS3/mobileweb-upload-static-server.img/appid_18/yyztpic/9879179d-13f8-4c97-9009-fab59b9b685f.png", + "dark": "https://i.thsi.cn/staticS3/mobileweb-upload-static-server.img/appid_18/yyztpic/c5d2db41-f019-4024-a278-7b9f8e94916d.png" + }, + "layout_type": 1, + "bury_point": "optionRank", + "show_title": 0, + "is_new": 1, + "check_block": 0, + "rec_info": null, + "card_list": [ + { + "card_id": 6456, + "card_key": "futuresHomepageOptionRankSingleCard", + "title_type": 1, + "is_toggle": 0, + "render_key": "options-radar", + "card_size": 4, + "explain_title": null, + "explain_message": null, + "card_title": { + "value": "期权榜单", + "type": "text" + }, + "card_url": { + "ios": "", + "android": "" + }, + "background_gradient": null, + "data_completed": null, + "mod_data": [ + { + "url": "https://ftapi.10jqka.com.cn/futgwapi/api/om/homepage/v2/component/option_rank", + "param_list": [ + "" + ], + "req_desc": "http_get" + } + ], + "config": null, + "bury_point": "optionRank", + "check_block": 0 + } + ] + }, + { + "id": 3957, + "key": "futuresHomepageMainTrendFloor", + "title": "主力趋势", + "introduce": "跟踪机构主力持仓动向", + "preview": { + "light": "https://s.thsi.cn/js/m/upload/yyzt/1721649813_5174.png", + "dark": "https://s.thsi.cn/js/m/upload/yyzt/1721649839_6586.png" + }, + "layout_type": 1, + "bury_point": "mainTrend", + "show_title": 0, + "is_new": 1, + "check_block": 0, + "rec_info": null, + "card_list": [ + { + "card_id": 3953, + "card_key": "futuresHomepageMainTrendSingleCard", + "title_type": 1, + "is_toggle": 0, + "render_key": "main-trend", + "card_size": 4, + "explain_title": null, + "explain_message": null, + "card_title": { + "value": "主力趋势", + "type": "text" + }, + "card_url": { + "ios": "client.html?action=ymtz^webid=2804^url=https://fupage.10jqka.com.cn/projects/m/position-analysis/index.html", + "android": "client.html?action=ymtz^webid=2804^url=https://fupage.10jqka.com.cn/projects/m/position-analysis/index.html" + }, + "background_gradient": null, + "data_completed": null, + "mod_data": [], + "config": null, + "bury_point": "mainTrend", + "check_block": 0 + } + ] + }, + { + "id": 6876, + "key": "futuresHomepageFundamentalAIDiagnosisFloor", + "title": "AI诊期", + "introduce": "基本面趋势AI诊断", + "preview": { + "light": "https://i.thsi.cn/staticS3/mobileweb-upload-static-server.img/appid_18/yyztpic/64be2ef7-9801-4b1f-b79f-a03deddc3d7b.png", + "dark": "https://i.thsi.cn/staticS3/mobileweb-upload-static-server.img/appid_18/yyztpic/af1d6f2f-29e1-4a75-8385-068172fa4cb5.png" + }, + "layout_type": 1, + "bury_point": "AIDiagnosis", + "show_title": 0, + "is_new": 1, + "check_block": 0, + "rec_info": null, + "card_list": [ + { + "card_id": 6875, + "card_key": "futuresHomepageFundamentalAIDiagnosisCard", + "title_type": 1, + "is_toggle": 0, + "render_key": "fundamental-ai-diagnosis", + "card_size": 4, + "explain_title": null, + "explain_message": null, + "card_title": { + "value": "AI诊期", + "type": "text" + }, + "card_url": { + "ios": "client.html?action=ymtz^webid=2804^url=https://fupage.10jqka.com.cn/industry-chain/diagnose.html^title=AI品种诊断", + "android": "client.html?action=ymtz^webid=2804^url=https://fupage.10jqka.com.cn/industry-chain/diagnose.html^title=AI品种诊断" + }, + "background_gradient": null, + "data_completed": null, + "mod_data": [ + { + "url": "https://dq.10jqka.com.cn/fuyao/futures_homepage_comps/f10/v1/ai_explain", + "param_list": [ + "" + ], + "req_desc": "http_get" + } + ], + "config": null, + "bury_point": "AIDiagnosis", + "check_block": 0 + } + ] + }, + { + "id": 5105, + "key": "futuresHomepageAIPickFloor", + "title": "AI选期", + "introduce": "AI多条件组合实时选期", + "preview": { + "light": "https://s.thsi.cn/js/m/upload/yyzt/1732878141_2376.png", + "dark": "https://s.thsi.cn/js/m/upload/yyzt/1732878152_9381.png" + }, + "layout_type": 1, + "bury_point": "aiPick", + "show_title": 0, + "is_new": 1, + "check_block": 0, + "rec_info": null, + "card_list": [ + { + "card_id": 5106, + "card_key": "futuresHomepageAIPickCard", + "title_type": 1, + "is_toggle": 0, + "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": "" + }, + "background_gradient": null, + "data_completed": null, + "mod_data": [ + { + "url": "https://ftapi.10jqka.com.cn/futgwapi/api/ai_diagnosis/home_strategy/v1/user_data", + "param_list": [ + "" + ], + "req_desc": "http_get" + } + ], + "config": null, + "bury_point": "aiPick", + "check_block": 0 + } + ] + }, + { + "id": 4202, + "key": "futuresHomepageInstitutionalStrategyFloor", + "title": "机构策略", + "introduce": "不会选品种?来看看机构观点", + "preview": { + "light": "https://s.thsi.cn/js/m/upload/yyzt/1721828556_3492.png", + "dark": "https://s.thsi.cn/js/m/upload/yyzt/1721828547_6904.png" + }, + "layout_type": 1, + "bury_point": "InStrate", + "show_title": 0, + "is_new": 1, + "check_block": 0, + "rec_info": null, + "card_list": [ + { + "card_id": 4200, + "card_key": "futuresHomepageInstitutionalStrategySingleCard", + "title_type": 1, + "is_toggle": 1, + "render_key": "hot-list", + "card_size": 4, + "explain_title": null, + "explain_message": null, + "card_title": { + "value": "机构策略", + "type": "text" + }, + "card_url": { + "ios": "https://news.10jqka.com.cn/tapp/theme/TZ-11357?stat_collect_sign=%7B%22enter_sign%22%3A%22nrgcyeka%22%2C%22activity_sign%22%3A%22null%22%2C%22adid_sign%22%3A%22296385%22%2C%22adlogid_sign%22%3A%22412686%22%7D", + "android": "https://news.10jqka.com.cn/tapp/theme/TZ-11357?stat_collect_sign=%7B%22enter_sign%22%3A%22nrgcyeka%22%2C%22activity_sign%22%3A%22null%22%2C%22adid_sign%22%3A%22296385%22%2C%22adlogid_sign%22%3A%22412686%22%7D" + }, + "background_gradient": null, + "data_completed": null, + "mod_data": [ + { + "url": "https://fupage.10jqka.com.cn/futgwapi/domain/news/homepage/v1/common_list?cota_id=2940", + "param_list": [ + "" + ], + "req_desc": "http_get" + } + ], + "config": null, + "bury_point": "InStrate", + "check_block": 0 + } + ] + }, + { + "id": 4201, + "key": "futuresHomepageVarietyNewsFloor", + "title": "品种资讯", + "introduce": "实时掌握关注品种的最新消息", + "preview": { + "light": "https://s.thsi.cn/js/m/upload/yyzt/1721828523_4267.png", + "dark": "https://s.thsi.cn/js/m/upload/yyzt/1721828506_5952.png" + }, + "layout_type": 1, + "bury_point": "SelfNews", + "show_title": 0, + "is_new": 1, + "check_block": 0, + "rec_info": null, + "card_list": [ + { + "card_id": 4199, + "card_key": "futuresHomepageVartieyNewsSingleCard", + "title_type": 1, + "is_toggle": 1, + "render_key": "hot-list", + "card_size": 4, + "explain_title": null, + "explain_message": null, + "card_title": { + "value": "品种资讯", + "type": "text" + }, + "card_url": { + "ios": "https://fupage.10jqka.com.cn/find-page/recommend.html", + "android": "https://fupage.10jqka.com.cn/find-page/recommend.html" + }, + "background_gradient": null, + "data_completed": null, + "mod_data": [ + { + "url": "https://ftapi.10jqka.com.cn/futgwapi/domain/news/homepage/v1/recommend_list", + "param_list": [ + "" + ], + "req_desc": "http_get" + } + ], + "config": null, + "bury_point": "SelfNews", + "check_block": 0 + } + ] + }, + { + "id": 9999, + "key": "futuresHomepageArbitragePairFloor", + "title": "套利对", + "introduce": "套利对监控", + "preview": { + "light": "", + "dark": "" + }, + "layout_type": 1, + "bury_point": "arbitragePair", + "show_title": 0, + "is_new": 0, + "check_block": 0, + "rec_info": null, + "card_list": [ + { + "card_id": 9998, + "card_key": "futuresHomepageArbitragePairSingleCard", + "title_type": 1, + "is_toggle": 1, + "render_key": "arbitrage-pair", + "card_size": 4, + "explain_title": null, + "explain_message": null, + "card_title": { + "value": "套利对", + "type": "text" + }, + "card_url": { + "ios": "", + "android": "" + }, + "background_gradient": null, + "data_completed": true, + "mod_data": [ + [ + { + "key": "hot", + "name": "热门", + "data": [ + { + "arbitrage_type": 3, + "change": 0.28, + "quantile": 21.58, + "name": "豆一2607&2609", + "id": 4, + "ratio_unit": "元/吨", + "ratio_price": 54.17 + }, + { + "arbitrage_type": 1, + "change": 1, + "quantile": 12.39, + "name": "沪金&沪银2608", + "id": 5, + "ratio_unit": "元/克", + "ratio_price": -33 + } + ] + }, + { + "key": "crossVariety", + "name": "跨品种", + "data": [ + { + "arbitrage_type": 1, + "change": 1, + "quantile": 12.39, + "name": "沪金&沪银2608", + "id": 5, + "ratio_unit": "元/克", + "ratio_price": -33 + } + ] + }, + { + "key": "crossPeriod", + "name": "跨期", + "data": [ + { + "arbitrage_type": 3, + "change": 0.28, + "quantile": 21.58, + "name": "豆一2607&2609", + "id": 4, + "ratio_unit": "元/吨", + "ratio_price": 54.17 + } + ] + } + ] + ], + "config": null, + "bury_point": "arbitragePair", + "check_block": 0 + } + ] + } + ] +} diff --git a/src/main/ets/components/mainpage/FirstPage.ets b/src/main/ets/components/mainpage/FirstPage.ets new file mode 100644 index 0000000..a640a16 --- /dev/null +++ b/src/main/ets/components/mainpage/FirstPage.ets @@ -0,0 +1,413 @@ +import { AdsPageConfig, AppInfoConfig, FirstPageTabManager, FrameId, GlobalContext } from 'biz_common' +import { CustomEntryListNodeComponent } from '../../node/view/CustomEntryListNodeComponent' +import FirstPageTitleBar from '../../views/FirstPageTitleBar' +import { BannerNodeComponent } from '../../node/view/BannerNodeComponent' +import { FirstPageDataFetcher } from '../../datacenter/FirstPageDataFetcher' +import { FirstPageNodeModel } from '../../datacenter/model/FirstPageNodeModel' +import { FourEntryListNodeComponent } from '../../node/view/FourEntryListNodeComponent' +import { FirstPageConstant } from '../../datacenter/FirstPageConstant' +import { TopImageNodeComponent } from '../../node/view/TopImageNodeComponent' +import { TITLE_BAR_HEIGHT } from '../../util/FirstPageConstant' +import { FirstPageContentBg } from '../../views/FirstPageContentBg' +import { PreferenceUtils } from 'hxutil' +import { getResourceString } from '../../util/ResourceUtil' +import { router } from '@kit.ArkUI' +import { AppUtil } from '@pura/harmony-utils' +import { SelfSelectedStockNodeComponent } from '../../node/view/SelfSelectedStockNodeComponent' +import { FirstPagePopupAdHelper } from '../../node/helper/FirstPagePopupAdHelper' +import { OnAdsDataUpdateListener, BaseAd, AdsManager, ADS_TYPE_INDEX_POPUP, AdsPopupManager } from '@b2c-f/fuhm-ads' +import { DebugToolUtil } from 'biz_debug' +import { KaihuFlagManager, NewUserManager, UserSourceManager } from 'biz_hxservice' +import { AdsPopupHost } from '../../popup/AdsPopupHost' +import { AiDiagnosisNodeComponent } from '../../node/view/AiDiagnosisNodeComponent' +import { AiRiseFallNodeComponent } from '../../node/view/AiRiseFallNodeComponent' +import { HotListNodeComponent } from '../../node/view/HotListNodeComponent' +import { CommodityOptionsNodeComponent } from '../../node/view/CommodityOptionsNodeComponent' +import { MarketRankingNodeComponent } from '../../market-ranking/MarketRankingNodeComponent' + + +/** + * 首页 + * @author wubingsong@myhexin.com + */ +@Component +export struct FirstPage { + //首页tab管理器 + @ObjectLink @Watch('onVisibilityChange') tabManager: FirstPageTabManager; + @Provide firstPageVisible: boolean = true; + @State nodeModelArr: FirstPageNodeModel[] = [] + private topBarHeight: number = 0 + private scrollHeight: number = 0 + @State isTopViewShow: boolean = false + @State isRefreshing: boolean = false + private refreshTimer: number = -1 + @State isStartRefresh: boolean = true + @State refreshTitle: string = '下拉可以刷新' + @State refreshTime: string = '' + @State refreshRotateAngle: number = 0 + @State needAnimation: boolean = false + /** 广告数据监听器 */ + private adsPopupListener: OnAdsDataUpdateListener = { + onAdsDataUpdate: (adsType: string, adsDataModels: BaseAd[]) => { + if (this.firstPageVisible) { + FirstPagePopupAdHelper.getInstance().onAdsDataUpdate(adsType, adsDataModels) + } + } + } + // 卡片刷新触发器 + @State refreshTrigger: number = 0 + // 自动刷新定时器 ID + private autoRefreshTimer: number = -1 + // 自动刷新间隔(60秒) + private readonly AUTO_REFRESH_INTERVAL = 60000 + private kaihuFlagListener = (): void => { + const kaihuFlagManager = KaihuFlagManager.getInstance() + //归宿 更新了加载一遍首页和广告 + if (kaihuFlagManager.isFlagChanged()) { + kaihuFlagManager.resetFlagChanged() + this.refreshFirstPageDataAndAds() + } + } + private userSourceUpdateListener = (): void => { + //来源更新后,只有当没有归属时,才重新刷新首页和广告 + if (!KaihuFlagManager.getInstance().getKaihuFlag()) { + this.refreshFirstPageDataAndAds() + } + } + + onVisibilityChange() { + this.firstPageVisible = this.tabManager.tabVisibility; + FirstPagePopupAdHelper.getInstance().firstPageVisible = this.firstPageVisible; + if (this.tabManager.tabVisibility) { + FirstPageDataFetcher.getInstance().remoteFetchData() + FirstPagePopupAdHelper.getInstance().showPopupAdWhenOnResume() + this.refreshAdsData() + AdsManager.refreshNotifyAds(ADS_TYPE_INDEX_POPUP) + AdsPopupHost.getInstance().onPageJump(AdsPageConfig.FIRST_PAGE_ID) + // 启动定时器 + this.startAutoRefresh() + } else { + KaihuFlagManager.getInstance().removeListener(this.kaihuFlagListener) + UserSourceManager.getInstance().removeListener(this.userSourceUpdateListener) + AdsPopupHost.getInstance().onPageJump(AdsPageConfig.INVALID) + // 暂停定时器 + this.stopAutoRefresh() + this.stopDelayRefresh() + } + } + + private refreshAdsData() { + KaihuFlagManager.getInstance().addListener(this.kaihuFlagListener) + UserSourceManager.getInstance().addListener(this.userSourceUpdateListener) + if (KaihuFlagManager.getInstance().isFlagChanged()) { + //用户开户标志如果有更新此时发送首页请求了则将其复原重置,用来防止没有变化却重复发送请求 + KaihuFlagManager.getInstance().resetFlagChanged() + } + if (NewUserManager.getInstance().needRequestAdsNow()) { + //发送请求首页广告 + this.refreshFirstPageDataAndAds() + } + } + + aboutToAppear(): void { + this.firstPageVisible = this.tabManager.tabVisibility; + FirstPagePopupAdHelper.getInstance().firstPageVisible = this.firstPageVisible; + this.topBarHeight = px2vp(GlobalContext.statusBarHeight) + TITLE_BAR_HEIGHT + if (DebugToolUtil.isInnerNetSimulateMode()) { + // 内网请求不到首页板块,默认只填充自选盯盘 + const hqNode = new FirstPageNodeModel() + hqNode.id = 0 + hqNode.targetTypeId = FirstPageConstant.KEY_HANGQING_TABLE.toString() + this.nodeModelArr = [hqNode] + } + FirstPageDataFetcher.getInstance().nodeConfigChange = (response) => { + this.finishRefresh() + if (response != null) { + for (let index = 0; index < response.length; index++) { + const model = response[index]; + if (model.targetTypeId == FirstPageConstant.KEY_TOP_BG.toString()) { + if (index > 0) { + let topModel = response.splice(index, 1) + response.unshift(...topModel) + } + this.isTopViewShow = true + break + } else if (index == response.length - 1) { + this.isTopViewShow = false + } + } + let hotListModel = new FirstPageNodeModel() + hotListModel.targetTypeId = FirstPageConstant.KEY_HOT_LIST.toString() + let commodityOptionModel = new FirstPageNodeModel() + commodityOptionModel.targetTypeId = FirstPageConstant.KEY_COMMODITY_OPTIONS.toString() + let aiDiagnosisModel = new FirstPageNodeModel() + aiDiagnosisModel.targetTypeId = FirstPageConstant.KEY_AI_DIAGNOSIS.toString() + let aiRiseFallModel = new FirstPageNodeModel() + aiRiseFallModel.targetTypeId = FirstPageConstant.KEY_AI_RISEFALL.toString() + let marketRankingModel = new FirstPageNodeModel() + marketRankingModel.targetTypeId = FirstPageConstant.KEY_MARKET_RANKING.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) + this.nodeModelArr = response + } + } + FirstPageDataFetcher.getInstance().onError = () => { + this.isRefreshing = false + } + FirstPageDataFetcher.getInstance().fetchDataLocal() + FirstPageDataFetcher.getInstance().remoteFetchData() + this.refreshAdsData() + AdsManager.addAdsDataUpdateListener(ADS_TYPE_INDEX_POPUP, this.adsPopupListener) + if (this.firstPageVisible) { + AdsManager.refreshNotifyAds(ADS_TYPE_INDEX_POPUP) + } + // 启动ai卡片定时器 + this.startAutoRefresh() + } + + /** + * 启动自动刷新定时器 + */ + private startAutoRefresh(): void { + if (this.autoRefreshTimer !== -1) { + return + } + this.autoRefreshTimer = setInterval(() => { + this.refreshTrigger++ + }, this.AUTO_REFRESH_INTERVAL) + } + + /** + * 停止自动刷新定时器 + */ + private stopAutoRefresh(): void { + if (this.autoRefreshTimer !== -1) { + clearInterval(this.autoRefreshTimer) + this.autoRefreshTimer = -1 + } + } + + private startDelayRefresh(): void { + this.refreshTimer = setTimeout(() => { + this.isRefreshing = false + }, 1000) + } + + private stopDelayRefresh(): void { + if (this.refreshTimer !== -1) { + clearTimeout(this.refreshTimer) + this.refreshTimer = -1 + } + } + + aboutToDisappear(): void { + AdsManager.removeAdsDataUpdateListener(ADS_TYPE_INDEX_POPUP, this.adsPopupListener) + // 停止自动刷新定时器 + this.stopAutoRefresh() + this.stopDelayRefresh() + } + + private refreshFirstPageDataAndAds(): void { + AdsManager.refreshNotifyFirstPageAds(true) + } + + private finishRefresh() { + this.refreshTime = PreferenceUtils.getStringSync(GlobalContext.get(), 'SpFirstPage', 'refreshTime', '') + // 刷新完成后递增卡片刷新触发器 + this.refreshTrigger++ + this.startDelayRefresh() + } + build() { + Refresh({ refreshing: $$this.isRefreshing, builder: this.updateRefreshHeader()}) { + Stack({ alignContent: Alignment.TopStart }) { + FirstPageTitleBar().zIndex(5) + Scroll() { + Stack({ alignContent: Alignment.TopStart }) { + FirstPageContentBg() + .visibility(this.isTopViewShow ? Visibility.None : Visibility.Visible) + Column() { + Column() + .width('100%') + .height(this.topBarHeight) + .visibility(this.isTopViewShow ? Visibility.None : Visibility.Visible) + + ForEach(this.nodeModelArr, (model: FirstPageNodeModel, index: number) => { + this.createNodeView(model, index) + }) + } + .margin({bottom: 10}) + } + .width('100%') + } + .scrollBar(BarState.Off) + .width('100%') + .height('auto') + .backgroundColor($r('app.color.surface_layer1_background')) + .onScrollStart(()=>{ + AdsPopupHost.getInstance().onScrollEvent() + }) + } + .width('100%') + .height('100%') + } + .width('100%') + .height('100%') + .refreshOffset(90) + .pullToRefresh(true) + .onStateChange((refreshStatus: RefreshStatus) => { + if (refreshStatus == RefreshStatus.Drag) { + this.refreshTitle = getResourceString(getContext(),$r('app.string.drag_refresh_pull')); + this.needAnimation = false + this.isStartRefresh = true + this.refreshRotateAngle = 0 + } else if (refreshStatus == RefreshStatus.OverDrag) { + this.refreshTitle = getResourceString(getContext(),$r('app.string.drag_refresh_to_refresh')); + this.needAnimation = true + this.refreshRotateAngle = 180 + } else if (refreshStatus == RefreshStatus.Refresh) { + this.refreshTitle = getResourceString(getContext(),$r('app.string.drag_refresh_loading')); + this.refreshTime = PreferenceUtils.getStringSync(GlobalContext.get(), 'SpFirstPage', 'refreshTime', '') + this.isStartRefresh = false + } + }) + .onRefreshing(() => { + FirstPageDataFetcher.getInstance().remoteFetchData() + }) + } + + @Builder + updateRefreshHeader() { + Row() { + Image($r('app.media.firstpage_pull_status_arrow')) + .width(18) + .height(40) + .rotate({ angle: this.refreshRotateAngle }) + .animation(this.needAnimation ? { + duration: 500, + } : null) + .visibility(this.isStartRefresh ? Visibility.Visible : Visibility.None) + + LoadingProgress() + .width(30) + .height(30) + .visibility(this.isStartRefresh ? Visibility.None : Visibility.Visible) + + Column() { + Text(this.refreshTitle) + .fontSize(15) + .fontColor($r('app.color.elements_text_primary_02')) + + Row() { + Text($r('app.string.drag_refresh_last_update_time')) + .fontSize(15) + .fontColor($r('app.color.elements_text_primary_02')) + .margin({right: 10}) + + Text(this.refreshTime) + .fontSize(15) + .fontColor($r('app.color.elements_text_primary_02')) + } + .visibility(this.refreshTime == '' ? Visibility.None : Visibility.Visible) + } + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + .margin({left: 10}) + } + .justifyContent(FlexAlign.Center) + .alignItems(VerticalAlign.Center) + .width('100%') + } + + @Builder + createNodeView(model: FirstPageNodeModel, index: number) { + if (model.targetTypeId == FirstPageConstant.KEY_BANNER.toString()) { + BannerNodeComponent({ + nodeModel: model + }).margin({top: index > 0 ? 10 : 0, left: 10, right: 10}) + } else if (model.targetTypeId == FirstPageConstant.KEY_FOUR_ENTRY_LIST.toString()) { + FourEntryListNodeComponent({ + nodeModel: model + }).margin({top: index > 0 ? 10 : 0, left: 10, right: 10}) + } else if (model.targetTypeId == FirstPageConstant.KEY_CUSTOM_ENTRY_LIST.toString()) { + CustomEntryListNodeComponent({ + nodeModel: model + }).margin({top: (AppInfoConfig.Official && !this.isTopViewShow || index) > 0 ? 10 : 0, left: 10, right: 10}) + } else if (model.targetTypeId == FirstPageConstant.KEY_AI_DIAGNOSIS.toString()) { + AiDiagnosisNodeComponent({ + nodeModel: model, + refreshTrigger: this.refreshTrigger, + }).margin({ top: index > 0 ? 10 : 0, left: 10, right: 10 }) + } else if (model.targetTypeId == FirstPageConstant.KEY_AI_RISEFALL.toString()) { + AiRiseFallNodeComponent({ + nodeModel: model, + refreshTrigger: this.refreshTrigger, + }).margin({ top: index > 0 ? 10 : 0, left: 10, right: 10 }) + } else if (model.targetTypeId == FirstPageConstant.KEY_HOT_LIST.toString()) { + HotListNodeComponent({ + nodeModel: model, + refreshTrigger: this.refreshTrigger, + }).margin({ top: index > 0 ? 10 : 0, left: 10, right: 10 }) + } else if (model.targetTypeId == FirstPageConstant.KEY_COMMODITY_OPTIONS.toString()) { + CommodityOptionsNodeComponent({ + refreshTrigger: this.refreshTrigger, + }).margin({ top: index > 0 ? 10 : 0, left: 10, right: 10 }) + } else if (model.targetTypeId == FirstPageConstant.KEY_MARKET_RANKING.toString()) { + MarketRankingNodeComponent({ + 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 + }) + } else if (model.targetTypeId == FirstPageConstant.KEY_FEED_BACK.toString()) { + this.buildFeedBackView(index) + }else if (model.targetTypeId == FirstPageConstant.KEY_HANGQING_TABLE.toString()){ + SelfSelectedStockNodeComponent().margin({top: index > 0 ? 10 : 0, left: 10, right: 10}) + } + else if (model.targetTypeId == FirstPageConstant.KEY_END_TIP.toString()) { + Text($r('app.string.drag_refresh_to_bottom')) + .fontSize(14) + .fontColor($r('app.color.color_a3a3a3')) + .textAlign(TextAlign.Center) + .width('100%') + .margin({top: 16, bottom: 15}) + } + } + + @Builder + private buildFeedBackView(index:number){ + Column(){ + Row(){ + Image($r('app.media.icon_edit_square_24')).width(16).height(16).align(Alignment.Center) + .fillColor($r('app.color.elements_icon_primary_02')) + Text($r('app.string.feed_back_help_text')) + .fontSize(15) + .fontColor($r('app.color.elements_text_primary_02')) + .textAlign(TextAlign.Center) + .align(Alignment.Center) + + }.width('100%') + .height(44) + .justifyContent(FlexAlign.Center) + .alignItems(VerticalAlign.Center) + .backgroundColor($r('app.color.surface_layer1_foreground')) + .borderRadius(10) + .onClick((event)=>{ + router.pushUrl({ url: 'pages/WebViewComponentPage', params: { + url:AppUtil.getContext().resourceManager.getStringSync($r('app.string.fu_feed_back_help_url')), + title: AppUtil.getContext().resourceManager.getStringSync($r('app.string.feed_back_help_text')), + userCookie:true, + isLoginRefreshWeb:true, + refreshOnWidthChange:true + } }) + }) + + }.width('100%') + .padding({left:10,right:10}) + .padding({top: index > 0 ? 10 : 0, left: 10, right: 10}) + + } +} diff --git a/src/main/ets/constants/FirstPageConstants.ets b/src/main/ets/constants/FirstPageConstants.ets new file mode 100644 index 0000000..962e80c --- /dev/null +++ b/src/main/ets/constants/FirstPageConstants.ets @@ -0,0 +1,96 @@ +/** + * author : liuqingliang@myhexin.com + * time : created on 2026/6/2 + * desc : 首页模块公共常量 + */ + +// cbas相关 +export const CBAS_SPLIT_DIAN = "." + +export const CBAS_SERVE_PAGE_ROBOT = "robot" + +export const CBAS_PREFIX_SHOUYE = "newshouye" + +export const CBAS_FIRST_PAGE_TOP_FUNCT = "topFunc" + +export const CBAS_FIRST_PAGE_TOP_BANNER = "topBanner" + +export const CBAS_FIRST_PAGE_USER_CENTER = "userCenter" + +export const CBAS_FIRST_PAGE_BOTTOM_FUNCT = "bottom" + +export const WEB_RESOURCES_ID = "free_per_login" + +export const CBAS_FIRST_PAGE_NOT_LOGIN = "weidenglu" + +export const CBAS_FIRST_PAGE_MESSAGE = "message" + +export const CBAS_SEARCH = "search" + +export const CBAS_SHOW = "show" + +export const CBAS_CLICK = "click" + +export const CBAS_BOTTOM_MANAGE = "pageManage" + +export const CBAS_BOTTOM_FEED_BACK = "feedback" + +/** + * 首页运营广告 CBAS 前缀 + */ +export const CBAS_FIRST_PAGE_PREFIX = "newshouye" + +/** + * 广告 CBAS object 前缀 + */ +export const CBAS_OBJECT_PREFIX_AD = "ad" + +/** + * Banner 点击 CBAS 数字部分(无点号后缀),配合 sendStandardJumpPageCbas 使用 + */ +export const CBAS_CLICK_BANNER_NO_DOT = "1" + +/** + * WebView 页面 frameId,对齐 Android FRAME_ID_COMMON_BROWSER + */ +export const FRAME_ID_COMMON_BROWSER = "web" + +/** + * 点击操作 + */ +export const AD_CLICK_TYPE_INVALIDE: number = -1 + +export const ACTION_OPERATE_TYPE_CLICK = 1 + +export const AD_TYPE_INVALID = "" + +export const AD_CLICK_TYPE_UNINTEREST = 0 + +export const AD_CLICK_TYPE_INTEREST = 1 + +// 广告操作类型 +/***广告显示 */ +export const AD_OPERATION_SHOW = 1 + +/***广告点击 */ +export const AD_OPERATION_CLICK = 2 + +/***广告请求错误 */ +export const AD_OPERATION_ERROR = 3 + +/*** 广告手动轮播 */ +export const AD_OPERATION_SWITCH = 4 + +/*** 广告自动轮播 */ +export const AD_OPERATION_AUTO = 5 + +/*** 首页运营广告 */ +export const AD_LOCATION_YUNYING_SHOUYE = "indexFeedYunYing" + +export const CBAS_FIRSTPAGE_PREFIX = '_tanchuangad.%s.' + +export const CBAS_FIRSTPAGE_TANGCHUANGAD_BAOGUANG = 'baoguang.ad%s' + +export const CBAS_FIRSTPAGE_TANGCHUANGAD_CLOSE = 'close.ad%s' + +export const CBAS_FIRSTPAGE_TANGCHUANGAD_DIANJI = "_tanchuangad.dianji.ad%s"; //弹窗广告 \ No newline at end of file diff --git a/src/main/ets/constants/FirstPageFiveCBASConstants.ets b/src/main/ets/constants/FirstPageFiveCBASConstants.ets new file mode 100644 index 0000000..12dc096 --- /dev/null +++ b/src/main/ets/constants/FirstPageFiveCBASConstants.ets @@ -0,0 +1,12 @@ +/** + * author : liuqingliang@myhexin.com + * time : created on 2026/6/12 + * desc : 首页五段式埋点常量 + */ + +export class FirstPageFiveCBASConstants { + static CBAS_BANNER_LOGMAP_ADID = 'adId' + // ========== OID常量 ========== + static CBAS_SHOUYE_BANNER = 'qht_qhtshouye_Banner_banner' + static CBAS_ADS_POPUP = 'qht_xuanfuqiu_fuchuang_FloatingWindow' +} \ No newline at end of file diff --git a/src/main/ets/datacenter/FirstPageConstant.ets b/src/main/ets/datacenter/FirstPageConstant.ets new file mode 100644 index 0000000..eef7c5d --- /dev/null +++ b/src/main/ets/datacenter/FirstPageConstant.ets @@ -0,0 +1,17 @@ +export class FirstPageConstant { + + static KEY_BANNER = 0 //轮播图 + static KEY_RDZX = 10 //热点资讯 + static KEY_FOUR_ENTRY_LIST = 2 //四宫格 + static KEY_TOP_BG = 13 //顶部运营图 + static KEY_BROADCAST = 14 //广播 + static KEY_CUSTOM_ENTRY_LIST = 15 //自定义宫格 + static KEY_HANGQING_TABLE = 1 //行情盯盘 + static KEY_FEED_BACK = -8 //意见反馈 + static KEY_END_TIP = -9 //到底提醒 + static KEY_AI_DIAGNOSIS = 16 //AI品种诊断 + static KEY_AI_RISEFALL = 17 //AI看涨跌 + static KEY_HOT_LIST = 18 //热点排行 + static KEY_COMMODITY_OPTIONS = 19 //商品期权 + static KEY_MARKET_RANKING = 20 //市场排名 +} \ No newline at end of file diff --git a/src/main/ets/datacenter/FirstPageDataFetcher.ets b/src/main/ets/datacenter/FirstPageDataFetcher.ets new file mode 100644 index 0000000..0be2d8c --- /dev/null +++ b/src/main/ets/datacenter/FirstPageDataFetcher.ets @@ -0,0 +1,86 @@ +import { FirstPageNodeModel, FirstPageNodeResult } from './model/FirstPageNodeModel'; +import { http } from '@kit.NetworkKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { buildHeader } from '../util/Header'; +import { PreferenceUtils, StringUtils } from 'hxutil'; +import { FileManager } from '../util/FileManager'; +import { GlobalContext, HXLog } from 'biz_common'; +import { DateUtils } from '../util/DateUtils'; + +/** + * 首页节点数据管理 + * @author wubingsong@myhexin.com + */ +export class FirstPageDataFetcher { + + private static CACHE_FILE_NAME = '/firstpage/firstpage_nodes.txt' + + private static instance: FirstPageDataFetcher + nodeConfigChange?: (nodes : FirstPageNodeModel[] | null) => void + onError?: (errMsg : string) => void + + static getInstance() { + if (!FirstPageDataFetcher.instance) { + FirstPageDataFetcher.instance = new FirstPageDataFetcher + } + return FirstPageDataFetcher.instance + } + + fetchDataLocal() { + let cacheData = FileManager.getInstance().getCachePathSync(FirstPageDataFetcher.CACHE_FILE_NAME) + if (cacheData) { + const dataObj = this.parseData(cacheData) + if (dataObj && this.nodeConfigChange) { + this.nodeConfigChange(dataObj) + } + } + } + + remoteFetchData() : void { + let httpRequest = http.createHttp() + // let url = GlobalContext.get().resourceManager.getStringSync($r('app.string.first_page_advance_url_test')) + let url = GlobalContext.get().resourceManager.getStringSync($r('app.string.first_page_advance_url')) + httpRequest.request(url, { + method: http.RequestMethod.GET, + header: buildHeader(), + }, (err: BusinessError, data: http.HttpResponse) => { + httpRequest.destroy() + if (err) { + if (this.onError) { + this.onError(err.message) + } + return + } + if (data.responseCode === http.ResponseCode.OK) { + let daraStr = data.result as string + const dataObj = this.parseData(daraStr) + if (dataObj) { + FileManager.getInstance().saveCacheSync(daraStr, FirstPageDataFetcher.CACHE_FILE_NAME) + if (this.nodeConfigChange) { + PreferenceUtils.putSync(GlobalContext.get(), 'SpFirstPage', 'refreshTime', DateUtils.formatDate(new Date(), 'MM-dd HH:mm')) + this.nodeConfigChange(dataObj) + } + return + } + } + if (this.onError) { + this.onError('') + } + }) + } + + private parseData(data : string) : FirstPageNodeModel[] | null { + let resultData : FirstPageNodeModel[] | null = null; + if (StringUtils.isNotEmpty(data)) { + try { + const firstPageNodeResult = JSON.parse(data) as FirstPageNodeResult; + if (firstPageNodeResult) { + resultData = firstPageNodeResult.data.content; + } + } catch (e) { + HXLog.e("FirstpageDataExecutor.parseData", "error " + JSON.stringify(e)); + } + } + return resultData; + } +} diff --git a/src/main/ets/datacenter/GrideNodeDataFetcher.ets b/src/main/ets/datacenter/GrideNodeDataFetcher.ets new file mode 100644 index 0000000..187561e --- /dev/null +++ b/src/main/ets/datacenter/GrideNodeDataFetcher.ets @@ -0,0 +1,77 @@ +import { http } from '@kit.NetworkKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { buildHeader } from '../util/Header'; +import { StringUtils } from 'hxutil'; +import { GlobalContext, HXLog } from 'biz_common'; +import { Classify, GridModel, GridModelResult } from './model/GridModel'; +import { GridModelGroup } from './model/GridModelGroup'; +import { FirstPageDebugUtil } from '../util/FirstPageDebugUtil'; + +export class GrideNodeDataFetcher { + + private static instance: GrideNodeDataFetcher + + static getInstance() { + if (!GrideNodeDataFetcher.instance) { + GrideNodeDataFetcher.instance = new GrideNodeDataFetcher + } + return GrideNodeDataFetcher.instance + } + + remoteFetchConfig(onSuccess : (response : GridModelGroup[] | null) => void, onError : (errMsg : string) => void) : void { + let httpRequest = http.createHttp() + let url = FirstPageDebugUtil.getGridNodeUrl(FirstPageDebugUtil.isAllGridNodeUseTestUrl) + httpRequest.request(url, { + method: http.RequestMethod.GET, + header: buildHeader(), + }, (err: BusinessError, data: http.HttpResponse) => { + httpRequest.destroy() + if (err) { + onError(err.message) + return; + } + if (data.responseCode === http.ResponseCode.OK) { + const dataObj = this.parseData(data.result as string) + if (dataObj) { + onSuccess(dataObj) + return + } + onError("Parse Data failed") + } else { + onError("Request failed, responseCode = " + data.responseCode) + } + }) + } + + private parseData(data : string) : GridModelGroup[] | null { + if (StringUtils.isNotEmpty(data)) { + try { + const gridModelResult = JSON.parse(data) as GridModelResult; + if (gridModelResult && gridModelResult.data) { + let otherFunctionList = gridModelResult.data.otherFunctionList + let classifySortedList = gridModelResult.data.classifySortedList + if (otherFunctionList && otherFunctionList.length > 0 && classifySortedList && classifySortedList.length > 0) { + let gridModelGroups: GridModelGroup[] = [] + classifySortedList.forEach((classify: Classify) => { + let gridModelGroup: GridModelGroup = new GridModelGroup() + gridModelGroup.classify = classify.classify + gridModelGroup.classifyName = classify.classifyName + let groupModels: GridModel[] = [] + otherFunctionList.forEach((model: GridModel) => { + if (model.classify == classify.classify) { + groupModels.push(model) + } + }) + gridModelGroup.data = groupModels + gridModelGroups.push(gridModelGroup) + }) + return gridModelGroups + } + } + } catch (e) { + HXLog.e("FirstpageDataExecutor.parseData", "error " + JSON.stringify(e)); + } + } + return null + } +} diff --git a/src/main/ets/datacenter/NewsDataFetcher.ets b/src/main/ets/datacenter/NewsDataFetcher.ets new file mode 100644 index 0000000..afda073 --- /dev/null +++ b/src/main/ets/datacenter/NewsDataFetcher.ets @@ -0,0 +1,117 @@ +import { http } from '@kit.NetworkKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { NewsModel, NewsModelResult } from './model/NewsModel'; +import { buildHeader } from '../util/Header'; +import { StringUtils } from 'hxutil'; +import { DateUtils } from '../util/DateUtils'; +import { HXLog } from 'biz_common'; + +const NEWS_DATA_URL = 'https://ftapi.10jqka.com.cn/futgwapi/api/oem/tab_news/v1/news_list?news_list_id=1749&page=1&page_size=15&review_version=' + +export class NewsDataFetcher { + + private static ONE_SECOND = 1000; //1秒 + private static ONE_MINUTE = 60 * 1000; //1分 + private static ONE_HOUR = 60 * 60 * 1000; // 1小时 + private static ONE_DAY = 24 * 60 * 60 * 1000; //1天 + private static TWO_DAY = 2 * 24 * 60 * 60 * 1000; //2天 + private static MINUTE_BEFORE = "分钟前"; + private static HOUR_BEFORE = "小时前"; + private static YESTERDAY = "昨天"; + + loadMoreUrl: string = '' + + private static instance: NewsDataFetcher + + static getInstance() { + if (!NewsDataFetcher.instance) { + NewsDataFetcher.instance = new NewsDataFetcher + } + return NewsDataFetcher.instance + } + + remoteFetchData(onSuccess : (response : NewsModel[] | null) => void, onError : (errMsg : string) => void) : void { + this.loadMoreUrl = '' + this.requestData(NEWS_DATA_URL, onSuccess, onError) + } + + remoteFetchMoreData(onSuccess : (response : NewsModel[] | null) => void, onError : (errMsg : string) => void) : void { + if (!StringUtils.isNotEmpty(this.loadMoreUrl)) { + onSuccess(null) + } + this.requestData(this.loadMoreUrl, onSuccess, onError) + } + + private requestData(url: string, onSuccess : (response : NewsModel[] | null) => void, onError : (errMsg : string) => void) : void { + let httpRequest = http.createHttp(); + httpRequest.request(url, { + method: http.RequestMethod.GET, + header: buildHeader(), + }, (err: BusinessError, data: http.HttpResponse) => { + httpRequest.destroy() + if (err) { + onError(err.message) + return + } + if (data.responseCode === http.ResponseCode.OK) { + const dataObj = this.parseData(data.result as string) + if (dataObj) { + onSuccess(dataObj) + return + } + onError("Parse Data failed"); + } else { + onError("Request failed, responseCode = " + data.responseCode) + } + }) + } + + private parseData(data : string) : NewsModel[] | null { + let resultData : NewsModel[] | null = null + if (StringUtils.isNotEmpty(data)) { + try { + const newsModelResult = JSON.parse(data) as NewsModelResult + if (newsModelResult) { + resultData = this.handlerNewsModel(newsModelResult.data.page_items) + this.loadMoreUrl = newsModelResult.data.next_page_url + } + } catch (e) { + HXLog.e("FirstpageDataExecutor.parseData", "error " + JSON.stringify(e)); + } + } + return resultData; + } + + private handlerNewsModel(models: NewsModel[]): NewsModel[] { + models.forEach((item) => { + item.create_time = this.transferTime(item.create_time) + if (item.tags.length > 2) { + item.tags = item.tags.slice(0, 2) + } + }) + return models + } + + private transferTime(time: string): string { + let showTime = '' + let timeTmp = parseInt(time) + if (timeTmp) { + timeTmp = timeTmp * NewsDataFetcher.ONE_SECOND + let currentTime = new Date().getTime() + let timeSpace = currentTime - timeTmp + + if (timeSpace < NewsDataFetcher.ONE_MINUTE) { + showTime = '1' + NewsDataFetcher.MINUTE_BEFORE + } else if (timeSpace < NewsDataFetcher.ONE_HOUR) { + showTime = Math.floor(timeSpace / NewsDataFetcher.ONE_MINUTE) + NewsDataFetcher.MINUTE_BEFORE + } else if (timeSpace < NewsDataFetcher.ONE_DAY) { + showTime = Math.floor(timeSpace / NewsDataFetcher.ONE_HOUR) + NewsDataFetcher.HOUR_BEFORE + } else if (timeSpace < NewsDataFetcher.TWO_DAY) { + showTime = NewsDataFetcher.YESTERDAY + } else { + showTime = DateUtils.formatDate(new Date(timeTmp), 'MM月dd日') + } + } + return showTime + } +} diff --git a/src/main/ets/datacenter/model/FirstPageNodeModel.ts b/src/main/ets/datacenter/model/FirstPageNodeModel.ts new file mode 100644 index 0000000..40f00a0 --- /dev/null +++ b/src/main/ets/datacenter/model/FirstPageNodeModel.ts @@ -0,0 +1,76 @@ + +export type FirstPageNodeResult = { + code: string, + mes: string, + data: FirstPageNodeResultData +} + +export type FirstPageNodeResultData = { + content: FirstPageNodeModel[] +} + +export class FirstPageNodeModel { + id: number = 0 + /** + * 模版ID + **/ + targetTypeId: string = '' + /** + * 模块入口的url + **/ + url: string = '' + /** + * 模块内容 + **/ + extraData: string = '' + /** + * 标题 + **/ + title: string = '' + /** + * 模块所在的位置 + **/ + position: number = -1 + /** + * 左上放的图标url + */ + iconUrl: string = '' + /** + * 模块的统计ID + */ + buriedPoint: string = '' + /** + * 跳转到相关网页的统计id,与tjID是不一样的,webRsId是进入网页后cbas记录 + */ + buriedPointSource: string = '' + /** + * 模块title跳转链接地址 + */ + titleUrl: string = '' + /** + * 模块起始版本 + */ + startVersion: number = 0 + /** + * 模块结束版本 + */ + endVersion: number = 0 + /** + * 是否为收费快捷方式运营位 + */ + isShutCut: boolean = false + /** + * 模块起始时间 + */ + startTime: number = 0 + /** + * 模块结束时间 + */ + endTime: number = 0 + /** + * 模块类型 + */ + targetType: string = '' + + content: string = '' +} \ No newline at end of file diff --git a/src/main/ets/datacenter/model/GridModel.ts b/src/main/ets/datacenter/model/GridModel.ts new file mode 100644 index 0000000..028d29c --- /dev/null +++ b/src/main/ets/datacenter/model/GridModel.ts @@ -0,0 +1,31 @@ + +export type GridModelResult = { + code: number, + msg: string, + data: GridModelData +} + +export type GridModelData = { + otherFunctionList: GridModel[], + classifySortedList: Classify[] +} + +export type GridModel = { + id: number, + title: string, + imgUrl: string, + jumpUrl: string, + classify: string, + classifyName: string, + startTime: number, + endTime: number, + createTime: number, + updateTime: number, + subScript: '', + isDynamic: number, +} + +export type Classify = { + classify: string, + classifyName: string +} \ No newline at end of file diff --git a/src/main/ets/datacenter/model/GridModelGroup.ets b/src/main/ets/datacenter/model/GridModelGroup.ets new file mode 100644 index 0000000..9b7e55d --- /dev/null +++ b/src/main/ets/datacenter/model/GridModelGroup.ets @@ -0,0 +1,8 @@ +import { GridModel } from './GridModel' + +export class GridModelGroup { + + classify: string = '' + classifyName: string = '' + data: GridModel[] = [] +} \ No newline at end of file diff --git a/src/main/ets/datacenter/model/NewsModel.ts b/src/main/ets/datacenter/model/NewsModel.ts new file mode 100644 index 0000000..668eb7c --- /dev/null +++ b/src/main/ets/datacenter/model/NewsModel.ts @@ -0,0 +1,33 @@ +export type NewsModelResult = { + code: string, + mes: string, + data: NewsModelPage +} + +export type NewsModelPage = { + pages: number, + total: number, + current_page: number, + next_page_url: string, + page_items: NewsModel[] +} + +export type NewsModel = { + seq: string, // 资讯唯一标识 + title: string, // 资讯标题 + source: string, // 来源 + url: string, // 资讯跳转地址 + img_url: string, // 图片地址 + create_time: string, // 资讯发布时间 + copyright: string, // 资讯版权 + type: string, // 资讯分类 + tags: NewsTagModel[], // 资讯标签 +} + +export type NewsTagModel = { + seq: string, // 资讯唯一标识 + name: string, // 标签名称 + weight: string, // 资讯权重 + code: string, // 品种代码 + market: string, // 市场id +} \ No newline at end of file diff --git a/src/main/ets/market-ranking/MarketRankingConstant.ets b/src/main/ets/market-ranking/MarketRankingConstant.ets new file mode 100644 index 0000000..d14ee28 --- /dev/null +++ b/src/main/ets/market-ranking/MarketRankingConstant.ets @@ -0,0 +1,20 @@ +import { MetricId, PeriodId, PeriodRange, SortOrder, TabMetric } from './MarketRankingModels'; +import { TableConstants } from './TableConstants'; + +// 一级 Tab 配置:涨幅/跌幅/涨速/跌速/成交额/日增仓。 +export const TAB_METRICS: TabMetric[] = [ + { id: MetricId.RISE, label: $r('app.string.market_ranking_tab_rise'), api: 'rise_percent', hqId: TableConstants.DATA_ID_ZF, sortOrder: SortOrder.ASC, hasChildren: false }, + { id: MetricId.FALL, label: $r('app.string.market_ranking_tab_fall'), api: 'fall_percent', hqId: TableConstants.DATA_ID_ZF, sortOrder: SortOrder.DESC, hasChildren: false }, + { id: MetricId.RISE_SPEED, label: $r('app.string.market_ranking_tab_rise_speed'), api: 'rise_percent', hqId: TableConstants.DATA_ID_CHG_SPEED_1MIN, sortOrder: SortOrder.ASC, hasChildren: true }, + { id: MetricId.FALL_SPEED, label: $r('app.string.market_ranking_tab_fall_speed'), api: 'fall_percent', hqId: TableConstants.DATA_ID_CHG_SPEED_1MIN, sortOrder: SortOrder.DESC, hasChildren: true }, + { id: MetricId.TURN_OVER, label: $r('app.string.market_ranking_tab_turnover'), api: 'turnover', hqId: TableConstants.DATA_ID_TURNOVER, sortOrder: SortOrder.ASC, hasChildren: false }, + { id: MetricId.INCRE_POSIT, label: $r('app.string.market_ranking_tab_increase_position'), api: 'increase_position', hqId: TableConstants.DATA_ID_INCRE_POSIT, sortOrder: SortOrder.ASC, hasChildren: false }, +]; + +// 二级 Tab 配置:仅涨速/跌速下出现,按周期筛选 +export const PERIOD_RANGES: PeriodRange[] = [ + { id: PeriodId.ONE_MIN, label: $r('app.string.market_ranking_period_1min'), api: 'one_minute', hqId: TableConstants.DATA_ID_CHG_SPEED_1MIN }, + { id: PeriodId.FIVE_MIN, label: $r('app.string.market_ranking_period_5min'), api: 'five_minute', hqId: TableConstants.DATA_ID_CHG_SPEED_5MIN }, + { id: PeriodId.TEN_MIN, label: $r('app.string.market_ranking_period_10min'), api: 'ten_minute', hqId: TableConstants.DATA_ID_CHG_SPEED_10MIN }, + { id: PeriodId.FIFTEEN_MIN, label: $r('app.string.market_ranking_period_15min'), api: 'fifteen_minute', hqId: TableConstants.DATA_ID_CHG_SPEED_15MIN }, +]; diff --git a/src/main/ets/market-ranking/MarketRankingDataFetcher.ets b/src/main/ets/market-ranking/MarketRankingDataFetcher.ets new file mode 100644 index 0000000..d2b1f7d --- /dev/null +++ b/src/main/ets/market-ranking/MarketRankingDataFetcher.ets @@ -0,0 +1,68 @@ +import { http } from '@kit.NetworkKit'; +import { HXLog } from 'biz_common'; +import { buildHeader } from '../util/Header'; +import { FirstPageDebugUtil } from '../util/FirstPageDebugUtil'; +import { FirstPageCardsConfigLoader } from '../node/datacenter/FirstPageCardsConfigLoader'; +import { AllCardsCard } from '../node/model/AiDiagnosisNodeModel'; +import { + RecommendFuturesItem, + RecommendFuturesResponse, +} from './MarketRankingModels'; +import { PERIOD_RANGES, TAB_METRICS } from './MarketRankingConstant'; + +export class MarketRankingDataFetcher { + private static readonly instance: MarketRankingDataFetcher = new MarketRankingDataFetcher(); + + private constructor() { + } + + static getInstance(): MarketRankingDataFetcher { + return MarketRankingDataFetcher.instance; + } + + fetchCardConfig(): AllCardsCard | undefined { + const config = FirstPageCardsConfigLoader.getConfig(); + return config?.marketRanking; + } + + async fetchRecommendFutures(primaryIdx: number, secondaryIdx: number): Promise { + const quoteType: string = this.getQuoteType(primaryIdx, secondaryIdx); + const requestUrl: string = FirstPageDebugUtil + .getMarketRankingRecommendUrl(FirstPageDebugUtil.isMarketRankingUseTestUrl) + .replace('%s', quoteType); + const requestOptions: http.HttpRequestOptions = { + method: http.RequestMethod.GET, + header: buildHeader(), + connectTimeout: 60000, + readTimeout: 60000, + }; + + const httpRequest: http.HttpRequest = http.createHttp(); + try { + 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 = JSON.parse(responseText) as RecommendFuturesResponse; + if (result.code !== 0 || !result.data) { + throw new Error('response code: ' + result.code); + } + return result.data; + } catch (requestError) { + const errorInfo = requestError as Error; + HXLog.e('MarketRankingDataFetcher', 'fetchRecommendFutures error: ' + errorInfo.message); + throw new Error(errorInfo.message); + } finally { + httpRequest.destroy(); + } + } + + private getQuoteType(primaryIdx: number, secondaryIdx: number): string { + const metric = TAB_METRICS[primaryIdx]; + if (metric.hasChildren) { + return `${PERIOD_RANGES[secondaryIdx].api}_${metric.api}`; + } + return metric.api; + } +} diff --git a/src/main/ets/market-ranking/MarketRankingModels.ets b/src/main/ets/market-ranking/MarketRankingModels.ets new file mode 100644 index 0000000..2afa549 --- /dev/null +++ b/src/main/ets/market-ranking/MarketRankingModels.ets @@ -0,0 +1,73 @@ +export enum MetricId { + RISE = 'rise', + FALL = 'fall', + RISE_SPEED = 'riseSpeed', + FALL_SPEED = 'fallSpeed', + TURN_OVER = 'turnOver', + INCRE_POSIT = 'increPosit', +} + +export enum PeriodId { + ONE_MIN = '1min', + FIVE_MIN = '5min', + TEN_MIN = '10min', + FIFTEEN_MIN = '15min', +} + +export enum SortOrder { + ASC = '0', + DESC = '1', +} + +// ============ 视图数据类型(合并接口 + 行情后,供 View 消费) ============ +// 保留原始字段,不在数据层预先格式化/预先判断涨跌色;HTTP 占位行允许行情字段暂缺。 +export interface RankItem { + code: string; + market: string; + name: string; + price?: number; + // 涨跌幅(%数值,如 3.21 表示 +3.21%)。行情推送里固定字段,任何 Tab 下都存在, + // 用于「最新价」列的取色,也是 涨幅/跌幅 Tab 第三列的数据来源。 + price_chg?: number; + // 涨速/跌速(%数值)。仅 riseSpeed/fallSpeed Tab 下有值。 + chg_speed?: number; + // 成交额(原始数值,未换算单位)。仅 turnOver Tab 下有值。 + turnover?: number; + // 日增仓(原始数值,未换算单位)。仅 increPosit Tab 下有值。 + incre_posit?: number; +} + +// 当前市场排名卡片的完整展示数据;网络/行情更新后整体替换以触发 UI 刷新。 +export interface CardData { + tableList: RankItem[]; +} + +export interface TabMetric { + id: MetricId; + label: ResourceStr; + api: string; // HTTP 接口 quote_type 参数用 + hqId: number; // 行情订阅 sortid 用 + sortOrder: string; // 行情排序方向 '0' 升序 '1' 降序 + hasChildren: boolean; // Vue 用 children 数组本身判断,这里简化为布尔标记 +} + +export interface PeriodRange { + id: PeriodId; + label: ResourceStr; + api: string; + hqId: number; +} + +// ============ 后端接口原始响应类型(recommend_futures) ============ +// 对应 docs/cards/interfaces.md「一、HTTP 接口 - 1. 市场排名列表」 + +export interface RecommendFuturesItem { + contract_code: string; + contract_name: string; + market: string; +} + +export interface RecommendFuturesResponse { + code: number; + data: RecommendFuturesItem[]; +} diff --git a/src/main/ets/market-ranking/MarketRankingNodeComponent.ets b/src/main/ets/market-ranking/MarketRankingNodeComponent.ets new file mode 100644 index 0000000..9b4c6fa --- /dev/null +++ b/src/main/ets/market-ranking/MarketRankingNodeComponent.ets @@ -0,0 +1,706 @@ +import { emitter } from '@kit.BasicServicesKit'; +import { + CardData, + MetricId, + PeriodRange, + RankItem, RecommendFuturesItem, TabMetric, +} from './MarketRankingModels'; +import { MarketRankingDataFetcher } from './MarketRankingDataFetcher'; +import { MarketRankingHqRequestClient } from '../node/clients/MarketRankingHqRequestClient'; +import { formatGreatNumber, formatPercent, formatPrice, riseFallColor } from '../common/NumberFormat'; +import { AllCardsCard } from '../node/model/AiDiagnosisNodeModel'; +import { PERIOD_RANGES, TAB_METRICS } from './MarketRankingConstant'; +import { enableDarkMode } from '@kernel/theme_manager'; +import { EmitterConstants, GlobalContext, HXLog, jumpToQuote } from 'biz_common'; +import { QuoteCustomSettingManager, StandardPriceType } from 'biz_quote'; +import { RouterUtil } from '../util/RouterUtil'; + +const NORMAL_SIZE = 3; +const MAX_SIZE = 5; + +@Component +export struct MarketRankingNodeComponent { + // 外部状态 + @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 = { tableList: [] }; + private contracts: RecommendFuturesItem[] = []; + // 区分加载中和暂无数据,与 HotListNodeComponent 的 dataReady 约定保持一致 + @State dataReady: boolean = false; + + // Tab 与展开状态 + @State primaryIdx: number = 0; + @State secondaryIdx: number = 0; + @State unfolded: boolean = false; + + // Tab 滚动区域状态 + @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 lastStandardPriceType: StandardPriceType = + QuoteCustomSettingManager.getInstance().getStandardPriceType(); + 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(this.primaryIdx, this.secondaryIdx); + } + + aboutToDisappear(): void { + this.isComponentActive = false; + this.invalidateDataRequest(); + this.stopSubscribeHq(); + this.unSubscribeTcpNetStatus(); + this.contracts = []; + } + + // ==================== 配置与刷新 ==================== + + // 从首页统一卡片配置中读取市场排名配置。 + private updateCardConfig(): void { + this.cardConfig = MarketRankingDataFetcher.getInstance().fetchCardConfig(); + } + + // refreshTrigger 变化时重新获取当前 Tab 的合约,并在请求完成后重建行情订阅。 + onRefreshTriggerChange(): void { + if (!this.isComponentActive) { + return; + } + this.updateCardConfig(); + this.updateCardData(this.primaryIdx, this.secondaryIdx); + } + + // ==================== 可见性与 TCP 重连 ==================== + + // 页面不可见时停止行情;恢复可见后使用已有合约重建订阅。 + onFirstPageVisibleChange(): void { + if (!this.isComponentActive) { + return; + } + if (!this.firstPageVisible) { + this.stopSubscribeHq(); + this.unSubscribeTcpNetStatus(); + return; + } + this.subscribeTcpNetStatus(); + this.syncStandardPriceType(); + this.restartCurrentSubscription(); + } + + private syncStandardPriceType(): void { + const currentStandardPriceType: StandardPriceType = + QuoteCustomSettingManager.getInstance().getStandardPriceType(); + if (this.lastStandardPriceType !== currentStandardPriceType) { + HXLog.d('MarketRankingNodeComponent', 'Standard price type changed, resubscribe'); + this.lastStandardPriceType = currentStandardPriceType; + } + } + + 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.contracts.length > 0) { + this.subscribeHq(this.primaryIdx, this.secondaryIdx); + return; + } + if (this.dataReady) { + this.updateCardData(this.primaryIdx, this.secondaryIdx); + } + } + + // ==================== Tab 交互 ==================== + + private selectPrimaryTab(index: number): void { + if (this.primaryIdx === index) { + return; + } + this.primaryIdx = index; + this.secondaryIdx = 0; + this.updateCardData(index, 0); + } + + private selectSecondaryTab(index: number): void { + if (this.secondaryIdx === index) { + return; + } + this.secondaryIdx = index; + this.updateCardData(this.primaryIdx, index); + } + + // ==================== 数据请求与行情订阅 ==================== + + // 先请求合约榜单填充列表,再根据当前 CardData 订阅行情。 + private async updateCardData(primaryIdx: number, secondaryIdx: number): Promise { + if (!this.isComponentActive) { + return; + } + const requestVersion: number = this.dataRequestVersion + 1; + this.dataRequestVersion = requestVersion; + // 切换 Tab 后立即清理旧列表,避免新请求返回前闪现上一 Tab 的数据。 + this.stopSubscribeHq(); + this.contracts = []; + this.cardData = { tableList: [] }; + this.dataReady = false; + try { + const contracts = await MarketRankingDataFetcher + .getInstance() + .fetchRecommendFutures(primaryIdx, secondaryIdx); + if (!this.isCurrentDataRequest(requestVersion, primaryIdx, secondaryIdx)) { + return; + } + this.contracts = contracts; + this.cardData = { + tableList: contracts.map((contract: RecommendFuturesItem): RankItem => ({ + code: contract.contract_code, + market: contract.market, + name: contract.contract_name, + })), + }; + this.dataReady = true; + if (this.firstPageVisible) { + this.subscribeHq(primaryIdx, secondaryIdx); + } + } catch { + if (!this.isCurrentDataRequest(requestVersion, primaryIdx, secondaryIdx)) { + return; + } + this.contracts = []; + this.cardData = { tableList: [] }; + this.dataReady = true; + } + } + + // 使用 HTTP 保存的原始合约列表订阅行情,推送后整体更新 CardData。 + private subscribeHq(primaryIdx: number, secondaryIdx: number): void { + this.stopSubscribeHq(); + if (!this.canUseHq() || this.contracts.length === 0) { + return; + } + const subscriptionVersion: number = this.hqSubscriptionVersion; + + this.hqUnsubscribe = MarketRankingHqRequestClient + .getInstance() + .subscribeHq(this.contracts, primaryIdx, secondaryIdx, (cardData: CardData): void => { + if (this.canUseHq() && + subscriptionVersion === this.hqSubscriptionVersion && + primaryIdx === this.primaryIdx && + secondaryIdx === this.secondaryIdx) { + 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 invalidateDataRequest(): void { + this.dataRequestVersion += 1; + } + + private isCurrentDataRequest( + requestVersion: number, + primaryIdx: number, + secondaryIdx: number, + ): boolean { + return this.isComponentActive && + requestVersion === this.dataRequestVersion && + primaryIdx === this.primaryIdx && + secondaryIdx === this.secondaryIdx; + } + + // ==================== 页面跳转 ==================== + + // 对齐 futures-homepage jumpToFenShi,跳转客户端行情分时页。 + private jumpToDetail(item: RankItem): void { + const codeList: string = + this.contracts.map((contract: RecommendFuturesItem): string => contract.contract_code).join(','); + const marketIdList: string = + this.contracts.map((contract: RecommendFuturesItem): string => contract.market).join(','); + const nameList: string = + this.contracts.map((contract: RecommendFuturesItem): string => contract.contract_name).join(','); + jumpToQuote(item.code, item.market, codeList, marketIdList, nameList); + } + + // 跳转到卡片配置中的标题地址。 + private jumpToCardTitle(): void { + const jumpUrl: string = + this.cardConfig?.card_url.android || this.cardConfig?.card_url.ios || ''; + if (jumpUrl === '') { + return; + } + RouterUtil.jumpPage(jumpUrl, true); + } + + // ==================== UI 构建 ==================== + + build() { + Column() { + this.buildTitleBar() + this.buildTabBar() + + if (this.cardData.tableList.length > 0) { + this.buildContent() + } else { + this.buildEmptyView() + } + } + .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() { + Text(this.cardConfig?.card_title.value || $r('app.string.market_ranking_title')) + .fontColor($r('app.color.elements_text_primary_02')) + .fontSize(18) + .fontWeight(500) + .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')) + } + + 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; + }) + } + } + .alignItems(VerticalAlign.Center) + .height('100%') + .onClick(() => { + this.jumpToCardTitle(); + }) + + // 展开/收起按钮,样式对齐 HotListNodeComponent 的展开/收起按钮 + if (this.cardData.tableList.length > NORMAL_SIZE) { + Row() { + Text(this.unfolded + ? $r('app.string.hotlist_collapse') + : $r('app.string.hotlist_expand')) + .fontColor($r('app.color.elements_text_tertiary')) + .fontSize(12) + .height(16) + .width('100%') + .textAlign(TextAlign.Center) + } + .width(40) + .height(20) + .borderRadius(10) + .backgroundColor($r('app.color.elements_button_bg_disable_02')) + .margin({ left: 8 }) + .onClick(() => { + this.unfolded = !this.unfolded; + }) + } + + Blank() + } + .width('100%') + .height(48) + } + + @Builder + buildExplainSheetContent() { + Column() { + Stack({ alignContent: Alignment.End }) { + Text(this.cardConfig?.explain_title || + this.cardConfig?.card_title.value || + $r('app.string.market_ranking_title')) + .fontSize(18) + .fontWeight(500) + .textAlign(TextAlign.Center) + .width('100%') + + Row() { + Image($r('app.media.icon_close_24')) + .width(24) + .height(24) + .fillColor($r('app.color.elements_icon_primary_02')) + .onClick(() => { + this.showExplainSheet = false; + }) + } + .height('100%') + .justifyContent(FlexAlign.End) + } + .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 + buildTabBar() { + Column() { + // 一级 Tab(胶囊按钮,可横向滚动;Tab 数量多于 CommodityOptions 等固定两栏卡片, + // 因此保留横向滚动 + 边缘渐隐,属于本卡片必要的交互差异,非样式不一致)。 + Stack({ alignContent: Alignment.End }) { + Scroll() { + Row({ space: 8 }) { + ForEach(TAB_METRICS, (metric: TabMetric, index: number) => { + Text(metric.label) + .fontColor(this.primaryIdx === index + ? $r('app.color.elements_text_primary_01') + : $r('app.color.elements_text_secondary')) + .fontSize(14) + .fontWeight(this.primaryIdx === index ? FontWeight.Medium : FontWeight.Normal) + .padding({ left: 8, right: 8 }) + .height(30) + .constraintSize({ minWidth: 66 }) + .textAlign(TextAlign.Center) + .backgroundColor(this.primaryIdx === index + ? $r('app.color.elements_others_bg_primary') + : $r('app.color.elements_button_bg_tertiary')) + .borderRadius(4) + .onClick(() => { + this.selectPrimaryTab(index); + }) + }, (metric: TabMetric) => metric.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: [['#00FFFFFF', 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); + }) + + // 二级 Tab(仅涨速/跌速出现,右对齐文字 + 分隔线) + if (TAB_METRICS[this.primaryIdx].hasChildren) { + Row() { + ForEach(PERIOD_RANGES, (period: PeriodRange, index: number) => { + if (index !== 0) { + Divider() + .vertical(true) + .height(10) + .color($r('app.color.elements_others_divider_primary')) + .margin({ left: 8, right: 8 }) + } + Text(period.label) + .fontSize(12) + .fontWeight(this.secondaryIdx === index ? FontWeight.Medium : FontWeight.Normal) + .fontColor(this.secondaryIdx === index + ? $r('app.color.elements_text_primary_02') + : $r('app.color.elements_text_tertiary')) + .padding({ top: 4, bottom: 4 }) + .onClick(() => { + this.selectSecondaryTab(index); + }) + }, (period: PeriodRange) => period.id) + } + .width('100%') + .justifyContent(FlexAlign.End) + .margin({ top: 8 }) + } + } + .width('100%') + } + + @Builder + buildEmptyView() { + Column() { + Text(this.dataReady ? $r('app.string.hotlist_no_data') : $r('app.string.hotlist_loading')) + .fontColor($r('app.color.elements_text_tertiary')) + .fontSize(14) + } + .width('100%') + .height(this.emptyHeight()) + .margin({ top: 10 }) + .alignItems(HorizontalAlign.Center) + .justifyContent(FlexAlign.Center) + } + + @Builder + buildContent() { + Column() { + // 表头 + Row({ space: 8 }) { + Text($r('app.string.commodity_options_table_header_name')) + .fontColor($r('app.color.elements_text_tertiary')) + .fontSize(14) + .width('35%') + Text($r('app.string.commodity_options_table_header_price')) + .fontColor($r('app.color.elements_text_tertiary')) + .fontSize(14) + .layoutWeight(1) + .textAlign(TextAlign.End) + Text(this.thirdColumnTitle()) + .fontColor($r('app.color.elements_text_tertiary')) + .fontSize(14) + .layoutWeight(1) + .textAlign(TextAlign.End) + } + .height(40) + .width('100%') + + // 表格列表 + ForEach( + this.cardData.tableList.slice(0, this.displaySize()), + (item: RankItem, index: number) => { + this.buildRankRow(item) + }, + // 行情更新时字段参与 key,确保整体替换 CardData 后列表行同步刷新。 + (item: RankItem) => + `${item.code}_${item.market}_${item.price ?? ''}_${item.price_chg ?? ''}_` + + `${item.chg_speed ?? ''}_${item.turnover ?? ''}_${item.incre_posit ?? ''}`, + ) + } + .width('100%') + } + + @Builder + buildRankRow(item: RankItem) { + Row({ space: 8 }) { + Text(item.name) + .fontColor($r('app.color.elements_text_primary_02')) + .fontSize(16) + .maxFontSize(16) + .minFontSize(9) + .width('35%') + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(formatPrice(item.price)) + .fontColor(riseFallColor(item.price_chg)) + .fontSize(16) + .fontWeight(500) + .maxFontSize(16) + .minFontSize(9) + .layoutWeight(1) + .textAlign(TextAlign.End) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.thirdColumnValue(item)) + .fontColor(this.thirdColumnColor(item)) + .fontSize(16) + .fontWeight(500) + .maxFontSize(16) + .minFontSize(9) + .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); + }) + } + + // ==================== 展示计算 ==================== + + private showTabGradient(): boolean { + const maxOffset = this.tabContentWidth - this.tabViewportWidth; + return maxOffset > 0 && this.tabScrollOffset < maxOffset; + } + + private emptyHeight(): number { + const rowHeight = 45; + const headerHeight = 40; + return this.displaySize() * rowHeight + headerHeight; + } + + private displaySize(): number { + return this.unfolded ? MAX_SIZE : NORMAL_SIZE; + } + + // 第三列标题随 Tab 变化 + private thirdColumnTitle(): ResourceStr { + const metricId = TAB_METRICS[this.primaryIdx].id; + if (metricId === MetricId.RISE || metricId === MetricId.FALL) { + return $r('app.string.market_ranking_change_rate'); + } + if (metricId === MetricId.RISE_SPEED) { + switch (this.secondaryIdx) { + case 0: + return $r('app.string.market_ranking_1min_rise_speed'); + case 1: + return $r('app.string.market_ranking_5min_rise_speed'); + case 2: + return $r('app.string.market_ranking_10min_rise_speed'); + default: + return $r('app.string.market_ranking_15min_rise_speed'); + } + } + if (metricId === MetricId.FALL_SPEED) { + switch (this.secondaryIdx) { + case 0: + return $r('app.string.market_ranking_1min_fall_speed'); + case 1: + return $r('app.string.market_ranking_5min_fall_speed'); + case 2: + return $r('app.string.market_ranking_10min_fall_speed'); + default: + return $r('app.string.market_ranking_15min_fall_speed'); + } + } + return TAB_METRICS[this.primaryIdx].label; + } + + // 成交额/日增仓列用中性色,其余按涨跌上色 + private isNeutralColumn(): boolean { + const metricId = TAB_METRICS[this.primaryIdx].id; + return metricId === MetricId.TURN_OVER || metricId === MetricId.INCRE_POSIT; + } + + // 第三列展示值:按当前一级 Tab 从对应原始字段取值并格式化 + private thirdColumnValue(item: RankItem): string { + const metricId = TAB_METRICS[this.primaryIdx].id; + if (metricId === MetricId.TURN_OVER) { + return formatGreatNumber(item.turnover); + } + if (metricId === MetricId.INCRE_POSIT) { + return formatGreatNumber(item.incre_posit); + } + if (metricId === MetricId.RISE_SPEED || metricId === MetricId.FALL_SPEED) { + return formatPercent(item.chg_speed); + } + return formatPercent(item.price_chg); + } + + // 第三列取色:成交额/日增仓走中性色,其余按对应原始字段的正负取色 + private thirdColumnColor(item: RankItem): Resource { + if (this.isNeutralColumn()) { + return $r('app.color.elements_text_primary_02'); + } + const metricId = TAB_METRICS[this.primaryIdx].id; + const raw = (metricId === MetricId.RISE_SPEED || metricId === MetricId.FALL_SPEED) ? item.chg_speed : item.price_chg; + return riseFallColor(raw); + } +} diff --git a/src/main/ets/market-ranking/TableConstants.ets b/src/main/ets/market-ranking/TableConstants.ets new file mode 100644 index 0000000..cb6f712 --- /dev/null +++ b/src/main/ets/market-ranking/TableConstants.ets @@ -0,0 +1,21 @@ +// 市场排名使用的 4106 行情字段 ID。 +export class TableConstants { + static readonly DATA_ID_CODE_4: number = 4; // 合约代码 + static readonly DATA_ID_PRE_PRICE: number = 6; // 昨收价 + static readonly DATA_ID_OPEN_PRICE: number = 7; // 今日开盘价 + static readonly DATA_ID_PRICE: number = 10; // 最新价 + static readonly DATA_ID_TURNOVER: number = 19; // 成交额 + static readonly DATA_ID_NAME: number = 55; // 合约名称 + static readonly DATA_ID_JSJ: number = 66; // 昨日结算价 + static readonly DATA_ID_CHG_SPEED_5MIN: number = 34325; // 5 分钟涨跌速 + static readonly DATA_ID_INCRE_POSIT: number = 34355; // 日增仓 + static readonly DATA_ID_ZF: number = 34818; // 涨跌幅 + 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_MARKET: number = 36103; // 市场 ID + + private constructor() { + } +} diff --git a/src/main/ets/node/clients/AiDiagnosisRequestClient.ets b/src/main/ets/node/clients/AiDiagnosisRequestClient.ets new file mode 100644 index 0000000..fc3c074 --- /dev/null +++ b/src/main/ets/node/clients/AiDiagnosisRequestClient.ets @@ -0,0 +1,111 @@ +import { SortItem, TableRequestClient, TableConstants } from '@b2c/lib_baseui'; +import { HeaderItem, TableData } from '@b2b/hq_table'; +import { StandardPriceTypeHelper } from 'biz_quote'; +import { RequestHelper } from '@kernel/lib_communication'; + +/** + * AI诊断卡片行情请求客户端 + * 参考 FirstPageSelfStockRequestClient 实现,支持动态 computemode 和基准价 + */ +export class AiDiagnosisRequestClient extends TableRequestClient { + // 合约代码 + private code: string = '' + // 市场代码 + private market: string = '' + // 扩展字段ID列表(用于基准价计算) + private extFieldIds: number[] = [] + + constructor(frameId: number, pageId: number, code: string, market: string, + headerConfig: Array, sortItem?: SortItem | null, + dataReceiveListener?: (tableData: TableData | string) => void) { + super(frameId, pageId, headerConfig, sortItem, dataReceiveListener, undefined) + + this.code = code + this.market = market + + // 设置订阅字段 ID(包含扩展字段用于基准价计算) + let ids: number[] = [] + headerConfig.forEach((item: HeaderItem) => { + this.addId(ids, item.mobileid) + }) + // 添加基准价计算所需的扩展字段 + this.extFieldIds = [ + TableConstants.DATA_ID_CODE_4, + TableConstants.DATA_ID_PRICE, + TableConstants.DATA_ID_ZF, + TableConstants.DATA_ID_ZD, + TableConstants.DATA_ID_MARKET, + TableConstants.DATA_ID_MARKET_OLD, + StandardPriceTypeHelper.FIELD_ZUO_SHOU, + StandardPriceTypeHelper.FIELD_JIN_KAI, + StandardPriceTypeHelper.FIELD_ZUO_JIE + ] + this.extFieldIds.forEach((id: number) => { + this.addId(ids, id) + }) + this.setSubscribeIds(ids) + } + + private addId(ids: number[], id: number): void { + if (!ids.includes(id)) { + ids.push(id) + } + } + + /** + * 重写请求参数构建 + * 与 hqHelper.js buildHqParams 保持一致的参数格式 + */ + protected createRequestParam(): string { + let param = "\r\nsortid=-1" + param += "\r\nsortorder=0" + param += "\r\npush=1" + param += "\r\n" + this.createHeaderParam() + param += this.createStockListParam() // 合约列表参数(format4106Data 格式) + param += "\r\nscenario=qht_qihuo_sort" + param += "\r\nprecision=1" + param += "\r\npushtime=2.5" + return param + } + + protected doRequest() { + let requestParam = this.createRequestParam() + RequestHelper.getHqManager().addRequestStructToBuff(this.frameId, this.pageId, this.getInstanceId(), requestParam) + RequestHelper.build().base(this.frameId, this.pageId, this, requestParam).request(); + } + + /** + * 构建合约列表参数 + * 与 hqHelper.js format4106Data 保持一致格式: market1(code1,code2,);market2(code3,); + */ + private createStockListParam(): string { + if (!this.code || !this.market) { + return '' + } + // 使用 format4106Data 格式 + return "\r\ncodelist=" + this.market + "(" + this.code + ",);" + } + + /** + * 生成 dataitem 参数 + * 与 hqHelper.js dataitem 保持一致格式:字段ID用逗号分隔 + */ + private createHeaderParam(): string { + let param = "dataitem=" + + this.headerConfig.forEach((item: HeaderItem) => { + param += item.mobileid + "," + if (item.extDataIds) { + item.extDataIds.forEach((id: number) => { + param += id + "," + }) + } + }) + + return param + } + + release(isDestory: boolean = false): void { + super.release(isDestory) + } +} \ No newline at end of file diff --git a/src/main/ets/node/clients/AiRiseFallHqRequestClient.ets b/src/main/ets/node/clients/AiRiseFallHqRequestClient.ets new file mode 100644 index 0000000..fb42d2d --- /dev/null +++ b/src/main/ets/node/clients/AiRiseFallHqRequestClient.ets @@ -0,0 +1,212 @@ +import { TableConstants, TableRequestClient } from '@b2c/lib_baseui'; +import { AbsRowData, HeaderItem, RowData, TableData } from '@b2b/hq_table'; +import { TableConstants as HXTableConstants } from '@b2c/lib_baseui/src/main/ets/constant/ConstantsTable'; +import { HXLog } from 'biz_common'; +import { StandardPriceTypeHelper, applyStandardPriceToTableData } from 'biz_quote'; +import { RequestHelper, StuffTableStruct } from '@kernel/lib_communication'; + +/** + * AI看涨跌卡片行情请求客户端 + * 参考 FirstPageSelfStockRequestClient 实现,支持动态 computemode 和基准价 + */ +export class AiRiseFallHqRequestClient extends TableRequestClient { + // 行情数据回调 + private hqDataCallback?: (hqs: ContractHqItem[]) => void + // 合约代码列表 + private codeList: string[] = [] + // 市场代码列表 + private marketList: string[] = [] + // 扩展字段ID列表(用于基准价计算) + private extFieldIds: number[] = [] + + constructor(frameId: number, pageId: number, headerConfig: Array, + dataReceiveListener?: (tableData: TableData | string) => void) { + super(frameId, pageId, headerConfig, null, dataReceiveListener, undefined) + this.initSubscribeIds(headerConfig) + } + + /** + * 初始化订阅字段ID(包含扩展字段用于基准价计算) + */ + private initSubscribeIds(headerConfig: Array): void { + let ids: number[] = [] + headerConfig.forEach((item: HeaderItem) => { + this.addId(ids, item.mobileid) + }) + // 添加基准价计算所需的扩展字段 + this.extFieldIds = [ + TableConstants.DATA_ID_CODE_4, + TableConstants.DATA_ID_PRICE, + TableConstants.DATA_ID_ZF, + TableConstants.DATA_ID_ZD, + TableConstants.DATA_ID_MARKET, + TableConstants.DATA_ID_MARKET_OLD, + StandardPriceTypeHelper.FIELD_ZUO_SHOU, + StandardPriceTypeHelper.FIELD_JIN_KAI, + StandardPriceTypeHelper.FIELD_ZUO_JIE + ] + this.extFieldIds.forEach((id: number) => { + this.addId(ids, id) + }) + this.setSubscribeIds(ids) + } + + private addId(ids: number[], id: number): void { + if (!ids.includes(id)) { + ids.push(id) + } + } + + /** + * 设置合约列表并请求数据 + * @param codes 合约代码数组 + * @param markets 市场代码数组 + */ + setStockListAndRequest(codes: string[], markets: string[]): void { + if (codes.length !== markets.length) { + HXLog.e('AiRiseFallHqRequestClient', 'codes and markets length mismatch') + return + } + this.codeList = codes + this.marketList = markets + this.request(0, codes.length) + } + + /** + * 设置行情数据回调 + */ + setHqDataCallback(callback: (hqs: ContractHqItem[]) => void): void { + this.hqDataCallback = callback + } + + /** + * 重写 release 方法 + */ + release(isDestory: boolean = false): void { + super.release(isDestory) + } + + /** + * 重写请求参数构建 + * 与 hqHelper.js buildHqParams 保持一致的参数格式 + */ + protected createRequestParam(): string { + let param = "\r\nsortid=-1" + param += "\r\nsortorder=0" + param += "\r\npush=1" + param += "\r\n" + this.createHeaderParam() + param += this.createStockListParam() // 合约列表参数(format4106Data 格式) + param += "\r\nscenario=qht_qihuo_sort" + param += "\r\nprecision=1" + param += "\r\npushtime=2.5" + return param + } + + protected doRequest() { + let requestParam = this.createRequestParam() + RequestHelper.getHqManager().addRequestStructToBuff(this.frameId, this.pageId, this.getInstanceId(), requestParam) + RequestHelper.build().base(this.frameId, this.pageId, this, requestParam).request(); + } + + /** + * 构建合约列表参数 + * 与 hqHelper.js format4106Data 保持一致格式: market1(code1,code2,);market2(code3,); + */ + private createStockListParam(): string { + if (this.codeList.length === 0 || this.marketList.length === 0) { + return '' + } + // 按市场分组,生成 format4106Data 格式 + const marketMap: Map = new Map() + for (let i = 0; i < this.codeList.length; i++) { + const market = this.marketList[i] + const code = this.codeList[i] + if (marketMap.has(market)) { + marketMap.get(market)!.push(code + ',') + } else { + marketMap.set(market, [code + ',']) + } + } + let codelist = '' + marketMap.forEach((codes, market) => { + codelist += market + '(' + codes.join('') + ');' + }) + return "\r\ncodelist=" + codelist + } + + /** + * 构建表头参数 + */ + private createHeaderParam(): string { + let param = "dataitem=" + let addedIds: number[] = [] + + this.headerConfig.forEach((item: HeaderItem) => { + param += item.mobileid + "," + addedIds.push(item.mobileid) + if (item.extDataIds) { + item.extDataIds.forEach((id: number) => { + param += id + "," + addedIds.push(id) + }) + } + }) + + return param + } + + /** + * 数据解析完成回调 + */ + protected afterParserModel(tableData: TableData, structData: StuffTableStruct): void { + // 先按当前基准价类型补齐 originalData 并重算 ZF/ZD(首次与实时推送都会走到这里), + // 保证下面读取的 priceChg 是按用户所选基准价计算的口径。 + applyStandardPriceToTableData(tableData, structData, this.extFieldIds) + + // 解析行情数据 + const hqs: ContractHqItem[] = [] + if (tableData.tableRows) { + tableData.tableRows.forEach((row: AbsRowData) => { + if (row instanceof RowData) { + const data = row.originalData + const code = data.get(HXTableConstants.DATA_ID_CODE_4) + const market = data.get(HXTableConstants.DATA_ID_MARKET) + const name = data.get(HXTableConstants.DATA_ID_NAME) + const price = data.get(HXTableConstants.DATA_ID_PRICE) + const priceChg = data.get(HXTableConstants.DATA_ID_ZF) // 涨跌幅字段 + + if (typeof code === 'string' && typeof market === 'string') { + let priceChgValue: number | null = null + if (priceChg && typeof priceChg === 'string' && priceChg !== '--') { + priceChgValue = parseFloat(priceChg) + } + + hqs.push({ + code: code, + market: market, + name: typeof name === 'string' ? name : '', + price: typeof price === 'string' ? price : '--', + priceChg: priceChgValue + }) + } + } + }) + } + + // 回调通知 + if (this.hqDataCallback && hqs.length > 0) { + this.hqDataCallback(hqs) + } + } +} + +/** + * 合约行情数据项 + */ +export interface ContractHqItem { + code: string + market: string + name: string + price: string + priceChg: number | null +} \ No newline at end of file diff --git a/src/main/ets/node/clients/CommodityOptionsHqRequestClient.ets b/src/main/ets/node/clients/CommodityOptionsHqRequestClient.ets new file mode 100644 index 0000000..f522030 --- /dev/null +++ b/src/main/ets/node/clients/CommodityOptionsHqRequestClient.ets @@ -0,0 +1,175 @@ +import { TableConstants, TableRequestClient } from '@b2c/lib_baseui'; +import { AbsRowData, HeaderItem, RowData, TableData } from '@b2b/hq_table'; +import { HXLog } from 'biz_common'; +import { RequestHelper } from '@kernel/lib_communication'; +/** + * 商品期权卡片行情请求客户端 + * 参考 AiRiseFallHqRequestClient 实现,使用 codelist 格式 + * + * Author: YS + * Date: 2026-06-03 + */ +export class CommodityOptionsHqRequestClient extends TableRequestClient { + private codeList: string[] = [] + private marketList: string[] = [] + private hqDataCallback?: (hqs: CommodityOptionHqItem[]) => void + + constructor(frameId: number, pageId: number, headerConfig: Array, + dataReceiveListener?: (tableData: TableData | string) => void) { + super(frameId, pageId, headerConfig, null, dataReceiveListener, undefined) + this.initSubscribeIds(headerConfig) + } + + private initSubscribeIds(headerConfig: Array): void { + let ids: number[] = [] + headerConfig.forEach((item: HeaderItem) => { + if (!ids.includes(item.mobileid)) { + ids.push(item.mobileid) + } + }) + // 订阅行情所需字段 + const extFieldIds = [ + TableConstants.DATA_ID_CODE_4, + TableConstants.DATA_ID_PRICE, + TableConstants.DATA_ID_MARKET, + TableConstants.DATA_ID_ZF_YTD, + ] + extFieldIds.forEach((id: number) => { + if (!ids.includes(id)) { + ids.push(id) + } + }) + this.setSubscribeIds(ids) + } + + setStockListAndRequest(codes: string[], markets: string[]): void { + if (codes.length !== markets.length) { + HXLog.e('CommodityOptionsHqRequestClient', 'codes and markets length mismatch') + return + } + this.codeList = codes + this.marketList = markets + this.request(0, codes.length) + } + + setHqDataCallback(callback: (hqs: CommodityOptionHqItem[]) => void): void { + this.hqDataCallback = callback + } + + /** + * 重写请求参数构建 + */ + protected createRequestParam(): string { + let param = "\r\nsortid=" + TableConstants.DATA_ID_ZF_YTD + param += "\r\nsortorder=0" + param += "\r\npush=1" + param += "\r\n" + this.createHeaderParam() + param += this.createStockListParam() + param += "\r\nscenario=qht_qiquan_sort" + param += "\r\nprecision=1" + param += "\r\npushtime=2.5" + return param + } + + protected doRequest() { + let requestParam = this.createRequestParam() + RequestHelper.getHqManager().addRequestStructToBuff(this.frameId, this.pageId, this.getInstanceId(), requestParam) + RequestHelper.build().base(this.frameId, this.pageId, this, requestParam).request(); + } + + /** + * 构建合约列表参数 + * 与 hqHelper.js format4106Data 保持一致格式: market1(code1,code2,);market2(code3,); + */ + private createStockListParam(): string { + if (this.codeList.length === 0 || this.marketList.length === 0) { + return '' + } + // 按市场分组,生成 format4106Data 格式 + const marketMap: Map = new Map() + for (let i = 0; i < this.codeList.length; i++) { + const market = this.marketList[i] + const code = this.codeList[i] + if (marketMap.has(market)) { + marketMap.get(market)!.push(code + ',') + } else { + marketMap.set(market, [code + ',']) + } + } + let codelist = '' + marketMap.forEach((codes, market) => { + codelist += market + '(' + codes.join('') + ');' + }) + return "\r\ncodelist=" + codelist + } + + /** + * 生成 dataitem 参数 + * 与 hqHelper.js dataitem 保持一致格式:字段ID用逗号分隔 + */ + private createHeaderParam(): string { + let param = "dataitem=" + let addedIds: number[] = [] + + this.headerConfig.forEach((item: HeaderItem) => { + param += item.mobileid + "," + addedIds.push(item.mobileid) + if (item.extDataIds) { + item.extDataIds.forEach((id: number) => { + param += id + "," + addedIds.push(id) + }) + } + }) + + return param + } + + protected afterParserModel(tableData: TableData): void { + const hqs: CommodityOptionHqItem[] = [] + if (tableData.tableRows) { + tableData.tableRows.forEach((row: AbsRowData) => { + if (row instanceof RowData) { + const data = row.originalData + const code = data.get(TableConstants.DATA_ID_CODE_4) + const market = data.get(TableConstants.DATA_ID_MARKET) + const name = data.get(TableConstants.DATA_ID_NAME) + const price = data.get(TableConstants.DATA_ID_PRICE) + const priceChg = data.get(TableConstants.DATA_ID_ZF_YTD) + + if (typeof code === 'string' && typeof market === 'string') { + let priceChgValue: number | null = null + if (priceChg && typeof priceChg === 'string' && priceChg !== '--') { + priceChgValue = parseFloat(priceChg) + } + + hqs.push({ + code: code, + market: market, + name: typeof name === 'string' ? name : '', + price: typeof price === 'string' ? price : '--', + priceChg: priceChgValue, + expiredDate: '' // 到期日稍后从 contractList 中补充 + }) + } + } + }) + } + + if (this.hqDataCallback && hqs.length > 0) { + this.hqDataCallback(hqs) + } + } +} + +/** + * 商品期权行情数据项 + */ +export interface CommodityOptionHqItem { + code: string + market: string + name: string + price: string + priceChg: number | null + expiredDate: string // 到期日(格式:20250628) +} \ No newline at end of file diff --git a/src/main/ets/node/clients/FirstPageSelfStockRequestClient.ets b/src/main/ets/node/clients/FirstPageSelfStockRequestClient.ets new file mode 100644 index 0000000..cd0043d --- /dev/null +++ b/src/main/ets/node/clients/FirstPageSelfStockRequestClient.ets @@ -0,0 +1,98 @@ +import { SortItem, TableConstants, TableRequestClient } from '@b2c/lib_baseui'; +import { HeaderItem, TableData } from '@b2b/hq_table'; +import { + QuoteCustomSettingManager, + StandardPriceTypeHelper, + applyStandardPriceToTableData +} from 'biz_quote'; +import { StuffTableStruct } from '@kernel/lib_communication'; + +const REQUIRED_IDS_FOR_STANDARD_PRICE: number[] = [ + TableConstants.DATA_ID_CODE_4, + TableConstants.DATA_ID_PRICE, + TableConstants.DATA_ID_ZF, + TableConstants.DATA_ID_ZD, + TableConstants.DATA_ID_MARKET, + TableConstants.DATA_ID_MARKET_OLD, + StandardPriceTypeHelper.FIELD_ZUO_SHOU, + StandardPriceTypeHelper.FIELD_JIN_KAI, + StandardPriceTypeHelper.FIELD_ZUO_JIE +] + +export class FirstPageSelfStockRequestClient extends TableRequestClient { + + constructor(frameId: number, pageId: number, headerConfig: Array, sortItem?: SortItem | null, + dataReceiveListener?: (tableData: TableData | string) => void, extRequestText?: string) { + super(frameId, pageId, headerConfig, sortItem, dataReceiveListener, extRequestText) + + let ids: number[] = [] + this.headerConfig.forEach((item: HeaderItem) => { + this.addId(ids, item.mobileid) + }) + REQUIRED_IDS_FOR_STANDARD_PRICE.forEach((id: number) => { + this.addId(ids, id) + }) + + this.setSubscribeIds(ids) + } + + + /** + * 增加自选股列表请求的定制参数 + * @returns + */ + protected createRequestParam(): string { + let param = super.createRequestParam() + param += "\r\n" + this.createHeaderParam() + param += "\r\nupdate=1" + param += "\r\ncomputemode=" + QuoteCustomSettingManager.getInstance().getStandardPriceType() + return param; + } + + protected doRequest(){ + super.doRequest() + } + + protected afterParserModel(tableData: TableData, structData: StuffTableStruct): void { + applyStandardPriceToTableData(tableData, structData, REQUIRED_IDS_FOR_STANDARD_PRICE) + } + + release(isDestory:boolean = false): void { + super.release(isDestory) + } + + /** + * 目前直接请求全部字段的数据,暂不考虑横向分页 + * @returns + */ + private createHeaderParam(): string { + let param = "columnorder=" + let addedIds: number[] = [] + this.headerConfig.forEach((item: HeaderItem) => { + if (!addedIds.includes(item.mobileid)) { + param += item.mobileid + "|" + addedIds.push(item.mobileid) + } + if (item.extDataIds) { + item.extDataIds.forEach((id: number) => { + if (!addedIds.includes(id)) { + param += id + "|" + addedIds.push(id) + } + }) + } + }) + REQUIRED_IDS_FOR_STANDARD_PRICE.forEach((id: number) => { + if (!addedIds.includes(id)) { + param += id + "|" + } + }) + return param + } + + private addId(ids: number[], id: number): void { + if (!ids.includes(id)) { + ids.push(id) + } + } +} diff --git a/src/main/ets/node/clients/MarketRankingHqRequestClient.ets b/src/main/ets/node/clients/MarketRankingHqRequestClient.ets new file mode 100644 index 0000000..8c2cc9c --- /dev/null +++ b/src/main/ets/node/clients/MarketRankingHqRequestClient.ets @@ -0,0 +1,200 @@ +import { AbsRowData, RowData, TableData } from '@b2b/hq_table'; +import { TableRequestClient } from '@b2c/lib_baseui'; +import { RequestHelper, StuffTableStruct } from '@kernel/lib_communication'; +import { applyStandardPriceToTableData } from 'biz_quote'; +import { + CardData, + RankItem, + RecommendFuturesItem, + TabMetric, +} from '../../market-ranking/MarketRankingModels'; +import { + PERIOD_RANGES, + TAB_METRICS, +} from '../../market-ranking/MarketRankingConstant'; +import { TableConstants } from '../../market-ranking/TableConstants'; + +const FRAME_ID = 2201; +const PAGE_ID = 4106; + +export type MarketRankingCardDataListener = (cardData: CardData) => void; + +/** + * 单次市场排名行情请求。 + */ +class MarketRankingTableRequestClient extends TableRequestClient { + private requestParams: string; + private fieldIds: number[]; + private sortId: number; + private listener: MarketRankingCardDataListener; + + constructor( + requestParams: string, + fieldIds: number[], + sortId: number, + listener: MarketRankingCardDataListener, + ) { + super(FRAME_ID, PAGE_ID, [], null, undefined); + this.requestParams = requestParams; + this.fieldIds = fieldIds; + this.sortId = sortId; + this.listener = listener; + this.setSubscribeIds(fieldIds); + } + + 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 { + applyStandardPriceToTableData(tableData, structData, this.fieldIds); + this.listener(this.tableDataToCardData(tableData)); + } + + private tableDataToCardData(tableData: TableData): CardData { + const tableList: RankItem[] = []; + if (tableData.tableRows) { + tableData.tableRows.forEach((row: AbsRowData): void => { + if (row instanceof RowData) { + 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 item: RankItem = { + code: `${codeValue}`, + market: `${marketValue}`, + name: this.getString(row, TableConstants.DATA_ID_NAME), + }; + item.price = this.getNumber(row, TableConstants.DATA_ID_PRICE); + item.price_chg = this.getNumber(row, TableConstants.DATA_ID_ZF); + if (this.sortId === TableConstants.DATA_ID_TURNOVER) { + item.turnover = this.getNumber(row, TableConstants.DATA_ID_TURNOVER); + } else if (this.sortId === TableConstants.DATA_ID_INCRE_POSIT) { + item.incre_posit = this.getNumber(row, TableConstants.DATA_ID_INCRE_POSIT); + } else if (this.sortId !== TableConstants.DATA_ID_ZF) { + item.chg_speed = this.getNumber(row, this.sortId); + } + tableList.push(item); + } + }); + } + return { tableList: tableList }; + } + + private getString(row: RowData, fieldId: number): string { + const value = row.originalData.get(fieldId); + return value === undefined ? '' : `${value}`; + } + + 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 = Number(value); + return Number.isNaN(result) ? undefined : result; + } + return undefined; + } +} + +/** + * 市场排名行情入口:根据当前 Tab 生成字段和请求参数,并管理真实行情订阅。 + */ +export class MarketRankingHqRequestClient { + private static readonly instance: MarketRankingHqRequestClient = + new MarketRankingHqRequestClient(); + + private constructor() { + } + + static getInstance(): MarketRankingHqRequestClient { + return MarketRankingHqRequestClient.instance; + } + + subscribeHq( + contracts: RecommendFuturesItem[], + primaryIdx: number, + secondaryIdx: number, + listener: MarketRankingCardDataListener, + ): () => void { + if (contracts.length === 0) { + return (): void => { + }; + } + + const sortId: number = this.getSortId(primaryIdx, secondaryIdx); + const fieldIds: number[] = [ + TableConstants.DATA_ID_NAME, + TableConstants.DATA_ID_PRICE, + TableConstants.DATA_ID_CODE_4, + TableConstants.DATA_ID_MARKET, + TableConstants.DATA_ID_ZF, + TableConstants.DATA_ID_PRE_PRICE, + TableConstants.DATA_ID_OPEN_PRICE, + TableConstants.DATA_ID_JSJ, + ]; + if (!fieldIds.includes(sortId)) { + fieldIds.push(sortId); + } + + const requestParams: string = + this.buildRequestParams(contracts, primaryIdx, sortId, fieldIds); + const requestClient = new MarketRankingTableRequestClient( + requestParams, + fieldIds, + sortId, + listener, + ); + requestClient.request(0, contracts.length); + + return (): void => { + requestClient.release(true); + }; + } + + private buildRequestParams( + contracts: RecommendFuturesItem[], + primaryIdx: number, + sortId: number, + fieldIds: number[], + ): string { + const metric: TabMetric = TAB_METRICS[primaryIdx]; + const sortOrder: string = metric.sortOrder; + return `\r\nsortid=${sortId}\r\n` + + `sortorder=${sortOrder}\r\n` + + `push=1\r\n` + + `dataitem=${fieldIds.map((fieldId: number): string => `${fieldId},`).join('')}\r\n` + + `codelist=${this.formatCodeList(contracts)}\r\n` + + `scenario=qht_qihuo_sort\r\n` + + `precision=1\r\n` + + `pushtime=2.5\r\n`; + } + + private getSortId(primaryIdx: number, secondaryIdx: number): number { + const metric: TabMetric = TAB_METRICS[primaryIdx]; + return metric.hasChildren ? PERIOD_RANGES[secondaryIdx].hqId : metric.hqId; + } + + private formatCodeList(contracts: RecommendFuturesItem[]): string { + const markets: string[] = []; + const codeGroups: string[][] = []; + contracts.forEach((contract: RecommendFuturesItem): void => { + const marketIndex: number = markets.indexOf(contract.market); + if (marketIndex >= 0) { + codeGroups[marketIndex].push(contract.contract_code); + } else { + markets.push(contract.market); + codeGroups.push([contract.contract_code]); + } + }); + return markets.map((market: string, index: number): string => + `${market}(${codeGroups[index].map((code: string): string => `${code},`).join('')});` + ).join(''); + } +} diff --git a/src/main/ets/node/datacenter/AiDiagnosisDataFetcher.ets b/src/main/ets/node/datacenter/AiDiagnosisDataFetcher.ets new file mode 100644 index 0000000..2e1b799 --- /dev/null +++ b/src/main/ets/node/datacenter/AiDiagnosisDataFetcher.ets @@ -0,0 +1,115 @@ +import { http } from '@kit.NetworkKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { buildHeader } from '../../util/Header'; +import { AiDiagnosisData, AiDiagnosisResponse, AllCardsCard } from '../model/AiDiagnosisNodeModel'; +import { HXLog } from 'biz_common'; +import { FirstPageCardsConfigLoader } from './FirstPageCardsConfigLoader'; + +/** + * AI 诊断数据获取器 + * 职责: + * 1. 从本地 first_page_cards_config.json 读取卡片配置 + * 2. 调用 mod_data[0].url 获取卡片数据 + */ + +export class AiDiagnosisDataFetcher { + private static instance: AiDiagnosisDataFetcher + + // 卡片配置回调 + onCardConfigChange?: (config: AllCardsCard | null) => void + // 卡片数据回调 + onCardDataChange?: (data: AiDiagnosisData | null) => void + // 错误回调 + onError?: (errMsg: string) => void + + // 当前卡片配置 + private cardConfig: AllCardsCard | null = null + // 当前卡片数据 + private cardData: AiDiagnosisData | null = null + + static getInstance(): AiDiagnosisDataFetcher { + if (!AiDiagnosisDataFetcher.instance) { + AiDiagnosisDataFetcher.instance = new AiDiagnosisDataFetcher() + } + return AiDiagnosisDataFetcher.instance + } + + getCardConfig(): AllCardsCard | null { + return this.cardConfig + } + + getCardData(): AiDiagnosisData | null { + return this.cardData + } + + /** + * 请求卡片配置(从本地 first_page_cards_config.json 读取) + */ + fetchCardConfig(): void { + const aiCardsConfig = FirstPageCardsConfigLoader.getConfig() + + if (aiCardsConfig?.aiDiagnosis) { + this.cardConfig = aiCardsConfig.aiDiagnosis + if (this.onCardConfigChange) { + this.onCardConfigChange(this.cardConfig) + } + this.fetchCardData() + } else { + if (this.onCardConfigChange) { + this.onCardConfigChange(null) + } + HXLog.e('AiDiagnosisDataFetcher', 'fetchCardConfig: aiDiagnosis config not found') + } + } + + /** + * 请求卡片数据(调用 mod_data[0].url) + */ + fetchCardData(): void { + if (!this.cardConfig?.mod_data?.[0]?.url) { + return + } + + let httpRequest = http.createHttp() + const url = this.cardConfig.mod_data[0].url + + httpRequest.request(url, { + method: http.RequestMethod.GET, + header: buildHeader(), + }, (err: BusinessError, data: http.HttpResponse) => { + if (err) { + HXLog.e('AiDiagnosisDataFetcher', 'fetchCardData error: ' + err.message) + if (this.onError) { + this.onError(err.message) + } + httpRequest.destroy() + return + } + + if (data.responseCode === http.ResponseCode.OK) { + try { + const resultStr = data.result as string + const result = JSON.parse(resultStr) as AiDiagnosisResponse + if (result.status_code === 0 && result.data) { + this.cardData = result.data + } + if (this.onCardDataChange) { + this.onCardDataChange(this.cardData) + } + } catch (e) { + const error = e as BusinessError + HXLog.e('AiDiagnosisDataFetcher', `parse data error ${error.code} ${error.message}`) + if (this.onError) { + this.onError('数据解析失败') + } + } + } else { + HXLog.e('AiDiagnosisDataFetcher', 'fetchCardData responseCode: ' + data.responseCode) + if (this.onError) { + this.onError('请求失败: ' + data.responseCode) + } + } + httpRequest.destroy() + }) + } +} \ No newline at end of file diff --git a/src/main/ets/node/datacenter/AiRiseFallDataFetcher.ets b/src/main/ets/node/datacenter/AiRiseFallDataFetcher.ets new file mode 100644 index 0000000..81dedba --- /dev/null +++ b/src/main/ets/node/datacenter/AiRiseFallDataFetcher.ets @@ -0,0 +1,116 @@ +import { http } from '@kit.NetworkKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { buildHeader } from '../../util/Header'; +import { AiRiseFallData, AiRiseFallResponse } from '../model/AiRiseFallNodeModel'; +import { AllCardsCard } from '../model/AiDiagnosisNodeModel'; +import { HXLog } from 'biz_common'; +import { FirstPageCardsConfigLoader } from './FirstPageCardsConfigLoader'; + +/** + * AI 看涨跌数据获取器 + * 职责: + * 1. 从本地 first_page_cards_config.json 读取卡片配置 + * 2. 调用 mod_data[0].url 获取卡片数据 + */ + +export class AiRiseFallDataFetcher { + private static instance: AiRiseFallDataFetcher + + // 卡片配置回调 + onCardConfigChange?: (config: AllCardsCard | null) => void + // 卡片数据回调 + onCardDataChange?: (data: AiRiseFallData | null) => void + // 错误回调 + onError?: (errMsg: string) => void + + // 当前卡片配置 + private cardConfig: AllCardsCard | null = null + // 当前卡片数据 + private cardData: AiRiseFallData | null = null + + static getInstance(): AiRiseFallDataFetcher { + if (!AiRiseFallDataFetcher.instance) { + AiRiseFallDataFetcher.instance = new AiRiseFallDataFetcher() + } + return AiRiseFallDataFetcher.instance + } + + getCardConfig(): AllCardsCard | null { + return this.cardConfig + } + + getCardData(): AiRiseFallData | null { + return this.cardData + } + + /** + * 请求卡片配置(从本地 first_page_cards_config.json 读取) + */ + fetchCardConfig(): void { + const aiCardsConfig = FirstPageCardsConfigLoader.getConfig() + + if (aiCardsConfig?.aiRiseFall) { + this.cardConfig = aiCardsConfig.aiRiseFall + if (this.onCardConfigChange) { + this.onCardConfigChange(this.cardConfig) + } + this.fetchCardData() + } else { + if (this.onCardConfigChange) { + this.onCardConfigChange(null) + } + HXLog.e('AiRiseFallDataFetcher', 'fetchCardConfig: aiRiseFall config not found') + } + } + + /** + * 请求卡片数据 + */ + fetchCardData(): void { + if (!this.cardConfig?.mod_data?.[0]?.url) { + return + } + + let httpRequest = http.createHttp() + const url = this.cardConfig.mod_data[0].url + + httpRequest.request(url, { + method: http.RequestMethod.GET, + header: buildHeader(), + }, (err: BusinessError, data: http.HttpResponse) => { + if (err) { + HXLog.e('AiRiseFallDataFetcher', 'fetchCardData error: ' + err.message) + if (this.onError) { + this.onError(err.message) + } + httpRequest.destroy() + return + } + + if (data.responseCode === http.ResponseCode.OK) { + try { + const resultStr = data.result as string + const result = JSON.parse(resultStr) as AiRiseFallResponse + if (result.code === 0 && result.data) { + this.cardData = result.data + } + if (this.onCardDataChange) { + this.onCardDataChange(this.cardData) + } + } catch (e) { + const error = e as BusinessError + HXLog.e('AiRiseFallDataFetcher', `parse data error ${error.code} ${error.message}`) + if (this.onError) { + this.onError('数据解析失败') + } + } + } else { + HXLog.e('AiRiseFallDataFetcher', 'fetchCardData responseCode: ' + data.responseCode) + if (this.onError) { + this.onError('请求失败: ' + data.responseCode) + } + } + httpRequest.destroy() + }) + } +} \ No newline at end of file diff --git a/src/main/ets/node/datacenter/CommodityOptionsDataFetcher.ets b/src/main/ets/node/datacenter/CommodityOptionsDataFetcher.ets new file mode 100644 index 0000000..8152e6b --- /dev/null +++ b/src/main/ets/node/datacenter/CommodityOptionsDataFetcher.ets @@ -0,0 +1,176 @@ +import { http } from '@kit.NetworkKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { buildHeader } from '../../util/Header'; +import { CommodityOptionsContract, CommodityOptionsResponse } from '../model/CommodityOptionsNodeModel'; +import { COMMODITY_OPTIONS_TABS, CommodityOptionsTab } from '../model/CommodityOptionsNodeModel'; +import { HXLog } from 'biz_common'; +import { FirstPageCardsConfigLoader, CommodityOptionsCardConfig } from './FirstPageCardsConfigLoader'; +import { AppUtil } from '@pura/harmony-utils'; + +/** + * 商品期权数据获取器 + * 职责: + * 1. 请求推荐期权数据(热门/临期期权) + * 2. 支持 Tab 切换 + * + * Author: YS + * Date: 2026-06-03 + */ + +export class CommodityOptionsDataFetcher { + private static instance: CommodityOptionsDataFetcher + + // 数据回调 + onCardDataChange?: (data: CommodityOptionsContract[], tabIndex: number) => void + // 卡片配置回调 + onCardConfigChange?: (config: CommodityOptionsCardConfig | null) => void + + // 卡片配置 + private cardConfig: CommodityOptionsCardConfig | null = null + + // 当前选中的 Tab 索引 + private currentTabIndex: number = 0 + // 缓存的数据(按 Tab 索引存储) + private cachedData: Map = new Map() + + static getInstance(): CommodityOptionsDataFetcher { + if (!CommodityOptionsDataFetcher.instance) { + CommodityOptionsDataFetcher.instance = new CommodityOptionsDataFetcher() + } + return CommodityOptionsDataFetcher.instance + } + + /** + * 获取卡片配置 + */ + getCardConfig(): CommodityOptionsCardConfig | null { + return this.cardConfig + } + + /** + * 请求卡片配置(从 FirstPageCardsConfigLoader 读取) + */ + fetchCardConfig(): void { + const config = FirstPageCardsConfigLoader.getConfig() + + if (config?.commodityOptions) { + this.cardConfig = config.commodityOptions + if (this.onCardConfigChange) { + this.onCardConfigChange(this.cardConfig) + } + // 同时请求所有tab的数据 + this.fetchAllTabData() + } else { + if (this.onCardConfigChange) { + this.onCardConfigChange(null) + } + HXLog.e('CommodityOptionsDataFetcher', 'fetchCardConfig: commodityOptions config not found') + } + } + + /** + * 同时请求所有Tab的数据并缓存 + */ + fetchAllTabData(): void { + COMMODITY_OPTIONS_TABS.forEach((tab, index) => { + this.requestOptions(index) + }) + } + + /** + * 获取当前 Tab + */ + getCurrentTab(): CommodityOptionsTab { + return COMMODITY_OPTIONS_TABS[this.currentTabIndex] + } + + /** + * 切换 Tab + * @param index Tab 索引 + */ + switchTab(index: number): void { + if (index < 0 || index >= COMMODITY_OPTIONS_TABS.length) { + return + } + this.currentTabIndex = index + // 触发数据获取 + this.fetchCardData(index) + } + + /** + * 获取指定Tab的数据 + */ + getDataByTab(tabIndex: number): CommodityOptionsContract[] { + return this.cachedData.get(tabIndex) || [] + } + + /** + * 请求数据 + */ + fetchCardData(index: number): void { + this.requestOptions(index) + } + /** + * 清理资源 + */ + destroy(): void { + this.cachedData.clear() + } + + /** + * 请求期权数据 + * @param quoteType API类型 + * @param tabIndex Tab索引(可选,用于同时请求多个tab) + */ + private requestOptions(tabIndex: number): void { + if (tabIndex >= COMMODITY_OPTIONS_TABS.length) { + return + } + const tabConfig = COMMODITY_OPTIONS_TABS[tabIndex] + const url = this.buildRequestUrl(tabConfig.api) + + let httpRequest = http.createHttp() + httpRequest.request(url, { + method: http.RequestMethod.GET, + header: buildHeader(), + }, (err: BusinessError, data: http.HttpResponse) => { + if (err) { + HXLog.e('CommodityOptionsDataFetcher', 'requestOptions error: ' + err.message) + httpRequest.destroy() + return + } + + if (data.responseCode === http.ResponseCode.OK) { + try { + const resultStr = data.result as string + const result = JSON.parse(resultStr) as CommodityOptionsResponse + if (result.code === 0 && result.data && result.data.length > 0) { + // 缓存数据到指定tab + this.cachedData.set(tabIndex, result.data) + + // 如果是当前tab,触发回调 + if (this.onCardDataChange) { + this.onCardDataChange(result.data, tabIndex) + } + } else { + HXLog.e('CommodityOptionsDataFetcher', 'requestOptions: data is empty') + } + } catch (e) { + const error = e as BusinessError + HXLog.e('CommodityOptionsDataFetcher', 'parse data error: ' + error.message) + } + } else { + HXLog.e('CommodityOptionsDataFetcher', 'requestOptions responseCode: ' + data.responseCode) + } + httpRequest.destroy() + }) + } + + /** + * 构建请求 URL + */ + private buildRequestUrl(quoteType: string): string { + const urlTemplate: string = AppUtil.getContext().resourceManager.getStringSync($r('app.string.commodity_options_recommend_url')) + return urlTemplate.replace('%s', quoteType) + } +} \ No newline at end of file diff --git a/src/main/ets/node/datacenter/FirstPageCardsConfigLoader.ets b/src/main/ets/node/datacenter/FirstPageCardsConfigLoader.ets new file mode 100644 index 0000000..a20793d --- /dev/null +++ b/src/main/ets/node/datacenter/FirstPageCardsConfigLoader.ets @@ -0,0 +1,94 @@ +import { BusinessError } from '@kit.BasicServicesKit'; +import { GlobalContext, HXLog } from 'biz_common'; +import util from '@ohos.util'; +import { AllCardsCard, CardTitle, CardUrl, ModDataItem } from '../model/AiDiagnosisNodeModel'; + +/** + * 首页卡片配置加载器 + * 职责: + * 1. 从本地 first_page_cards_config.json 读取卡片配置 + * 2. 提供公共方法供各 Fetcher 使用 + * + * Author: yansong@myhexin.com + * Date: 2026/06/04 + */ + +// 配置文件名 +const FIRST_PAGE_CARDS_CONFIG_FILE = 'first_page_cards_config.json' + +/** + * 热点资讯卡片配置 + */ +export interface HotListCardConfig { + card_id: number + card_key: string + card_title: CardTitle + card_url: CardUrl + mod_data: ModDataItem[] +} + +/** + * 商品期权卡片配置 + */ +export interface CommodityOptionsCardConfig { + card_id: number + card_key: string + card_title: CardTitle + card_url: CardUrl + mod_data: ModDataItem[] + explain_title: string | null + explain_message: string | null +} + +// AI卡片配置接口 +export interface FirstPageCardsConfig { + hotList: HotListCardConfig + aiDiagnosis: AllCardsCard + aiRiseFall: AllCardsCard + commodityOptions: CommodityOptionsCardConfig + marketRanking: AllCardsCard +} + +export class FirstPageCardsConfigLoader { + private static cardsConfig: FirstPageCardsConfig | null = null + + static getConfig(): FirstPageCardsConfig | null { + if (FirstPageCardsConfigLoader.cardsConfig) { + return FirstPageCardsConfigLoader.cardsConfig + } + + const rawData = FirstPageCardsConfigLoader.readRawFile(FIRST_PAGE_CARDS_CONFIG_FILE) + if (!rawData) { + HXLog.e('AiCardsConfigLoader', 'read first_page_cards_config.json failed') + return null + } + + try { + FirstPageCardsConfigLoader.cardsConfig = JSON.parse(rawData) as FirstPageCardsConfig + HXLog.d('AiCardsConfigLoader', 'load success') + } catch (e) { + const error = e as BusinessError + HXLog.e('AiCardsConfigLoader', 'parse error ' + error.message) + } + return FirstPageCardsConfigLoader.cardsConfig + } + + /** + * 从 rawfile 读取配置文件 + * @param fileName rawfile 文件名 + * @returns 读取的字符串内容,失败返回空字符串 + */ + private static 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('AiCardsConfigLoader', `readRawFile ${fileName} error: ${error.code} ${error.message}`) + return '' + } + } +} diff --git a/src/main/ets/node/datacenter/HotListDataFetcher.ets b/src/main/ets/node/datacenter/HotListDataFetcher.ets new file mode 100644 index 0000000..4e9328e --- /dev/null +++ b/src/main/ets/node/datacenter/HotListDataFetcher.ets @@ -0,0 +1,128 @@ +import { http } from '@kit.NetworkKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { HotListItemModel, HotListResultModel } from '../model/HotListNodeModel'; +import { FileManager } from '../../util/FileManager'; +import { buildHeader } from '../../util/Header'; +import { HXLog } from 'biz_common'; +import { FirstPageCardsConfigLoader } from './FirstPageCardsConfigLoader'; + +/** + * 热点排行数据获取器 + * @author YS + * @date 2026-06-03 + */ +export class HotListDataFetcher { + // 单例实例 + private static instance: HotListDataFetcher + + // 数据变更回调 + hotListChange?: (data: HotListItemModel[] | null) => void + // 配置变更回调 + cardConfigChange?: (cardUrl: string) => void + + // API 地址 + private apiUrl: string = '' + // 跳转列表页 URL + private cardUrl: string = '' + + private constructor() {} + + static getInstance(): HotListDataFetcher { + if (!HotListDataFetcher.instance) { + HotListDataFetcher.instance = new HotListDataFetcher() + } + return HotListDataFetcher.instance + } + + /** + * 获取跳转列表页 URL + */ + getCardUrl(): string { + return this.cardUrl + } + + /** + * 加载配置(供外部调用) + */ + loadConfig(): void { + const config = FirstPageCardsConfigLoader.getConfig() + if (config?.hotList) { + // 获取 API 地址 + if (config.hotList.mod_data?.[0]?.url) { + this.apiUrl = config.hotList.mod_data[0].url + } + // 获取跳转 URL(使用 android 平台的 URL,鸿蒙类似 Android) + if (config.hotList.card_url) { + this.cardUrl = config.hotList.card_url.android || config.hotList.card_url.ios || '' + } + HXLog.d('HotListDataFetcher', `loadConfig: success, apiUrl=${this.apiUrl}, cardUrl=${this.cardUrl}`) + } else { + HXLog.e('HotListDataFetcher', 'loadConfig: hotList config not found') + } + } + + /** + * 远程获取数据 + */ + remoteFetchData(): void { + // 加载配置 + this.loadConfig() + + if (!this.apiUrl) { + HXLog.e('HotListDataFetcher', 'remoteFetchData: apiUrl is empty') + if (this.hotListChange) { + this.hotListChange(null) + } + return + } + + let httpRequest = http.createHttp() + httpRequest.request(this.apiUrl, { + method: http.RequestMethod.GET, + header: buildHeader() + }, (err: BusinessError, data: http.HttpResponse) => { + if (err) { + HXLog.e('HotListDataFetcher', `remoteFetchData: request error ${err.code} ${err.message}`) + if (this.hotListChange) { + this.hotListChange(null) + } + return + } + if (data.responseCode === http.ResponseCode.OK) { + let dataStr = data.result as string + const dataObj = this.parseData(dataStr) + if (dataObj) { + if (this.hotListChange) { + this.hotListChange(dataObj) + } + } else { + // 数据解析失败 + if (this.hotListChange) { + this.hotListChange(null) + } + } + } else { + // 非 200 响应 + if (this.hotListChange) { + this.hotListChange(null) + } + } + }) + } + + /** + * 解析数据 + */ + private parseData(dataStr: string): HotListItemModel[] | null { + try { + const result = JSON.parse(dataStr) as HotListResultModel + if (result && result.code === 0 && result.data && result.data.length > 0) { + return result.data + } + return null + } catch (e) { + HXLog.e('HotListDataFetcher', `parseData: ${e}`) + return null + } + } +} \ No newline at end of file diff --git a/src/main/ets/node/datacenter/HotNewsDataExecutor.ets b/src/main/ets/node/datacenter/HotNewsDataExecutor.ets new file mode 100644 index 0000000..70f30b7 --- /dev/null +++ b/src/main/ets/node/datacenter/HotNewsDataExecutor.ets @@ -0,0 +1,66 @@ +import { parseInt } from '@wolfx/lodash'; +import { GlobalContext, HXLog } from 'biz_common'; +import { StringUtils } from 'hxutil'; +import { DateUtils } from '../../util/DateUtils'; +import { FileManager } from '../../util/FileManager'; +import { HotNewsNodeModel, HotNewsNodeModelResult } from '../model/HotNewsNodeModel'; + +export class NewsDataExecutor { + + private static ONE_SECOND = 1000; //1秒 + static ONE_MINUTE = 60 * 1000; //1分 + static ONE_HOUR = 60 * 60 * 1000; // 1小时 + static ONE_DAY = 24 * 60 * 60 * 1000; //1天 + static TWO_DAY = 2 * 24 * 60 * 60 * 1000; //2天 + static MINUTE_BEFORE = "分钟前"; + static HOUR_BEFORE = "小时前"; + static YESTERDAY = "昨天"; + + static parseData(data : string) : HotNewsNodeModel[] | null { + let resultData : HotNewsNodeModel[] | null = null + if (StringUtils.isNotEmpty(data)) { + try { + const firstPageNodeResult = JSON.parse(data) as HotNewsNodeModelResult + if (firstPageNodeResult) { + resultData = NewsDataExecutor.handlerNewsModel(firstPageNodeResult.data) + } + } catch (e) { + HXLog.e("FirstpageDataExecutor.parseData", "error " + JSON.stringify(e)); + } + } + return resultData; + } + + static handlerNewsModel(models: HotNewsNodeModel[]): HotNewsNodeModel[] { + models.forEach((item) => { + item.create_time = NewsDataExecutor.transferTime(item.create_time) + if (item.tags.length > 2) { + item.tags = item.tags.slice(0, 2) + } + }) + return models + } + + static transferTime(time: string): string { + let showTime = '' + let timeTmp = parseInt(time) + if (timeTmp) { + timeTmp = timeTmp * NewsDataExecutor.ONE_SECOND + let currentTime = new Date().getTime() + let timeSpace = currentTime - timeTmp + + if (timeSpace < NewsDataExecutor.ONE_MINUTE) { + showTime = '1' + NewsDataExecutor.MINUTE_BEFORE + } else if (timeSpace < NewsDataExecutor.ONE_HOUR) { + showTime = Math.floor(timeSpace / NewsDataExecutor.ONE_MINUTE) + NewsDataExecutor.MINUTE_BEFORE + } else if (timeSpace < NewsDataExecutor.ONE_DAY) { + showTime = Math.floor(timeSpace / NewsDataExecutor.ONE_HOUR) + NewsDataExecutor.HOUR_BEFORE + } else if (timeSpace < NewsDataExecutor.TWO_DAY) { + showTime = NewsDataExecutor.YESTERDAY + } else { + showTime = DateUtils.formatDate(new Date(timeTmp), 'MM月dd日') + } + } + return showTime + } +} \ No newline at end of file diff --git a/src/main/ets/node/datacenter/HotNewsDataFetcher.ets b/src/main/ets/node/datacenter/HotNewsDataFetcher.ets new file mode 100644 index 0000000..a2db377 --- /dev/null +++ b/src/main/ets/node/datacenter/HotNewsDataFetcher.ets @@ -0,0 +1,54 @@ +import { http } from '@kit.NetworkKit'; +import { buildHeader } from '../../util/Header'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { NewsDataExecutor } from './HotNewsDataExecutor'; +import { HotNewsNodeModel } from '../model/HotNewsNodeModel'; +import { FileManager } from '../../util/FileManager'; + +export class NewsDataFetcher { + + private static CACHE_FILE_NAME = '/firstpage/firstpage_news.txt' + + private static instance: NewsDataFetcher + newsChange?: (news : HotNewsNodeModel[] | null) => void + + static getInstance() { + if (!NewsDataFetcher.instance) { + NewsDataFetcher.instance = new NewsDataFetcher + } + return NewsDataFetcher.instance + } + + fetchDataLocal() { + let cacheData = FileManager.getInstance().getCachePathSync(NewsDataFetcher.CACHE_FILE_NAME) + if (cacheData) { + const dataObj = NewsDataExecutor.parseData(cacheData) + if (dataObj && this.newsChange) { + this.newsChange(dataObj) + } + } + } + + remoteFetchData(url: string) { + let httpRequest = http.createHttp(); + httpRequest.request(url, { + method: http.RequestMethod.GET, + header: buildHeader(), + }, (err: BusinessError, data: http.HttpResponse) => { + httpRequest.destroy() + if (err) { + return + } + if (data.responseCode === http.ResponseCode.OK) { + let daraStr = data.result as string + const dataObj = NewsDataExecutor.parseData(daraStr) + if (dataObj) { + FileManager.getInstance().saveCacheSync(daraStr, NewsDataFetcher.CACHE_FILE_NAME) + if (this.newsChange) { + this.newsChange(dataObj) + } + } + } + }) + } +} diff --git a/src/main/ets/node/helper/FirstPagePopupAdHelper.ets b/src/main/ets/node/helper/FirstPagePopupAdHelper.ets new file mode 100644 index 0000000..310386c --- /dev/null +++ b/src/main/ets/node/helper/FirstPagePopupAdHelper.ets @@ -0,0 +1,420 @@ +import { BaseAd, AdsManager, ADS_TYPE_INDEX_POPUP } from "@b2c-f/fuhm-ads" +import { InfDialog, DialogHub } from "@hadss/dialoghub" +import { BusinessError, systemDateTime } from "@kit.BasicServicesKit" +import { AppUtil } from "@pura/harmony-utils" +import { HXLog, FrameId, AdsPageConfig } from "biz_common" +import { DateManager } from "biz_common/src/main/ets/datePicker/DateManager" +import { HXUserService } from "biz_hxservice" +import { getCurrentFrameId } from "biz_quote/src/main/ets/util/FrameId" +import { HexinVersionControl } from "biz_trade/src/main/ets/utils/HexinVersionControl" +import { ELK_LEVEL, sendElk } from "service_monitor" +import { + CBAS_FIRSTPAGE_TANGCHUANGAD_CLOSE, + CBAS_FIRSTPAGE_TANGCHUANGAD_BAOGUANG, + CBAS_FIRSTPAGE_PREFIX, + CBAS_FIRSTPAGE_TANGCHUANGAD_DIANJI +} from "../../constants/FirstPageConstants" +import { AdsPopupHost } from "../../popup/AdsPopupHost" +import { FileManager } from "../../util/FileManager" +import { ImageLoader } from "../../util/ImageLoader" +import { RouterUtil } from "../../util/RouterUtil" +import { PopupAdMode, adapterForIndexPopupActivity, AdFrequency } from "../model/PopupAdMode" +import { FirstPageDialogParam, FirstPageDialogBuilder } from "../view/FirstPageDialog" +import { JSON } from "@kit.ArkTS" + + +/** + * author : liuqingliang@myhexin.com + * time : created on 2026/5/28 + * desc : 首页弹窗广告帮助类 + */ +const TAG = 'FirstPagePopupAdHelper' + +export class FirstPagePopupAdHelper { + private mPopupAdModel: PopupAdMode | null = null + private static instance: FirstPagePopupAdHelper | null = null + firstPageVisible: boolean = true + /** + * 本次启动是否显示过弹窗广告 + */ + isShowedPopupAds = false + private adDialog: InfDialog | null = null + /** + * 是否有弹窗广告未显示(用于投教冲突时缓存) + */ + private isHasTanChuangAdNotShow = false + private popAdRecordFilePath = 'record/popAdRecordNew.txt' + + private constructor() { + } + + + static getInstance(): FirstPagePopupAdHelper { + if (!FirstPagePopupAdHelper.instance) { + FirstPagePopupAdHelper.instance = new FirstPagePopupAdHelper() + } + return FirstPagePopupAdHelper.instance + } + + /** + * 首页 OnResume 时调用 + * 场景:投教弹窗遮盖期间收到广告数据,返回首页后从此方法恢复 + */ + showPopupAdWhenOnResume(): void { + if (!this.isHasTanChuangAdNotShow) { + return + } + this.isHasTanChuangAdNotShow = false + const showResult = this.showFirstPagePopupAd() + const elkMsg = + `showPopupAd showPopupAdModel:${this.mPopupAdModel?.toString()} showFirstPagePopupAd:${showResult}` + this.uploadLog(ELK_LEVEL.ERROR, elkMsg) + } + + /** + * 广告数据更新入口 + * FirstPage 通过 AdsManager.addAdsDataUpdateListener 收到数据后调用此方法 + */ + onAdsDataUpdate(adsType: string, adsDataModels: BaseAd[]): void { + //如果隐藏交易直接不展示 + if (!this.isCanShowIndexPopupAd() || HexinVersionControl.hideTrade()) { + return + } + if (this.isShowedPopupAds) { + return + } + let elkMsg = `onAdsDataUpdate adType:${adsType} userId:${this.getNotNullUserId()}` + let popupAdModelList: PopupAdMode[] = adapterForIndexPopupActivity(adsDataModels) + elkMsg += ` popupAdModelList size:${popupAdModelList.length}` + // 找到本地弹窗记录 + let popAdRecordMap: Record = this.readPopAdRecord() + // 找到需要弹出的广告 + this.mPopupAdModel = this.findNeedPopAd(popAdRecordMap, popupAdModelList) + elkMsg += ` findNeedPopupAd:${this.mPopupAdModel?.toString()}` + if (this.mPopupAdModel === null && popupAdModelList.length !== 0) { + // 重置 + this.resetRecordFlag(popupAdModelList, popAdRecordMap) + // 找到需要弹出的广告 + this.mPopupAdModel = this.findNeedPopAd(popAdRecordMap, popupAdModelList) + elkMsg += ` resetRecordFlag:${this.mPopupAdModel?.toString()}` + } + // show ad + let showResult = this.showFirstPagePopupAd() + elkMsg += ` showResult:${showResult}` + if (!showResult) { + // 首页弹窗显隐确定时,通知广告组件 + AdsPopupHost.getInstance().notifyFirstPageMatch(false) + } + this.uploadLog(ELK_LEVEL.ERROR, elkMsg) + // 记录弹窗信息 + this.recordAdModel(popAdRecordMap, this.mPopupAdModel) + } + + /** + * 记录弹窗广告的弹出记录 + * + * @param popAdRecordMap 当前用户的所有弹出记录 + * @param showAdModel 当前弹出的弹窗广告 + */ + private recordAdModel(popAdRecordMap: Record, showAdModel: PopupAdMode | null) { + if (showAdModel == null) { + return + } + let userId = this.getNotNullUserId() + let adId = showAdModel.adid + showAdModel.hasShow = true + showAdModel.showTime = systemDateTime.getTime() + popAdRecordMap[adId] = showAdModel + let allUserRecord = this.readPopAdRecordFromFile() + allUserRecord[userId] = popAdRecordMap + let record = JSON.stringify(allUserRecord) + FileManager.getInstance().saveFilesSync(record, this.popAdRecordFilePath) + } + + /** + * 从文件中读取当前用户的广告弹窗的弹出记录,如果没有记录就自动生成一个记录Map + * @return 返回当前用户的广告记录 + */ + private readPopAdRecord(): Record { + const popAdRecord: Record> = this.readPopAdRecordFromFile() + const userId = this.getNotNullUserId() + const adModels = popAdRecord[userId] + return adModels ?? {} + } + + private getNotNullUserId(): string { + return HXUserService.getInstance().getUserIdFromLocal() + } + + /** + * 找到本地的弹窗纪录文件并取出所有的弹窗记录 + * @return 返回本机器所有用户的记录内容 + */ + private readPopAdRecordFromFile(): Record> { + let record: Record> = {} + const recordText = FileManager.getInstance().loadFilesSync(this.popAdRecordFilePath) + try { + record = JSON.parse(recordText) as Record> + } catch (e) { + HXLog.e(TAG, `readPopAdRecordFromFile parse failed. recordText:${recordText}`) + } + return record + } + + /** + * 从 BaseAd 列表中查找需要弹出的广告 + * @param adsDataModels 网络返回的 BaseAd 列表 + * @return 需要弹出的 BaseAd,若无则返回 null + */ + private findNeedPopAd(popAdRecord: Record, adList: PopupAdMode[]): PopupAdMode | null { + if (adList.length === 0) { + return null + } + for (let index = 0; index < adList.length; index++) { + //先获取adList中索引位置的adModel + const adModel = adList[index] + if (!this.isNeedShowPopupAd(adModel)) { + continue + } + let adId = adModel.adid + //判断本地纪录中是否含有该条广告 + if (popAdRecord[adId]) { + let recordModel = popAdRecord[adId] + //判断如果该条adModel是否显示过,true为显示过,false直接返回 + if (!this.isHasShowRecord(recordModel)) { + //将adList中的广告弹窗返回 + return adModel + } else { + if (index === adList.length - 1) { + //第一遍未查找到则直接返回null,在adlist长度不为0的情况下会有第二次查找 + return null + } + } + } else { + //将没有记录过的广告计入map记录 + adModel.hasShow = false + popAdRecord[adId] = adModel + return adModel + } + } + return null + } + + private isNeedShowPopupAd(popupAdModel: PopupAdMode) { + return !(!this.isAdModelValid(popupAdModel) || this.isShowedPopupAds) + } + + /** + * 是否已经显示过 + * @param recordModel + * @return + */ + private isHasShowRecord(recordModel: PopupAdMode) { + return recordModel != null && recordModel.hasShow + } + + /** + * 重置本地纪录中的弹窗广告记录标志为false + * @param adList 本次请求的弹窗广告 + * @param popAdRecordMap 本地的弹窗广告记录 + */ + private resetRecordFlag(adList: PopupAdMode[], popAdRecordMap: Record) { + //先判断每次广告弹窗的显示频率,重置所有每次登录都显示的显示标志为false + //每日一次的广告是否需要重置根据上一次显示时间确定 + for (let adIndex = 0; adIndex < adList.length; adIndex++) { + let id = adList[adIndex].adid + if (!popAdRecordMap[id]) { + continue + } + let recordAdModel = popAdRecordMap[id] + if (!recordAdModel) { + continue + } + let frequency = recordAdModel.frequency + //每天只弹一次的弹窗 + if (frequency == AdFrequency.FREQUENCY_EVERY_DAY && + !DateManager.isSameDay(new Date(), new Date(recordAdModel.showTime)) + ) { + //如果记录的显示时间和今天不是同一天 + recordAdModel.hasShow = false + } + //每次都要弹出的弹窗 + if (frequency == AdFrequency.FREQUENCY_EVERY_LOGIN) { + recordAdModel.hasShow = false + } + } + } + + /** + * 去获取广告图片,展示首页弹窗广告 + * @return false:不弹首页弹窗广告 + */ + private showFirstPagePopupAd(): boolean { + if (this.mPopupAdModel == null) { + return false + } + if (!this.isSupportDialogAd()) { + return false + } + this.sendMessage(this.mPopupAdModel) + return true + } + + private sendMessage(showAdModel: PopupAdMode): void { + let isShow = this.showPopupAdDialog(showAdModel) + let elkMsg = + `handle popup index ad userId:${this.getNotNullUserId()} poPupAdModel:${showAdModel.toString()} showPopupAdDialog:${isShow}` + if (isShow) { + //发送弹窗广告曝光埋点 + this.sendPopupAndStat(showAdModel) + elkMsg += ` isShowedPopupAds:${this.isShowedPopupAds}` + this.isShowedPopupAds = true + // 首页弹窗显隐确定时,通知广告组件 + AdsPopupHost.getInstance().notifyFirstPageMatch(true) + } else { + // 首页弹窗显隐确定时,通知广告组件 + AdsPopupHost.getInstance().notifyFirstPageMatch(false) + } + this.uploadLog(ELK_LEVEL.ERROR, elkMsg) + } + + private showPopupAdDialog(showAdModel: PopupAdMode): boolean { + if (!this.isAdModelValid(showAdModel)) { + return false + } + //显示投教就先不显示弹窗广告,并计入有弹窗广告没显示 + if (this.isShowToujiaoViewed()) { + this.isHasTanChuangAdNotShow = true + return false + } + + try { + let dialogParam: FirstPageDialogParam = new FirstPageDialogParam(showAdModel.toBaseAd(), () => { + // 全域运营位广告的5分钟防抖也被首页弹窗影响 + AdsPopupHost.getInstance().notifyFirstPageClose() + // 发送关闭埋点 + this.sendPopupAdCbas(CBAS_FIRSTPAGE_TANGCHUANGAD_CLOSE, showAdModel) + HXLog.d(TAG, + `[埋点] sendClickStat page:AD_FIRST_PAGE_DIALOG, adid:${showAdModel.adid}, type:AD_CLICK_TYPE_UNINTEREST`) + this.adDialog?.dismiss() + this.adDialog = null + dialogParam.releaseAdImg() + }, () => { + this.adDialog?.dismiss() + this.adDialog = null + dialogParam.releaseAdImg() + // 发送点击埋点 + this.sendPopupAdEnterCbas(showAdModel) + HXLog.d(TAG, + `[埋点] sendClickStat page:AD_FIRST_PAGE_DIALOG, adid:${showAdModel.adid}, type:AD_CLICK_TYPE_INTEREST`) + AdsManager.clickAd(ADS_TYPE_INDEX_POPUP, showAdModel.toBaseAd()) + if (showAdModel.isOpenInnerWebView) { + RouterUtil.jumpPage(showAdModel.jumpUrl) + } else { + // 外部浏览器打开 + this.openWithExternalWebView(showAdModel.jumpUrl ?? '') + } + AdsPopupHost.getInstance().onPageJump(AdsPageConfig.INVALID) + AdsPopupHost.getInstance().notifyFirstPageMatch(false) + }) + this.adDialog = DialogHub.getCustomDialog() + .setContent(wrapBuilder(FirstPageDialogBuilder), dialogParam) + .setStyle({ backgroundColor: Color.Transparent, maskColor: $r('app.color.color_80000000') }) + .setConfig({ dialogBehavior: { autoDismiss: false } }) + .build() + //加载弹窗图片 + ImageLoader.load(showAdModel.imgUrl ?? "", (errMsg) => { + let elkMsg = `onLoadFailed showAdModel:${showAdModel.toString()} Exception:${errMsg}` + this.uploadLog(ELK_LEVEL.ERROR, elkMsg) + }, (img) => { + if (this.firstPageVisible) { + AdsManager.showAd(ADS_TYPE_INDEX_POPUP, showAdModel.toBaseAd()) + dialogParam.releaseAdImg() + dialogParam.adImg = img + this.adDialog?.updateContent(dialogParam) + // 首页弹窗显隐确定时,通知广告组件 + AdsPopupHost.getInstance().notifyFirstPageMatch(true) + this.adDialog?.show() + return + } + img.release() + let elkMsg = `onResourceReady showAdModel:${showAdModel.toString()} frameId:${getCurrentFrameId()}` + this.uploadLog(ELK_LEVEL.ERROR, elkMsg) + }) + return true + } catch (e) { + const err = e as BusinessError + let elkMsg = `BadTokenException showAdModel:${showAdModel.toString()} Exception:${err.message}` + this.uploadLog(ELK_LEVEL.ERROR, elkMsg) + } + return false + } + + /** + * 外部浏览器打开 + */ + private openWithExternalWebView(url: string): void { + const context = AppUtil.getContext() + context.openLink(url).then(() => { + HXLog.i(TAG, `openWithExternalWebView success`) + }).catch((e: BusinessError) => { + HXLog.e(TAG, `openWithExternalWebView failed. code:${e.code} msg:${e.message}`) + }) + } + + /** + * 是否支持弹框广告,true,公版默认支持,false,不支持 + */ + isSupportDialogAd(): boolean { + return true + } + + /** + * 对于首页弹窗可能会与投承广告跳转冲突问题进行特殊解决 + * 投承限定于新用户,对于新用户来说在第一次到首页可能会有投承,所以对新用户第二次回到首页才重新进行显示弹窗 + * + * @return + */ + isCanShowIndexPopupAd(): boolean { + // !NewUserManager.isNewUser() || isHasJumpOtherPage || isHasQhJump + return true + } + + + isAdModelValid(popupAdMode: PopupAdMode) { + return !(!popupAdMode.imgUrl || !popupAdMode.jumpUrl) + } + + isShowToujiaoViewed() { + return false + } + + // ==================== 埋点 ==================== + private sendPopupAndStat(popupAdModel: PopupAdMode) { + // 发送曝光埋点 + this.sendPopupAdCbas(CBAS_FIRSTPAGE_TANGCHUANGAD_BAOGUANG, popupAdModel) + // 发送 HTTPStat 曝光 + HXLog.d(TAG, + `[埋点] sendNonClickStat operationType:AD_OPERATION_SHOW, page:AD_FIRST_PAGE_DIALOG, adid:${popupAdModel.adid}`) + } + + /** + * 发送非跳转页面类的埋点 + */ + private sendPopupAdCbas(cbas: string, popupAdModel: PopupAdMode) { + const cbasObj = CBAS_FIRSTPAGE_PREFIX + cbas + const formatCbas = cbasObj.replace('%s', popupAdModel.adid) + HXLog.d(TAG, `[埋点] sendPopupAdCbas: ${formatCbas}`) + } + + + private sendPopupAdEnterCbas(popupAdModel: PopupAdMode) { + const cbasObj = CBAS_FIRSTPAGE_PREFIX + CBAS_FIRSTPAGE_TANGCHUANGAD_DIANJI + const formatCbas = cbasObj.replace('%s', popupAdModel.adid) + HXLog.d(TAG, `[埋点] sendPopupAdEnterCbas: ${formatCbas}`) + } + + private uploadLog(biz_level: ELK_LEVEL, msg: string) { + sendElk('biz_firstpage', msg, biz_level, true, TAG) + } +} diff --git a/src/main/ets/node/helper/StatLogFilter.ets b/src/main/ets/node/helper/StatLogFilter.ets new file mode 100644 index 0000000..ffc1ee5 --- /dev/null +++ b/src/main/ets/node/helper/StatLogFilter.ets @@ -0,0 +1,38 @@ +/** + * author : liuqingliang@myhexin.com + * time : created on 2026/6/2 + * desc : 广告埋点过滤器 -- 防止同一 adId 在同一轮播周期内重复发送曝光埋点。 + */ +export class StatLogFilter { + /** 已发送过的 adId 集合 */ + private adIdSet: Set = new Set() + + /** + * 判断 adId 是否已在过滤列表中,是则跳过埋点。 + * @param adId 广告 id + * @returns true=已存在需过滤,false=新广告需发送 + */ + filterAdId(adId: string): boolean { + if (this.adIdSet.has(adId)) { + return true + } else { + this.adIdSet.add(adId) + } + return false + } + + /** + * 将 adId 添加到过滤列表。 + * @param adId 广告 id + */ + addAdId(adId: string): void { + this.adIdSet.add(adId) + } + + /** + * 重置过滤器,清空所有记录。 + */ + resetFilter(): void { + this.adIdSet.clear() + } +} \ No newline at end of file diff --git a/src/main/ets/node/model/AiDiagnosisNodeModel.ets b/src/main/ets/node/model/AiDiagnosisNodeModel.ets new file mode 100644 index 0000000..2866152 --- /dev/null +++ b/src/main/ets/node/model/AiDiagnosisNodeModel.ets @@ -0,0 +1,109 @@ +/** + * getAllCards 接口响应 + */ +export interface AllCardsResponse { + code: number + msg: string + data: AllCardsFloor[] +} + +/** + * 楼层数据 + */ +export interface AllCardsFloor { + id: number + key: string + title: string + card_list: AllCardsCard[] +} + +/** + * 卡片配置 + */ +export interface AllCardsCard { + card_id: number + card_key: string + render_key: string + card_title: CardTitle + card_url: CardUrl + mod_data: ModDataItem[] + bury_point: string + explain_title?: string + explain_message?: string +} + +/** + * 卡片标题 + */ +export interface CardTitle { + value: string + type: string +} + +/** + * 卡片跳转链接 + */ +export interface CardUrl { + ios: string + android: string +} + +/** + * 数据请求配置 + */ +export interface ModDataItem { + url: string + param_list: string[] + req_desc: string +} + +/** + * AI 诊断接口响应 + */ +export interface AiDiagnosisResponse { + status_code: number + status_msg: string + data: AiDiagnosisData +} + +/** + * AI 诊断数据 + */ +export class AiDiagnosisData { + market: string = '' + score: string = '' + code: string = '' + factor_list: FactorItem[] = [] + name: string = '' + recommend: RecommendItem[] = [] + message: string = '' +} + +/** + * 因子项 + */ +export class FactorItem { + score: string = '' + judge: string = '' + factor: string = '' +} + +/** + * 推荐合约项 + */ +export class RecommendItem { + market: string = '' + score: string = '' + code: string = '' + name: string = '' +} + +/** + * 推荐合约(用于点击跳转) + */ +export interface RecommendContract { + name: string + score: string + code: string + market: string +} \ No newline at end of file diff --git a/src/main/ets/node/model/AiRiseFallNodeModel.ets b/src/main/ets/node/model/AiRiseFallNodeModel.ets new file mode 100644 index 0000000..675fd5d --- /dev/null +++ b/src/main/ets/node/model/AiRiseFallNodeModel.ets @@ -0,0 +1,50 @@ +/** + * AI 看涨跌接口响应 + */ +export interface AiRiseFallResponse { + code: number + msg: string + data: AiRiseFallData +} + +/** + * AI 看涨跌数据 + */ +export interface AiRiseFallData { + date: string // 日期 "2026-06-01" + last_trade_date_accuracy: string // 昨日正确率 "66.7" + last_twenty_trade_date_accuracy: string // 近20日正确率 "66.4" + rise: RiseFallListData // 看涨列表 + fall: RiseFallListData // 看跌列表 +} + +/** + * 看涨/看跌列表数据 + */ +export interface RiseFallListData { + count: number + data: RiseFallItem[] +} + +/** + * 看涨/看跌项 + */ +export interface RiseFallItem { + variety: string // 品种代码 "SA" + name: string // 合约名称 "纯碱2609" + contract: string // 合约代码 "SA2609" + market: string // 市场代码 "67" + forecast: number // 预测方向 0=看涨, 1=看跌 + accuracy: string // 品种正确率 "80.0" + show_quote: number // 是否显示行情 1=显示 +} + +/** + * 合约行情数据(用于行情订阅) + */ +export interface ContractHqData { + contract: string + market: string + name: string + priceChg: number | null // 涨跌幅,null 表示待开盘 +} \ No newline at end of file diff --git a/src/main/ets/node/model/BannerNodeModel.ts b/src/main/ets/node/model/BannerNodeModel.ts new file mode 100644 index 0000000..bfb5f35 --- /dev/null +++ b/src/main/ets/node/model/BannerNodeModel.ts @@ -0,0 +1,9 @@ + +export type BannerNodeModel = { + id: number, + title: string + imgUrl: string + jumpUrl: string + startTime: number + endTime: number +} \ No newline at end of file diff --git a/src/main/ets/node/model/CommodityOptionsNodeModel.ets b/src/main/ets/node/model/CommodityOptionsNodeModel.ets new file mode 100644 index 0000000..36851fd --- /dev/null +++ b/src/main/ets/node/model/CommodityOptionsNodeModel.ets @@ -0,0 +1,56 @@ +/** + * 商品期权卡片数据模型 + * + * Author: YS + * Date: 2026-06-03 + */ + +/** + * Tab 配置项 + */ +export interface CommodityOptionsTab { + label: ResourceStr // Tab 标签 + api: string // API 标识 + stat: string // 统计标识 + defaultMsg: ResourceStr // 空数据提示信息 + index: number // Tab 索引 +} + +/** + * Tab 配置 + */ +export const COMMODITY_OPTIONS_TABS: CommodityOptionsTab[] = [ + { + label: $r('app.string.tab_hot'), + api: 'rise_percent', + stat: 'hot', + defaultMsg: $r('app.string.commodity_options_hot_default_msg'), + index: 0, + }, + { + label: $r('app.string.tab_near_expired'), + api: 'near_expired', + stat: 'impend', + defaultMsg: $r('app.string.commodity_options_near_expired_default_msg'), + index: 1, + }, +] + +/** + * 推荐期权合约项 + */ +export class CommodityOptionsContract { + contract_code: string = '' // 合约代码 + contract_name: string = '' // 合约名称 + market: string = '' // 市场代码 + expired_date: string = '' // 到期日(格式:20250628) +} + +/** + * API 响应 + */ +export interface CommodityOptionsResponse { + code: number + msg: string + data: CommodityOptionsContract[] +} \ No newline at end of file diff --git a/src/main/ets/node/model/CustomEntryListNodeModel.ets b/src/main/ets/node/model/CustomEntryListNodeModel.ets new file mode 100644 index 0000000..8222560 --- /dev/null +++ b/src/main/ets/node/model/CustomEntryListNodeModel.ets @@ -0,0 +1,13 @@ +export class CustomEntryListNodeModel { + id: number = -1 + title: string = '' + imgUrl: string | Resource = '' + jumpUrl: string = '' + position: number = -1 + startVersion: number = 0 + endVersion: number = 0 + buriedPoint: string = '' + buriedPointSource: string = '' + gridTag: string = '' + gridTagEffect: string = '' +} \ No newline at end of file diff --git a/src/main/ets/node/model/EntryListNodeModel.ts b/src/main/ets/node/model/EntryListNodeModel.ts new file mode 100644 index 0000000..72eb326 --- /dev/null +++ b/src/main/ets/node/model/EntryListNodeModel.ts @@ -0,0 +1,14 @@ + +export type EntryListNodeModel = { + id: number, + title: string, + imgUrl: string, + jumpUrl: string, + startVersion: string, + endVersion: string, + startTime: number, + endTime: number, + buriedPoint: string, + buriedPointSource: string, + gridTag: string +} \ No newline at end of file diff --git a/src/main/ets/node/model/HotListNodeModel.ets b/src/main/ets/node/model/HotListNodeModel.ets new file mode 100644 index 0000000..daec375 --- /dev/null +++ b/src/main/ets/node/model/HotListNodeModel.ets @@ -0,0 +1,55 @@ +/** + * 热点排行数据模型 + * @author YS + * @date 2026-06-03 + */ + +/** + * 热点排行数据项 + * API 返回字段: title, url, tag, color, sorts, hot_value + */ +export interface HotListItemModel { + url: string // 跳转地址 + title: string // 资讯标题 + tag: string | null // 标签名称(可能为 null) + color: string | null // 颜色名称("red" | "orange",可能为 null) + sorts: number // 排序值 + hot_value: string // 热度值 +} + +/** + * 热点排行接口返回结果 + */ +export interface HotListResultModel { + code: number // 接口返回码 + msg: string // 返回消息 + data: HotListItemModel[] // 热点数据列表 +} + +/** + * 颜色名称到资源ID的映射 + */ +export const COLOR_NAME_MAP: Record = { + 'purple': $r('app.color.hotlist_purple'), + 'blue': $r('app.color.hotlist_blue'), + 'acidblue': $r('app.color.hotlist_acid_blue'), + 'green': $r('app.color.hotlist_green'), + 'orange': $r('app.color.hotlist_orange'), + 'red': $r('app.color.hotlist_red'), +} + +/** + * 默认颜色资源 + */ +const DEFAULT_COLOR: Resource = $r('app.color.hotlist_blue') + +/** + * 根据颜色名称获取颜色值 + */ +export function getColorValue(colorName: string | null): ResourceStr { + if (!colorName) { + return DEFAULT_COLOR + } + const color = COLOR_NAME_MAP[colorName] + return color ?? DEFAULT_COLOR +} \ No newline at end of file diff --git a/src/main/ets/node/model/HotNewsNodeModel.ts b/src/main/ets/node/model/HotNewsNodeModel.ts new file mode 100644 index 0000000..b9249e6 --- /dev/null +++ b/src/main/ets/node/model/HotNewsNodeModel.ts @@ -0,0 +1,27 @@ + +export type HotNewsNodeModelResult = { + code: string, + mes: string, + data: HotNewsNodeModel[] +} + +export type HotNewsNodeModel = { + seq: string, // 资讯唯一标识 + title: string, // 资讯标题 + source: string, // 来源 + url: string, // 资讯跳转地址 + img_url: string, // 图片地址 + create_time: string, // 资讯发布时间 + copyright: string, // 资讯版权 + type: string, // 资讯分类 + tags: HotNewsTagNodeModel[], // 资讯标签 +} + +export type HotNewsTagNodeModel = { + seq: string, // 资讯唯一标识 + name: string, // 标签名称 + weight: string, // 资讯权重 + code: string, // 品种代码 + market: string, // 市场id +} + diff --git a/src/main/ets/node/model/PopupAdMode.ets b/src/main/ets/node/model/PopupAdMode.ets new file mode 100644 index 0000000..8db4b23 --- /dev/null +++ b/src/main/ets/node/model/PopupAdMode.ets @@ -0,0 +1,83 @@ +import { BaseAd, FatigueDegree } from "@b2c-f/fuhm-ads" + +/** + * author : liuqingliang@myhexin.com + * time : created on 2026/2/27 + * desc : 弹窗广告数据模型 + */ +export class PopupAdMode { + adid: string = '' + startTime: number = 0 + endTime: number = 0 + imgUrl: string | null = null + jumpUrl: string | null = null + isOpenInnerWebView: boolean = false + frequency: AdFrequency = AdFrequency.FREQUENCY_EVERY_LOGIN + position: number = 0 + /** + * 上次显示时间 + */ + showTime: number = 0 + /** + * 上次是否显示过 + */ + hasShow: boolean = false + fatigueDegree: FatigueDegree | null = null + + toString(): string { + return `PopupAdMode (adid='${this.adid}', frequency=${this.frequency}, position=${this.position}, hasShow=${this.hasShow}, fatigueDegree=${this.fatigueDegree})` + } + + toBaseAd() { + let newAd = new BaseAd() + newAd.adId = this.adid + newAd.startTime = this.startTime + newAd.endTime = this.endTime + newAd.imgUrl = this.imgUrl ?? "" + newAd.jumpUrl = this.jumpUrl ?? "" + newAd.isOpenInnerWebView = this.isOpenInnerWebView + newAd.fatigueDegree = this.fatigueDegree + newAd.position = this.position + return newAd + } +} + +/** + * 首页弹窗广告显示频率 + */ +export enum AdFrequency { + /** + * 有效期间内,每次登录都显示 + */ + FREQUENCY_EVERY_LOGIN = 0, + /** + * 有效期间内,每天显示一次 + */ + FREQUENCY_EVERY_DAY = 1, + /** + * 有效期间内,只显示一次 + */ + FREQUENCY_ONLY_ONCE = 2 +} + +/** + * 首页cpyyy适配器 + * */ +export function adapterForIndexPopupActivity(newAdModels: BaseAd[]) { + let oldAdModels: PopupAdMode[] = [] + newAdModels.forEach((it) => { + let oldPopupAdMode = new PopupAdMode() + oldPopupAdMode.adid = it.adId ?? "" + oldPopupAdMode.startTime = it.startTime + oldPopupAdMode.endTime = it.endTime + oldPopupAdMode.imgUrl = it.imgUrl + oldPopupAdMode.jumpUrl = it.jumpUrl + oldPopupAdMode.isOpenInnerWebView = it.isOpenInnerWebView + oldPopupAdMode.frequency = + it.fatigueDegree?.isDailyShowOnce == 1 ? AdFrequency.FREQUENCY_EVERY_DAY : AdFrequency.FREQUENCY_EVERY_LOGIN + oldPopupAdMode.fatigueDegree = it.fatigueDegree + oldPopupAdMode.position = it.position + oldAdModels.push(oldPopupAdMode) + }) + return oldAdModels +} \ No newline at end of file diff --git a/src/main/ets/node/model/SelfSelectedStockModel.ets b/src/main/ets/node/model/SelfSelectedStockModel.ets new file mode 100644 index 0000000..e97d730 --- /dev/null +++ b/src/main/ets/node/model/SelfSelectedStockModel.ets @@ -0,0 +1,152 @@ +import { HXUtils, StockMarketUtil } from "@b2c/lib_baseui"; +import { NumberUtils } from "hxutil"; + +export interface SelfSelectedStockModel { + id: string; + stockCode: string; + stockName: string; + currentPrice: number; + changeAmount: number; + changePercent: number; + updateTime?: number; +} + +export interface SelfSelectedStockModelMock { + code: string; + name: string; + price: number; + change: number; + percent: number; +} + +export interface StockGroupModel { + id: string; + groupName: string; + stocks: SelfSelectedStockModel[]; + isSelected: boolean; +} + + +const HQ_DATA_INVALID = "--" + +@Observed +export class SwiperHqDataItemModel extends Array{} + +@Observed +export class GridHqDataItemModel extends Array{} + +/** + * 行情自定义数据结构 + */ +@Observed +export class HQDataItemModel{ + name:string = HQ_DATA_INVALID + code:string = HQ_DATA_INVALID + showCode:string = HQ_DATA_INVALID + market:string = "0" + price:string = HQ_DATA_INVALID + riseFail:string = HQ_DATA_INVALID //涨跌幅 + priceRise:string = HQ_DATA_INVALID + nameColor:ResourceColor = 0 + priceColor:ResourceColor = 0 + riseFailColor:ResourceColor = 0 + priceRiseColor:ResourceColor = 0 + showAddImage:boolean = false + /** + * -1:跌,1:涨,0:不变 + */ + riseFailFlag:number = 0 + + priceChange:boolean = false + + + + constructor(stockCode?:string,stockMarket?:string,stockName?:string) { + this.code = stockCode??HQ_DATA_INVALID + this.market = stockMarket??HQ_DATA_INVALID + this.name = stockName??HQ_DATA_INVALID + this.showCode = stockCode??HQ_DATA_INVALID + } + + copyFrom(dataItemModel:HQDataItemModel){ + this.name = dataItemModel.name + const market = dataItemModel.market + const showCodeTemp = dataItemModel.showCode + if ((StockMarketUtil.isOption(market+"") || StockMarketUtil.isFutureTL(market+"")) && HXUtils.isValidContractData(showCodeTemp+"")) { + this.showCode = showCodeTemp + }else{ + this.showCode = this.code + } + + this.riseFailFlag = this.getRiseFailFlag(dataItemModel.price,this.price) + this.priceChange = this.riseFailFlag != 0 + this.price = dataItemModel.price + this.riseFail = dataItemModel.riseFail + this.priceRise = dataItemModel.priceRise + this.riseFailColor = dataItemModel.riseFailColor + this.riseFailColor = dataItemModel.riseFailColor + } + + getMapKey():string{ + return this.code+"_"+this.market + } + + + + private getRiseFailFlag(newPrice:string,lastPrice:string){ + let ret = 0 + if (newPrice == lastPrice){ + return ret + } + if (NumberUtils.isNumeric(newPrice) && NumberUtils.isNumeric(lastPrice)) { + const newPriceNumber = parseFloat(newPrice) + const lastPriceNumber = parseFloat(lastPrice) + if (newPriceNumber == lastPriceNumber) { + ret = 0 + }else { + ret = newPriceNumber > lastPriceNumber ? 1:-1 + } + } + return ret + } + + copy():HQDataItemModel{ + let copyDataItemModel = new HQDataItemModel(this.code,this.market,this.name) + copyDataItemModel.showCode = this.showCode + copyDataItemModel.price = this.price + copyDataItemModel.riseFail = this.riseFail + copyDataItemModel.priceRise = this.priceRise + copyDataItemModel.nameColor = this.nameColor + copyDataItemModel.riseFailColor = this.riseFailColor + copyDataItemModel.priceRiseColor = this.priceRiseColor + copyDataItemModel.showAddImage = this.showAddImage + copyDataItemModel.priceChange = this.priceChange + copyDataItemModel.riseFailFlag = this.riseFailFlag + return copyDataItemModel + } +} + +export class GridDataSource implements IDataSource{ + private dataArray:HQDataItemModel[] = [] + + constructor(data:HQDataItemModel[]) { + this.dataArray = data + } + totalCount(): number { + return this.dataArray.length + } + + getData(index: number): HQDataItemModel { + return this.dataArray[index] + } + + registerDataChangeListener(listener: DataChangeListener): void { + + } + + unregisterDataChangeListener(listener: DataChangeListener): void { + + } + +} + diff --git a/src/main/ets/node/view/AiDiagnosisNodeComponent.ets b/src/main/ets/node/view/AiDiagnosisNodeComponent.ets new file mode 100644 index 0000000..d911b0a --- /dev/null +++ b/src/main/ets/node/view/AiDiagnosisNodeComponent.ets @@ -0,0 +1,782 @@ +import { LengthMetrics, router } from '@kit.ArkUI'; +import { emitter } from '@kit.BasicServicesKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { AiDiagnosisDataFetcher } from '../datacenter/AiDiagnosisDataFetcher'; +import { AiDiagnosisData, AllCardsCard, FactorItem, RecommendItem } from '../model/AiDiagnosisNodeModel'; +import { FirstPageNodeModel } from '../../datacenter/model/FirstPageNodeModel'; +import { AiDiagnosisRequestClient } from '../clients/AiDiagnosisRequestClient'; +import { HeaderItem, RowData, CellArray, TableData } from '@b2b/hq_table'; +import { EmitterConstants, GlobalContext, HXLog } from 'biz_common'; +import { RouterUtil } from '../../util/RouterUtil'; +import { enableDarkMode } from '@kernel/theme_manager'; +import { TableConstants } from '@b2c/lib_baseui'; +import { QuoteCustomSettingManager, StandardPriceType } from 'biz_quote'; +import { TabType } from 'biz_quote/src/main/ets/config/tabConfig'; +import util from '@ohos.util'; + +// 评分背景图片配置文件 +const AI_SCORE_BG_IMAGES_FILE = 'ai_score_background_images.json' + +// 评分背景图片配置数据结构 +interface ScoreBgImagesConfig { + light: Record + 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 }) + } +} \ No newline at end of file diff --git a/src/main/ets/node/view/AiRiseFallNodeComponent.ets b/src/main/ets/node/view/AiRiseFallNodeComponent.ets new file mode 100644 index 0000000..b0a1708 --- /dev/null +++ b/src/main/ets/node/view/AiRiseFallNodeComponent.ets @@ -0,0 +1,738 @@ +import { router } from '@kit.ArkUI'; +import { emitter } from '@kit.BasicServicesKit'; +import { AiRiseFallDataFetcher } from '../datacenter/AiRiseFallDataFetcher'; +import { AiRiseFallData, RiseFallListData, RiseFallItem } from '../model/AiRiseFallNodeModel'; +import { AllCardsCard } from '../model/AiDiagnosisNodeModel'; +import { FirstPageNodeModel } from '../../datacenter/model/FirstPageNodeModel'; +import { AiRiseFallHqRequestClient, ContractHqItem } from '../clients/AiRiseFallHqRequestClient'; +import { HeaderItem, 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 { getResourceString } from '../../util/ResourceUtil'; +import { QuoteCustomSettingManager, StandardPriceType } from 'biz_quote'; + +// FrameId 和 PageId +const FRAME_ID_AI_RISEFALL = 2201 +const PAGE_ID_AI_RISEFALL = 4106 + +// 显示数量限制 +const MAX_CONTRACT_COUNT = 3 + +@Component +export struct AiRiseFallNodeComponent { + nodeModel: FirstPageNodeModel | null = null + @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: AiRiseFallData | null = null + + // Tab 状态 + @State private currentTabIndex: number = 0 + + // 合约行情数据 Map(key: contract_market) + @State private contractHqMap: Map = new Map() + + // 行情请求客户端 + private hqRequestClient: AiRiseFallHqRequestClient | null = null + + // TCP 重连监听 + private mIsEventSubscribed: boolean = false + private netRelinkCallBack = () => { + if (this.firstPageVisible) { + setTimeout(() => { + if (this.cardData) { + this.subscribeAllContracts(this.cardData) + } + }) + } + } + + // 上一次刷新时使用的基准价类型,用于在变可见时判断是否需要按新基准价重新请求 + private lastStandardPriceType: StandardPriceType = + QuoteCustomSettingManager.getInstance().getStandardPriceType() + + // 表头配置 + private headerConfig: HeaderItem[] = [ + new HeaderItem(TableConstants.DATA_ID_NAME, ''), + new HeaderItem(TableConstants.DATA_ID_CODE_4, ''), + new HeaderItem(TableConstants.DATA_ID_MARKET, ''), + new HeaderItem(TableConstants.DATA_ID_PRICE, ''), + new HeaderItem(TableConstants.DATA_ID_ZF, '') + ] + + aboutToAppear(): void { + AiRiseFallDataFetcher.getInstance().onCardConfigChange = (config) => { + this.cardConfig = config + } + + AiRiseFallDataFetcher.getInstance().onCardDataChange = (data) => { + this.cardData = data + if (data) { + // 订阅所有合约的行情 + this.subscribeAllContracts(data) + } + } + + this.initHqRequestClient() + this.refreshCardData() + + // 订阅 TCP 重连事件 + if (this.firstPageVisible) { + this.subscribeTcpNetStatus() + } + } + + onRefreshTriggerChange(): void { + this.refreshCardData() + } + + /** + * Tab 可见性变化时的回调 + * 可见时请求行情数据,不可见时释放行情请求 + */ + onTabVisibilityChange(): void { + HXLog.d('AiRiseFallNodeComponent', 'onTabVisibilityChange: ' + this.firstPageVisible) + if (this.firstPageVisible) { + // 可见时重新请求数据 + if (this.cardData) { + this.subscribeAllContracts(this.cardData) + } + // 订阅 TCP 重连事件 + this.subscribeTcpNetStatus() + // 检测基准价是否切换,切换后重新订阅 + const currentStandardPriceType = QuoteCustomSettingManager.getInstance().getStandardPriceType() + if (this.lastStandardPriceType !== currentStandardPriceType) { + HXLog.d('AiRiseFallNodeComponent', 'Standard price type changed, resubscribe') + this.lastStandardPriceType = currentStandardPriceType + if (this.cardData) { + this.subscribeAllContracts(this.cardData) + } + } + } else { + // 不可见时释放行情订阅 + this.releaseHqClient() + // 取消订阅 TCP 重连事件 + this.unSubscribeTcpNetStatus() + } + } + + /** + * 初始化行情请求客户端 + */ + private initHqRequestClient(): void { + this.hqRequestClient = new AiRiseFallHqRequestClient( + FRAME_ID_AI_RISEFALL, + PAGE_ID_AI_RISEFALL, + this.headerConfig, + (tableData: TableData | string) => {} + ) + + // 设置行情数据回调 + this.hqRequestClient.setHqDataCallback((hqs: ContractHqItem[]) => { + this.onHqDataReceive(hqs) + }) + } + + refreshCardData(): void { + AiRiseFallDataFetcher.getInstance().fetchCardConfig() + } + + 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) + } + + /** + * 订阅所有合约的行情数据 + */ + private subscribeAllContracts(data: AiRiseFallData): void { + const codes: string[] = [] + const markets: string[] = [] + + const collectContracts = (items: RiseFallItem[]) => { + if (items) { + items.slice(0, MAX_CONTRACT_COUNT).forEach((item: RiseFallItem) => { + if (item.show_quote === 1 && item.contract && item.market) { + codes.push(item.contract) + markets.push(item.market) + } + }) + } + } + + // 收集看涨和看跌列表中的合约 + collectContracts(data.rise?.data || []) + collectContracts(data.fall?.data || []) + + if (codes.length > 0) { + // 先清理上次的订阅,避免重复 + this.releaseHqClient() + // 重新初始化客户端 + this.initHqRequestClient() + this.hqRequestClient?.setStockListAndRequest(codes, markets) + } + } + + /** + * 处理行情数据接收 + */ + private onHqDataReceive(hqs: ContractHqItem[]): void { + hqs.forEach((hq: ContractHqItem) => { + const key = `${hq.code}_${hq.market}` + this.contractHqMap.set(key, hq) + }) + } + + private releaseHqClient(): void { + if (this.hqRequestClient) { + this.hqRequestClient.release(true) + this.hqRequestClient = null + } + } + + /** + * 格式化涨跌幅 + */ + formatPriceChg(priceChg: number | null): ResourceStr { + if (priceChg === null) { + return $r('app.string.wait_open') + } + const sign = priceChg > 0 ? '+' : '' + return `${sign}${priceChg.toFixed(2)}%` + } + + /** + * 获取涨跌幅颜色 + */ + getPriceChgColor(priceChg: number | null): ResourceStr { + if (priceChg === null) { + return $r('app.color.price_even') + } + if (priceChg > 0) { + return $r('app.color.price_up_100') + } else if (priceChg < 0) { + return $r('app.color.price_down_100') + } + return $r('app.color.price_even') + } + + /** + * 获取方向文案 + */ + getDirectionText(forecast: number): ResourceStr { + return forecast === 0 ? $r('app.string.tab_fall') : $r('app.string.tab_rise') + } + + /** + * 处理标题点击 + */ + handleTitleClick() { + if (!this.cardConfig?.card_url) { + return + } + const url = this.cardConfig.card_url.ios || this.cardConfig.card_url.android || '' + if (url.length === 0) { + return + } + RouterUtil.jumpPage(url, true) + } + + /** + * 处理正确率点击(跳转历史页面) + */ + handleAccuracyClick(label: string) { + if (label === '昨日正确率') { + const url = getResourceString(getContext(this), $r('app.string.ai_rise_fall_history_url')) + RouterUtil.jumpPage(url, true) + } + } + + /** + * 处理合约点击 + */ + handleContractClick(item: RiseFallItem) { + if (item.contract && item.market) { + router.pushUrl({ + url: 'pages/QuoteDetailPage', + params: { + marketId: item.market, + code: item.contract, + name: item.name, + codeList: item.contract, + marketIdList: item.market, + nameList: item.name, + periodIndex: 0, + defaultTabType: 0 + } + }) + } + } + + /** + * 获取当前 Tab 数据 + */ + getCurrentTabData(): RiseFallListData | null { + if (!this.cardData) { + return null + } + return this.currentTabIndex === 0 ? this.cardData.rise : this.cardData.fall + } + + build() { + Column() { + this.buildTitleBar() + + if (this.cardData) { + this.buildCardContent() + } + } + .padding({ left: 10, right: 10, bottom: 10 }) + .width('100%') + .backgroundColor($r('app.color.surface_layer1_foreground')) + .borderRadius(4) + .bindSheet($$this.isSheetShow, this.buildExplainSheetContent, { + height: SheetSize.FIT_CONTENT, // 固定高度 + dragBar: true, + showClose: false, + maskColor: $r('app.color.mask_level2'), + onDisappear: () => { + // 关闭时重置状态 + }, + radius: { + topLeft: 10, + topRight: 10 + } + }) + } + + @Builder + buildTitleBar() { + Row() { + Row() { + Text(this.cardConfig?.card_title?.value || 'AI看涨跌') + .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')) + + // 功能提醒图标 + 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.showExplainDialog()) + } + } + .alignItems(VerticalAlign.Center) + .height('100%') + .onClick(() => this.handleTitleClick()) + + Blank() + } + .width('100%') + .height(48) + } + + /** + * 显示功能说明弹窗(底部弹出,占屏幕80%高度,宽度撑满) + */ + showExplainDialog(): void { + const title = this.cardConfig?.explain_title ?? $r('app.string.ai_rise_fall_explain_title') + const message = this.cardConfig?.explain_message ?? '' + + // 使用 bindSheet 实现底部弹出效果 + this.sheetTitle = title + this.sheetMessage = message + this.isSheetShow = true + } + + @State private sheetTitle: ResourceStr = '' + @State private sheetMessage: string = '' + @State private isSheetShow: boolean = false + + @Builder + buildExplainSheetContent() { + Column() { + Row() { + Blank().layoutWeight(1) + + Text(this.sheetTitle) + .fontSize(18) + .fontWeight(500) + .layoutWeight(1) + + Row() { + Image($r('app.media.icon_close_24')) + .width(24) + .height(24) + .fillColor($r('app.color.elements_icon_primary_02')) + .onClick(() => { + this.isSheetShow = false + }) + } + .height('100%') + .justifyContent(FlexAlign.End) + .layoutWeight(1) + } + .justifyContent(FlexAlign.Center) + .height(64) + .width('100%') + + + Text(this.sheetMessage.replace(/\\n/g, '\n')) + .fontSize(14) + .lineHeight(22) + .fontColor($r('app.color.elements_text_primary_02')) + .width('100%') + + + Button($r('app.string.confirm_button')) + .type(ButtonType.Normal) + .backgroundColor($r('app.color.elements_button_bg_primary')) + .width('100%') + .height(44) + .borderRadius(4) + .onClick(() => { + this.isSheetShow = false + }) + } + .width('100%') + .backgroundColor($r('app.color.surface_layer3_background')) + .padding({ + bottom: px2vp(GlobalContext.navigationBarHeight), + left: 16, + right: 16 + }) + } + + @Builder + buildCardContent() { + Column({ space: 10 }) { + Column({ space: 10 }) { + // 正确率展示 + this.buildDataDisplay() + + // 风险提示(在正确率下方) + this.buildRiskTip() + } + .padding({ + top: 8, + bottom: 8, + left: 4, + right: 4 + }) + .width('100%') + .backgroundColor($r('app.color.elements_others_bg_layer2')) + .borderRadius(4) + + // Tab 切换 + this.buildTabBar() + + // 合约表格 + this.buildContractTable() + + // 显示更多 + this.buildSeeMore() + } + .width('100%') + } + + @Builder + buildDataDisplay() { + Row() { + // 近20日正确率 + Column() { + Text($r('app.string.accuracy_recent_twenty')) + .fontColor($r('app.color.elements_text_tertiary')) + .fontSize(13) + .margin({ bottom: 4 }) + + Text(this.cardData ? this.cardData.last_twenty_trade_date_accuracy + '%' : '--') + .fontColor(this.getAccuracyColor(this.cardData?.last_twenty_trade_date_accuracy)) + .fontSize(16) + .fontWeight(500) + } + .layoutWeight(1) + + // 分割线 + Column() + .width(0.5) + .height(32) + .backgroundColor($r('app.color.elements_others_divider_primary')) + + // 昨日正确率(可点击) + Column() { + Row() { + Text($r('app.string.accuracy_yesterday')) + .fontColor($r('app.color.elements_text_tertiary')) + .fontSize(13) + .margin({ bottom: 4 }) + + Image($r('app.media.icon_arrow_forward_20')) + .width(16) + .height(16) + .fillColor($r('app.color.elements_icon_primary_02')) + .margin({ left: 4, bottom: 4 }) + } + + Text(this.cardData ? this.cardData.last_trade_date_accuracy + '%' : '--') + .fontColor(this.getAccuracyColor(this.cardData?.last_trade_date_accuracy)) + .fontSize(16) + .fontWeight(500) + } + .layoutWeight(1) + .onClick(() => this.handleAccuracyClick('昨日正确率')) + } + .width('100%') + .borderRadius(8) + } + + getAccuracyColor(value: string | undefined): ResourceStr { + if (!value) { + return $r('app.color.price_even') + } + const num = parseFloat(value) + if (num > 0) { + return $r('app.color.price_up_100') + } else if (num < 0) { + return $r('app.color.price_down_100') + } + return $r('app.color.price_even') + } + + @Builder + buildTabBar() { + Row() { + // 看涨 Tab + Row({ space: 8 }) { + Text($r('app.string.tab_rise')) + .fontColor(this.currentTabIndex === 0 ? $r('app.color.elements_text_primary_01') : $r('app.color.elements_text_secondary')) + .fontSize(14) + .fontWeight(this.currentTabIndex === 0 ? FontWeight.Medium : FontWeight.Normal) + + Text(`(${this.cardData?.rise?.count || 0}个)`) + .fontColor(this.currentTabIndex === 0 ? $r('app.color.elements_text_primary_01') : $r('app.color.elements_text_secondary')) + .fontSize(14) + .fontWeight(this.currentTabIndex === 0 ? FontWeight.Medium : FontWeight.Normal) + } + .height(30) + .constraintSize({ + minWidth: 66 + }) + .padding({ left: 8, right: 8, top: 8, bottom: 8 }) + .backgroundColor(this.currentTabIndex === 0 ? $r('app.color.elements_others_bg_primary') : $r('app.color.elements_button_bg_tertiary')) + .borderRadius(4) + .onClick(() => { + if (this.currentTabIndex !== 0) { + this.currentTabIndex = 0 + } + }) + + Blank().width(12) + + // 看跌 Tab + Row({ space: 8 }) { + Text($r('app.string.tab_fall')) + .fontColor(this.currentTabIndex === 1 ? $r('app.color.elements_text_primary_01') : $r('app.color.elements_text_secondary')) + .fontSize(14) + .fontWeight(this.currentTabIndex === 1 ? FontWeight.Medium : FontWeight.Normal) + + Text(`(${this.cardData?.fall?.count || 0}个)`) + .fontColor(this.currentTabIndex === 1 ? $r('app.color.elements_text_primary_01') : $r('app.color.elements_text_secondary')) + .fontSize(14) + .fontWeight(this.currentTabIndex === 1 ? FontWeight.Medium : FontWeight.Normal) + } + .height(30) + .constraintSize({ + minWidth: 66 + }) + .padding({ left: 8, right: 8, top: 8, bottom: 8 }) + .backgroundColor(this.currentTabIndex === 1 ? $r('app.color.elements_others_bg_primary') : $r('app.color.elements_button_bg_tertiary')) + .borderRadius(4) + .onClick(() => { + if (this.currentTabIndex !== 1) { + this.currentTabIndex = 1 + } + }) + + Blank() + + // 更新提示 + Text($r('app.string.update_time_tip')) + .fontColor($r('app.color.elements_text_tertiary')) + .fontSize(12) + } + .width('100%') + } + + @Builder + buildContractTable() { + Column() { + // 表头 + Row({ space: 8 }) { + Text($r('app.string.table_header_main_contract')) + .fontColor($r('app.color.elements_text_tertiary')) + .fontSize(14) + .layoutWeight(1) + + Text($r('app.string.table_header_direction')) + .fontColor($r('app.color.elements_text_tertiary')) + .fontSize(14) + .width(72) + .textAlign(TextAlign.End) + + Text($r('app.string.table_header_actual_change')) + .fontColor($r('app.color.elements_text_tertiary')) + .fontSize(14) + .width(72) + .textAlign(TextAlign.End) + + Text($r('app.string.table_header_variety_accuracy')) + .fontColor($r('app.color.elements_text_tertiary')) + .fontSize(14) + .width(72) + .textAlign(TextAlign.End) + } + .width('100%') + .padding({ top: 8, bottom: 8 }) + .border({ + width: { bottom: 1 }, + color: $r('app.color.elements_others_divider_primary'), + }) + + // 数据行 + ForEach(this.getCurrentTabData()?.data?.slice(0, MAX_CONTRACT_COUNT) || [], (item: RiseFallItem, index: number) => { + this.buildContractRow(item, index) + }, (item: RiseFallItem) => `${item.contract}_${item.market}`) + } + .width('100%') + } + + @Builder + buildContractRow(item: RiseFallItem, index: number) { + Row({space: 8}) { + // 主力合约 + + Row() { + Text(item.name || item.contract) + .fontColor($r('app.color.elements_text_primary_02')) + .fontSize(16) + .layoutWeight(1) + + // 方向 + Text(this.getDirectionText(item.forecast)) + .fontColor(this.currentTabIndex === 0 ? $r('app.color.price_up_100') : $r('app.color.price_down_100')) + .fontSize(16) + .width(40) + .fontWeight(500) + .textAlign(TextAlign.End) + } + .height(20) + .layoutWeight(1) + + // 实际涨幅 + Text(this.formatPriceChg(this.getContractHqPriceChg(item))) + .fontColor(this.getPriceChgColor(this.getContractHqPriceChg(item))) + .fontSize(16) + .width(72) + .height(20) + .textAlign(TextAlign.End) + .fontWeight(500) + + // 正确率 + Text(item.accuracy + '%') + .fontColor($r('app.color.elements_text_primary_02')) + .fontSize(16) + .width(72) + .height(20) + .textAlign(TextAlign.End) + .fontWeight(500) + } + .width('100%') + .padding({ top: 12, bottom: 8 }) + .border({ + width: { bottom: 1 }, + color: $r('app.color.elements_others_divider_primary') + }) + .onClick(() => this.handleContractClick(item)) + } + + /** + * 获取合约的行情涨跌幅 + */ + private getContractHqPriceChg(item: RiseFallItem): number | null { + const key = `${item.contract}_${item.market}` + return this.contractHqMap.get(key)?.priceChg ?? null + } + + @Builder + buildRiskTip() { + Text($r('app.string.risk_tip_content')) + .fontColor($r('app.color.elements_text_quaternary')) + .maxFontSize(10) + .minFontSize(9) + .textAlign(TextAlign.Center) + .width('100%') + } + + @Builder + buildSeeMore() { + Row() { + Text($r('app.string.see_more')) + .fontColor($r('app.color.elements_text_tertiary')) + .fontSize(16) + .height(20) + } + .width('100%') + .justifyContent(FlexAlign.Center) + .alignItems(VerticalAlign.Center) + .padding({ top: 2, bottom: 6 }) + .onClick(() => this.handleSeeMore()) + } + + /** + * 处理显示更多点击 + */ + handleSeeMore() { + // 跳转到 AI 看涨跌详情页面 + if (this.cardConfig?.card_url) { + const url = this.cardConfig.card_url.ios || this.cardConfig.card_url.android || '' + RouterUtil.jumpPage(url, true) + } + } +} \ No newline at end of file diff --git a/src/main/ets/node/view/BannerNodeComponent.ets b/src/main/ets/node/view/BannerNodeComponent.ets new file mode 100644 index 0000000..4d8433d --- /dev/null +++ b/src/main/ets/node/view/BannerNodeComponent.ets @@ -0,0 +1,224 @@ +import { OnAdsDataUpdateListener, BaseAd, AdsManager, ADS_TYPE_INDEX_BANAER } from '@b2c-f/fuhm-ads' +import { FirstPageNodeModel } from '../../datacenter/model/FirstPageNodeModel' +import { RouterUtil } from '../../util/RouterUtil' +import { HXLog } from 'biz_common' +import { systemDateTime } from '@kit.BasicServicesKit' +import { LengthMetrics } from '@kit.ArkUI' +import { + AD_CLICK_TYPE_INTEREST, + AD_CLICK_TYPE_INVALIDE, + AD_LOCATION_YUNYING_SHOUYE, + AD_OPERATION_CLICK, + AD_OPERATION_SHOW, + AD_OPERATION_SWITCH, + AD_TYPE_INVALID +} from '../../constants/FirstPageConstants' +import { StatLogFilter } from '../helper/StatLogFilter' +import { Action, HxCBASAgentProvider, logMapValue } from '@b2c-spi/hx_cbas_api' +import { FirstPageFiveCBASConstants } from '../../constants/FirstPageFiveCBASConstants' + +const TAG = 'BannerNodeComponent' + +/** + * 首页轮播图节点 + * @author wubingsong@myhexin.com + */ +@Component +export struct BannerNodeComponent { + nodeModel: FirstPageNodeModel | null = null + @State bannerHeight: number = 135 + @State private isNodeShow: boolean = false + @State private bannerNodeModelArr: BaseAd[] = [] + @State private isNeedIndicator: boolean = false + @State private currentIndex: number = 0 + @Consume @Watch('onShowStatusChange') firstPageVisible: boolean = true; + private CYCLE_GAP = 3000 + /** 上一次手动触摸时间,用于区分手动切换和自动轮播 */ + private lastTouchTime: number = 0 + /** 广告数据监听器 */ + private adsListener: OnAdsDataUpdateListener = { + onAdsDataUpdate: (adsType: string, adsDataModels: BaseAd[]) => { + if (this.firstPageVisible) { + this.handleAdsDataUpdate(adsDataModels) + } + } + } + /** 广告埋点过滤器 */ + private statLogFilter: StatLogFilter = new StatLogFilter() + /** 当前轮播真实广告数量 */ + private realCount: number = 0 + + aboutToAppear(): void { + // 注册广告数据监听 + AdsManager.addAdsDataUpdateListener(ADS_TYPE_INDEX_BANAER, this.adsListener) + if (this.firstPageVisible) { + // 请求广告数据 + AdsManager.refreshNotifyAds(ADS_TYPE_INDEX_BANAER) + } + } + + aboutToDisappear(): void { + // 移除广告数据监听 + AdsManager.removeAdsDataUpdateListener(ADS_TYPE_INDEX_BANAER, this.adsListener) + this.statLogFilter.resetFilter() + } + + onShowStatusChange() { + if (this.firstPageVisible) { + this.statLogFilter.resetFilter() + AdsManager.refreshNotifyAds(ADS_TYPE_INDEX_BANAER) + } + } + + /** + * 处理广告数据更新 + */ + private handleAdsDataUpdate(adsDataModels: BaseAd[]): void { + HXLog.i(TAG, `${TAG} onAdsDataUpdate, size = ${adsDataModels.length}`) + // 按 position 排序 + adsDataModels.sort((a, b) => a.position - b.position) + this.bannerNodeModelArr = adsDataModels + this.isNeedIndicator = adsDataModels.length > 1 + this.isNodeShow = adsDataModels.length > 0 + this.realCount = adsDataModels.length + // 数据到达且当前可见时,发曝光 stat + if (adsDataModels.length > 0 && this.firstPageVisible) { + this.sendAdStat(AD_OPERATION_SHOW, AD_CLICK_TYPE_INVALIDE, true) + } + } + + /** + * 发送广告统计日志 + * + * @param operationType 操作类型 + * @param clickType 点击类型 + * @param isNeedFilter 是否过滤 + */ + private sendAdStat(operationType: number, clickType: number, isNeedFilter: boolean): void { + const adItem = this.bannerNodeModelArr[this.currentIndex] + const adId = adItem?.adId + if (!adId) { + return + } + // 需要过滤且 adId 已存在则跳过 + if (isNeedFilter && this.statLogFilter.filterAdId(adId)) { + return + } + const adModule: string = AD_LOCATION_YUNYING_SHOUYE + if (operationType === AD_OPERATION_SWITCH && !this.statLogFilter.filterAdId(adId)) { + // 手动切换到广告且未在过滤列表时,先发一次曝光埋点,再发切换埋点 + this.doSendNonClickStat(AD_OPERATION_SHOW, adModule, adId, AD_TYPE_INVALID) + } + + if (operationType === AD_OPERATION_CLICK) { + this.doSendClickStat(adModule, adId, AD_TYPE_INVALID, clickType) + AdsManager.clickAd(ADS_TYPE_INDEX_BANAER, adItem) + } else { + this.sendCbas(Action.SHOW, adItem) + this.doSendNonClickStat(operationType, adModule, adId, AD_TYPE_INVALID) + AdsManager.showAd(ADS_TYPE_INDEX_BANAER, adItem) + } + } + + // 发送带广告类型的非点击STAT日志 + private doSendNonClickStat(operationType: number, adModule?: string, adId?: string, adType?: string) { + HXLog.i(TAG, `doSendNonClickStat operationType=${operationType} adModule=${adModule} adId=${adId} adType=${adType}`) + } + + // 发送带广告类型的点击STAT日志 + private doSendClickStat(adModule?: string, adId?: string, adType?: string, clickType?: number) { + HXLog.i(TAG, `doSendClickStat adModule=${adModule} adId=${adId} adType=${adType} clickType=${clickType}`) + } + + private sendCbas(action: Action, item: BaseAd) { + const oid = FirstPageFiveCBASConstants.CBAS_SHOUYE_BANNER + let logMap: Map = new Map() + logMap.set(FirstPageFiveCBASConstants.CBAS_BANNER_LOGMAP_ADID, item.adId) + HxCBASAgentProvider.getInstance().doEvent(oid, action, logMap) + HXLog.i(TAG, `sendCbas action:${action} adId=${item.adId}`) + } + + /** + * 广告点击处理 + */ + private handleAdClick(model: BaseAd): void { + // 发送点击埋点 + this.sendCbas(Action.CLICK, model) + // 处理网页埋点的初始化 + let jumUrl = model.jumpUrl + if (jumUrl && jumUrl.length !== 0) { + const adModule: string = AD_LOCATION_YUNYING_SHOUYE + this.doSendClickStat(adModule, model.adId, AD_TYPE_INVALID, AD_CLICK_TYPE_INTEREST) + AdsManager.clickAd(ADS_TYPE_INDEX_BANAER, model) + RouterUtil.jumpPage(model.jumpUrl) + } + } + + /** + * 记录用户触摸时间,用于区分手动切换和自动轮播 + */ + private handleTouch(): void { + this.lastTouchTime = systemDateTime.getTime(false) + } + + /** + * Swiper 页面切换回调 + * 3 秒内切换视为手动切换,发 SWITCH 埋点;否则视为自动轮播,发 SHOW 埋点 + */ + private handlePageSelected(index: number): void { + this.currentIndex = index + const currentTime = systemDateTime.getTime(false) + if (currentTime - this.lastTouchTime < this.CYCLE_GAP) { + this.lastTouchTime = 0 + this.sendAdStat(AD_OPERATION_SWITCH, AD_CLICK_TYPE_INVALIDE, false) + } else { + this.sendAdStat(AD_OPERATION_SHOW, AD_CLICK_TYPE_INVALIDE, true) + } + } + + build() { + Swiper() { + ForEach(this.bannerNodeModelArr, (model: BaseAd) => { + Image(model.imgUrl) + .width('100%') + .height('100%') + .objectFit(ImageFit.Fill) + .onClick(() => { + this.handleAdClick(model) + }) + }) + } + .width('100%') + .height(this.bannerHeight) + .loop(true) + .autoPlay(this.isNeedIndicator ? true : false) + .interval(this.CYCLE_GAP) + .indicator(this.isNeedIndicator ? Indicator.dot() + .itemWidth(6) + .itemHeight(3) + .selectedItemHeight(3) + .selectedItemWidth(8) + .space(LengthMetrics.vp(4)) + .bottom(LengthMetrics.vp(5), true) + .color($r('app.color.color_ffffff')) + .selectedColor($r('app.color.color_3d3d42')) : false + ) + .effectMode(EdgeEffect.None) + .onSizeChange((oldValue: SizeOptions, newValue: SizeOptions) => { + if (newValue.width) { + let width = newValue.width as number + this.bannerHeight = width * 0.25 + } + }) + .borderRadius(6) + .visibility(this.isNodeShow ? Visibility.Visible : Visibility.None) + .onChange((index: number) => { + this.handlePageSelected(index) + }) + .onTouch((event: TouchEvent) => { + if (event.type === TouchType.Down) { + this.handleTouch() + } + }) + } +} \ No newline at end of file diff --git a/src/main/ets/node/view/CommodityOptionsNodeComponent.ets b/src/main/ets/node/view/CommodityOptionsNodeComponent.ets new file mode 100644 index 0000000..f2bcad1 --- /dev/null +++ b/src/main/ets/node/view/CommodityOptionsNodeComponent.ets @@ -0,0 +1,535 @@ +import { router } from '@kit.ArkUI'; +import { emitter } from '@kit.BasicServicesKit'; +import { CommodityOptionsDataFetcher } from '../datacenter/CommodityOptionsDataFetcher'; +import { CommodityOptionsContract, COMMODITY_OPTIONS_TABS, CommodityOptionsTab } from '../model/CommodityOptionsNodeModel'; +import { CommodityOptionsHqRequestClient, CommodityOptionHqItem } from '../clients/CommodityOptionsHqRequestClient'; +import { HeaderItem, TableData } from '@b2b/hq_table'; +import { EmitterConstants, GlobalContext, HXLog } from 'biz_common'; +import { TableConstants } from '@b2c/lib_baseui'; + +// FrameId 和 PageId +const FRAME_ID_COMMODITY_OPTIONS = 2201 +const PAGE_ID_COMMODITY_OPTIONS = 4106 + +// 显示数量限制 +const MAX_SHOW_COUNT = 5 + +@Component +export struct CommodityOptionsNodeComponent { + // 刷新触发器(首页下拉刷新时会递增) + @Link @Watch('onRefreshTriggerChange') refreshTrigger: number + + // Tab 可见性 + @Watch('onTabVisibilityChange') @Consume firstPageVisible: boolean = true + + // 当前 Tab 索引 + @State private currentTabIndex: number = 0 + + // 合约数据(50条,后端按涨跌幅降序排列) + @State private contractList: CommodityOptionsContract[] = [] + + // 合约列表(5条) + @State private displayHqList: CommodityOptionHqItem[] = [] + // 行情数据缓存(按 tabIndex 存储) + private displayHqCache: Map = new Map() + + // 功能提示弹窗状态 + @State private isSheetShow: boolean = false + @State private explainTitle: ResourceStr = $r('app.string.commodity_options_explain_title') + @State private explainMessage: string = '' + + // 行情请求客户端 + private hqRequestClient: CommodityOptionsHqRequestClient | null = null + + // TCP 重连监听 + private mIsEventSubscribed: boolean = false + private netRelinkCallBack = () => { + if (this.firstPageVisible && this.contractList.length > 0) { + setTimeout(() => { + this.subscribeContracts() + }) + } + } + + // 表头配置 + private headerConfig: HeaderItem[] = [ + new HeaderItem(TableConstants.DATA_ID_NAME, ''), + new HeaderItem(TableConstants.DATA_ID_CODE_4, ''), + new HeaderItem(TableConstants.DATA_ID_MARKET, ''), + new HeaderItem(TableConstants.DATA_ID_PRICE, ''), + new HeaderItem(TableConstants.DATA_ID_ZF_YTD, '') + ] + + aboutToAppear(): void { + // 设置卡片配置回调 + CommodityOptionsDataFetcher.getInstance().onCardConfigChange = (config) => { + if (config) { + // 从配置中读取功能说明 + if (config.explain_message) { + this.explainMessage = config.explain_message + } + } + } + + // 设置数据回调 + CommodityOptionsDataFetcher.getInstance().onCardDataChange = (data, tabIndex) => { + if (tabIndex !== this.currentTabIndex) { + return + } + this.contractList = data + if (data.length > 0) { + this.subscribeContracts() + } + } + + // 初始请求配置和数据 + CommodityOptionsDataFetcher.getInstance().fetchCardConfig() + + // 初始请求数据 + this.initHqRequestClient() + this.refreshCardData() + + // 订阅 TCP 重连事件 + if (this.firstPageVisible) { + this.subscribeTcpNetStatus() + } + } + + aboutToDisappear(): void { + this.releaseHqClient() + this.unSubscribeTcpNetStatus() + CommodityOptionsDataFetcher.getInstance().destroy() + } + + onRefreshTriggerChange(): void { + this.refreshCardData() + } + + onTabVisibilityChange(): void { + if (this.firstPageVisible) { + if (this.contractList.length > 0) { + this.subscribeContracts() + } + this.subscribeTcpNetStatus() + } else { + this.releaseHqClient() + this.unSubscribeTcpNetStatus() + } + } + + private initHqRequestClient(): void { + this.hqRequestClient = new CommodityOptionsHqRequestClient( + FRAME_ID_COMMODITY_OPTIONS, + PAGE_ID_COMMODITY_OPTIONS, + this.headerConfig, + (tableData: TableData | string) => {} + ) + this.hqRequestClient.setHqDataCallback((hqs: CommodityOptionHqItem[]) => { + this.onHqDataReceive(hqs) + }) + } + + refreshCardData(): void { + CommodityOptionsDataFetcher.getInstance().fetchCardData(this.currentTabIndex) + } + + private subscribeTcpNetStatus(): void { + if (this.mIsEventSubscribed) { + return + } + this.mIsEventSubscribed = true + emitter.on({ eventId: EmitterConstants.NETWORK_TCP_RECONNECT }, this.netRelinkCallBack) + } + + private unSubscribeTcpNetStatus(): void { + if (!this.mIsEventSubscribed) { + return + } + this.mIsEventSubscribed = false + emitter.off(EmitterConstants.NETWORK_TCP_RECONNECT, this.netRelinkCallBack) + } + + private subscribeContracts(): void { + const codes: string[] = [] + const markets: string[] = [] + + // 传入全部合约请求行情(与Web端一致) + this.contractList.forEach((item: CommodityOptionsContract) => { + if (item.contract_code && item.market) { + codes.push(item.contract_code) + markets.push(item.market) + } + }) + + if (codes.length > 0) { + this.releaseHqClient() + this.initHqRequestClient() + this.hqRequestClient?.setStockListAndRequest(codes, markets) + } + } + + private onHqDataReceive(hqs: CommodityOptionHqItem[]): void { + // 行情接口返回的数据已按涨跌幅降序排列,直接取前5条(无需再次排序) + const top5Hqs = hqs.slice(0, MAX_SHOW_COUNT) + + // 为每个合约设置到期日(从 contractList 中获取) + top5Hqs.forEach((hq: CommodityOptionHqItem) => { + const contract = this.contractList.find(c => + `${c.contract_code}_${c.market}` === `${hq.code}_${hq.market}` + ) + if (contract) { + hq.expiredDate = String(contract.expired_date) + } + }) + + // 更新当前tab的显示列表 + this.displayHqList = top5Hqs + // 同时缓存到对应tab + this.displayHqCache.set(this.currentTabIndex, top5Hqs) + } + + private releaseHqClient(): void { + if (this.hqRequestClient) { + this.hqRequestClient.release(true) + this.hqRequestClient = null + } + } + + formatPriceChg(priceChg: number | null): string { + if (priceChg === null) { + return '--' + } + const sign = priceChg > 0 ? '+' : '' + return `${sign}${priceChg.toFixed(2)}%` + } + + getDueDays(expiredDate: string | number): number { + if (expiredDate === null || expiredDate === undefined || expiredDate === '') { + return -1 + } + // 转换为字符串处理 + const dateStr = String(expiredDate) + if (dateStr.length !== 8) { + return -1 + } + const year = parseInt(dateStr.substring(0, 4)) + const month = parseInt(dateStr.substring(4, 6)) - 1 + const day = parseInt(dateStr.substring(6, 8)) + const expiredTime = new Date(year, month, day).getTime() + const now = Date.now() + const diffDays = Math.ceil((expiredTime - now) / (24 * 60 * 60 * 1000)) + return diffDays >= 0 ? diffDays : -1 + } + + handleContractClick(item: CommodityOptionsContract): void { + if (item.contract_code && item.market) { + // 构建合约列表 + const codeList = this.contractList.map(c => c.contract_code).join(',') + const marketList = this.contractList.map(c => c.market).join(',') + const nameList = this.contractList.map(c => c.contract_name).join(',') + + router.pushUrl({ + url: 'pages/QuoteDetailPage', + params: { + marketId: item.market, + code: item.contract_code, + name: item.contract_name, + codeList: codeList, + marketIdList: marketList, + nameList: nameList, + periodIndex: 0 // 分时 + } + }, router.RouterMode.Standard, (err) => { + if (err) { + HXLog.e('CommodityOptionsNodeComponent', `jumpToQuote failed: ${err.code}, ${err.message}`) + } + }) + } + } + + handleTabClick(index: number): void { + if (this.currentTabIndex !== index) { + // 先从缓存切换到对应tab的行情数据(解决切换tab显示旧数据问题) + this.displayHqList = this.displayHqCache.get(index) ?? [] + this.contractList = CommodityOptionsDataFetcher.getInstance().getDataByTab(index) + if (this.contractList.length > 0) { + this.subscribeContracts() + } + this.currentTabIndex = index + // 切换到新tab,触发数据更新 + CommodityOptionsDataFetcher.getInstance().switchTab(index) + } + } + + /** + * 显示功能说明弹窗 + */ + showExplainDialog(): void { + this.isSheetShow = true + } + + build() { + Column() { + this.buildTitleBar() + this.buildTabBar() + this.buildContent() + } + .width('100%') + .backgroundColor($r('app.color.surface_layer1_foreground')) + .borderRadius(4) + .padding({ left: 10, right: 10, bottom: 10 }) + .bindSheet($$this.isSheetShow, this.buildExplainSheetContent, { + height: SheetSize.FIT_CONTENT, + dragBar: true, + showClose: false, + maskColor: $r('app.color.mask_level2'), + radius: { + topLeft: 10, + topRight: 10 + } + }) + } + + @Builder + buildTitleBar() { + Row() { + Row({ space: 8 }) { + Text($r('app.string.commodity_options_title')) + .fontColor($r('app.color.elements_text_primary_02')) + .fontSize(18) + .height(22) + .fontWeight(500) + + // 功能提醒图标 + Image($r('app.media.icon_info_24')) + .width(20) + .height(20) + .fillColor($r('app.color.elements_icon_tertiary')) + .onClick(() => this.showExplainDialog()) + } + .alignItems(VerticalAlign.Center) + .height('100%') + + Blank() + } + .width('100%') + .height(48) + } + + @Builder + buildExplainSheetContent() { + Column() { + Stack({ alignContent: Alignment.End }) { + Text(this.explainTitle) + .fontSize(18) + .fontWeight(500) + .textAlign(TextAlign.Center) + .width('100%') + + Row() { + Image($r('app.media.icon_close_24')) + .width(24) + .height(24) + .fillColor($r('app.color.elements_icon_primary_02')) + .onClick(() => { + this.isSheetShow = false + }) + } + .height('100%') + .justifyContent(FlexAlign.End) + } + .height(64) + .width('100%') + + Text(this.explainMessage.replace(/\\n/g, '\n')) + .fontSize(14) + .lineHeight(22) + .fontColor($r('app.color.elements_text_primary_02')) + .width('100%') + + Button($r('app.string.commodity_options_confirm')) + .type(ButtonType.Normal) + .backgroundColor($r('app.color.elements_button_bg_primary')) + .width('100%') + .height(44) + .borderRadius(4) + .onClick(() => { + this.isSheetShow = false + }) + } + .width('100%') + .backgroundColor($r('app.color.surface_layer3_background')) + .padding({ + bottom: px2vp(GlobalContext.navigationBarHeight), + left: 16, + right: 16 + }) + } + + @Builder + buildTabBar() { + Row({ space: 8 }) { + ForEach(COMMODITY_OPTIONS_TABS, (tab: CommodityOptionsTab, index: number) => { + Text(tab.label) + .fontColor(this.currentTabIndex === index ? $r('app.color.elements_text_primary_01') : $r('app.color.elements_text_secondary')) + .fontSize(14) + .fontWeight(this.currentTabIndex === index ? FontWeight.Medium : FontWeight.Normal) + .padding({ left: 8, right: 8 }) + .height(30) + .constraintSize({ + minWidth: 66 + }) + .textAlign(TextAlign.Center) + .backgroundColor(this.currentTabIndex === index ? $r('app.color.elements_others_bg_primary') : $r('app.color.elements_button_bg_tertiary')) + .borderRadius(4) + .onClick(() => this.handleTabClick(index)) + }) + } + .height(46) + .width('100%') + } + + @Builder + buildContent() { + Column() { + // 数据列表(与Web端一致:表头包含在列表组件内,数据为空时整个组件不显示) + if (this.displayHqList.length > 0) { + // 表头 + Row({ space: 8 }) { + Text($r('app.string.commodity_options_table_header_name')) + .fontColor($r('app.color.elements_text_tertiary')) + .fontSize(14) + .layoutWeight(1) + + Text($r('app.string.commodity_options_table_header_price')) + .fontColor($r('app.color.elements_text_tertiary')) + .fontSize(14) + .width(96) + .textAlign(TextAlign.End) + + Text($r('app.string.commodity_options_table_header_change')) + .fontColor($r('app.color.elements_text_tertiary')) + .fontSize(14) + .width(96) + .textAlign(TextAlign.End) + } + .height(40) + .width('100%') + + // 列表数据 + ForEach(this.displayHqList, (item: CommodityOptionHqItem, index: number) => { + this.buildContractRow(item, index) + }) + } else { + // 空数据占位(与Web端一致:数据为空时整个区域不显示表头) + this.buildEmptyView() + } + } + .width('100%') + } + + @Builder + buildEmptyView() { + Column() { + Text(this.getDefaultMsg()) + .fontColor($r('app.color.elements_text_tertiary')) + .fontSize(14) + } + .width('100%') + .height(120) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + } + + private getDefaultMsg(): ResourceStr { + if (this.currentTabIndex >= COMMODITY_OPTIONS_TABS.length) { + return $r('app.string.commodity_options_no_data') + } + return COMMODITY_OPTIONS_TABS[this.currentTabIndex].defaultMsg + } + + @Builder + buildContractRow(item: CommodityOptionHqItem, index: number) { + Row({ space: 8 }) { + Column({ space: 2 }) { + // 合约名称 + Text(item.name) + .fontColor($r('app.color.elements_text_primary_02')) + .fontSize(16) + .height(20) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + + // 临期期权 Tab 显示"x天到期"标签 + if (this.currentTabIndex === 1 && this.getDueDays(item.expiredDate) >= 0) { + Row() { + Text(String(this.getDueDays(item.expiredDate))) + .fontColor($r('app.color.commodity_options_expire_label_color')) + .fontSize(9) + Text($r('app.string.commodity_options_expire_suffix')) + .fontColor($r('app.color.commodity_options_expire_label_color')) + .fontSize(9) + } + .height(10) + .padding({ left: 1, right: 1 }) + .border({ + radius: 1, + width: 0.33, + color: $r('app.color.commodity_options_expire_label_color') + }) + .alignSelf(ItemAlign.Start) + } + } + .margin({ top: 12, bottom: 8 }) + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + + // 最新价 + Text(item.price) + .fontColor(this.getPriceColor(item.priceChg)) + .fontSize(16) + .fontWeight(500) + .textAlign(TextAlign.End) + + // 涨跌幅(昨收) + Text(this.formatPriceChg(item.priceChg)) + .fontColor(this.getPriceChgColor(item.priceChg)) + .fontSize(16) + .fontWeight(500) + .width(96) + .textAlign(TextAlign.End) + } + .width('100%') + .border({ + width: { top: 1 }, + color: $r('app.color.elements_others_divider_primary') + }) + .onClick(() => this.handleContractClickByHq(item)) + } + + private getPriceColor(priceChg: number | null): ResourceStr { + if (priceChg === null) { + return $r('app.color.elements_text_primary_02') + } + if (priceChg > 0) { + return $r('app.color.price_up_100') + } else if (priceChg < 0) { + return $r('app.color.price_down_100') + } + return $r('app.color.elements_text_primary_02') + } + + private getPriceChgColor(priceChg: number | null): ResourceStr { + return this.getPriceColor(priceChg) + } + + private handleContractClickByHq(item: CommodityOptionHqItem): void { + // 从 hq 数据中获取对应的原始合约信息进行跳转 + const contract = this.contractList.find(c => + `${c.contract_code}_${c.market}` === `${item.code}_${item.market}` + ) + if (contract) { + this.handleContractClick(contract) + } + } +} \ No newline at end of file diff --git a/src/main/ets/node/view/CustomEntryListNodeComponent.ets b/src/main/ets/node/view/CustomEntryListNodeComponent.ets new file mode 100644 index 0000000..4fe78b0 --- /dev/null +++ b/src/main/ets/node/view/CustomEntryListNodeComponent.ets @@ -0,0 +1,89 @@ +import { StringUtils } from 'hxutil'; +import { FirstPageNodeModel } from '../../datacenter/model/FirstPageNodeModel'; +import { CustomEntryListNodeModel } from '../model/CustomEntryListNodeModel'; +import { router } from '@kit.ArkUI'; +import { RouterUtil } from '../../util/RouterUtil'; + +/** + * 首页自定义宫格节点 + * @author wubingsong@myhexin.com + */ +@Component +export struct CustomEntryListNodeComponent { + + nodeModel: FirstPageNodeModel | null = null + @State + private isNodeShow: boolean = false + @State + private customEntryListNodeModelArr: CustomEntryListNodeModel[] | null = null; + private static MAX_SHOW_ITEM_COUNT = 10 + + aboutToAppear(): void { + if (this.nodeModel != null) { + let extraData = this.nodeModel.extraData + if (StringUtils.isNotEmpty(extraData)) { + let customEntryListNodes = JSON.parse(extraData) as CustomEntryListNodeModel[] + if (customEntryListNodes && customEntryListNodes.length > 0) { + let moreModel: CustomEntryListNodeModel = new CustomEntryListNodeModel() + moreModel.title = '更多' + moreModel.imgUrl = $r('app.media.firstpage_grid_item_all') + customEntryListNodes.push(moreModel) + if (customEntryListNodes.length > CustomEntryListNodeComponent.MAX_SHOW_ITEM_COUNT) { + this.customEntryListNodeModelArr = customEntryListNodes.slice(0, CustomEntryListNodeComponent.MAX_SHOW_ITEM_COUNT) + } else { + this.customEntryListNodeModelArr = customEntryListNodes + } + this.isNodeShow = true + } + } + } + } + + build() { + List({ initialIndex: 0 }) { + ForEach(this.customEntryListNodeModelArr, (item: CustomEntryListNodeModel) => { + ListItem() { + this.createItem(item) + } + }, (item: CustomEntryListNodeModel) => item.id.toString()) + } + .width("100%") + .height("auto") + .lanes(5) + .alignListItem(ListItemAlign.Center) + .scrollBar(BarState.Off) + .backgroundColor($r('app.color.surface_layer1_foreground')) + .borderRadius(10) + .visibility(this.isNodeShow ? Visibility.Visible : Visibility.None) + } + + @Builder + createItem(item: CustomEntryListNodeModel) { + Column() { + Image(item.imgUrl) + .width(40) + .height(40) + Text(item.title) + .margin( { top : 4 }) + .fontColor($r('app.color.elements_text_primary_02')) + .maxLines(1) + .minFontSize(9) + .maxFontSize(12) + .fontSize(12) + } + .padding( { top : 16, bottom: 16 }) + .onClick(() => { + if (item.title == '更多') { + router.pushNamedRoute({ + name: 'GridNodeAllPage' + }) + } else { + let fullscreen = false + if (item.title.includes("智能客服")) { + fullscreen = true + } + RouterUtil.jumpPage(item.jumpUrl, fullscreen) + } + }) + } +} \ No newline at end of file diff --git a/src/main/ets/node/view/FirstPageDialog.ets b/src/main/ets/node/view/FirstPageDialog.ets new file mode 100644 index 0000000..9d18387 --- /dev/null +++ b/src/main/ets/node/view/FirstPageDialog.ets @@ -0,0 +1,63 @@ +import { BaseAd } from '@b2c-f/fuhm-ads' +import { image } from '@kit.ImageKit' + +/** + * author : liuqingliang@myhexin.com + * time : created on 2026/2/27 + * desc : 首页弹窗广告组件 + */ +export class FirstPageDialogParam { + constructor(showAd: BaseAd, onClose: () => void, onClick: () => void) { + this.showAd = showAd + this.onClose = onClose + this.onClick = onClick + } + + /** 广告数据(从外部传入) */ + showAd: BaseAd + /** 关闭按钮回调 */ + onClose: () => void + /** 广告点击回调 */ + onClick: () => void + adImg?: image.PixelMap + + releaseAdImg(): void { + if (!this.adImg) { + return + } + this.adImg.release() + this.adImg = undefined + } +} + +@Builder +export function FirstPageDialogBuilder(param: FirstPageDialogParam) { + + // 广告内容 + Column() { + // 关闭按钮 + Row() { + Blank() + Image($r('app.media.firstpage_dialog_tanchuang_close')) + .width(28) + .height(28) + .onClick(() => { + param.onClose() + }) + } + .width('100%') + + Image(param.adImg ?? param.showAd.imgUrl) + .width('100%') + .layoutWeight(1) + .objectFit(ImageFit.Fill) + .borderRadius(12) + .onClick(() => { + param.onClick() + }) + .margin({ top: 10 }) + } + .width(255) + .height(378) + .backgroundColor(Color.Transparent) +} diff --git a/src/main/ets/node/view/FourEntryListNodeComponent.ets b/src/main/ets/node/view/FourEntryListNodeComponent.ets new file mode 100644 index 0000000..705a309 --- /dev/null +++ b/src/main/ets/node/view/FourEntryListNodeComponent.ets @@ -0,0 +1,82 @@ +import { StringUtils } from 'hxutil'; +import { FirstPageNodeModel } from '../../datacenter/model/FirstPageNodeModel'; +import { EntryListNodeModel } from '../model/EntryListNodeModel'; +import { RouterUtil } from '../../util/RouterUtil'; + +/** + * 首页四宫格节点 + * @author wubingsong@myhexin.com + */ +@Component +export struct FourEntryListNodeComponent { + + nodeModel: FirstPageNodeModel | null = null + @State + private isNodeShow: boolean = false + @State + private entryListNodeModelArr: EntryListNodeModel[] | null = null + private static COLUMN_COUNT = 4 + + aboutToAppear(): void { + if (this.nodeModel != null) { + let extraData = this.nodeModel.extraData + if (StringUtils.isNotEmpty(extraData)) { + let entryListNodes = JSON.parse(extraData) as EntryListNodeModel[] + if (entryListNodes) { + if (entryListNodes.length > FourEntryListNodeComponent.COLUMN_COUNT) { + this.entryListNodeModelArr = entryListNodes.slice(0, FourEntryListNodeComponent.COLUMN_COUNT) + } else { + this.entryListNodeModelArr = entryListNodes + } + if (this.entryListNodeModelArr.length > 0) { + this.isNodeShow = true + } + } + } + } + } + + build() { + Column() { + List({ initialIndex: 0 }) { + ForEach(this.entryListNodeModelArr, (item: EntryListNodeModel) => { + ListItem() { + this.createItem(item) + } + }, (item: EntryListNodeModel) => item.id.toString()) + } + .width("100%") + .height('auto') + .lanes(FourEntryListNodeComponent.COLUMN_COUNT) + .alignListItem(ListItemAlign.Center) + .scrollBar(BarState.Off) + .visibility(this.isNodeShow ? Visibility.Visible : Visibility.None) + } + .width("100%") + .height(80) + .backgroundImageSize(ImageSize.FILL) + + } + + @Builder + createItem(item: EntryListNodeModel) { + Column() { + Image(item.imgUrl) + .width(40) + .height(40) + Text(item.title) + .margin( { top : 4 }) + .fontColor($r('app.color.color_333333')) + .maxLines(1) + .minFontSize(9) + .maxFontSize(14) + .fontSize(14) + } + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + .height(80) + .onClick(() => { + RouterUtil.jumpPage(item.jumpUrl) + }) + } +} \ No newline at end of file diff --git a/src/main/ets/node/view/HotListNodeComponent.ets b/src/main/ets/node/view/HotListNodeComponent.ets new file mode 100644 index 0000000..4c5f74a --- /dev/null +++ b/src/main/ets/node/view/HotListNodeComponent.ets @@ -0,0 +1,224 @@ +import { HotListDataFetcher } from '../datacenter/HotListDataFetcher'; +import { HotListItemModel, getColorValue } from '../model/HotListNodeModel'; +import { FirstPageNodeModel } from '../../datacenter/model/FirstPageNodeModel'; +import { RouterUtil } from '../../util/RouterUtil'; + +/** + * 热点排行卡片组件 + * @author YS + * @date 2026-06-03 + */ +@Component +export struct HotListNodeComponent { + nodeModel: FirstPageNodeModel | null = null + + // 刷新触发器(首页下拉刷新时会递增) + @Link @Watch('onRefreshTriggerChange') refreshTrigger: number + + @State private newsList: HotListItemModel[] | null = null + @State private isUnfold: boolean = false + @State private dataReady: boolean = false // 数据加载状态(区分加载中/暂无数据) + + // 展示数量:折叠3条,展开5条 + private readonly NORMAL_SIZE: number = 3 + private readonly MAX_SIZE: number = 5 + + // 排名背景色 + private readonly bgColors: ResourceColor[] = [ + $r('app.color.hotlist_rank_bg_1'), + $r('app.color.hotlist_rank_bg_2'), + $r('app.color.hotlist_rank_bg_3') + ] + + aboutToAppear(): void { + if (this.nodeModel != null) { + HotListDataFetcher.getInstance().hotListChange = (data) => { + this.newsList = data + this.dataReady = true // 标记数据已加载 + } + this.refreshCardData() + } + } + + onRefreshTriggerChange(): void { + this.refreshCardData() + } + + refreshCardData(): void { + // 初始状态为加载中 + this.dataReady = false + // 远程获取最新数据(从配置文件读取 API 地址) + HotListDataFetcher.getInstance().remoteFetchData() + } + + /** + * 切换展开/折叠状态 + */ + toggleUnfold(): void { + this.isUnfold = !this.isUnfold + } + + /** + * 跳转到列表页(点击标题栏) + */ + jumpToListPage(): void { + const cardUrl = HotListDataFetcher.getInstance().getCardUrl() + if (cardUrl) { + RouterUtil.jumpPage(cardUrl, true) + } + } + + /** + * 跳转到资讯详情(点击列表项) + */ + jumpToDetail(item: HotListItemModel): void { + RouterUtil.jumpPage(item.url, true) + } + + /** + * 获取显示的数据 + */ + private getDisplayList(): HotListItemModel[] { + if (!this.newsList || this.newsList.length === 0) { + return [] + } + const size = this.isUnfold ? this.MAX_SIZE : this.NORMAL_SIZE + return this.newsList.slice(0, size) + } + + build() { + Column() { + // 标题栏:标题 + 展开/收起 按钮 + this.buildTitleBar() + + if (this.newsList && this.newsList.length > 0) { + this.buildContentList() + } else { + // 空数据占位(与Web端一致:dataReady为false显示"加载中",否则显示"暂无数据") + this.buildEmptyView() + } + } + .width('100%') + .backgroundColor($r('app.color.surface_layer1_foreground')) + .borderRadius(4) + .padding({ left: 10, right: 10, bottom: 10 }) + } + + @Builder + buildEmptyView() { + Column() { + Text(this.dataReady ? $r('app.string.hotlist_no_data') : $r('app.string.hotlist_loading')) + .fontColor($r('app.color.elements_text_tertiary')) + .fontSize(14) + } + .width('100%') + .height(80) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + } + + @Builder + buildTitleBar() { + Row() { + Row() { + Text($r('app.string.hotlist_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.jumpToListPage() + }) + + // 展开/收起 按钮 + if (this.newsList && this.newsList.length > this.NORMAL_SIZE) { + Row() { + Text(this.isUnfold ? $r('app.string.hotlist_collapse') : $r('app.string.hotlist_expand')) + .fontColor($r('app.color.elements_text_tertiary')) + .fontSize(12) + .height(16) + .width('100%') + .textAlign(TextAlign.Center) + } + .width(40) + .height(20) + .borderRadius(10) + .backgroundColor($r('app.color.elements_button_bg_disable_02')) + .margin({ left: 8 }) + .onClick(() => { + this.toggleUnfold() + }) + } + + Blank() + } + .width('100%') + .height(48) + } + + @Builder + buildContentList() { + Column({ space: 12 }) { + ForEach(this.getDisplayList(), (item: HotListItemModel, index: number) => { + this.buildNewsItem(item, index) + }) + } + .width('100%') + .padding({ top: 8, bottom: 8 }) + } + + @Builder + buildNewsItem(item: HotListItemModel, index: number) { + Row() { + // 排名序号 + Row() { + Text((index + 1).toString()) + .fontSize(14) + .fontWeight(700) + .fontColor(index < 3 ? $r('app.color.elements_text_on_color') : $r('app.color.elements_text_tertiary')) + } + .width(16) + .height(16) + .borderRadius(2) + .backgroundColor(index < this.bgColors.length ? this.bgColors[index] : undefined) + .justifyContent(FlexAlign.Center) + .alignItems(VerticalAlign.Center) + + // 标题 + Text(item.title) + .fontSize(16) + .fontColor($r('app.color.elements_text_primary_02')) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .margin({ left: 8 }) + .flexShrink(1) + + // 标签(tag 存在时显示) + if (item.tag) { + Text(item.tag) + .fontSize(10) + .fontWeight(500) + .fontColor($r('app.color.elements_text_on_color')) + .height(14) + .borderRadius(2) + .padding(2) + .backgroundColor(getColorValue(item.color)) + .margin({ left: 4 }) + .flexShrink(0) + } + } + .width('100%') + .height(22) + .alignItems(VerticalAlign.Center) + .onClick(() => { + this.jumpToDetail(item) + }) + } +} \ No newline at end of file diff --git a/src/main/ets/node/view/SelfSelectedStockNodeComponent.ets b/src/main/ets/node/view/SelfSelectedStockNodeComponent.ets new file mode 100644 index 0000000..aa17308 --- /dev/null +++ b/src/main/ets/node/view/SelfSelectedStockNodeComponent.ets @@ -0,0 +1,593 @@ +import { GroupInfo } from 'service_watchlist'; +import { FirstPageSelfCodeGroupView } from '../../views/FirstPageSelfCodeGroupView'; +import { + GridDataSource, + GridHqDataItemModel, + HQDataItemModel, + SwiperHqDataItemModel, +} from '../model/SelfSelectedStockModel'; +import { router } from '@kit.ArkUI'; +import { StrUtil } from '@pura/harmony-utils'; +import { CellArray, HeaderItem, RowData, TableData } from '@b2b/hq_table'; +import { FirstPageSelfStockRequestClient } from '../clients/FirstPageSelfStockRequestClient'; +import { EmitterConstants, HQPageConstants, jumpToQuote, PreferenceService } from 'biz_common'; +import { emitter } from '@kit.BasicServicesKit'; +import { NumberUtils } from 'hxutil'; +import { FistPageSelfCodeGridItemView } from '../../views/FistPageSelfCodeGridItemView'; +import { HXUtils, TableConstants } from '@b2c/lib_baseui'; +import { QuoteCustomSettingManager, StandardPriceType } from 'biz_quote'; + +//TableRowBGView TableConstants + +/** + * 每页显示item数量3 + */ +const ITEM_COUNT_PER_PAGE_3 = 3 +/** + * 每页显示item数量5 + */ +const ITEM_COUNT_PER_PAGE_5 = 5 + + +const DATA_ID_ZUO_SHOU = 6 //左收 +const DATA_ID_JIN_KAI = 7 //左收 + + +const STR_DEFAULT = "--" +const SELF_CODE_DATA_IDS: number[] = [ + TableConstants.DATA_ID_NAME, //股票名称 + TableConstants.DATA_ID_PRICE, //最新价 + TableConstants.DATA_ID_ZF, //涨跌幅 + TableConstants.DATA_ID_ZD, //涨跌 + TableConstants.DATA_ID_SHOW_CODE, //showCode + TableConstants.DATA_ID_CODE_4, //股票代码 + DATA_ID_ZUO_SHOU, //左收 + DATA_ID_JIN_KAI, //今开 + TableConstants.DATA_ID_JSJ, //昨结 + TableConstants.DATA_ID_MARKET,//市场id +] +/** + * 此处frameId修改要慎重,在启动的时候执行FutureMarketDataManager.init() 发送socket请求(frameId从FrameIdStore.get()),不一致可能会导致启动的时候行情刷新失败 + */ +const FRAME_ID_SELF_CODE = 2201 //自选股行情页面frameId +const PAGE_ID_SELF_CODE = 1264 //自选股行情pageId +const SP_KEY_FIRST_PAGE_SELF_CODE_EXPAND = "sp_key_first_page_self_code_expand" + +@Component +export struct SelfSelectedStockNodeComponent { + @Consume @Watch("onVisibleChanged") firstPageVisible: boolean; + //一多断点 + @StorageLink('displayBreakPoint') @Watch("displayChanged") curBp: string = 'sm' + @StorageLink('windowWidthVp') windowWidthVp: number = 0; + @State isExtend: boolean = false; + @State mSwiperPagesItems: SwiperHqDataItemModel = new SwiperHqDataItemModel() + private mDataItemModels: HQDataItemModel[] = [] + @State private swiperIndex: number = 0; + private swiperController: SwiperController = new SwiperController(); + private mRequestClient?: FirstPageSelfStockRequestClient + private mHeaderConfig: Array = [] + private mPageItemCount = ITEM_COUNT_PER_PAGE_3 + private mMaxTotalItemCount = 3 * ITEM_COUNT_PER_PAGE_3 + private mFrameId = FRAME_ID_SELF_CODE + private mPageId = PAGE_ID_SELF_CODE + private mGroupInfo?: GroupInfo + private mIsNeedRealData = true + private mIsHasReceivedData = false + private mIsEventSubscribed = false + // 上一次刷新时使用的基准价类型,用于在变可见时判断是否需要按新基准价重新请求 + private lastStandardPriceType: StandardPriceType = + QuoteCustomSettingManager.getInstance().getStandardPriceType(); + + aboutToAppear(): void { + this.initHeadConfig(); + this.initPageConfig() + if (this.firstPageVisible) { + this.subscribeTcpNetStatus() + } + } + + aboutToDisappear(): void { + this.unSubscribeTcpNetStatus() + this.mRequestClient?.release(true) + this.mRequestClient = undefined + } + + initPageConfig() { + this.isExtend = + PreferenceService.getBooleanValueSync(FirstPageSelfCodeGroupView.SP_FILE_NAME_FIRST_PAGE_SELF_CODE_FILE_NAME, + SP_KEY_FIRST_PAGE_SELF_CODE_EXPAND, false) + this.mPageItemCount = this.getPageItemCount() + this.mMaxTotalItemCount = this.getTotalItemCount() + } + + displayChanged() { + this.mPageItemCount = this.getPageItemCount() + this.mMaxTotalItemCount = this.getTotalItemCount() + this.updateSwiperPagesItemsWidthDataItemModels() + } + + build() { + Column() { + this.buildCardHeader() + FirstPageSelfCodeGroupView({ + onForeground: this.firstPageVisible, + onSelectGroupInfo: (index: number, groupInfo: GroupInfo) => { + this.onSelectGroupInfo(index, groupInfo) + } + }).margin({ left: 11 }) + this.buildStockGrid() + } + .width('100%') + .backgroundColor($r('app.color.surface_layer1_foreground')) + .borderRadius(4) + } + + onSelectGroupInfo(_index: number, groupInfo: GroupInfo) { + if (this.mGroupInfo && JSON.stringify(this.mGroupInfo) == JSON.stringify(groupInfo)) { + return + } + this.mGroupInfo = groupInfo + this.initPageStocks() + this.requestData() + + } + + @Builder + buildCardHeader() { + Row() { + Text('自选盯盘') + .fontColor($r('app.color.elements_text_primary_02')) + .fontSize(16) + .margin({ left: 16, right: 10 }) + this.buildExpandButton() + Blank() + Row() { + Text('更多') + .fontColor($r('app.color.elements_text_tertiary')) + .fontSize(14) + .margin({ right: 2 }) + + Image($r('app.media.icon_arrow_forward_24')) + .fillColor($r('app.color.elements_icon_tertiary')) + .width(17) + .height(17) + .margin({ right: 8 }) + }.onClick(() => { + emitter.emit({eventId:EmitterConstants.TAB_UI_MANAGER_CHANGE_INDEX},{ + data: { + tabUIManagerTabIndex:1, + hqPageSubTabIndex: HQPageConstants.HQ_PAGE_INDEX_SELF_CODE_PAGE + } + }) + + }) + } + .justifyContent(FlexAlign.Start) + .alignItems(VerticalAlign.Center) + .width('100%') + .height(44) + + + } + + @Builder + buildStockGrid() { + Column() { + Swiper(this.swiperController) { + ForEach(this.mSwiperPagesItems, (pageStocks: GridHqDataItemModel, pageIndex: number) => { + Grid() { + ForEach(pageStocks, (stock: HQDataItemModel) => { + GridItem() { + FistPageSelfCodeGridItemView({ hqItemData: stock,jumpToQuoteCallBack:(stockModel: HQDataItemModel)=>{ + this.jumpToQuote(stockModel) + },jumpToMoreCallBack:()=>{ + this.jumpToMore() + } }) + } + }) + } + .columnsTemplate('1fr 1fr 1fr') + .rowsTemplate(this.isExtend ? '1fr 1fr' : '1fr') + .rowsGap(8) + .padding({ bottom: 30 }) + .width('100%') + .height(this.isExtend ? 170 : 90) + }) + } + .index(this.swiperIndex) + .autoPlay(false) + .loop(false) + .duration(0) + .itemSpace(0) + .displayCount(1) + .backgroundColor($r('app.color.surface_layer1_foreground')) + .cachedCount(1) + + .indicatorInteractive(true) + .indicator(new DotIndicator().bottom(0) + .selectedItemWidth(8) + .itemWidth(5) + .itemHeight(5) + .selectedItemHeight(5)) + .onChange((index: number) => { + this.swiperIndex = index; + }) + .onTouch((event) => { + switch (event.type) { + case TouchType.Down: + this.mIsNeedRealData = false + break; + case TouchType.Up: + case TouchType.Cancel: + setTimeout(()=>{ + this.mIsNeedRealData = true + },500) + + break; + default: + break; + } + }) + .width('100%') + } + .width('100%') + } + + @Builder + buildExpandButton() { + Row() { + Image( $r('app.media.icon_arrow_forward_24')) + .fillColor($r('app.color.elements_icon_primary_02')) + .width(24) + .height(16) + .padding({ right: 8 }) + .onClick((event)=>{ + emitter.emit({eventId:EmitterConstants.TAB_UI_MANAGER_CHANGE_INDEX},{ + data: { + tabUIManagerTabIndex:1, + hqPageSubTabIndex: HQPageConstants.HQ_PAGE_INDEX_SELF_CODE_PAGE + } + }) + }) + + Text(this.isExtend ? '收起' : '展开') + .fontSize(12) + .fontColor($r('app.color.elements_text_tertiary')) + .padding({top:3,bottom:3,left:10,right:10}) + .backgroundColor($r('app.color.elements_others_bg_layer1')) + .borderRadius(10) + } + .onClick(() => this.toggleExpand()) + } + + onVisibleChanged() { + if (this.firstPageVisible) { + this.onForeground() + } else { + this.onBackground() + } + } + + onForeground() { + this.requestData() + this.subscribeTcpNetStatus() + // 切换基准价后回到前台:computemode 由 FirstPageSelfStockRequestClient 实时拼装,requestData 已会按新基准价重发,这里仅同步快照 + this.lastStandardPriceType = QuoteCustomSettingManager.getInstance().getStandardPriceType(); + } + + onBackground() { + this.unSubscribeTcpNetStatus() + this.mRequestClient?.release() + this.mRequestClient = undefined + } + + onReceiveData(tableData: string | TableData) { + if (tableData instanceof TableData) { + if (!this.mIsNeedRealData && this.mIsHasReceivedData) { + return + } + const dataItems: HQDataItemModel[] = [] + tableData?.tableRows?.forEach((rowData, index) => { + if (rowData instanceof RowData) { + const dataItem = this.parseRowCellData2HQDataItemModel(rowData.scrollableCols) + dataItems.push(dataItem) + } + }) + this.updatePageDataItemModels(dataItems) + this.mIsHasReceivedData = true + } else { + console.log("firstPageSelfCodeData:" + JSON.stringify(tableData)) + + } + } + + //socket连接成功 + private netRelinkCallBack = () => { + if (this.firstPageVisible) { + setTimeout(() => { + this.requestData() + }) + } + } + + private subscribeTcpNetStatus() { + if (this.mIsEventSubscribed) { + return + } + this.mIsEventSubscribed = true + emitter.on({ eventId: EmitterConstants.NETWORK_TCP_RECONNECT }, this.netRelinkCallBack) + } + + private unSubscribeTcpNetStatus() { + if (!this.mIsEventSubscribed) { + return + } + this.mIsEventSubscribed = false + emitter.off(EmitterConstants.NETWORK_TCP_RECONNECT, this.netRelinkCallBack) + } + + private mockData() { + let dataItems: HQDataItemModel[] = [] + for (let index = 0; index < this.mDataItemModels.length; index++) { + const element = this.mDataItemModels[index]; + if (element.showAddImage) { + const moreDataHqDataItemModel = new HQDataItemModel(STR_DEFAULT, STR_DEFAULT, STR_DEFAULT) + moreDataHqDataItemModel.showAddImage = true; + dataItems.push(moreDataHqDataItemModel) + } else { + let haDataItemModel = new HQDataItemModel(element.code, element.market, element.name) + haDataItemModel.showCode = element.showCode + haDataItemModel.price = this.updatePrice(element.price, index) + haDataItemModel.riseFail = element.riseFail + haDataItemModel.priceRise = element.priceRise + haDataItemModel.nameColor = element.nameColor + haDataItemModel.riseFail = element.riseFail + haDataItemModel.priceColor = element.priceColor + haDataItemModel.riseFailColor = element.riseFailColor + haDataItemModel.priceRiseColor = element.priceRiseColor + dataItems.push(haDataItemModel) + } + } + this.updatePageDataItemModels(dataItems) + } + + private updatePrice(oldPrice: string, index: number) { + if (NumberUtils.isNumeric(oldPrice)) { + let newPrice = index % 2 == 0 ? (parseFloat(oldPrice) + 1).toString() : (parseFloat(oldPrice) - 1).toString() + return newPrice + } + return oldPrice + } + + private getPageItemCount(): number { + if (this.isFoldExpand()) { + return this.isExtend ? 2 * ITEM_COUNT_PER_PAGE_5 : ITEM_COUNT_PER_PAGE_5 + } else { + return this.isExtend ? 2 * ITEM_COUNT_PER_PAGE_3 : ITEM_COUNT_PER_PAGE_3 + } + } + + private getTotalItemCount(): number { + if (this.isFoldExpand()) { + return this.isExtend ? 2 * 2 * ITEM_COUNT_PER_PAGE_5 : 2 * ITEM_COUNT_PER_PAGE_5 + } else { + return this.isExtend ? 3 * 2 * ITEM_COUNT_PER_PAGE_3 : 3 * ITEM_COUNT_PER_PAGE_3 + } + } + + private isFoldExpand(): boolean { + return this.curBp === "lg" + } + + private initHeadConfig() { + const headerConfig: HeaderItem[] = [] + SELF_CODE_DATA_IDS.forEach((idValue, index) => { + headerConfig.push(new HeaderItem(idValue, "")) + }) + this.mHeaderConfig = headerConfig + } + + private initPageStocks() { + if (!this.mGroupInfo) { + this.mDataItemModels = [] + this.mSwiperPagesItems = [] + this.swiperIndex = 0 + return + } + + const stocks = this.mGroupInfo.stocks + let hqDataItems: HQDataItemModel[] = [] + stocks.forEach((security, index) => { + if (StrUtil.isNotEmpty(security.code) && StrUtil.isNotEmpty(security.market)) { + hqDataItems.push(new HQDataItemModel(security.code, security.market, security.name)) + } + }) + let isNeedAddMoreItem = false + if (hqDataItems.length < this.mMaxTotalItemCount) { + // 数据没满,增加更多 + isNeedAddMoreItem = true; + } else { + // 数据满了,只返回前mMaxTotalItemCount个数据 + hqDataItems = hqDataItems.slice(0, this.mMaxTotalItemCount); + } + + if (isNeedAddMoreItem) { + const moreDataHqDataItemModel = new HQDataItemModel(STR_DEFAULT, STR_DEFAULT, STR_DEFAULT) + moreDataHqDataItemModel.showAddImage = true + hqDataItems.push(moreDataHqDataItemModel) + } + this.mDataItemModels = hqDataItems + this.swiperIndex = 0 + this.updateSwiperPagesItemsWidthDataItemModels() + } + + private updatePageDataItemModels(hqDataItems: HQDataItemModel[]) { + if (this.mDataItemModels.length === 0) { + this.mSwiperPagesItems.length = 0 + return; + } + if (hqDataItems.length > 0) { + for (let currIndex = 0; currIndex < hqDataItems.length; currIndex++) { + const currentDataItem = hqDataItems[currIndex] + const stockCode = currentDataItem.code + const market = currentDataItem.market + if (!HXUtils.isValidContractData(stockCode) || !HXUtils.isValidContractData(market)) { + continue; + } + for (let oldIndex = this.mDataItemModels.length - 1; oldIndex >= 0; oldIndex--) { + const lastDataItemModel = this.mDataItemModels[oldIndex] + if (lastDataItemModel && !lastDataItemModel.showAddImage && stockCode == lastDataItemModel.code) { + lastDataItemModel.copyFrom(currentDataItem) + break; + } + } + } + } + this.updateSwiperPagesItemsWidthDataItemModels() + } + + /** + * 根据数据模型构建Swiper + */ + private updateSwiperPagesItemsWidthDataItemModels(): void { + const itemsPerPage = this.mPageItemCount; + const pages: SwiperHqDataItemModel = new SwiperHqDataItemModel(); + + for (let i = 0; i < this.mDataItemModels.length; i += itemsPerPage) { + let gridHqDataItemModel = new GridHqDataItemModel() + gridHqDataItemModel.push(...this.mDataItemModels.slice(i, + Math.min(i + itemsPerPage, this.mDataItemModels.length))) + pages.push(gridHqDataItemModel); + } + this.mSwiperPagesItems = pages + + } + + private requestData() { + this.mIsNeedRealData = true + this.mIsHasReceivedData = false + if (!this.firstPageVisible) { + return + } + const stockListRequestText = this.buildExtRequestText(); + if (stockListRequestText.length < 0) { + return + } + if (!this.mRequestClient) { + this.mRequestClient = new FirstPageSelfStockRequestClient(this.mFrameId, this.mPageId, this.mHeaderConfig, + null, (tableData: TableData | string) => { + this.onReceiveData(tableData) + }, stockListRequestText) + } + this.mRequestClient?.setExtRequestText(stockListRequestText) + this.mRequestClient.request(0, 25) + } + + private buildExtRequestText(): string { + if (this.mDataItemModels.length > 0) { + let stockListText = "stocklist=" + let marketListText = "marketlist=" + let isValidStock = false; + this.mDataItemModels.forEach((value, index) => { + if (!value.showAddImage && HXUtils.isValidContractData(value.code) && + HXUtils.isValidContractData(value.market)) { + isValidStock = true + stockListText = stockListText + value.code + "|" + marketListText = marketListText + value.market + "|" + } + }) + if (isValidStock) { + return "selfstockcustom=1\r\n" + stockListText + "\r\n" + marketListText + } else { + return "" + } + } + return "" + } + + private parseRowCellData2HQDataItemModel(cellArrayData: CellArray): HQDataItemModel { + const dataItem = new HQDataItemModel() + cellArrayData.forEach((cellData, index) => { + switch (cellData.dataId) { + case TableConstants.DATA_ID_NAME: + dataItem.name = cellData.data + dataItem.nameColor = cellData.color + break; + case TableConstants.DATA_ID_MARKET: + dataItem.market = cellData.data + break; + case TableConstants.DATA_ID_CODE_4: + dataItem.code = cellData.data + break; + case TableConstants.DATA_ID_SHOW_CODE: + dataItem.showCode = cellData.data + break; + case TableConstants.DATA_ID_PRICE: + dataItem.price = cellData.data + dataItem.priceColor = cellData.color + break; + case TableConstants.DATA_ID_ZF: + dataItem.riseFail = cellData.data + dataItem.riseFailColor = cellData.color + break; + case TableConstants.DATA_ID_ZD: + dataItem.priceRise = cellData.data + dataItem.priceRiseColor = cellData.color + break; + default: + break; + } + }) + return dataItem + } + + private toggleExpand(): void { + animateTo({ + duration: 300, + curve: Curve.EaseInOut + }, () => { + this.isExtend = !this.isExtend; + PreferenceService.putVal(FirstPageSelfCodeGroupView.SP_FILE_NAME_FIRST_PAGE_SELF_CODE_FILE_NAME, + SP_KEY_FIRST_PAGE_SELF_CODE_EXPAND, this.isExtend, true) + this.swiperIndex = 0; + this.mPageItemCount = this.getPageItemCount() + this.mMaxTotalItemCount = this.getTotalItemCount() + this.initPageStocks() + this.requestData() + }); + } + + private jumpToMore() { + router.pushUrl({ + url: 'pages/SearchPage' // 目标url + }, router.RouterMode.Standard, (err) => { + if (err) { + console.error(`Invoke pushUrl failed, code is ${err.code}, message is ${err.message}`); + return; + } + console.info('Invoke pushUrl succeeded.'); + }); + } + + private jumpToQuote(dataModel: HQDataItemModel) { + let code = dataModel.code + let market = dataModel.market + + let codeList: Array = [] + let marketList: Array = [] + let nameList: Array = [] + + this.mDataItemModels.forEach((dataModel, _index) => { + if (!dataModel.showAddImage) { + codeList.push(dataModel.code ?? '') + marketList.push(dataModel.market ?? '') + nameList.push(dataModel.name ?? '') + } + }) + try { + jumpToQuote(code, market, codeList.join(","), marketList.join(","), nameList.join(",")); + } catch (error) { + console.log("Invoke pushUrl from selfCode to QuoteDetailPage Failed") + } + } +} diff --git a/src/main/ets/node/view/TopImageNodeComponent.ets b/src/main/ets/node/view/TopImageNodeComponent.ets new file mode 100644 index 0000000..dac8243 --- /dev/null +++ b/src/main/ets/node/view/TopImageNodeComponent.ets @@ -0,0 +1,64 @@ +import { StringUtils } from 'hxutil' +import { FirstPageNodeModel } from '../../datacenter/model/FirstPageNodeModel' +import { display } from '@kit.ArkUI' +import { RouterUtil } from '../../util/RouterUtil' + +/** + * 首页顶部运营图节点 + * @author wubingsong@myhexin.com + */ +@Component +export struct TopImageNodeComponent { + + nodeModel: FirstPageNodeModel | null = null + @State + private isNodeShow: boolean = false + @State bgUrl: string = '' + private jumpUrl: string = '' + @State nodeHeight: number = 70 + + aboutToAppear(): void { + if (this.nodeModel != null) { + this.bgUrl = this.nodeModel.iconUrl + this.jumpUrl = this.nodeModel.url + if (StringUtils.isNotEmpty(this.bgUrl)) { + this.isNodeShow = true + this.calculateHeight() + } + } + } + + private calculateHeight() { + let dis = display.getDefaultDisplaySync() + if (dis.densityPixels > 0) { + let screenWidth = dis.width / dis.densityPixels + if (StringUtils.isNotEmpty(this.bgUrl)) { + this.nodeHeight = screenWidth * 0.61 + } else { + this.nodeHeight = screenWidth * 0.3 + } + } + } + + build() { + + Image(this.bgUrl) + .width('100%') + .height(this.nodeHeight) + .objectFit(ImageFit.Fill) + .onSizeChange((oldValue: SizeOptions, newValue: SizeOptions) => { + if (newValue.width) { + let width = newValue.width as number + if (StringUtils.isNotEmpty(this.bgUrl)) { + this.nodeHeight = width * 0.61 + } else { + this.nodeHeight = width * 0.3 + } + } + }) + .onClick(() => { + RouterUtil.jumpPage(this.jumpUrl) + }) + .visibility(this.isNodeShow ? Visibility.Visible : Visibility.None) + } +} \ No newline at end of file diff --git a/src/main/ets/pages/GridNodeAllPage.ets b/src/main/ets/pages/GridNodeAllPage.ets new file mode 100644 index 0000000..da15dfe --- /dev/null +++ b/src/main/ets/pages/GridNodeAllPage.ets @@ -0,0 +1,115 @@ +import { AppUtil } from '@pura/harmony-utils' +import { GrideNodeDataFetcher } from '../datacenter/GrideNodeDataFetcher' +import { GridModel } from '../datacenter/model/GridModel' +import { GridModelGroup } from '../datacenter/model/GridModelGroup' +import { FirstPageDebugUtil } from '../util/FirstPageDebugUtil' +import { RouterUtil } from '../util/RouterUtil' +import { TitleBar } from '../views/TitleBar' + +/** + * 九宫格全部节点 + * @author wubingsong@myhexin.com + */ +@Entry({ routeName: 'GridNodeAllPage' }) +@Component +export struct GridNodeAllPage { + + @State gridModelGroups: GridModelGroup[] | null = null + + aboutToAppear(): void { + GrideNodeDataFetcher.getInstance().remoteFetchConfig((response) => { + if (response != null) { + this.gridModelGroups = response + } + }, (errorMsg) => { + + }) + } + + + build() { + Scroll() { + Column() { + TitleBar({ + showStatusBar: true, + title: '全部服务' + }) + + List() { + ForEach(this.gridModelGroups, (item: GridModelGroup) => { + ListItem() { + Column() { + this.createGroupView(item) + } + } + }) + } + .sticky(StickyStyle.Header) + .width('100%') + .height('auto') + .layoutWeight(1) + } + .backgroundColor($r('app.color.surface_layer1_background')) + } + .width('100%') + .height('100%') + } + + @Builder + createGroupView(item: GridModelGroup) { + Column() { + Text(item.classifyName) + .fontSize(18) + .fontColor($r('app.color.elements_text_primary_02')) + .fontWeight(FontWeight.Bold) + .textAlign(TextAlign.Start) + .margin({left: 23, top: 10}) + .width('100%') + + List({ initialIndex: 0 }) { + ForEach(item.data, (model: GridModel) => { + ListItem() { + this.createItemView(model) + } + }, (item: GridModel) => item.id.toString()) + } + .width("100%") + .height("auto") + .lanes(5) + .alignListItem(ListItemAlign.Center) + .scrollBar(BarState.Off) + .margin({top: 10}) + } + .backgroundColor($r('app.color.surface_layer1_foreground')) + .borderRadius(10) + .margin({left: 10, right: 10, top: 10}) + + } + + @Builder + createItemView(item: GridModel) { + Column() { + Image(item.imgUrl) + .width(40) + .height(40) + Text(item.title) + .margin( { top : 4 }) + .fontColor($r('app.color.elements_text_primary_02')) + .maxLines(1) + .minFontSize(9) + .maxFontSize(12) + .fontSize(12) + } + .padding( { top : 16, bottom: 16 }) + .onClick(() => { + if (item.jumpUrl && item.jumpUrl !== '') { + if (item.title.includes("智能客服")) { + RouterUtil.jumpPage(item.jumpUrl, true) + } else { + let jumpUrl = item.jumpUrl + RouterUtil.jumpPage(jumpUrl) + } + } + }) + } +} \ No newline at end of file diff --git a/src/main/ets/pages/HotNewsDetailPage.ets b/src/main/ets/pages/HotNewsDetailPage.ets new file mode 100644 index 0000000..4820ccc --- /dev/null +++ b/src/main/ets/pages/HotNewsDetailPage.ets @@ -0,0 +1,45 @@ +import { router } from '@kit.ArkUI' +import { NewsRouter } from '../router/NewsRouter' +import { TitleBar } from '../views/TitleBar' +import { WebViewComponent } from 'biz_webview' + +/** + * 热点资讯详情页 + * @author wubingsong@myhexin.com + */ +@Entry({ routeName: 'HotNewsDetailPage' }) +@Component +export struct HotNewsDetailPage { + + @State title: string = '' + url: string = '' + @State onShow: boolean = true + + aboutToAppear(): void { + let param = router.getParams() as NewsRouter + this.url = param.url + this.title = param.title + } + + // 网页标题变更 + onTitleReceive = (title: string) => { + this.title = title + } + + build() { + Column() { + TitleBar({ + showStatusBar: true, + title: this.title + }) + + WebViewComponent({ + urlString: this.url, + onShow: this.onShow, + onTitleReceive: this.onTitleReceive, + updateH5Title: true + }) + .layoutWeight(1) + } + } +} \ No newline at end of file diff --git a/src/main/ets/pages/HotNewsPage.ets b/src/main/ets/pages/HotNewsPage.ets new file mode 100644 index 0000000..a8f3a72 --- /dev/null +++ b/src/main/ets/pages/HotNewsPage.ets @@ -0,0 +1,199 @@ +import { HotNewsNodeModel } from '../node/model/HotNewsNodeModel'; +import { NewsDataFetcher } from '../datacenter/NewsDataFetcher'; +import { StringUtils } from 'hxutil'; +import { NewsTagModel } from '../datacenter/model/NewsModel'; +import { promptAction, router } from '@kit.ArkUI'; +import { NewsRouter } from '../router/NewsRouter'; +import { TitleBar } from '../views/TitleBar'; +import { MainContractManager } from 'biz_market'; +import { jumpToQuote } from 'biz_common'; + +/** + * 热点资讯列表页 + * @author wubingsong@myhexin.com + */ +@Entry({ routeName: 'HotNewsPage' }) +@Component +export struct HotNewsPage { + + @State + private newsModelArr: HotNewsNodeModel[] | null = null; + @State isRefreshing: boolean = false + @State isNoMoreData: boolean = false + + aboutToAppear(): void { + this.onRefresh() + } + + private onRefresh() { + this.isNoMoreData = false + NewsDataFetcher.getInstance().remoteFetchData((response) => { + this.isRefreshing = false + if (response != null) { + this.newsModelArr = response + } + }, (errorMsg) => { + this.isRefreshing = false + }) + } + + private onLoadMore() { + NewsDataFetcher.getInstance().remoteFetchMoreData((response) => { + if (response != null) { + if (response.length == 0) { + this.isNoMoreData = true + promptAction.showToast({ message: '没有更多数据' }) + return + } + if (this.newsModelArr == null) { + this.newsModelArr = response + } else { + this.newsModelArr.push(...response) + } + } + }, (errorMsg) => { + + }) + } + + build() { + Column() { + TitleBar({ + showStatusBar: true, + title: '热点资讯' + }) + + Refresh({ refreshing: $$this.isRefreshing }) { + List() { + ForEach(this.newsModelArr, (item: HotNewsNodeModel, index: number) => { + ListItem() { + Column() { + this.createNewsView(item) + + Divider() + .color($r('app.color.elements_others_divider_primary')) + } + .width('100%') + } + }) + ListItem() { + Column() { + this.footerLoadingBuilder() + + Divider() + .color($r('app.color.elements_others_divider_primary')) + .visibility(this.isNoMoreData ? Visibility.None : Visibility.Visible) + } + .width('100%') + } + } + .sticky(StickyStyle.Header) + .width('100%') + .height('100%') + .scrollBar(BarState.Off) + .onReachEnd(() => { + this.onLoadMore() + }) + } + .onRefreshing(() => { + this.onRefresh() + }) + .layoutWeight(1) + .width('100%') + } + } + + @Builder + createNewsView(item: HotNewsNodeModel) { + Row() { + Column() { + Text(item.title) + .fontSize(16) + .fontColor($r('app.color.elements_text_primary_02')) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .margin({left: 15}) + + Row() { + Text(item.create_time) + .fontSize(11) + .fontColor($r('app.color.elements_text_tertiary')) + .ellipsisMode(EllipsisMode.END) + .margin({left: 15}) + + Row() { + ForEach(item.tags, (tag: NewsTagModel, index: number) => { + Text(tag.name) + .height(16) + .fontSize(11) + .fontColor($r('app.color.elements_text_primary_01')) + .backgroundColor($r('app.color.elements_others_bg_primary')) + .borderRadius(15) + .borderColor($r('app.color.elements_others_bg_primary')) + .borderWidth(1) + .padding({left: 9, right:9}) + .margin({left: index == 0 ? 0 : 15}) + .onClick(() => { + let stockCode = tag.code + let market = tag.market + let mainContracts = MainContractManager.get().getMainContractByVariety(tag.code) + if (mainContracts.length > 0) { + stockCode = mainContracts[0].contractCode + market = mainContracts[0].market + } else { + stockCode = stockCode + '9999' + } + let stockInfo = MainContractManager.get().getCanTradeStockInfo(stockCode, market) + if (stockInfo) { + jumpToQuote(stockInfo.code, stockInfo.market, '', '', '') + } + }) + }) + } + } + .justifyContent(FlexAlign.SpaceBetween) + .alignItems(VerticalAlign.Center) + .width('100%') + + + } + .justifyContent(FlexAlign.SpaceBetween) + .alignItems(HorizontalAlign.Start) + .height(60) + .margin({right: 15}) + .layoutWeight(1) + + Image(StringUtils.isNotEmpty(item.img_url) ? item.img_url : $r('app.media.firstpage_news_image')) + .width(80) + .height(60) + .margin({right: 15}) + .borderRadius(2) + } + .width('100%') + .backgroundColor($r('app.color.surface_layer1_foreground')) + .padding({top: 15, bottom: 15}) + .onClick(() => { + router.pushNamedRoute({ + name: 'HotNewsDetailPage', + params: new NewsRouter('热点资讯', item.url) + }) + }) + } + + @Builder + footerLoadingBuilder() { + Row() { + Progress({ value: 40, total: 150, type: ProgressType.Ring }).width(30).height(30) + + Text('正在载入') + .fontSize(14) + .fontColor($r('app.color.color_000000')) + .margin({left: 100}) + } + .justifyContent(FlexAlign.Center) + .alignItems(VerticalAlign.Center) + .height(60) + .backgroundColor($r('app.color.surface_layer1_foreground')) + .visibility(this.isNoMoreData ? Visibility.None : Visibility.Visible) + } +} \ No newline at end of file diff --git a/src/main/ets/popup/AdFloatState.ets b/src/main/ets/popup/AdFloatState.ets new file mode 100644 index 0000000..46aa6c9 --- /dev/null +++ b/src/main/ets/popup/AdFloatState.ets @@ -0,0 +1,11 @@ +/** + * author : liuqingliang@myhexin.com + * time : created on 2026/6/4 + * desc : 枚举值 FIRST: 状态1,文本、初始图片完整展示 SECOND: 状态2,文本框收起不展示 THIRD: 状态3,文本框收起,初始图片隐藏,展示小图 + */ +export enum AdFloatState { + FIRST, + SECOND, + THIRD, + INVALID +} \ No newline at end of file diff --git a/src/main/ets/popup/AdsBallManager.ets b/src/main/ets/popup/AdsBallManager.ets new file mode 100644 index 0000000..6018c53 --- /dev/null +++ b/src/main/ets/popup/AdsBallManager.ets @@ -0,0 +1,106 @@ +import { DialogHub, InfDialog } from '@hadss/dialoghub' +import { ImageLoader } from '../util/ImageLoader' +import { AdsFloatBallParam, buildAdsFloatBall } from './AdsFloatBall' +import { PopupRecord } from './PopupRecord' +import { ColourModeStatus, ThemeManagerEmitterConstants } from '@kernel/theme_manager' +import { HXLog } from 'biz_common' +import { emitter, systemDateTime } from '@kit.BasicServicesKit' + +/** + * author : liuqingliang@myhexin.com + * time : created on 2026/6/4 + * desc : 广告悬浮球管理类 + */ +const TAG = 'AdsBallManager' +const TIME_INTERVAL = 300 + +export class AdsBallManager { + private static instance: AdsBallManager | null = null + private floatBall: InfDialog | null = null + private floatBallIds: string[] = [] + private lastRecord: PopupRecord | null = null + private lastCreateTime: number = 0 + + private constructor() { + emitter.on(ThemeManagerEmitterConstants.COLOUR_MODE_UPDATE, this.onThemeModeChange) + } + + public static getInstance() { + if (!AdsBallManager.instance) { + AdsBallManager.instance = new AdsBallManager() + } + return AdsBallManager.instance + } + + private onThemeModeChange = () => { + if (this.lastRecord) { + this.createAndShow(this.lastRecord) + } + } + + /** + * 创建广告悬浮球 + */ + async createAndShow(record: PopupRecord) { + const currentTime = systemDateTime.getTime() + if ((currentTime - this.lastCreateTime) < TIME_INTERVAL) { + return + } + this.lastCreateTime = currentTime + this.lastRecord = record + this.destroy() + const enableDarkMode: boolean = ColourModeStatus.enableDarkMode() + // 完整图 + const fullPixelMap = await this.loadImage(record.pop.getDisplayImage(enableDarkMode, false)) + // 半图 + const halfPixelMap = await this.loadImage(record.pop.getDisplayImage(enableDarkMode, true)) + + if (!fullPixelMap || !halfPixelMap) { + // 存在图片加载失败 + return + } + + this.floatBall = DialogHub.getCustomDialog() + .setContent(wrapBuilder(buildAdsFloatBall), new AdsFloatBallParam(record, fullPixelMap, halfPixelMap)) + .setConfig({ + dialogBehavior: { + passThroughGesture: true, + isModal: false + }, + dialogPosition: { + alignment: DialogAlignment.CenterEnd, + } + }) + .setStyle({ + backgroundColor: Color.Transparent, + }) + .build() + this.floatBallIds.push(this.floatBall.dialogId) + this.floatBall.show() + } + + /** + * 销毁广告悬浮球 + */ + destroy() { + if (this.floatBall) { + this.floatBall.dismiss() + this.floatBall = null + } + // 兜底 + DialogHub.getCurrentPageDialogs().forEach((dialog) => { + let dialogId = dialog.dialogId; + if (this.floatBallIds.includes(dialogId)) { + dialog.dismiss() + } + }) + } + + private async loadImage(url: string) { + const img = await ImageLoader.loadSync(url) + if (!img) { + HXLog.e(TAG, `loadImage failed. url:${url}`) + } + return img + } +} \ No newline at end of file diff --git a/src/main/ets/popup/AdsFloatBall.ets b/src/main/ets/popup/AdsFloatBall.ets new file mode 100644 index 0000000..b146bc6 --- /dev/null +++ b/src/main/ets/popup/AdsFloatBall.ets @@ -0,0 +1,113 @@ +import { AdsBallManager, PopupRecord } from '@b2c/first_page' +import { AdsPopupView } from '@b2c/first_page' +import { HXLog } from 'biz_common' +import { JSON } from '@kit.ArkTS' +import { AdsPopupHost } from './AdsPopupHost' +import { image } from '@kit.ImageKit' + +/** + * author : liuqingliang@myhexin.com + * time : created on 2026/6/4 + * desc : 广告悬浮球弹窗 + */ +const TAG = 'AdsFloatBall' +/** 悬浮球距离屏幕顶部的边界偏移 */ +const MIN_OFFSET = 100 +const MAX_OFFSET = 600 + +@Component +struct AdsFloatBall { + @Prop floatRecord: PopupRecord + @State startOffsetY: number = 0 + @State currentOffsetY: number = NaN + /** 完整图 PixelMap */ + fullPixelMap?: image.PixelMap + /** 半图 PixelMap */ + halfPixelMap?: image.PixelMap + + aboutToAppear(): void { + HXLog.d(TAG, `${JSON.stringify(this.floatRecord)}`) + this.currentOffsetY = this.floatRecord.yPosition + } + + /** + * 限制 Y 偏移量在有效范围内 + * @param offsetY 原始偏移量 + * @returns 限制后的偏移量 + */ + private clampOffsetY(offsetY: number): number { + if (offsetY < MIN_OFFSET) { + return MIN_OFFSET + } + if (offsetY > MAX_OFFSET) { + return MAX_OFFSET + } + return offsetY + } + + build() { + Column() { + Column() { + if (this.floatRecord.pop) { + AdsPopupView({ + qhtmktPop: this.floatRecord.pop, + fullPixelMap: this.fullPixelMap, + halfPixelMap: this.halfPixelMap, + onClose: () => { + // 关闭:写关闭疲劳度 + 5min 防抖时间戳 + 埋点 + 销毁 Dialog + AdsPopupHost.getInstance().recordClose(this.floatRecord.pop) + AdsBallManager.getInstance().destroy() + }, + onSecondAppear: () => { + // 完整曝光:写展示疲劳度 + 埋点 + AdsPopupHost.getInstance().recordShow(this.floatRecord.pop) + }, + onAdjustStateHandlerReady: (handler) => { + AdsPopupHost.getInstance().setAdjustStateHandler(handler) + } + }) + } + } + .offset({ y: this.currentOffsetY }) + .hitTestBehavior(HitTestMode.Transparent) + .gesture( + PanGesture(new PanGestureOptions({ direction: PanDirection.Vertical })) + .onActionStart(() => { + this.startOffsetY = this.currentOffsetY + }) + .onActionUpdate((event: GestureEvent) => { + // event.offsetY是本次手势相对于起始点的偏移量 + let newOffsetY = this.startOffsetY + event.offsetY + this.currentOffsetY = this.clampOffsetY(newOffsetY) + }) + .onActionEnd(() => { + // translationY 变化后写入 FloatBallHost,onPageJump 时 saveYPosition + AdsPopupHost.getInstance().setCurrentYOffset(this.currentOffsetY) + }) + ) + } + .hitTestBehavior(HitTestMode.Transparent) + .height('100%') + } +} + +@Builder +export function buildAdsFloatBall(param: AdsFloatBallParam) { + AdsFloatBall({ + floatRecord: param.record, + fullPixelMap: param.fullPixelMap, + halfPixelMap: param.halfPixelMap, + }) +} + +export class AdsFloatBallParam { + constructor(record: PopupRecord, fullPixelMap: image.PixelMap, halfPixelMap: image.PixelMap) { + this.record = record + this.fullPixelMap = fullPixelMap + this.halfPixelMap = halfPixelMap + } + + record: PopupRecord; + fullPixelMap: image.PixelMap; + halfPixelMap: image.PixelMap +} \ No newline at end of file diff --git a/src/main/ets/popup/AdsPopupHost.ets b/src/main/ets/popup/AdsPopupHost.ets new file mode 100644 index 0000000..92255c5 --- /dev/null +++ b/src/main/ets/popup/AdsPopupHost.ets @@ -0,0 +1,653 @@ +import { + ADS_TYPE_QHTMKTPOP, + AdsManager, + AdsPageItem, + AdsUtils, + PopupAdsListenOptions, + PopupAdsListener, + PopupAdsRequestResult, + PopupPageInfoListener, + PopupPageInfoResult, + QhtmktPop, + AdsPopupManager +} from '@b2c-f/fuhm-ads' +import { TradeDateCenter } from '@b2c-f/futures_ohos_trade_sdk' +import { EmitterConstants, HXLog, jumpToWebPage, StringUtil } from 'biz_common' +import { getCurrentFrameId } from 'biz_quote/src/main/ets/util/FrameId' +import { AccountProvider } from 'biz_trade' +import { Account } from 'biz_trade/src/main/ets/constants/TypeConstants' +import { HexinVersionControl } from 'biz_trade/src/main/ets/utils/HexinVersionControl' +import { landingPageRouteParam } from '../route' +import { PopupRecord } from './PopupRecord' +import { SpAdsComponent } from './SpAdsComponent' +import { HXUserService } from 'biz_hxservice' +import { AdsBallManager } from './AdsBallManager' +import { AdsPageConfig } from 'biz_common' +import { Action, HxCBASAgentProvider } from '@b2c-spi/hx_cbas_api' +import { FirstPageFiveCBASConstants } from '../constants/FirstPageFiveCBASConstants' +import { emitter } from '@kit.BasicServicesKit' + +// 持仓页面和交易首页需要特殊处理 因为它们的实盘/模拟都是同一个页面 +const MONI_KEY: string = 'moni' +const SHIPAN_KEY: string = 'shipan' + +/** 主动关闭后 5 分钟拦截阈值(ms) */ +const CLOSE_INTERVAL_MS: number = 5 * 60 * 1000 +/** popupMap 最大缓存页面数 */ +const MAX_CACHED_PAGES: number = 2 +/** 滚动事件节流阈值(ms) */ +const SCROLL_THROTTLE_MS: number = 300 +/** 默认悬浮球纵向位置 */ +const DEFAULT_OFFSET_Y: number = 600 +const CLOSE_BALL_DEBOUNCE_MS: number = 100 + +/** + * author : liuqingliang@myhexin.com + * time : created on 2026/6/4 + * desc : 业务侧全域运营位宿主控制器 + */ + +const TAG = 'AdsPopupHost' + +export class AdsPopupHost implements PopupPageInfoListener, PopupAdsListener { + private static instance: AdsPopupHost | null = null + private adjustStateHandler: (() => void) | null = null + private lastScrollTime: number = 0 + /** 当前缓存的悬浮球广告 + Y 坐标; key=`${pageId}|${adId}` */ + private popupMap: Map = new Map() + /** 当前展示中的悬浮球缓存 key; null 表示无悬浮球 */ + private showingAdKey: string | null = null + /** 首页弹窗是否正在显示,命中时跳过悬浮球展示 */ + private showFirstPage: boolean = false + /** 宿主显式传入的当前 pageId,Adapter.getCurrentPageId 返回空时作为兜底 */ + private currentPageId: AdsPageConfig = AdsPageConfig.INVALID + /** 当前悬浮球 Y 坐标,由 AdsFloatBall 拖动时写入,onPageJump 时用于 saveYPosition */ + private currentYOffset: number = DEFAULT_OFFSET_Y + /** 延时关闭悬浮球,防止悬浮球还未创建便触发 */ + private closePopupId: number | null = null + + constructor() { + AdsPopupManager.registerPageInfoListener(this) + emitter.on(EmitterConstants.ADS_POPUP_PAGE_CHANGE, this.onPageChangeListen) + } + + static getInstance(): AdsPopupHost { + if (!AdsPopupHost.instance) { + AdsPopupHost.instance = new AdsPopupHost() + } + return AdsPopupHost.instance + } + + private onPageChangeListen = (eventData: emitter.EventData) => { + if (eventData && eventData.data) { + const pageId: AdsPageConfig = eventData.data['pageId'] + if (pageId != null && pageId != undefined) { + this.onPageJump(pageId) + } + } + } + + // ==================== 公开 API (业务方调用) ==================== + + /** 请求页面配置;成功后自动 match + show */ + requestPageInfo(timestamp: string) { + AdsPopupManager.requestPageInfo(timestamp) + } + + /** pageItemList 请求完成回调;上层拿到列表后重新匹配当前页面并触发广告请求 */ + onPageInfoResult(result: PopupPageInfoResult) { + HXLog.d(TAG, `onPageInfoResult result: ${JSON.stringify(result)}`) + if (!result.ok) { + HXLog.w(TAG, `AdsPopupHostController requestPageInfo failed: ${result.errMsg}`) + return + } + this.matchAndShow() + } + + /** Popup 广告请求完成回调;过滤、选择、展示缓存全部在上层完成 */ + onPopupAdsResult(result: PopupAdsRequestResult) { + HXLog.d(TAG, `onPopupAdsResult result: ${JSON.stringify(result)}`) + if (!result.ok) { + HXLog.w(TAG, `AdsPopupHostController requestPopupAds failed: ${result.errMsg}`) + return + } + const record = this.handleQhtmktPopShow(result.ads) + this.clearPopupTimeout() + this.notify(record) + } + + /** 宿主 PageJump 触发后同步当前可展示悬浮球 */ + onPageJump(pageId: AdsPageConfig) { + HXLog.d(TAG, `onPageJump pageId:${pageId}`) + this.currentPageId = pageId + this.clearPopupTimeout() + if (pageId === AdsPageConfig.INVALID) { + this.closePopupId = setTimeout(() => { + this.notify(null) + this.closePopupId = null + }, CLOSE_BALL_DEBOUNCE_MS) + return + } + // 先保存当前 Y 坐标,再 dismiss + // saveYPosition 依赖 showingAdKey 存在,故必须在 clearShowing() 之前调用 + this.saveYPosition() + this.clearShowing() + if (pageId === AdsPageConfig.FIRST_PAGE_ID && this.showFirstPage) { + this.notify(null) + return + } + if (AdsPopupManager.pageInfoList.length === 0 && AdsPopupManager.isNeedRequestPageInfo) { + this.requestPageInfo(AdsPopupManager.pushTimestamp) + return + } + this.matchAndShow(pageId) + } + + /** 处理策略广告推送 */ + handlePush() { + const pageId = this.resolveCurrentPageId() + const pageItem = this.getCurrentPageItem(pageId) + if (pageItem === null) { + this.notify(null) + return + } + this.requestAndShow(pageItem) + } + + /** 首页弹窗关闭后启动 5 分钟全域防抖,刷新展示态 */ + notifyFirstPageClose(): void { + SpAdsComponent.setLastPopupCloseTime(AdsUtils.currentTime) + this.showFirstPage = false + this.syncRecord() + } + + /** 首页弹窗显隐变化:显示时停止悬浮球,隐藏时尝试匹配 */ + notifyFirstPageMatch(isShow: boolean) { + this.showFirstPage = isShow + if (isShow) { + this.clearShowing() + this.notify(null) + return + } + if (!this.showFirstPage) { + this.matchAndShow() + } + } + + /** 持仓页模拟/实盘切换后同步 */ + notifyTradePositionChange() { + this.clearShowing() + this.matchAndShow() + } + + /** 交易首页模拟/实盘切换后同步 */ + notifyTradeHomePageChange() { + this.clearShowing() + this.matchAndShow() + } + + /** 业务侧滚动事件透传, */ + onScrollEvent() { + const currentTime = Date.now() + if (currentTime - this.lastScrollTime <= SCROLL_THROTTLE_MS) { + return + } + this.lastScrollTime = currentTime + if (this.adjustStateHandler) { + this.adjustStateHandler() + } + } + + /** AdsFloatBall 挂载时注入状态切换 handler,销毁时传 null */ + setAdjustStateHandler(handler: (() => void) | null) { + this.adjustStateHandler = handler + } + + /** 悬浮球拖动结束后保存 Y 坐标到 UI 缓存 */ + saveYPosition(): void { + if (!this.showingAdKey) { + return + } + const record = this.popupMap.get(this.showingAdKey) + if (record) { + record.yPosition = this.currentYOffset + } + } + + /** 展示编排:写展示疲劳度 + 上报埋点 */ + recordShow(qhtmktPop: QhtmktPop) { + if (StringUtil.isEmpty(qhtmktPop.adId)) { + return + } + AdsManager.adsFatigueManager.showAdByParams( + ADS_TYPE_QHTMKTPOP, + qhtmktPop.adId, + qhtmktPop.startTime, + qhtmktPop.endTime, + qhtmktPop.fatigueDegree + ) + // TODO: 埋点上报,需业务侧接入真实埋点SDK + this.sendStat('show', qhtmktPop, qhtmktPop.pageId) + } + + /** 点击编排:写点击疲劳度 + 埋点 + 命中疲劳清缓存 + 宿主跳转 + 同步展示态 */ + recordClick(qhtmktPop: QhtmktPop) { + if (StringUtil.isEmpty(qhtmktPop.adId)) { + return + } + AdsManager.adsFatigueManager.clickAdByParams( + ADS_TYPE_QHTMKTPOP, + qhtmktPop.adId, + qhtmktPop.startTime, + qhtmktPop.endTime, + qhtmktPop.fatigueDegree + ) + this.sendCbas(Action.CLICK) + if (AdsManager.adsFatigueManager.isFatigueAd(ADS_TYPE_QHTMKTPOP, qhtmktPop.adId, qhtmktPop.fatigueDegree)) { + this.popupMap.delete(this.getPopupKey(qhtmktPop)) + } + // TODO: H5内嵌WebView跳转,需业务侧提供WebView容器 + if (qhtmktPop.link !== null && qhtmktPop.link.url.length > 0) { + jumpToWebPage(landingPageRouteParam(qhtmktPop.link.url, qhtmktPop.link.url, qhtmktPop.link.isShowOnClient())) + } + if (this.showingAdKey === this.getPopupKey(qhtmktPop)) { + this.clearShowing() + } + this.syncRecord() + } + + /** 半收起态点击展开埋点;不写疲劳度 */ + recordHalfClick(qhtmktPop: QhtmktPop) { + if (StringUtil.isEmpty(qhtmktPop.adId)) { + return + } + // TODO: 埋点上报,需业务侧接入真实埋点SDK + this.sendStat('halfClick', qhtmktPop, qhtmktPop.pageId) + } + + /** 关闭编排:清 UI 缓存 + 写 SP 防抖时间戳 + 埋点 + 写点击疲劳度 + 同步展示态 */ + recordClose(qhtmktPop: QhtmktPop) { + if (StringUtil.isEmpty(qhtmktPop.adId)) { + return + } + this.popupMap.delete(this.getPopupKey(qhtmktPop)) + SpAdsComponent.setLastPopupCloseTime(AdsUtils.currentTime) + // TODO: 埋点上报,需业务侧接入真实埋点SDK + this.sendStat('close', qhtmktPop, qhtmktPop.pageId) + AdsManager.adsFatigueManager.clickAdByParams( + ADS_TYPE_QHTMKTPOP, + qhtmktPop.adId, + qhtmktPop.startTime, + qhtmktPop.endTime, + qhtmktPop.fatigueDegree + ) + if (this.showingAdKey === this.getPopupKey(qhtmktPop)) { + this.clearShowing() + } + this.syncRecord() + } + + /** 加载失败:hasNoCache 时清 UI 缓存,不写关闭疲劳度 */ + recordLoadFailed(qhtmktPop: QhtmktPop, message: string) { + HXLog.w(TAG, `AdsFloatBall ${message}: adId=${qhtmktPop.adId}`) + if (qhtmktPop.hasNoCache) { + this.popupMap.delete(this.getPopupKey(qhtmktPop)) + } + if (this.showingAdKey === this.getPopupKey(qhtmktPop)) { + this.clearShowing() + } + this.syncRecord() + } + + /** 清理业务侧 UI 控制状态;同时清理 AdsPopupManager 请求、缓存和监听 */ + clear(): void { + this.adjustStateHandler = null + this.lastScrollTime = 0 + this.popupMap.clear() + this.showingAdKey = null + this.showFirstPage = false + this.clearPopupTimeout() + this.currentPageId = AdsPageConfig.INVALID + AdsPopupManager.unregisterPageInfoListener(this) + AdsPopupManager.unregisterPopupAdsListener(this) + AdsPopupManager.clear() + this.notify(null) + } + + // ==================== 内部:匹配 ==================== + // 检测当前页面是否符合配置信息 + private matchAndShow(pageId: AdsPageConfig = AdsPageConfig.INVALID) { + const currentPageId = StringUtil.isEmpty(pageId) ? this.currentPageId : pageId + if (StringUtil.isEmpty(currentPageId)) { + this.notify(null) + return + } + // 当前页面数据 + const pageItem = this.getCurrentPageItem(currentPageId) + if (!pageItem) { + this.notify(null) + return + } + const isChicang = currentPageId === AdsPageConfig.TRADE_CHICANG_PAGE_ID + if (isChicang && !this.isMoniChicangCanShow()) { + this.notify(null) + return + } + this.requestAndShow(pageItem) + } + + private async requestAndShow(pageItem: AdsPageItem): Promise { + if (HexinVersionControl.hideTrade()) { + this.notify(null) + return + } + const options = new PopupAdsListenOptions() + options.pageItem = pageItem + options.userId = HXUserService.getInstance().getUserIdFromLocal() + options.adType = ADS_TYPE_QHTMKTPOP + options.fatigueDegreeIds = AdsManager.adsFatigueManager.getFatigueAdsText(ADS_TYPE_QHTMKTPOP) + AdsPopupManager.registerPopupAdsListener(options, this) + } + + /** 获取当前页面配置信息 */ + private getCurrentPageItem(pageId: string): AdsPageItem | null { + const currentPageId = StringUtil.isEmpty(pageId) ? this.currentPageId : pageId + if (StringUtil.isEmpty(currentPageId)) { + return null + } + + if (currentPageId === AdsPageConfig.TRADE_CHICANG_PAGE_ID || currentPageId === AdsPageConfig.TRADE_HOME_PAGE_ID) { + return this.filterPageItem(currentPageId, false) + } else { + // 当前是H5页面 + const isH5 = this.isWebPage(currentPageId) + const finalPage = isH5 ? this.getCurrentPageUrl() : currentPageId + return this.filterPageItem(finalPage, isH5) + } + } + + // 获取页面配置信息 + // Params:currentPageId :可能是页面id,也可能是H5地址 + private filterPageItem(currentPageId: string, isH5: boolean): AdsPageItem | null { + const pageInfoList = AdsPopupManager.pageInfoList + if (pageInfoList.length === 0) { + return null + } + + return pageInfoList.find((it) => { + const pageInfo = it.page + if (StringUtil.isEmpty(pageInfo)) { + return false + } + if (isH5) { + return this.matchH5(it, pageInfo, currentPageId) + } + if (currentPageId === AdsPageConfig.TRADE_CHICANG_PAGE_ID || currentPageId === AdsPageConfig.TRADE_HOME_PAGE_ID) { + // 持仓页面、交易首页需要获取当前交易帐号 + let currentAccount = AccountProvider.getInstance().getCurrentAccount() + // 没登录交易帐号,返回 + if (StringUtil.isEmpty(currentAccount?.account)) { + return false + } + return this.matchTradeFragment(pageInfo, currentPageId, currentAccount) + } else { + return pageInfo.includes(currentPageId) + } + }) ?? null + } + + /** 匹配H5页面 */ + private matchH5(item: AdsPageItem, pageInfo: string, currentPageId: string): boolean { + // H5页面 + const condition1 = !item.isClientPage() + // 匹配规则 currentPageId可能会比pageInfo的长,因为H5页面加载出来可能会带点参数 + const condition2 = item.isMatchFull() ? pageInfo === currentPageId : currentPageId.includes(pageInfo) + return condition1 && condition2 + } + + private matchTradeFragment(pageInfo: string, currentPageId: string, currentAccount: Account): boolean { + // 当前帐号是否是模拟帐号 + const isCurrentAccountMoni = currentAccount?.isMoni() + const isMoni = pageInfo.includes(MONI_KEY) + const isShipan = pageInfo.includes(SHIPAN_KEY) + // 需要包含-moni、-shipan + const condition1 = isCurrentAccountMoni ? isMoni : isShipan + // 需要pageId相等 + const condition2 = pageInfo.includes(currentPageId) + return condition1 && condition2 + } + + // ==================== 内部:业务策略 ==================== + + /** 投放时间过滤 */ + private filterByTime(ads: QhtmktPop[]): QhtmktPop[] { + const result: QhtmktPop[] = [] + ads.forEach((item: QhtmktPop) => { + if (this.isValidPopupTime(item)) { + result.push(item) + } + }) + return result + } + + /** 疲劳度过滤 */ + private filterByFatigue(ads: QhtmktPop[]): QhtmktPop[] { + const result: QhtmktPop[] = [] + ads.forEach((item: QhtmktPop) => { + if (!AdsManager.adsFatigueManager.isFatigueAd(ADS_TYPE_QHTMKTPOP, item.adId, item.fatigueDegree)) { + result.push(item) + } + }) + return result + } + + /** 按 priority 取最大 */ + private pickByPriority(candidates: QhtmktPop[]): QhtmktPop | null { + if (candidates.length === 0) { + return null + } + const sorted = candidates.slice().sort((left: QhtmktPop, right: QhtmktPop) => right.priority - left.priority) + return sorted[0] + } + + /** 5 分钟全域防抖判定 */ + private isWithinDebounce(): boolean { + return (AdsUtils.currentTime - SpAdsComponent.getLastPopupCloseTime()) <= CLOSE_INTERVAL_MS + } + + /** 首页弹窗显示时悬浮球阻塞 */ + private isFirstPageBlocked(): boolean { + return this.showFirstPage + } + + /** 缓存替换:最多 MAX_CACHED_PAGES 页;同页旧记录替换并复用 yPosition;新增 + 防抖命中时回空 */ + private replaceUiCache(target: QhtmktPop): PopupRecord | null { + let yPosition = DEFAULT_OFFSET_Y + const oldKeys: string[] = [] + const oldRecords: PopupRecord[] = [] + this.popupMap.forEach((record: PopupRecord, key: string) => { + if (record.pop.pageId === target.pageId) { + oldKeys.push(key) + oldRecords.push(record) + } + }) + if (oldRecords.length === 0 && this.popupMap.size >= MAX_CACHED_PAGES) { + return null + } + if (oldRecords.length === 0 && this.isWithinDebounce()) { + return null + } + if (oldKeys.length > 0) { + const cachedRecord = oldRecords[0] + this.popupMap.delete(oldKeys[0]) + if (this.isSameQhtmktPop(cachedRecord.pop, target)) { + target.hasNoCache = false + yPosition = cachedRecord.yPosition + } + } + const record = new PopupRecord(target, yPosition) + const targetKey = this.getPopupKey(target) + this.popupMap.set(targetKey, record) + this.showingAdKey = targetKey + return record + } + + /** 过滤 + 优先级 + 首页联动 + 缓存替换(含 5min 防抖) */ + private handleQhtmktPopShow(ads: QhtmktPop[]): PopupRecord | null { + const validAds = this.filterByTime(ads) + const candidates = this.filterByFatigue(validAds) + const target = this.pickByPriority(candidates) + if (target === null) { + return this.getShowingRecord() + } + const currentPageId = this.resolveCurrentPageId() + const pageItem = this.getCurrentPageItem(currentPageId) + if (pageItem === null || target.pageId !== pageItem.id) { + return this.getShowingRecord() + } + if (this.isFirstPageBlocked()) { + return this.getShowingRecord() + } + const replaced = this.replaceUiCache(target) + return replaced === null ? this.getShowingRecord() : replaced + } + + // ==================== 内部:工具 ==================== + + private getPopupKey(qhtmktPop: QhtmktPop): string { + return `${qhtmktPop.pageId}|${qhtmktPop.adId}` + } + + private isSameQhtmktPop(left: QhtmktPop, right: QhtmktPop): boolean { + return left.adId === right.adId && + left.startTime === right.startTime && + left.endTime === right.endTime && + left.priority === right.priority && + JSON.stringify(left.fatigueDegree) === JSON.stringify(right.fatigueDegree) && + left.fullWhiteImg === right.fullWhiteImg && + left.fullBlackImg === right.fullBlackImg && + left.content === right.content && + left.pageId === right.pageId && + left.isFirstShowValue === right.isFirstShowValue && + JSON.stringify(left.link) === JSON.stringify(right.link) + } + + private resolveCurrentPageId(): string { + const injected = StringUtil.isEmpty(this.currentPageId) ? getCurrentFrameId().toString() : this.currentPageId + if (injected.length > 0) { + return injected + } + return this.currentPageId + } + + private isValidPopupTime(ad: QhtmktPop): boolean { + const nowTime = Math.floor(AdsUtils.currentTime / 1000) + const normalizedEndTime = ad.endTime <= 0 ? nowTime : ad.endTime + return nowTime >= ad.startTime && nowTime <= normalizedEndTime + } + + // 模拟持仓页是否能显示 + private isMoniChicangCanShow(): boolean { + const currentTime = AdsUtils.currentTime + const currentDay = AdsUtils.getCurrentTimeDay(currentTime) + const yesterday = this.getYesterdayDay(currentTime) + const currentTradable = TradeDateCenter.getInstance().isTradableDate(currentDay) + const yesterdayTradable = TradeDateCenter.getInstance().isTradableDate(yesterday) + if (currentTradable && yesterdayTradable) { + return this.inTimeRange(currentTime, 2, 30, 9, 0) || this.inTimeRange(currentTime, 15, 30, 21, 0) + } + if (currentTradable && !yesterdayTradable) { + return this.inTimeRange(currentTime, 0, 0, 9, 0) || this.inTimeRange(currentTime, 15, 30, 21, 0) + } + if (!currentTradable && yesterdayTradable) { + return this.inTimeRange(currentTime, 2, 30, 24, 0) + } + return true + } + + private getYesterdayDay(time: number): string { + const date = new Date(time) + date.setDate(date.getDate() - 1) + return AdsUtils.getCurrentTimeDay(date.getTime()) + } + + private inTimeRange(currentTime: number, startHour: number, startMinute: number, endHour: number, + endMinute: number): boolean { + const start = this.getTime(currentTime, startHour, startMinute) + const end = this.getTime(currentTime, endHour, endMinute) + return currentTime >= start && currentTime <= end + } + + private getTime(currentTime: number, hour: number, minute: number): number { + const date = new Date(currentTime) + date.setHours(hour, minute, 0, 0) + return date.getTime() + } + + private getShowingRecord(): PopupRecord | null { + if (this.showingAdKey === null) { + return null + } + return this.popupMap.get(this.showingAdKey) ?? null + } + + private clearShowing(): void { + this.showingAdKey = null + } + + private syncRecord(): void { + this.notify(this.getShowingRecord()) + } + + private notify(record: PopupRecord | null): void { + if (!record) { + AdsBallManager.getInstance().destroy() + return + } + AdsBallManager.getInstance().createAndShow(record) + } + + destroy() { + this.notify(null) + } + + /** 获取当前悬浮球 Y 坐标,用于 AdsFloatBall 恢复位置 */ + getCurrentYOffset(): number { + return this.currentYOffset + } + + /** 设置当前悬浮球 Y 坐标,由 AdsFloatBall 拖动时调用 */ + setCurrentYOffset(offset: number): void { + this.currentYOffset = offset + } + + sendStat(action: string, ad: QhtmktPop, pageId: string): void { + // TODO: 接入真实埋点SDK + HXLog.d(TAG, `action=${action} adId=${ad.adId} pageId=${pageId}`) + } + + sendCbas(action: Action) { + const oid = FirstPageFiveCBASConstants.CBAS_ADS_POPUP + HxCBASAgentProvider.getInstance().doEvent(oid, action) + HXLog.i(TAG, `sendCbas action:${action}`) + } + + isWebPage(pageId: string): boolean { + return this.WEB_VIEW_FRAME_ID.includes(pageId) + } + + getCurrentPageUrl(): string { + // TODO: 获取当前网页的网址 + HXLog.w(TAG, 'getCurrentPageUrl not implemented, H5 ad matching disabled') + return "" + } + + private WEB_VIEW_FRAME_ID = ['2804', '1727', '2816', '2808', '2810', '2829', '2817', '2904', '2805', '12033', '2369'] + + private clearPopupTimeout() { + if (this.closePopupId) { + clearTimeout(this.closePopupId) + this.closePopupId = null + } + } +} \ No newline at end of file diff --git a/src/main/ets/popup/AdsPopupView.ets b/src/main/ets/popup/AdsPopupView.ets new file mode 100644 index 0000000..c6f2576 --- /dev/null +++ b/src/main/ets/popup/AdsPopupView.ets @@ -0,0 +1,297 @@ +import { image } from '@kit.ImageKit' +import { QhtmktPop } from '@b2c-f/fuhm-ads' +import { AdFloatState } from './AdFloatState' +import { AdsPopupHost } from './AdsPopupHost' +import { ColourModeStatus, enableDarkMode } from '@kernel/theme_manager' +import { drawing } from '@kit.ArkGraphics2D' + +/** + * author : liuqingliang@myhexin.com + * time : created on 2026-06-04 + * desc : 全域运营位悬浮球组件 + */ + +const DELAY_TIME_FIRST_TO_SECOND: number = 5000 +const DELAY_TIME_SECOND_TO_THIRD: number = 3000 +const DELAY_TIME_SECOND_TO_FIRST_AFTER_EXPAND: number = 250 + +@Component +export struct AdsPopupView { + /** 运营中台返回的悬浮球广告数据 */ + @Prop qhtmktPop: QhtmktPop + /** 向业务层注册外部滚动触发的状态切换 handler,组件销毁时回传 null */ + onAdjustStateHandlerReady?: ((handler: (() => void) | null) => void) | null + /** 关闭事件 */ + onClose?: (() => void) | null + /** 完整曝光事件 */ + onSecondAppear?: (() => void) | null + /** 完整图 PixelMap */ + fullPixelMap?: image.PixelMap + /** 半图 PixelMap */ + halfPixelMap?: image.PixelMap + /** 当前显示状态 */ + @State currentState: AdFloatState = AdFloatState.INVALID + /** 上一个状态,用于判断 SECOND 态首次曝光(previousState != FIRST 时上报)*/ + @State lastState: AdFloatState = AdFloatState.INVALID + /** 预计算的下一状态 */ + @State nextState: AdFloatState = AdFloatState.INVALID + /** 点击状态3展开后,FIRST态动画结束时触发 updateState 的标志 */ + @State turnToStateThird: boolean = false + /** 初始状态,控制能否主动切到 THIRD */ + @State mInitState: AdFloatState = AdFloatState.INVALID + /** 点击状态3的标记 */ + @State clickStateThird: boolean = false + /** 首次完整曝光(SECOND 态首次进入)是否已上报,防止 onAppear 重复触发 */ + @State hasReportedShow: boolean = false + /** 自动状态切换定时器句柄 */ + private timerId: number = -1 + /** App 主题深色模式 */ + @StorageProp(enableDarkMode) enableDarkMode: boolean = ColourModeStatus.enableDarkMode() + + aboutToAppear(): void { + this.notifyAdjustStateHandlerReady() + this.initState() + } + + aboutToDisappear(): void { + this.stopTimer() + if (this.onAdjustStateHandlerReady) { + this.onAdjustStateHandlerReady(null) + } + } + + build() { + Stack({ alignContent: Alignment.TopEnd }) { + Row() { + // 文字 + if (this.currentState === AdFloatState.FIRST) { + Text(this.qhtmktPop.content) + .fontSize(14) + .fontColor($r('app.color.elements_text_on_color')) + .backgroundColor($r('app.color.solid_grey_900')) + .padding({ + left: 16, + right: 16, + top: 8, + bottom: 8 + }) + .borderRadius(6) + .transition(TransitionEffect.OPACITY) + Image($r('app.media.ads_popup_arrow_right')) + .fillColor($r('app.color.solid_grey_900')) + .colorFilter(drawing.ColorFilter.createBlendModeColorFilter({ + red: 59, + green: 59, + blue: 59, + alpha: 255 + }, drawing.BlendMode.SRC_IN)) + .height(8.5) + .width(4.5) + } + + // 完整图(双图加载成功才显示) + if ((this.currentState === AdFloatState.FIRST || this.currentState === AdFloatState.SECOND)) { + Image(this.fullPixelMap) + .width(60) + .height(60) + .margin({ right: 16 }) + .objectFit(ImageFit.Contain) + .onAppear(() => { + const previousState = this.lastState + // 双图都加载成功 + SECOND 态首次到达 + if (!this.hasReportedShow && previousState !== AdFloatState.FIRST) { + if (this.onSecondAppear !== undefined && this.onSecondAppear !== null) { + this.onSecondAppear() + } + this.hasReportedShow = true + } + }) + .transition(TransitionEffect.OPACITY) + } + + // 半图(THIRD态) + if (this.currentState === AdFloatState.THIRD) { + Image(this.halfPixelMap) + .width(40) + .height(60) + .objectFit(ImageFit.Contain) + .transition(TransitionEffect.OPACITY) + } + } + .height(60) + .margin({ top: 8 }) + .onClick(() => { + // 先读状态再做分支,防止竞态 + if (this.currentState === AdFloatState.THIRD) { + // THIRD 态点击展开只发 half click 埋点,不计入广告点击疲劳度 + this.animateExpandFromThird() + } else if (this.currentState === AdFloatState.FIRST || this.currentState === AdFloatState.SECOND) { + // FIRST/SECOND 态点击完整图:跳转广告落地页 + 销毁 + AdsPopupHost.getInstance().recordClick(this.qhtmktPop) + } + }) + + // 关闭按钮(FIRST/SECOND 态) + if (this.currentState === AdFloatState.FIRST || this.currentState === AdFloatState.SECOND) { + Image($r('app.media.icon_error_fill_24')) + .fillColor($r('app.color.elements_icon_quaternary')) + .width(16) + .height(16) + .transition(TransitionEffect.OPACITY) + .margin({ top: 0, right: 8 }) + .onClick(() => { + this.stopTimer() + this.dismissAndNotify() + }) + } + } + .height(68) + } + + /** 初始化状态机 */ + private initState(): void { + const pop = this.qhtmktPop + this.lastState = AdFloatState.INVALID + this.hasReportedShow = false + this.clickStateThird = false + + if (pop.hasNoCache) { + // hasNoCache=true:mInitState 固定为 FIRST + this.mInitState = AdFloatState.FIRST + this.currentState = AdFloatState.SECOND + this.nextState = pop.isFirstShow() ? AdFloatState.FIRST : AdFloatState.THIRD + this.scheduleTimer(DELAY_TIME_FIRST_TO_SECOND, this.nextState) + } else { + // hasNoCache=false:直接 SECOND + this.mInitState = AdFloatState.SECOND + this.currentState = AdFloatState.SECOND + this.nextState = AdFloatState.THIRD + this.scheduleTimer(DELAY_TIME_SECOND_TO_THIRD, AdFloatState.THIRD) + } + // 初始化完成后触发 initState(),触发第一次动画编排 + this.handleStateTransition() + } + + /** + * 状态切换后触发动画编排 + */ + private handleStateTransition(): void { + this.lastState = this.currentState + this.runAnimationForState(this.currentState) + } + + /** + * 根据目标状态计算下一轮预计算状态 + * 规则:FIRST→SECOND, THIRD→SECOND, SECOND→THIRD + */ + private computeNextState(target: AdFloatState): AdFloatState { + if (target === AdFloatState.FIRST) { + return AdFloatState.SECOND + } + if (target === AdFloatState.THIRD) { + return AdFloatState.SECOND + } + // SECOND → THIRD + return AdFloatState.THIRD + } + + /** 状态切换 + 触发对应动画 */ + private transitionTo(next: AdFloatState): void { + this.stopTimer() + this.lastState = this.currentState + this.currentState = next + // 在进入新状态后计算下一轮 nextState + this.nextState = this.computeNextState(next) + // 进入新状态时触发动画编排 + this.runAnimationForState(next) + } + + /** 根据目标状态执行对应动画 */ + private runAnimationForState(target: AdFloatState): void { + switch (target) { + case AdFloatState.FIRST: { + if (this.turnToStateThird || this.mInitState === AdFloatState.FIRST) { + this.turnToStateThird = false + this.scheduleTimer(DELAY_TIME_FIRST_TO_SECOND, AdFloatState.SECOND) + return + } + // 正常定时器:250ms 后切回 SECOND + this.scheduleTimer(DELAY_TIME_SECOND_TO_FIRST_AFTER_EXPAND, AdFloatState.SECOND) + break + } + case AdFloatState.SECOND: { + break + } + case AdFloatState.THIRD: { + break + } + default: { + break + } + } + } + + /** 点击 THIRD 态展开 */ + private animateExpandFromThird(): void { + this.stopTimer() + this.turnToStateThird = true + // 标记点击过状态3,允许后续通过外部滚动切回 THIRD + this.clickStateThird = true + // 点击状态3:THIRD → FIRST + this.lastState = AdFloatState.SECOND + this.currentState = AdFloatState.FIRST + this.nextState = this.computeNextState(AdFloatState.FIRST) + this.runAnimationForState(AdFloatState.FIRST) + } + + /** 计算 / 设定下一次状态切换 */ + private scheduleTimer(delay: number, nextState: AdFloatState): void { + this.stopTimer() + // 定时器触发前先更新 nextState,确保状态进入时动画方向正确 + this.nextState = nextState + this.timerId = setTimeout(() => { + this.timerId = -1 + this.transitionTo(nextState) + }, delay) + } + + private stopTimer(): void { + if (this.timerId !== -1) { + clearTimeout(this.timerId) + this.timerId = -1 + } + } + + private notifyAdjustStateHandlerReady(): void { + if (!this.onAdjustStateHandlerReady) { + return + } + this.onAdjustStateHandlerReady(() => { + this.turnToNextStateByOuterScroll() + }) + } + + /** 外部滚动触发状态切换 */ + private turnToNextStateByOuterScroll(): void { + // 如果 initState==FIRST 且未点击过状态3,禁止自动切 THIRD + if (this.currentState === AdFloatState.FIRST) { + this.transitionTo(AdFloatState.SECOND) + this.scheduleTimer(DELAY_TIME_SECOND_TO_THIRD, AdFloatState.THIRD) + return + } + if (this.currentState === AdFloatState.SECOND) { + // 如果是从 FIRST 初始化来的,必须通过点击状态3才能切 THIRD(自动禁止) + if (this.mInitState === AdFloatState.FIRST && !this.clickStateThird) { + return + } + this.transitionTo(AdFloatState.THIRD) + } + } + + private dismissAndNotify(): void { + this.stopTimer() + if (this.onClose !== undefined && this.onClose !== null) { + this.onClose() + } + } +} \ No newline at end of file diff --git a/src/main/ets/popup/AdsTextView.ets b/src/main/ets/popup/AdsTextView.ets new file mode 100644 index 0000000..1e2d54f --- /dev/null +++ b/src/main/ets/popup/AdsTextView.ets @@ -0,0 +1,131 @@ +/** + * author : liuqingliang@myhexin.com + * time : created on 2026-06-15 + * desc : 自定义气泡文本组件 + * 用 CanvasRenderingContext2D + Path2D 绘制带箭头气泡背景 + */ + +@Component +export struct AdsTextView { + /** 气泡背景颜色 */ + @Prop bgColor: string = '#FF1769E0' + /** 文字内容 */ + @Prop text: string = '' + /** 文字颜色 */ + @Prop textColor: ResourceColor = '#FFFFFF' + /** 文字字号 */ + @Prop fontSize: number = 14 + /** 水平内边距 */ + @Prop paddingH: number = 6 + /** 垂直内边距 */ + @Prop paddingV: number = 4 + /** 矩形圆角半径(单位 px) */ + @Prop rectangleCorner: number = 6 + /** 箭头端点小圆球半径(单位 px) */ + @Prop arrowCorner: number = 2 + /** 箭头水平宽度(单位 px) */ + @Prop arrowHeight: number = 12 + /** 是否显示右侧箭头 */ + @Prop showArrow: boolean = true + // Canvas 上下文 + private settings: RenderingContextSettings = new RenderingContextSettings(true) + private canvasContext: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings) + + /** + * 绘制带箭头的气泡背景 + * 1. 圆角矩形(宽度 = 总宽 - 箭头高度) + * 2. 箭头 = 三角形 ∪ 圆球 + * 3. 整体 = 矩形 UNION 箭头 + */ + private drawBubble(width: number, height: number): void { + this.canvasContext.clearRect(0, 0, width, height) + + const arrowH = this.showArrow ? this.arrowHeight : 0 + const rectCorner = this.rectangleCorner + const aCorner = this.arrowCorner + const rectWidth = width - arrowH + + this.canvasContext.fillStyle = this.bgColor + this.canvasContext.beginPath() + + // ========== 1. 绘制圆角矩形 ========== + if (rectWidth > 0) { + if (rectCorner <= 0) { + // 无圆角:普通矩形 + this.canvasContext.rect(0, 0, rectWidth, height) + } else { + // 圆角矩形 + const d = rectCorner * 2 + this.canvasContext.moveTo(rectCorner, 0) + this.canvasContext.lineTo(rectWidth - rectCorner, 0) + this.canvasContext.arcTo(rectWidth, 0, rectWidth, d, 270) + this.canvasContext.lineTo(rectWidth, height - rectCorner) + this.canvasContext.arcTo(rectWidth, height, rectWidth - d, height, 0) + this.canvasContext.lineTo(rectCorner, height) + this.canvasContext.arcTo(0, height, 0, height - d, 90) + this.canvasContext.lineTo(0, rectCorner) + this.canvasContext.arcTo(0, 0, d, 0, 180) + this.canvasContext.closePath() + } + } + + // ========== 2. 绘制右侧箭头 ========== + if (this.showArrow && width > arrowH + aCorner * 4 && height > aCorner * 4) { + const halfH = height / 2 + const triX = rectWidth + // 等边三角形高度 + const triHalfH = arrowH / Math.sqrt(3) + + // --- 2a. 三角形箭头 --- + this.canvasContext.moveTo(triX, halfH - triHalfH) + this.canvasContext.lineTo(width - aCorner * 3, halfH) + this.canvasContext.lineTo(triX, halfH + triHalfH) + this.canvasContext.closePath() + + // --- 2b. 箭头端点小圆球 --- + if (aCorner > 0) { + const circleX = width - aCorner * 2.5 + this.canvasContext.moveTo(circleX + aCorner, halfH) + this.canvasContext.arc(circleX, halfH, aCorner, 0, Math.PI * 2) + } + } + + this.canvasContext.fill() + } + + build() { + Stack({ alignContent: Alignment.Start }) { + // 气泡背景(Canvas 绘制) + Canvas(this.canvasContext) + .width('100%') + .height('100%') + .onReady(() => { + // 组件首次准备好时获取实际尺寸绘制 + const w = Number(this.canvasContext.width) || 100 + const h = Number(this.canvasContext.height) || 40 + this.drawBubble(w, h) + }) + + // 文字层 + Row() { + Text(this.text) + .fontSize(this.fontSize) + .fontColor(this.textColor) + .textAlign(TextAlign.Center) + } + .width('100%') + .height('100%') + .padding({ + left: this.paddingH, + right: this.showArrow ? (this.paddingH + this.arrowHeight) : this.paddingH, + top: this.paddingV, + bottom: this.paddingV + }) + .justifyContent(FlexAlign.Center) + .alignItems(VerticalAlign.Center) + .align(Alignment.Center) + } + .width('100%') + .height('100%') + } +} \ No newline at end of file diff --git a/src/main/ets/popup/PopupRecord.ets b/src/main/ets/popup/PopupRecord.ets new file mode 100644 index 0000000..a947894 --- /dev/null +++ b/src/main/ets/popup/PopupRecord.ets @@ -0,0 +1,17 @@ +import { QhtmktPop } from '@b2c-f/fuhm-ads' + +/** + * author : liuqingliang@myhexin.com + * time : created on 2026-06-04 + * desc : 业务侧悬浮球展示记录,存 Popup 广告 + 上次拖动 Y 坐标。 + */ + +export class PopupRecord { + pop: QhtmktPop + yPosition: number + + constructor(pop: QhtmktPop, yPosition: number) { + this.pop = pop + this.yPosition = yPosition + } +} \ No newline at end of file diff --git a/src/main/ets/popup/SpAdsComponent.ets b/src/main/ets/popup/SpAdsComponent.ets new file mode 100644 index 0000000..ff717f9 --- /dev/null +++ b/src/main/ets/popup/SpAdsComponent.ets @@ -0,0 +1,25 @@ +import { PreferenceService } from 'biz_common' + +/** + * author : liuqingliang@myhexin.com + * time : created on 2026-06-04 + * desc : 广告缓存 + */ + +export class SpAdsComponent { + /** 沿用 @SpFileName("SpAdsComponent") 等价命名 */ + private static readonly PREFERENCES_NAME: string = 'SpAdsComponent' + /** 沿用 @SpKeyName("lastPopupCloseTime") 等价 Key */ + private static readonly LAST_POPUP_CLOSE_TIME_KEY: string = 'lastPopupCloseTime' + + /** 读取主动关闭时间戳;读不到返回 0 */ + static getLastPopupCloseTime(): number { + return PreferenceService.getNumberValueSync(SpAdsComponent.PREFERENCES_NAME, + SpAdsComponent.LAST_POPUP_CLOSE_TIME_KEY, 0) + } + + /** 写入主动关闭时间戳 */ + static setLastPopupCloseTime(value: number): void { + PreferenceService.putVal(SpAdsComponent.PREFERENCES_NAME, SpAdsComponent.LAST_POPUP_CLOSE_TIME_KEY, value, true) + } +} \ No newline at end of file diff --git a/src/main/ets/route.ets b/src/main/ets/route.ets new file mode 100644 index 0000000..a1b3cb4 --- /dev/null +++ b/src/main/ets/route.ets @@ -0,0 +1,26 @@ +export function landingPageRouteParam(originalUrl: string, link: string, fullscreen?: boolean): Record { + let result: Record = {}; + let urlKey = 'url' + let refreshOnWidthChangeKey = 'refreshOnWidthChange' + let fullScreenKey = 'fullscreen' + result[urlKey] = link; + result[refreshOnWidthChangeKey] = "true" + result[fullScreenKey] = fullscreen + "" + + let protocolItems = originalUrl.split('^') + for (let index = 0; index < protocolItems.length; index++) { + const protocolItem = protocolItems[index] + let keyValuePair = protocolItem.split('=') + if (keyValuePair.length > 1) { + let key = keyValuePair[0] + if (key == urlKey || key == refreshOnWidthChangeKey) { + continue + } + if (fullscreen != null && key == fullScreenKey) { + continue + } + result[keyValuePair[0]] = keyValuePair[1] + } + } + return result; +} \ No newline at end of file diff --git a/src/main/ets/router/NewsRouter.ets b/src/main/ets/router/NewsRouter.ets new file mode 100644 index 0000000..38098d5 --- /dev/null +++ b/src/main/ets/router/NewsRouter.ets @@ -0,0 +1,9 @@ +export class NewsRouter { + title: string + url: string + + constructor(title: string, url: string) { + this.title = title + this.url = url + } +} \ No newline at end of file diff --git a/src/main/ets/util/DateUtils.ets b/src/main/ets/util/DateUtils.ets new file mode 100644 index 0000000..75da121 --- /dev/null +++ b/src/main/ets/util/DateUtils.ets @@ -0,0 +1,37 @@ +type DatePattern = 'yyyyMMdd' | 'yyyy-MM-dd' | 'yyyyMMddHHmmss' | 'HHmmss' | 'MM月dd日' | 'MM-dd HH:mm' + +export class DateUtils { + + static formatDate(date: Date, pattern: DatePattern = 'yyyyMMdd'): string { + let year = "" + let month = "" + let day = "" + let hour = "" + let minute = "" + let second = "" + try { + year = date.getFullYear().toString(); + month = ('0' + (date.getMonth() + 1)).slice(-2); + day = ('0' + date.getDate()).slice(-2); + hour = ('0' + date.getHours()).slice(-2); + minute = ('0' + date.getMinutes()).slice(-2); + second = ('0' + date.getSeconds()).slice(-2); + } catch (e) { + //日志获取失败 + return "" + } + if (pattern == 'yyyy-MM-dd') { + return year + '-' + month + '-' + day + } else if (pattern == 'yyyyMMddHHmmss') { + return year + month + day + hour + minute + second + } else if (pattern == 'HHmmss') { + return hour + minute + second + } else if (pattern == 'MM月dd日') { + return month + '月' + day + '日' + } else if (pattern == 'MM-dd HH:mm') { + return month + '-' + day + ' ' + hour + ':' + minute + } else { + return year + month + day + } + } +} \ No newline at end of file diff --git a/src/main/ets/util/FileManager.ets b/src/main/ets/util/FileManager.ets new file mode 100644 index 0000000..080da94 --- /dev/null +++ b/src/main/ets/util/FileManager.ets @@ -0,0 +1,137 @@ +import { GlobalContext, HXLog } from 'biz_common' +import fs from '@ohos.file.fs'; +import { BusinessError } from '@kit.BasicServicesKit'; + +export class FileManager { + private static readonly TAG = 'CacheManager' + private static instance: FileManager + + public static getInstance() { + if (!FileManager.instance) { + FileManager.instance = new FileManager() + } + return FileManager.instance + } + + saveCacheSync(saveContent: string, cacheDirPath: string): boolean { + let file: fs.File | null = null + try { + let filePath = this.getCachePathSync(cacheDirPath) + if (filePath) { + file = fs.openSync(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC) + if (saveContent.length > 0) { + fs.writeSync(file.fd, saveContent) + return true + } + } + } catch (error) { + let e: BusinessError = error as BusinessError + HXLog.e(FileManager.TAG, `error code + ${e.code}`) + } finally { + if (file !== null) { + try { + fs.closeSync(file) + } catch (error) { + let e: BusinessError = error as BusinessError + HXLog.e(FileManager.TAG, `close file error code + ${e.code}`) + } + } + } + return false + } + + loadCacheSync(cacheDirPath: string): string { + try { + let filePath = this.getCachePathSync(cacheDirPath) + if (filePath) { + let content = fs.readTextSync(filePath) + if (content) { + return content; + } + } + } catch (e) { + let error: BusinessError = e as BusinessError; + HXLog.e(FileManager.TAG, `error code ${error.code} and message ${error.message}`) + } + return '' + } + + saveFilesSync(saveContent: string, cacheDirPath: string): boolean { + let file: fs.File | null = null + try { + let filePath = this.getFilesPathSync(cacheDirPath) + if (filePath) { + file = fs.openSync(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC) + if (saveContent.length > 0) { + fs.writeSync(file.fd, saveContent) + return true + } + } + } catch (error) { + let e: BusinessError = error as BusinessError + HXLog.e(FileManager.TAG, `error code + ${e.code}`) + } finally { + if (file !== null) { + try { + fs.closeSync(file) + } catch (error) { + let e: BusinessError = error as BusinessError + HXLog.e(FileManager.TAG, `close file error code + ${e.code}`) + } + } + } + return false + } + + loadFilesSync(cacheDirPath: string): string { + try { + let filePath = this.getFilesPathSync(cacheDirPath) + if (filePath) { + let content = fs.readTextSync(filePath) + if (content) { + return content; + } + } + } catch (e) { + let error: BusinessError = e as BusinessError; + HXLog.e(FileManager.TAG, `error code ${error.code} and message ${error.message}`) + } + return '' + } + + public getCachePathSync(cacheDirPath: string): string | null { + try { + let context = GlobalContext.get() + let index = cacheDirPath.lastIndexOf('/') + let dirPath = context.cacheDir + cacheDirPath.substring(0, index) + let savePath = context.cacheDir + cacheDirPath + let res = fs.accessSync(dirPath) + if (!res) { + fs.mkdirSync(dirPath, true) + } + return savePath + } catch (error) { + let e: BusinessError = error as BusinessError; + HXLog.e(FileManager.TAG, `error code + ${e.code}`) + return null + } + } + + public getFilesPathSync(cacheDirPath: string): string | null { + try { + let context = GlobalContext.get() + let index = cacheDirPath.lastIndexOf('/') + let dirPath = context.filesDir + cacheDirPath.substring(0, index) + let savePath = context.filesDir + cacheDirPath + let res = fs.accessSync(dirPath) + if (!res) { + fs.mkdirSync(dirPath, true) + } + return savePath + } catch (error) { + let e: BusinessError = error as BusinessError; + HXLog.e(FileManager.TAG, `error code + ${e.code}`) + return null + } + } +} diff --git a/src/main/ets/util/FirstPageConstant.ets b/src/main/ets/util/FirstPageConstant.ets new file mode 100644 index 0000000..55192a5 --- /dev/null +++ b/src/main/ets/util/FirstPageConstant.ets @@ -0,0 +1,2 @@ + +export const TITLE_BAR_HEIGHT = 50 \ No newline at end of file diff --git a/src/main/ets/util/FirstPageDebugUtil.ets b/src/main/ets/util/FirstPageDebugUtil.ets new file mode 100644 index 0000000..d79d3ea --- /dev/null +++ b/src/main/ets/util/FirstPageDebugUtil.ets @@ -0,0 +1,36 @@ +import { AppUtil } from "@pura/harmony-utils" + +export class FirstPageDebugUtil { + /** + * 九宫格全部节点 + */ + static isAllGridNodeUseTestUrl: boolean = false + + /** + * 九宫格页面替换跳转页面 + * m/margins/index.html => account-open/index.html + * community/m/home.html => 测试环境futures-test.10jqka.com.cn/community/m/home.html + */ + static isGridNodeAllPageChangeUrl: boolean = false + + /** + * 市场排名推荐合约接口 + */ + static isMarketRankingUseTestUrl: boolean = false + + static getGridNodeUrl(isTest: boolean): string { + if (isTest) { + return AppUtil.getContext().resourceManager.getStringSync($r('app.string.all_gride_node_url_test')) + } + return AppUtil.getContext().resourceManager.getStringSync($r('app.string.all_gride_node_url')) + } + + static getMarketRankingRecommendUrl(isTest: boolean): string { + if (isTest) { + return AppUtil.getContext().resourceManager + .getStringSync($r('app.string.market_ranking_recommend_url_test')) + } + return AppUtil.getContext().resourceManager + .getStringSync($r('app.string.market_ranking_recommend_url')) + } +} diff --git a/src/main/ets/util/Header.ets b/src/main/ets/util/Header.ets new file mode 100644 index 0000000..5f07835 --- /dev/null +++ b/src/main/ets/util/Header.ets @@ -0,0 +1,16 @@ +import { AppConfigManager } from 'biz_common/src/main/ets/appconfig/AppConfigManager'; +import { CookieManager } from 'biz_hxservice'; + +export function buildHeader() : Record { + let header : Record = { + 'User-Agent': AppConfigManager.getInstance().getUA(), + 'Content-Type': 'application/json', + 'Connection': 'keep-alive', + } + // 接入Cookie,已登录用户可获取完整数据 + const cookie = CookieManager.getInstance().getCookieSync() + if (cookie) { + header['Cookie'] = cookie + } + return header; +} \ No newline at end of file diff --git a/src/main/ets/util/ImageLoader.ets b/src/main/ets/util/ImageLoader.ets new file mode 100644 index 0000000..80c0bf3 --- /dev/null +++ b/src/main/ets/util/ImageLoader.ets @@ -0,0 +1,71 @@ +import { image } from "@kit.ImageKit"; +import { http } from "@kit.NetworkKit"; +import { HXLog } from "biz_common"; +import { BusinessError } from "@kit.BasicServicesKit"; + +/** + * author : liuqingliang@myhexin.com + * time : created on 2026/6/1 + * desc : 网络图片加载 + */ +const TAG = 'ImageLoader' + +export class ImageLoader { + static load(imgUrl: string, onLoadFailed: (errMsg: string) => void, onResourceReady: (img: image.PixelMap) => void) { + try { + const httpRequest = http.createHttp() + httpRequest.request(imgUrl).then(async (response) => { + if (response.responseCode === 200) { + const arrayBuffer = response.result as ArrayBuffer + const imageSource = image.createImageSource(arrayBuffer) + try { + let img = imageSource.createPixelMapSync() + onResourceReady(img) + } finally { + await imageSource.release() + } + } else { + onLoadFailed(`ImageLoader load request failed. responseCode:${response.responseCode}`) + } + }).catch((err: Error) => { + onLoadFailed(err.message) + HXLog.e(TAG, JSON.stringify(err)) + }).finally(() => { + httpRequest.destroy() + }) + } catch (e) { + const err = e as BusinessError + HXLog.e(TAG, `ImageLoader load failed. msg:${err.message}`) + onLoadFailed(err.message) + } + } + + static async loadSync(imgUrl: string): Promise { + let img: image.PixelMap | null = null + let imageSource: image.ImageSource | null = null + try { + const httpRequest = http.createHttp() + try { + const response: http.HttpResponse = await httpRequest.request(imgUrl) + if (response.responseCode === 200) { + const arrayBuffer = response.result as ArrayBuffer + imageSource = image.createImageSource(arrayBuffer) + img = imageSource.createPixelMapSync() + } + } catch (e) { + const err = e as BusinessError + HXLog.e(TAG, `ImageLoader load failed. msg:${err.message}`) + } finally { + httpRequest.destroy() + } + } catch (e) { + const err = e as BusinessError + HXLog.e(TAG, `ImageLoader load failed. msg:${err.message}`) + } finally { + if (imageSource) { + imageSource.release() + } + } + return img + } +} diff --git a/src/main/ets/util/ResourceUtil.ets b/src/main/ets/util/ResourceUtil.ets new file mode 100644 index 0000000..701b545 --- /dev/null +++ b/src/main/ets/util/ResourceUtil.ets @@ -0,0 +1,8 @@ +/** + * author:zhangshan2 + * created on 2024/11/22 + * desc:处理资源工具类 + */ +export function getResourceString(context: Context, resource: Resource): string { + return context.resourceManager.getStringSync(resource) +} \ No newline at end of file diff --git a/src/main/ets/util/RouterUtil.ets b/src/main/ets/util/RouterUtil.ets new file mode 100644 index 0000000..d7574fa --- /dev/null +++ b/src/main/ets/util/RouterUtil.ets @@ -0,0 +1,36 @@ +import { HXLog, jumpToWebPage } from 'biz_common' +import { StringUtils } from 'hxutil' +import { landingPageRouteParam } from '../route' +import { promptAction } from '@kit.ArkUI'; +import { call } from '@kit.TelephonyKit'; +import { BusinessError } from '@kit.BasicServicesKit'; + +export class RouterUtil { + static jumpPage(url: string | null, fullscreen?: boolean) { + if (url && StringUtils.isNotEmpty(url)) { + if (url.startsWith('client.html')) { + let protocolItems = url.split('^') + for (let index = 0; index < protocolItems.length; index++) { + const protocolItem = protocolItems[index] + let urlKey = 'url' + if (protocolItem.startsWith(urlKey)) { + let jumpUrl = protocolItem.substring(urlKey.length + 1) + if (jumpUrl.startsWith('http://') || jumpUrl.startsWith('https://')) { + jumpToWebPage(landingPageRouteParam(url, jumpUrl, fullscreen)) + return + } + } + } + promptAction.showToast({ message: '鸿蒙暂不支持' }) + } else if (url.startsWith('tel://')) { + call.makeCall(url).then(() => { + HXLog.i("Telephony Service", "makeCallSuccess"); + }).catch((err: BusinessError) => { + HXLog.i("Telephony Service", "makeCallFailed with message " + err.message) + }); + } else { + jumpToWebPage(landingPageRouteParam(url, url, fullscreen)) + } + } + } +} \ No newline at end of file diff --git a/src/main/ets/views/FirstPageContentBg.ets b/src/main/ets/views/FirstPageContentBg.ets new file mode 100644 index 0000000..c91405f --- /dev/null +++ b/src/main/ets/views/FirstPageContentBg.ets @@ -0,0 +1,19 @@ +import { AppInfoConfig, GlobalContext } from 'biz_common' +import { TITLE_BAR_HEIGHT } from '../util/FirstPageConstant' + +@Component +export struct FirstPageContentBg { + + @State topHeight: number = 0 + @State topContentBg: Resource = $r('app.media.firstpage_top_default_bg') + + aboutToAppear(): void { + this.topHeight = px2vp(GlobalContext.statusBarHeight) + TITLE_BAR_HEIGHT + (AppInfoConfig.Official?0:150); + } + + build() { + Image(this.topContentBg) + .width('100%') + .height(this.topHeight) + } +} \ No newline at end of file diff --git a/src/main/ets/views/FirstPageSelfCodeGroupView.ets b/src/main/ets/views/FirstPageSelfCodeGroupView.ets new file mode 100644 index 0000000..63b0024 --- /dev/null +++ b/src/main/ets/views/FirstPageSelfCodeGroupView.ets @@ -0,0 +1,345 @@ +import { EmitterConstants, PreferenceService, RecommendContract, RecommendContractManager } from 'biz_common' +import { HXUserService } from 'biz_hxservice' +import { getSelfStock, GroupInfo, loadCustomGroups, queryWatchlistGroup, Securities } from 'service_watchlist' +import { emitter } from '@kit.BasicServicesKit' +import { SelectedIdOfWatchlistGroup } from 'biz_selfcode' +import { HXUtils } from '@b2c/lib_baseui' + +//自选股分组切换 +@Component +export struct FirstPageSelfCodeGroupView { + static readonly GROUPID_DEFAULT = "0_34" + static readonly GROUPID_RECOMMEND_CONTRACT = "RECOMMEND_CONTRACT" + static readonly SP_FILE_NAME_FIRST_PAGE_SELF_CODE_FILE_NAME = "sp_file_name_first_page_self_code" + static readonly SP_KEY_FIRST_PAGE_SELF_CODE_LAST_GROUPID = "sp_key_first_page_self_code_last_groupid" + @Link @Watch('onPageStatusChange') onForeground: boolean + @State groups: GroupInfo[] = [] + @State @Watch('onGroupIndexChange') currentIndex: number = 0 + @State showPopup: boolean = false + @Prop @Watch('onInitIndexChange') initIndex: number = 0 + onSelectGroup?: (index: number) => void + onSelectGroupInfo?: (index: number, groupInfo: GroupInfo) => void + private readonly OVERLAY_WIDTH = 30 + private maxGroupCount = 3 + private scroller = new Scroller() + private lastGroupId: string | null = null + private itemWidthArray: number[] = [] + private totalWidth: number = 0 + + aboutToAppear(): void { + const initCurrentIndex = this.currentIndex; + this.lastGroupId = + PreferenceService.getStringValueSync(FirstPageSelfCodeGroupView.SP_FILE_NAME_FIRST_PAGE_SELF_CODE_FILE_NAME, + FirstPageSelfCodeGroupView.SP_KEY_FIRST_PAGE_SELF_CODE_LAST_GROUPID, "") + this.subscribeAllEvent() + this.initGroupInfo() + if (this.groups.length === 0 && !this.isTemporaryUser()) { + queryWatchlistGroup() + } + this.findAndInitInitIndex() + if (this.initIndex > 0 && this.groups.length > this.initIndex) { + this.selectIndex(this.initIndex) + } + if (initCurrentIndex == this.currentIndex) { + //说明第一次初始化,调用一次变化 + this.onGroupIndexChange() + } + } + + findAndInitInitIndex() { + if (this.lastGroupId && this.lastGroupId.length > 0) { + this.groups.forEach((groupInfo, index) => { + if (groupInfo.id === this.lastGroupId) { + this.initIndex = index + } + }) + } + } + + subscribeGroupStocksChang() { + emitter.on({ + eventId: EmitterConstants.SELF_STOCK_GROUP_FILTER_CHANGE + }, this.refreshAction) + } + + userNameChanged() { + // + } + + subscribeSelfCodeQuerySucceededEvent() { + emitter.on({ + eventId: EmitterConstants.SELFCODE_UGC_QUERY_SUCCEEDED + }, this.refreshAction); + } + + subscribeGroupQuerySucceeded() { + emitter.on({ + eventId: EmitterConstants.SELFGROUP_UPDATE_QUERY_SUCCEEDED + }, this.refreshAction) + } + + subscribeAllEvent() { + this.subscribeSelfCodeQuerySucceededEvent() + this.subscribeLoginEvent() + this.subscribeGroupQuerySucceeded() + this.subscribeGroupStocksChang() + } + + unSubscribe() { + emitter.off(EmitterConstants.SELFCODE_UGC_QUERY_SUCCEEDED, this.refreshAction) + emitter.off(EmitterConstants.SELFGROUP_UPDATE_QUERY_SUCCEEDED, this.refreshAction) + emitter.off(EmitterConstants.SELF_STOCK_GROUP_FILTER_CHANGE, this.refreshAction) + + this.unSubscribeLoginEvent() + } + + unSubscribeGroupQuerySucceeded() { + emitter.off(EmitterConstants.SELFGROUP_UPDATE_QUERY_SUCCEEDED, this.refreshAction) + } + + aboutToDisappear(): void { + this.unSubscribe() + } + + build() { + if (this.groups.length > 0) { + Row() { + Row() { + Scroll(this.scroller) { + Row() { + ForEach(this.groups, (item: GroupInfo, index: number) => { + this.buildItem(item, index) + }) + } + } + .scrollBar(BarState.Off) + .scrollable(ScrollDirection.Horizontal) + .width('auto') + } + .layoutWeight(1) + .justifyContent(FlexAlign.Start) + } + .width('100%') + .alignItems(VerticalAlign.Center) + .justifyContent(FlexAlign.Start) + .visibility(this.groups.length > 1 ? Visibility.Visible : Visibility.None) + + } + } + + @Builder + buildItem(item: GroupInfo, index: number) { + Column() { + Text(item.name) + .fontSize(15) + .fontColor(index === this.currentIndex ? $r('app.color.elements_text_primary_01') : $r('app.color.elements_text_primary_02')) + .backgroundColor(index === this.currentIndex ? $r('app.color.color_2e58ff_alp_10') : + $r('app.color.surface_layer3_background')) + .borderColor($r('app.color.elements_text_primary_01')) + .borderRadius(2) + .padding({ + left: 10, + top: 5, + bottom: 5, + right: 10 + }) + + } + .justifyContent(FlexAlign.Start) + .alignItems(HorizontalAlign.Center) + .height(35) + .padding({ left: index == 0 ? 0 : 5, right: 5 }) + .onClick(() => { + if (this.currentIndex != index) { + this.selectIndex(index) + } + }) + } + + private refreshAction = () => { + this.initGroupInfo() + } + + private subscribeLoginEvent() { + emitter.on({ + eventId: EmitterConstants.USER_LOGIN + }, this.refreshAction) + emitter.on({ + eventId: EmitterConstants.USER_LOGOUT + }, this.refreshAction); + } + + private unSubscribeLoginEvent() { + emitter.off(EmitterConstants.USER_LOGIN, this.refreshAction); + emitter.off(EmitterConstants.USER_LOGOUT, this.refreshAction); + } + + private onGroupChange = () => { + this.initGroupInfo() + } + private onUserLogin = () => { + if (this.isTemporaryUser()) { + SelectedIdOfWatchlistGroup.setDefaultId() + } + this.initGroupInfo() + } + + private onPageStatusChange() { + if (this.onForeground) { + this.initGroupInfo() + emitter.on({ + eventId: EmitterConstants.SELF_STOCK_GROUP_FILTER_CHANGE + }, this.onGroupChange) + + emitter.on({ + eventId: EmitterConstants.USER_LOGIN + }, this.onUserLogin) + } else { + emitter.off(EmitterConstants.SELF_STOCK_GROUP_FILTER_CHANGE, this.onGroupChange) + emitter.off(EmitterConstants.USER_LOGIN, this.onUserLogin) + } + } + + private onGroupIndexChange() { + if (this.onSelectGroup) { + this.onSelectGroup(this.currentIndex) + } + if (this.onSelectGroupInfo && this.currentIndex < this.groups.length) { + this.onSelectGroupInfo(this.currentIndex, this.groups[this.currentIndex]) + } + } + + private onInitIndexChange() { + this.selectIndex(this.initIndex) + } + + private initGroupInfo() { + let lastSelectIndex = this.currentIndex + let lastGroupInfo = lastSelectIndex < this.groups.length ? this.groups[lastSelectIndex]:undefined + if (!this.isTemporaryUser()) { + let groups = loadCustomGroups(true) + let defaultSelectGroupInfo = getSelfStock() + if (defaultSelectGroupInfo.length <= 0){ + this.buildRecommendContractWhenSelfCodeNoData(lastSelectIndex,lastGroupInfo,groups) + return + } + groups.unshift({ + id: FirstPageSelfCodeGroupView.GROUPID_DEFAULT, + name: '默认', + stocks: defaultSelectGroupInfo + }) + this.groups = groups.slice(0, this.maxGroupCount) + this.initGroupIndexAndNotify(lastSelectIndex, lastGroupInfo) + } else { + let defaultStocks = getSelfStock() + if (defaultStocks.length > 0){ + //临时账户有添加过自选股 + let defaultGroupInfos: GroupInfo[] = [] + defaultGroupInfos.push({ + id: FirstPageSelfCodeGroupView.GROUPID_DEFAULT, + name: '默认', + stocks: getSelfStock() + }) + this.groups = defaultGroupInfos + this.initGroupIndexAndNotify(lastSelectIndex,lastGroupInfo) + return + } + + this.buildRecommendContractWhenSelfCodeNoData(lastSelectIndex,lastGroupInfo) + + } + + + } + + private buildRecommendContractWhenSelfCodeNoData(lastSelectIndex:number,lastGroupInfo?:GroupInfo,customGroupInfos?:GroupInfo[]){ + RecommendContractManager.getInstance().getRecommendContract().then((result)=>{ + if (result && result.length > 0) { + let groups:GroupInfo[] = [] + let stocks:Array = [] + result.forEach((value:RecommendContract,index:number) =>{ + if (value.stockInfo && HXUtils.isValidContractData(value.stockInfo.code) && HXUtils.isValidContractData(value.stockInfo.market)) { + let stock = new Securities(value.stockInfo.code,value.stockInfo.market) + stock.name = value.stockInfo.name??"--" + stocks.push(stock) + } + }) + if (customGroupInfos) { + customGroupInfos.unshift({ + id: FirstPageSelfCodeGroupView.GROUPID_DEFAULT, + name: '默认', + stocks: stocks + }) + this.groups = customGroupInfos.slice(0, this.maxGroupCount) + this.initGroupIndexAndNotify(lastSelectIndex, lastGroupInfo) + }else { + groups.push({ + id:FirstPageSelfCodeGroupView.GROUPID_RECOMMEND_CONTRACT, + name:"热门合约", + stocks:stocks + }) + this.groups = groups + } + this.initGroupIndexAndNotify(lastSelectIndex,lastGroupInfo) + }else { + this.groups = [] + this.initGroupIndexAndNotify(lastSelectIndex,lastGroupInfo) + } + }) + } + + private initGroupIndexAndNotify(lastSelectIndex: number, lastGroupInfo: GroupInfo | undefined) { + if (this.lastGroupId) { + this.groups.forEach((group, index) => { + if (group.id === this.lastGroupId) { + this.currentIndex = index + } + }) + } + + if (this.currentIndex >= this.groups.length) { + this.currentIndex = 0 + } + if (lastSelectIndex == this.currentIndex) { + let newGroupInfo = this.currentIndex < this.groups.length ? this.groups[this.currentIndex] : undefined + if (JSON.stringify(lastGroupInfo) != JSON.stringify(newGroupInfo)) { + this.onGroupIndexChange() + } + } + } + + private selectIndex(index: number) { + if (this.currentIndex != index && index < this.groups.length) { + //切换后保存当前的分组id + const groupInfo = this.groups[index] + this.lastGroupId = groupInfo.id + if (groupInfo.id != FirstPageSelfCodeGroupView.GROUPID_RECOMMEND_CONTRACT) { + //不进行热门合约的记录 + PreferenceService.putVal(FirstPageSelfCodeGroupView.SP_FILE_NAME_FIRST_PAGE_SELF_CODE_FILE_NAME, + FirstPageSelfCodeGroupView.SP_KEY_FIRST_PAGE_SELF_CODE_LAST_GROUPID, this.lastGroupId, true) + } + } + this.currentIndex = index + if (this.totalWidth > 0) { + let offset = 0 + let targetButtonWidth = this.itemWidthArray[index] + for (let i = 0; i < index; i++) { + let width = this.itemWidthArray[i] + if (width) { + offset += width + } + } + + if (targetButtonWidth) { + offset -= (this.totalWidth - targetButtonWidth - this.OVERLAY_WIDTH) / 2 + } + offset = Math.max(0, offset) + this.scroller.scrollTo({ xOffset: offset, yOffset: 0, animation: true }) + } + } + + //判断是否是临时账号 + private isTemporaryUser(): boolean { + return HXUserService.getInstance()?.isTemporaryUser() + } +} \ No newline at end of file diff --git a/src/main/ets/views/FirstPageTableRowBGView.ets b/src/main/ets/views/FirstPageTableRowBGView.ets new file mode 100644 index 0000000..95cb970 --- /dev/null +++ b/src/main/ets/views/FirstPageTableRowBGView.ets @@ -0,0 +1,90 @@ +import { HXLog } from 'biz_common' +import { HQDataItemModel } from '../node/model/SelfSelectedStockModel' + + +const TAG = "FistPageTableRowBGView" + +@Component +export struct FirstPageTableRowBGView { + @ObjectLink @Watch('onDataChange') hqItemDataModel: HQDataItemModel + @State bgOpacity: number = 0 + @State bgColor: ResourceColor = Color.Transparent + @State startColor: string = "#ffffff" + @State endColor: string = "#ffffff" + private lastColor: ResourceColor = "" + private lastChangeTime = 0 + + onDataChange() { + HXLog.d(TAG, " FistPageTableRowBGView onDataChange this.hqItemDataModel:" + + JSON.stringify(this.hqItemDataModel)) + + if (!this.hqItemDataModel.priceChange) { + return + } + let color = this.getRiseBgColor(this.hqItemDataModel.riseFailFlag) + if (this.lastColor.toString().length > 0 && this.lastColor == color) { + return; + } + this.lastColor = color ?? "" + let now = Date.now() + + if (typeof color === 'string' && color.length > 0 && now - this.lastChangeTime > 500) { + this.bgColor = color + this.startColor = this.getStartColor(this.hqItemDataModel.riseFailFlag) + this.endColor = this.getEndColor(this.hqItemDataModel.riseFailFlag) + this.bgOpacity = 1 + this.lastChangeTime = now + + animateTo({ + duration: 500, + curve: Curve.EaseOut + }, () => { + this.bgOpacity = 0 + }) + } + } + + build() { + this.buildBackgroundView() + } + + @Builder + buildBackgroundView() { + Row() + .linearGradient({ + direction: GradientDirection.Left, + colors: [[this.startColor, 0.0], [this.endColor, 1.0]] + }) + .height('100%') + .width('100%') + .opacity(this.bgOpacity) + .onAppear(() => { + this.onDataChange() + }) + .onDisAppear(() => { + this.bgOpacity = 0 + }) + + } + + private getRiseBgColor(riseFlag: number): string { + if (riseFlag == 0) { + return "#323232" + } + return riseFlag == 1 ? "#e93030" : "#009900" + } + + private getStartColor(riseFlag: number): string { + if (riseFlag == 0) { + return "#FFFFFF" + } + return riseFlag == 1 ? "#00FF2436" : "#0007ab4b" + } + + private getEndColor(riseFlag: number): string { + if (riseFlag == 0) { + return "#FFFFFF" + } + return riseFlag == 1 ? "#33ff2436" : "#3307ab4b" + } +} diff --git a/src/main/ets/views/FirstPageTitleBar.ets b/src/main/ets/views/FirstPageTitleBar.ets new file mode 100644 index 0000000..ce6770f --- /dev/null +++ b/src/main/ets/views/FirstPageTitleBar.ets @@ -0,0 +1,203 @@ +import { CommonToast, GlobalContext } from 'biz_common'; +import { getUserAvatarUrlByUid, LoginUtils } from 'biz_login'; +import router from '@ohos.router'; +import { TITLE_BAR_HEIGHT } from '../util/FirstPageConstant'; +import { HXUserService } from 'biz_hxservice'; +import { AppUtil } from '@pura/harmony-utils'; + +@Component +export default struct FirstPageTitleBar { + //用户userid,从根节点传入 + @Consume userId: string; + //用户userName,从根节点传入 + @Consume userName: string; + customToast: CustomDialogController = new CustomDialogController({ + builder: CommonToast({ textContent: '体验版本功能暂未开放' }), + alignment: DialogAlignment.Center, + customStyle: true, + }); + private showMessageTips: boolean = false + + build() { + Stack({ alignContent: Alignment.TopStart }) { + Column() { + Row() + .width('100%') + .height(px2vp(GlobalContext.statusBarHeight)) + .flexShrink(0) + Flex({ alignItems: ItemAlign.Center }) { + Column() { + Image(this.isTempUser(this.userName) ? $r('app.media.user_no_login') : getUserAvatarUrlByUid(this.userId)) + .width(24) + .height(24) + .borderRadius(12) + .flexShrink(0) + .alt($r("app.media.user_no_login")) + } + .alignItems(HorizontalAlign.Center) + .justifyContent(FlexAlign.Center) + .height('100%') + .width(24) + .onClick(() => { + LoginUtils.gotoZone() + }) + + this.searchBar() + this.agentIcon() + this.messageIcon() + + } + .padding({ left: 16, right: 16 }) + .height(TITLE_BAR_HEIGHT) + .width('100%') + } + .width('100%') + .zIndex(1) + + Column() { + } + .backgroundColor($r('app.color.surface_layer1_foreground')) + .width('100%') + .height(px2vp(GlobalContext.statusBarHeight) + TITLE_BAR_HEIGHT) + } + } + + @Builder + messageIcon() { + Stack({ alignContent: Alignment.TopEnd }) { + Image($r("app.media.icon_mail_24")) + .fillColor($r('app.color.elements_icon_primary_02')) + .width('100%') + .height('100%'); + + Circle() + .width(3) + .height(3) + .stroke(Color.Red) + .fill(Color.Red) + .margin(3) + .visibility(this.showMessageTips ? Visibility.Visible : Visibility.None); + }.width(24).height(24).onClick(() => { + this.onMessageClick() + }) + } + + @Builder + agentIcon() { + Stack({ alignContent: Alignment.TopEnd }) { + Image($r("app.media.icon_bot_24")) + .fillColor($r('app.color.elements_icon_primary_02')) + .width('100%') + .height('100%'); + + Circle() + .width(3) + .height(3) + .stroke(Color.Red) + .fill(Color.Red) + .margin({ left: 3}) + .visibility(this.showMessageTips ? Visibility.Visible : Visibility.None); + }.width(24).height(24).margin({ right: 10 }).onClick(() => { + this.onAgentClick() + }) + } + + @Builder + searchBar() { + Row() { + Image($r('app.media.icon_search_24')) + .width(18) + .height(18) + .fillColor($r('app.color.elements_icon_tertiary')) + + + Text("请输入代码/名称/简称") + .fontSize(14) + .fontColor($r('app.color.elements_text_quaternary')) + .margin({ left: 4 }) + } + .alignItems(VerticalAlign.Center) + .margin({ + top: 8, + bottom: 8, + left: 14, + right: 14 + }) + .padding({ left: 8 }) + .height(28) + .layoutWeight(1) + .backgroundColor($r('app.color.elements_others_bg_layer1')) + .borderRadius(25) + .flexGrow(1) + .onClick(() => { + router.pushUrl({ + url: 'pages/SearchPage' // 目标url + }, router.RouterMode.Standard, (err) => { + if (err) { + console.error(`Invoke pushUrl failed, code is ${err.code}, message is ${err.message}`); + return; + } + console.info('Invoke pushUrl succeeded.'); + }); + }) + } + + private isTempUser(userName: string): boolean { + return userName.length > 0 && userName.startsWith("mt_"); + } + + private onMessageClick() { + if (this.autoLogin()) { + return + } + router.pushUrl({ + url: 'pages/WebViewComponentPage', params: { + url: 'https://fupage.10jqka.com.cn/projects/m/message/index.html', + title: '消息中心', + updateH5Title: '0' + } + }, + router.RouterMode.Single, (err) => { + if (err) { + console.error(`go to message page failed , cause : ${err.message}`) + } + }) + } + + private onAgentClick() { + if (this.autoLogin()) { + return + } + router.pushUrl({ + url: 'pages/WebViewComponentPage', params: { + url:AppUtil.getContext().resourceManager.getStringSync($r('app.string.fu_feed_back_help_url')), + title: AppUtil.getContext().resourceManager.getStringSync($r('app.string.feed_back_help_text')), + userCookie:true, + isLoginRefreshWeb:true, + refreshOnWidthChange:true + } + }, + router.RouterMode.Single, (err) => { + if (err) { + console.error(`go to message page failed , cause : ${err.message}`) + } + }) + } + + private autoLogin(): boolean { + if (!HXUserService.getInstance().isLoggedIn()) { + const topRoutePath = router.getState().path + router.pushUrl({ + url: 'pages/login/LoginPage', + params: { + flexSwitchFlag: true, + sourcePage: `${topRoutePath}${router.getState().name}` + } + }) + return true; + } + return false; + } + + +} \ No newline at end of file diff --git a/src/main/ets/views/FistPageSelfCodeGridItemView.ets b/src/main/ets/views/FistPageSelfCodeGridItemView.ets new file mode 100644 index 0000000..332669e --- /dev/null +++ b/src/main/ets/views/FistPageSelfCodeGridItemView.ets @@ -0,0 +1,127 @@ +import { ScreenUtils } from '@b2b/hxutil' +import { ColorUtils } from '@b2c/lib_baseui' +import { TextMetricsMeasurer } from '@b2b/hq_table' +import { HQDataItemModel } from '../node/model/SelfSelectedStockModel' +import { FirstPageTableRowBGView } from './FirstPageTableRowBGView' + + +@Component +export struct FistPageSelfCodeGridItemView { + @ObjectLink hqItemData: HQDataItemModel + + jumpToQuoteCallBack?: (tockModel: HQDataItemModel) => void + jumpToMoreCallBack?:()=>void + + + + build() { + this.buildStockItem(this.hqItemData) + } + + @Builder + buildStockItem(stockModel: HQDataItemModel) { + if (!stockModel.showAddImage) { + Column() { + Text(stockModel.name) + .minFontSize(10) + .maxFontSize(this.getAvailableFontSize(stockModel.name)) + .fontColor($r('app.color.elements_text_primary_02')) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .maxLines(1) + .margin({ bottom: 4 }) + .width('100%') + Column(){ + Stack(){ + FirstPageTableRowBGView({ + hqItemDataModel: stockModel + }) + Text(stockModel.price.toString()) + .fontSize(16) + .fontWeight(FontWeight.Medium) + .fontColor(ColorUtils.resourceColorToRGBHex(stockModel.riseFailColor)) + .margin({ bottom: 2 }) + }.alignContent(Alignment.Start) + }.width('100%') + .alignItems(HorizontalAlign.Start) + .height(25) + + Row() { + Text(stockModel.priceRise) + .fontSize(11) + .fontColor(ColorUtils.resourceColorToRGBHex(stockModel.riseFailColor)) + .margin({ right: 4 }) + + Text(`${stockModel.riseFail}`) + .fontSize(11) + .fontColor(ColorUtils.resourceColorToRGBHex(stockModel.riseFailColor)) + } + } + .border({ + width: { right: 1 }, // 只画右边框 + color: $r('app.color.elements_others_divider_primary'), + style: BorderStyle.Solid + + }) + .onClick(() => { + this.jumpToQuote(stockModel) + }) + .width('100%') + .height('100%') + .backgroundColor(Color.Transparent) + .padding(10) + .alignItems(HorizontalAlign.Start) + .justifyContent(FlexAlign.Center) + } else { + this.buildMoreItem() + } + } + + jumpToQuote(stockModel: HQDataItemModel) { + if (this.jumpToQuoteCallBack) { + this.jumpToQuoteCallBack(stockModel) + } + } + + @Builder + buildMoreItem() { + Column() { + Column(){ + Image($r('app.media.icon_add_24')) + .fillColor($r('app.color.elements_icon_secondary')) + .width(20) + .height(20) + } .width('100%') + .height('100%') + .alignItems(HorizontalAlign.Center) + .justifyContent(FlexAlign.Center) + .border({ + width: 1, // 只画右边框 + color: $r('app.color.elements_others_border_base'), + style: BorderStyle.Dashed, + dashGap:{value:3,unit:1}, + radius:4 + }) + + } + .width('100%') + .height('100%') + .backgroundColor($r('app.color.surface_layer1_foreground')) + .padding(8) + .borderRadius(4) + .alignItems(HorizontalAlign.Center) + .justifyContent(FlexAlign.Center) + .onClick(() => { + this.jumpToMore() + }) + } + + jumpToMore() { + if (this.jumpToMoreCallBack) { + this.jumpToMoreCallBack() + } + } + + private getAvailableFontSize(name: string): number { + return TextMetricsMeasurer.getInstance().matchFontSize(name, (ScreenUtils.getScreenWidth(true) - 20) / 3 - 22, 14) + } +} diff --git a/src/main/ets/views/StatusBar.ets b/src/main/ets/views/StatusBar.ets new file mode 100644 index 0000000..2011ab6 --- /dev/null +++ b/src/main/ets/views/StatusBar.ets @@ -0,0 +1,14 @@ +import { GlobalContext } from 'biz_common'; + +@Component +export struct StatusBar { + @Prop bgColor:string | ResourceColor = $r('app.color.surface_layer1_foreground') + + build(){ + Row() + .width('100%') + .height(px2vp(GlobalContext.statusBarHeight)) + .backgroundColor(this.bgColor) + .flexShrink(0) + } +} \ No newline at end of file diff --git a/src/main/ets/views/TitleBar.ets b/src/main/ets/views/TitleBar.ets new file mode 100644 index 0000000..8a9ffd7 --- /dev/null +++ b/src/main/ets/views/TitleBar.ets @@ -0,0 +1,125 @@ +import { router } from '@kit.ArkUI' +import { StatusBar } from './StatusBar' + +@Component +export struct TitleBar { + + showStatusBar: boolean = false + @Prop statusBarBgColor:string + @Prop title: string + titleColor: ResourceColor = $r('app.color.elements_text_primary_02') + bgColor: ResourceColor = $r('app.color.surface_layer1_foreground') + leftWidth: number = 0 + rightWidth: number = 0 + @State leftMarginWidth: number = 0 + @State rightMarginWidth: number = 0 + @State showRefresh: boolean = false + onRefresh?: (() => void) | null + @BuilderParam buildLeftView?: (() => void) | null + @BuilderParam buildRightView?: (() => void) | null + + private calculateMiddleMargin() { + let space = Math.abs(this.leftWidth - this.rightWidth) + if (this.leftWidth > this.rightWidth) { + this.leftMarginWidth = 0 + this.rightMarginWidth = space + } else if (this.leftWidth < this.rightWidth) { + this.leftMarginWidth = space + this.rightMarginWidth = 0 + } else { + this.leftMarginWidth = 0 + this.rightMarginWidth = 0 + } + } + + build() { + Column() { + if (this.showStatusBar) { + StatusBar({ + bgColor: this.statusBarBgColor + }) + } + Row() { + Row() { + if (this.buildLeftView) { + this.buildLeftView() + } else { + this.buildBackView() + } + } + .height('100%') + .onSizeChange((oldValue: SizeOptions, newValue: SizeOptions) => { + this.leftWidth = newValue.width as number + this.calculateMiddleMargin() + }) + + Text(this.title) + .width('100%') + .height('100%') + .fontSize(16) + .fontColor(this.titleColor) + .textAlign(TextAlign.Center) + .maxLines(1) + .textOverflow({overflow: TextOverflow.Ellipsis}) + .layoutWeight(1) + .margin({left: this.leftMarginWidth, right: this.rightMarginWidth}) + + Row() { + if (this.buildLeftView) { + this.buildLeftView() + } else { + this.buildRefreshView() + } + } + .height('100%') + .onSizeChange((oldValue: SizeOptions, newValue: SizeOptions) => { + this.rightWidth = newValue.width as number + this.calculateMiddleMargin() + }) + } + .justifyContent(FlexAlign.Center) + .alignItems(VerticalAlign.Center) + .width('100%') + .height(42) + .backgroundColor(this.bgColor) + } + .width('100%') + } + + @Builder + buildBackView() { + Column() { + Image($r('app.media.icon_arrow_left_24')) + .fillColor($r('app.color.elements_icon_primary_02')) + .width(19) + .height(24) + } + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + .width(40) + .height('100%') + .onClick(() => { + router.back() + }) + } + + @Builder + buildRefreshView() { + if (this.showRefresh) { + Column() { + Image($r('app.media.title_bar_refresh')) + .width(20) + .height(20) + } + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + .width(40) + .height('100%') + .onClick(() => { + if (this.onRefresh) { + this.onRefresh() + } + }) + } + } +} \ No newline at end of file diff --git a/src/main/module.json5 b/src/main/module.json5 new file mode 100644 index 0000000..a93bbdb --- /dev/null +++ b/src/main/module.json5 @@ -0,0 +1,22 @@ +{ + "module": { + "name": "biz_firstpage", + "type": "har", + "deviceTypes": [ + "phone", + "tablet", + "2in1" + ], + "requestPermissions": [ + { + "usedScene": { + "abilities": [ + "EntryAbility" + ], + "when": "inuse" + }, + "name": "ohos.permission.INTERNET" + } + ] + } +} diff --git a/src/main/resources/base/element/color.json b/src/main/resources/base/element/color.json new file mode 100644 index 0000000..8957888 --- /dev/null +++ b/src/main/resources/base/element/color.json @@ -0,0 +1,123 @@ +{ + "color": [ + { + "name": "theme_color", + "value": "#ffffff" + }, + { + "name": "title_bar_title_color", + "value": "#000000" + }, + { + "name": "first_page_status_bar_color", + "value": "#178CFB" + }, + { + "name": "color_d6000000", + "value": "#D6000000" + }, + { + "name": "color_66000000", + "value": "#66000000" + }, + { + "name": "color_2E58FF", + "value": "#2E58FF" + }, + { + "name": "color_1A2E58FF", + "value": "#1A2E58FF" + }, + { + "name": "color_f6f7f8", + "value": "#f6f7f8" + }, + { + "name": "color_000000", + "value": "#000000" + }, + { + "name": "color_dde3f6", + "value": "#DDe3F6" + }, + { + "name": "color_fff03036", + "value": "#FFF03036" + }, + { + "name": "color_73ffffff", + "value": "#73FFFFFF" + }, + { + "name": "color_333333", + "value": "#333333" + }, + { + "name": "color_188bfa", + "value": "#188BFA" + }, + { + "name": "color_ff4f43", + "value": "#FF4F43" + }, + { + "name": "color_1aff4f43", + "value": "#1AFF4F43" + }, + { + "name": "color_0a000000", + "value": "#0A000000" + }, + { + "name": "hx_ui_color_f5f5f5", + "value": "#f5f5f5" + }, + { + "name": "color_eeeeee", + "value": "#eeeeee" + }, + { + "name": "color_666666", + "value": "#666666" + }, + { + "name": "color_4765c4", + "value": "#4765c4" + }, + { + "name": "color_f4f6fc", + "value": "#f4f6fc" + },{ + "name": "theme_color_323232_d6ffffff", + "value": "#323232" + }, + { + "name": "color_2e58ff_alp_10", + "value": "#1a2e58ff" + }, + { + "name": "new_color_alpha4", + "value": "#0A000000" + }, + { + "name": "new_color_99000000", + "value": "#99000000" + }, + { + "name": "color_80000000", + "value": "#80000000" + }, + { + "name": "color_3d3d42", + "value": "#3d3d42" + }, + { + "name": "solid_grey_900", + "value": "#3B3B3B" + }, + { + "name": "hotlist_purple", + "value": "#b341d9" + } + ] +} \ No newline at end of file diff --git a/src/main/resources/base/element/string.json b/src/main/resources/base/element/string.json new file mode 100644 index 0000000..45b25c2 --- /dev/null +++ b/src/main/resources/base/element/string.json @@ -0,0 +1,284 @@ +{ + "string": [ + { + "name": "page_show", + "value": "page from package" + }, + { + "name": "unsupported_tips", + "value": "体验版本功能暂未开放" + }, + { + "name": "drag_refresh_pull", + "value": "下拉可以刷新" + }, + { + "name": "drag_refresh_to_refresh", + "value": "松开立即刷新" + }, + { + "name": "drag_refresh_loading", + "value": "加载中..." + }, + { + "name": "drag_refresh_last_update_time", + "value": "最后更新时间:" + }, + { + "name": "drag_refresh_to_bottom", + "value": "- 已经到底啦 -" + }, + { + "name": "feed_back_help_text", + "value": "帮助与反馈" + }, + { + "name": "ai_diagnosis_title", + "value": "AI诊期" + }, + { + "name": "ai_rise_fall_title", + "value": "AI看涨跌" + }, + { + "name": "score_neutral", + "value": "中性" + }, + { + "name": "score_weak_bull", + "value": "弱利多" + }, + { + "name": "score_medium_bull", + "value": "中等利多" + }, + { + "name": "score_strong_bull", + "value": "强利多" + }, + { + "name": "score_weak_bear", + "value": "弱利空" + }, + { + "name": "score_medium_bear", + "value": "中等利空" + }, + { + "name": "score_strong_bear", + "value": "强利空" + }, + { + "name": "table_header_factor", + "value": "因子" + }, + { + "name": "table_header_direction", + "value": "方向" + }, + { + "name": "recommend_contracts", + "value": "推荐合约" + }, + { + "name": "risk_tip_content", + "value": "风险提示:AI推理方向, 不构成投资建议, 交易者需自主决策并承担风险" + }, + { + "name": "accuracy_recent_twenty", + "value": "近20日正确率" + }, + { + "name": "accuracy_yesterday", + "value": "昨日正确率" + }, + { + "name": "update_time_tip", + "value": "交易日 17:00 更新" + }, + { + "name": "tab_rise", + "value": "看涨" + }, + { + "name": "tab_fall", + "value": "看跌" + }, + { + "name": "table_header_main_contract", + "value": "主力合约" + }, + { + "name": "table_header_actual_change", + "value": "实际涨幅" + }, + { + "name": "table_header_variety_accuracy", + "value": "品种正确率" + }, + { + "name": "see_more", + "value": "查看更多" + }, + { + "name": "wait_open", + "value": "待开盘" + }, + { + "name": "ai_rise_fall_explain_title", + "value": "AI看涨跌说明" + }, + { + "name": "confirm_button", + "value": "我知道了" + }, + { + "name": "commodity_options_title", + "value": "商品期权" + }, + { + "name": "commodity_options_explain_title", + "value": "商品期权说明" + }, + { + "name": "commodity_options_table_header_name", + "value": "合约名称" + }, + { + "name": "commodity_options_table_header_price", + "value": "最新价" + }, + { + "name": "commodity_options_table_header_change", + "value": "涨跌幅(昨收)" + }, + { + "name": "commodity_options_confirm", + "value": "知道了" + }, + { + "name": "commodity_options_expire_suffix", + "value": "天到期" + }, + { + "name": "tab_hot", + "value": "热门" + }, + { + "name": "tab_near_expired", + "value": "临期期权" + }, + { + "name": "hotlist_title", + "value": "热点资讯" + }, + { + "name": "hotlist_expand", + "value": "展开" + }, + { + "name": "hotlist_collapse", + "value": "收起" + }, + { + "name": "hotlist_loading", + "value": "数据加载中..." + }, + { + "name": "hotlist_no_data", + "value": "暂无数据" + }, + { + "name": "commodity_options_no_data", + "value": "暂无数据" + }, + { + "name": "commodity_options_hot_default_msg", + "value": "暂无热门信息" + }, + { + "name": "commodity_options_near_expired_default_msg", + "value": "当前暂无到期日10天内的临期期权" + }, + { + "name": "market_ranking_title", + "value": "市场排名" + }, + { + "name": "market_ranking_change_rate", + "value": "涨跌幅" + }, + { + "name": "market_ranking_tab_rise", + "value": "涨幅" + }, + { + "name": "market_ranking_tab_fall", + "value": "跌幅" + }, + { + "name": "market_ranking_tab_rise_speed", + "value": "涨速" + }, + { + "name": "market_ranking_tab_fall_speed", + "value": "跌速" + }, + { + "name": "market_ranking_tab_turnover", + "value": "成交额" + }, + { + "name": "market_ranking_tab_increase_position", + "value": "日增仓" + }, + { + "name": "market_ranking_period_1min", + "value": "1分钟" + }, + { + "name": "market_ranking_period_5min", + "value": "5分钟" + }, + { + "name": "market_ranking_period_10min", + "value": "10分钟" + }, + { + "name": "market_ranking_period_15min", + "value": "15分钟" + }, + { + "name": "market_ranking_1min_rise_speed", + "value": "1分钟涨速" + }, + { + "name": "market_ranking_5min_rise_speed", + "value": "5分钟涨速" + }, + { + "name": "market_ranking_10min_rise_speed", + "value": "10分钟涨速" + }, + { + "name": "market_ranking_15min_rise_speed", + "value": "15分钟涨速" + }, + { + "name": "market_ranking_1min_fall_speed", + "value": "1分钟跌速" + }, + { + "name": "market_ranking_5min_fall_speed", + "value": "5分钟跌速" + }, + { + "name": "market_ranking_10min_fall_speed", + "value": "10分钟跌速" + }, + { + "name": "market_ranking_15min_fall_speed", + "value": "15分钟跌速" + } + ] +} diff --git a/src/main/resources/base/element/url.json b/src/main/resources/base/element/url.json new file mode 100644 index 0000000..c256d20 --- /dev/null +++ b/src/main/resources/base/element/url.json @@ -0,0 +1,52 @@ +{ + "string": [ + { + "name": "first_page_advance_url", + "value": "https://ftapi.10jqka.com.cn/futgwapi/api/oem/v1/homeConfig/getAdvancedHomeConfig?platform=gphone&version=1" + }, + { + "name": "first_page_advance_url_test", + "value": "https://futures-test.10jqka.com.cn/futgwapi/api/oem/v1/homeConfig/getAdvancedHomeConfig?platform=gphone&version=1" + }, + { + "name": "all_gride_node_url", + "value": "https://ftapi.10jqka.com.cn/futgwapi/api/oem/v1/homeGrid/getNineGridMore?platform=gphone" + }, + { + "name": "all_gride_node_url_test", + "value": "https://futures-test.10jqka.com.cn/futgwapi/api/oem/v1/homeGrid/getNineGridMore?platform=gphone" + }, + { + "name": "zone_url_index_nologin", + "value": "https://fupage.10jqka.com.cn/futures-projects/normal/ucenter/index.html?isnologin=1" + }, + { + "name": "zone_url_index", + "value": "https://fupage.10jqka.com.cn/futures-projects/normal/ucenter/index.html" + }, + { + "name": "fu_open_account_url", + "value": "https://fupage.10jqka.com.cn/account-open/index.html" + }, + { + "name": "fu_feed_back_help_url", + "value": "https://fupage.10jqka.com.cn/projects/m/feedback/feedback.html" + }, + { + "name": "ai_rise_fall_history_url", + "value": "client.html?action=ymtz^webid=2804^url=https://fupage.10jqka.com.cn/futures-frontend-kamis-renderer/index.0.3.6.html?token=KF9MTExODgB5^title=AI看涨跌" + }, + { + "name": "commodity_options_recommend_url", + "value": "https://ftapi.10jqka.com.cn/futgwapi/api/market/homepage/v1/recommend_options?quote_type=%s&number=50" + }, + { + "name": "market_ranking_recommend_url", + "value": "https://ftapi.10jqka.com.cn/futgwapi/api/market/homepage/v1/recommend_futures?quote_type=%s&number=50" + }, + { + "name": "market_ranking_recommend_url_test", + "value": "https://futures-test.10jqka.com.cn/futgwapi/api/market/homepage/v1/recommend_futures?quote_type=%s&number=50" + } + ] +} diff --git a/src/main/resources/base/media/ads_popup_arrow_right.png b/src/main/resources/base/media/ads_popup_arrow_right.png new file mode 100644 index 0000000000000000000000000000000000000000..96518ff6ebd1a46e398c815b7bfb480bc6b9214d GIT binary patch literal 2689 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 vSAq6qHZ?WPDJUoqU&M1aaS7 + + + + + + + + + + + + + diff --git a/src/main/resources/base/media/back_black2x_svg.svg b/src/main/resources/base/media/back_black2x_svg.svg new file mode 100644 index 0000000..157d1d3 --- /dev/null +++ b/src/main/resources/base/media/back_black2x_svg.svg @@ -0,0 +1,27 @@ + + + 导航菜单返回 2@2x + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/base/media/back_white2x_svg.svg b/src/main/resources/base/media/back_white2x_svg.svg new file mode 100644 index 0000000..b0ef858 --- /dev/null +++ b/src/main/resources/base/media/back_white2x_svg.svg @@ -0,0 +1,27 @@ + + + 导航菜单返回 2@2x + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/base/media/c1_fallback.png b/src/main/resources/base/media/c1_fallback.png new file mode 100644 index 0000000000000000000000000000000000000000..3877b3a44521fd6c1ffe4ea8a490400b82f07bef GIT binary patch literal 3693 zcmeHK=QkYM79S!Ek}#qqq7I@(nH#|I> zF-Gq_Mwu~cB>Jdtvff(n+xr9FTJOU?zrBBFuirjvpYvg#crz2-t1P@M007{szMi%@ z4d2l$1jtBRKi+v3LjyW5b6qV!)d1fbEzov+qW{9!7$8OSfdIPLKLHHCQfS6YGXUTc zmJV=Z4q&Ac4RQ4ekms-#{G;_WOA3~WLM zAPt9P?8fgkxPQYpmVbC3_-bbV{h#89;Qjgafr7vf8M8-{H{ZJpzbpe*B3e(~ zeS%KO>$9kB#PLETisWic*(=4!YMh+zo|HGZ2UmtSannl+3DRS2kssK!l#*_X&r@i~YT>WQ`)E z5V@H>j{S*7z+S^XVHwZ19xf44{s$!)z-F>hZdz1W=qp5SWAA2H?qM{r1@47uf7MIk8%ow(cS-9>eXo3`?%NPSR zvA~)8Tv9Qdv_dS#SO1p|V6CS+;I}Ychx~xY2v!$rgtn@vt8XIOp(-lRY+C$&juZPn zw?3;@3cc!Sj$;#q&2>mSuC!8D@;>xt%NZ(Ak5`9F#|Z?2%kgBDjg`&kfc<;ZnQR*8 zN6U7JIr9Mu~ldxi>rS z9CnfRIoQ?d`_~3PO$YL3v7>gpeQ(@R*DaLxdTFhgn@$wC%t(Ox=-Tj<6PO^RI%dAL zAibI)Zu%-A7^G^;3DfO#^hxAxntt*I<7z~Fyf)Dl&DwS+8;^l8#0Lps_H>3MwkIye zS~rcy1xHUDyBua%lj>(v`aEK+ev_Qum$tI96j}q_qTry(%RT1F1&*%1i%C7Evuc8R z-Ow>|_YJSpwB7s5MgpL`ye_gptbO~0EYh?8a0AZF`>j~CL->sS>V@s!2VFnFeBiR+ zVCAwN@eB-k@DiuSe8@SKG6_t;Bw=B`#6c#pcaish(0dcL%lZyW8ZRVTCDCke?>Z(T zv)=g2rmHf7&ecetc1s;}rT>X}wD`7WH33^`XIO1t36yeWZ3?qoJlgEcFp+yq9`0I( z#=o>67i#EohxVY(hVhEkb2E$sEuJo0^~=o%Yz9Md05`3c$V6|naf*Lfg-8@nvIkct zY@1Dl-<=l+MYU|_1J|2zP**Gb9g2y?Qv0^v`7D0 z46<(m*N*hE`R>Od4%MajkxaBUnM3i&^~tO)7s&LE@*y=yK0-^4Df5dCM-{5=>;Tr7 zG_X(qsa03}JT$e$t82$C)vL6KfA~+7f@j_{wI%QTCqBYON`v}On)rx)zRe>T=Tb;e zdh?DjH+ktm^zh$`0lzT>b2B^IZ7vr@>;j6x^{^Q&Fy*;a0Cs65QEy_#K02{gVnL7n zuO<+vhT_?|>bWTRycAlQx|I#u)KfD`p-x|s-7AU&(V7|(jQZR}k+(J)ZV#L)VJYw|)5iFsg*!4RY(6sxVl77991!mN~vW80fTLl09-1zrzWd zq0Zy8dHDF%NsS>SRyC2v(?HjD4G~#b@SVKKs?#CFd;TPBp#-y^*0qHyeHQKhq9v=Q zrXB3{0;Zc79Ga9SNGcTn4K;jSQROCFr1D1wp>&Efdk_@j)ooH)s~cJ|(a$Xy4#9n9 z288+k;Fl0YRwW3jT1#?vkYckka!OVxR%?F6VuyaBsOstO5T8IiH&h3ms(V91Rxm@Y zN7n&{>`570QLPV^k9a`9iT;ez2gvVrT}aiNf~s(-=H;@Buuz2=4a}ZV3PUiQhnl9_XCo2?YH&*n$dy8xYsQFE)woJn?CTU6>VEK8j&2Lio7xfQJ0&^DR6U` zZ9VZt^x!=*)kb~&^AL(LY%ksNPCwO(9k;E^%qP+b0p{GlzBXYMIBq}@-F5O1Gsy!n zPla1@s&D2|!-@GzbdoM0c(-cC{oCT1ajQq4sQxCryU>n`nma2tWGGAXvnwn9CGY&S z3cZ`h8Ez|;NTSPEwGY$<*8Jwuk;g5!c>|}0gQ`*)gVW0ST~5;EmU=whV=-b};Zrk??r%h}l$i&_t=2;ki zcK8;{^99^*P>7UowfGKtGBQ82lkZ58*( z!QYLZRg|rlDs?{o17);QL%>@%Za+IBZT4n!gK(*@pfJ>Ci`pxEX3bsV62C&k2!A!e zR~R(=^wZmlCnr-c+&MGwGJ>yRJ2lX)Q)n>87}{*zY9kw5rG%R2vo0NMTkSi4C|)1c zqSjH5e}%XQ=jR(@*VMW_LI(_4QRLFuEt?Y>8NZhh3&B_&Th~GV8sG>%JxZ>~SAX2O zQKMhvH?w^_-9C#jNm+_+VfUPM`RMdrw)QT9rW3*Wl4UA9E9g5{v&_*jS^;O=i{QrK zWzM=WT}Rv%V&No;iH{-Ko{?gd z(zc0Uk>C#6xR@lNXIkRm9?sv*C%ydjoLT$7-AeOwkKp_V?rB!Drld;}&s9?N?Rn2f zoxbg|^#W28P|WijI54b6T8#{z5Kp%xlNR2E)35;JpL1-v0Z3Op-u zhI7<$@hJ)6yc@8UOy2z)iYp)n4d4cMveV){o>L&KJb29=r+bgoQtN{KmTnJ$?yGD) z^uX1=Jw}?CrMrC%({s}TtVk2h2_7PL24!TPn5B0v%&Pa{>k~^p@T{gIU=;`0z(fH8oqkd9? z1?*Kz6@o6ede<;O$}0NHHDEsVrgvFEH&tL8z9#$cmb{2=% wjthv2Mp5wIudklIj)``amR-cZ034{@O8@`> literal 0 HcmV?d00001 diff --git a/src/main/resources/base/media/firstpage_dialog_tanchuang_close.png b/src/main/resources/base/media/firstpage_dialog_tanchuang_close.png new file mode 100644 index 0000000000000000000000000000000000000000..d1a50424e7a6c385af85807a1d51ae5f017a3008 GIT binary patch literal 2745 zcmaJ@dpOhkAKxhCn%F5KGvySs88h27g}Dr)P)0}D&02P|O*RoHHFdA@0T{5*9ujWq!P zfDXY6AD~>(tDlCNa=w=Av_-iX3EfG;KyHLEhRmk}TxeVh9ZX=88T0@;nI?{UNZ$zn zs9G|ENJ0|P7fa=`VdPaCn260&q5*)NyF@%PHIgm_Q|JsP2M3YgZ-RiCG#n(@foMzQ zxzP_Zy<++Fz*xT^YHTDGLxb#c2JaMMl>}_MkPH^FSsVdYgoAwG#VYr!*Ki2<;9u|;5QF<4s!_~U>m zz42)g*Z{o8M_sO> zHv6-+Ko~&(SMUEM76gfTba((=z>Vfpm4%D2Sarq2y7B2`A(tP-<+46yanE6{kSjRM z<$>MYRSqu7$d>^s2FKfYk5erv}fv*<#zl#0oO4&iH+t015JbZ3`I!D>@d}V99Mqkwf z0MvI7@Ge23XR?9=zJ?6L_FZ`WKUF|e_li5&7uNoiyj};Kdl#W_FTT&LaUaBQXG0ZN z`%n6uAUwg}dG0o-?y{wF?w2FTE3OJ)S%3A$OPjn>2)A;M z=G@IbXz||DkFiWs(FVs~n%9xibjLFQ_wwe|rDr613}f4i$TRco+feR7?2 ze0iWO@mq;pPzU~I)22;-*cWu0s_SiGahc3|dB)?K$fvNb-rh`qA~C)HFmmtmoT{$) zq)@gw@mBVWoEuTnp`oFWu(0^+5=pBSV`K*mmLaR6dY<+xee%Y~k)yo}j zs818`ef8><6vcja=i8#A*~eXD2hW+uV;|4Hy=7)>eD7>dPT(XPt#LVx@j!jW-E6c` zJg@IJ6Iza&&k8lxZy(UsOmEQogWYIkWOTjciIb#04t7qvuJFNiCnu*{k305L zYX^&L&9btx)+ZQOjYy{yQE{^^sVgbDmZyOeKFtb+BD%D+^y!kO$(qLI(&6EQ_}$iZ zw}1hF7_E5D_|NGz78Vwnd710t9(8ry1SuX4Rj4$2+ykjALmdfxW5RH@n$>Zhdr~fY zxACnDew%{?UW5_6Xn06FLaE;ESLA#t0%?pmC4&t8l(a|y?(#@!OEm(46gCC!uJ5Cq z7H^I}@m!=4gER_%8-0+HK(6++vbIi`NC_EhKBQfzLo}=^>6^7iB8412e~waL7*-w8 zO&Q4L>%}F@8w%(c)t+(o1yfvX9~3k*H_>@YM@L6pDwUqVok~jA+6XgABwo+bSaMhG z*@*}@e&HWFM|vk88G*McpWS(td{KW(OH01sWzRX0O4>29>fLaIyCt$rkj|#&pYkpP zBgXtABO^0|O7jZ}{*r*}L_(pk*}w~7!fLT(`P5iYIt&5&K*<st#Ad|FM+#rQWFVLcrbD=!8* zw7f&}WoxkqbjiRJ+BYdYq#^dfY*Uu2uCZ~&D9ggqu*Jc_A^p*N-_X~+rO_e>(k~@6iL(4C> z>$L+!dY*UAYX%wXb_ZlsaPvMT zRQfW}^^~WR_*SXj?#=R~tHX(J)Fi2GYP+?X>K+~7dAn`sJYs$g81CdDJ!c(U@$*Vy z?ep9dYgDUlj#WQ!08lnRI{U-G{TObs<7jT8AtG_|s_o;VwjzmSYk5$T7fP)14&oKF zAq0|KJ?5phZg_9Hhd~I5w8iah*^n-gQnf6Orxx$%1K z#fulm1KcdOrpg{(_3gI;d>!pCDJz>Y4Mn!zP7ABjd`X&j68cW3Q3pw<$!N&N!mN&L z&00q97M+v_&-tv|t?>NwWlNt@pn{4Zf^ICq=- z`}%H<|7XKM+@i;Fo;~>XkrMQkp2lIC-Kiz4NgUEReQJ?uWbhhFtl84w75Y*x-&Nbt zAiMT-N;Mv&+h~dvcC0xxYCoCT^Ot@97TlFe9gy4iA;I3>j|hhbC)zsLsDj$VHKEfp zN2}7}m!g7!=AGhW`xis?UH9zyrBIpBD;hvTq@fwjKFwX@yOCM_Nz)+vtQKf4uM24s zS4n~nqGe&)OMxx***PxB3#w9=z7ix7$?EOtIfw}4iBNIR9qf`MX16!?b@)ETx6L)d zCMdE!xu zhTi$N4*nQh{o{}CVcz+Zaa|GBIQ%7}@;cS5A0(iT;)R8U3u9wr`+w2=xn5#4*_9Kg z|6GwB!Eh-C9UB`!`H_3erb;x2m$x;N+Q4r+0^64jS3+F0cg$H>tt2BdonLh8c3Mx5 olgIZhgaGHX!Sj0clX$=W$&igF)d7sDUO$_NH75I&ZhLyF_d--KJOBPKfB9P({nF#MU)t0@G%rhM?>xuOx z7jayCIcH~~FVEj_yD4E~zlBRi?y`!ULWQG~H`#GqRg~)1ma0unr4ktQlS@iUGcDS* zLF(EdWqqmjJ=IIgg;kimx;hiZ!3p8w;g>?({ofyMhjBK!6_F}-pUUUVsg2eD_hj>x z+GD(@Meo@2?tAmf)q1iVh_{-KmNeWLfbT{qVv)*;HGZcfKTTl)P* zpc-1gPW*LrE|(LmtK}d5q*YBKlfGfjzIW{zkwmY3O&v*C33yt`aJs~ zcGi%%?T3M9aRj2UeEnMa=t)}H)>dEMUx)l>Q=AiIG~&qUN$*MdECJ>E1$>4MHM_mX z?UN+(KU(?)<$%bCO@FxyRMN;9dJUhUvd^AQ9boOZcR3rdhsY zn5DX=&q+JfW!vO6`QjzDtQ^oP?2_I^n5Capbh$GlHEEXOJcV;IkCh%fd7xoT55Irj zG=$wK{9BvvAfBy+Tg za|`8H`55asD%877Y|u~x{$@#?&Gh_C4!7Qdr~Un(XF(MkFOYXqJ@y50HqA;H59%5;%%?mzvkh}u;J z$fxJC>)?OE^-Hbw2hWS@HSaIsYN^r-*rGe~Lb+L8Nbs6|)a1Wk^YefUsx^#))$4&P@BO0sF;u6pW~5vtem1w& z<0$sbKAC<(;gm)lMQy`(eyECCwXsqdOj}=Hm+|oMpn6x3_obLnmrb&Y#rWOu(ech; zIsVo8^p%E@z>w));n-0T*WQk2Kp z6w)vXl|Me_50qk9x4z}#bI%LWW3z}~GA9=@rI8uUQavzg?hYkp&)(%&;>?@5p(=k$ zkwZgNdrNz}txAC!`49K$+jHsa)j&H+bs%&AxHZk=xl?bR3;uKhtl{E8)yGFO!jv5s zGJjSy%Q@Z&pV+VIIkCIct?!O=R%ahP*}>idWUE>(uBd%F0YAs@-@jku;;w?6c&ZZe zPRa9S(wX#}3O}NJn07c~T3UhF7K6en1gtm0TU}}s26DScMw`nQx(kC= zN_BqVc1woIbcEJF)x(^c(AY{|E)CuoRoiH->Rq6ucn|reWTpB|xdL~Ze~gA5dkWes z@m}rJ)RdY$U_F{PW2TsC*HI>wFacS_^q_?Lb2uFpINES_Z3r{ zZk1Megw1Cqz(8()a1Q`%fA86T?3aDsgYG4;j-8Br6BHl(05n$Ib3SP#(Ak8(MtXiq z&8@HKdlZjz?GvzX?B!Pv`1#}bjvYH_@q^*SpKr4CVqwy?p^~Vhf`XT}ntj#Qm2O*Z zw-vcjzWd~lT7^nl)jX48lI8JrXJy(mkUTt}j{F$uso7Yut(?lu+rVgdgg!_jLa||* zBy$@BkDoujnG(GET`EQb=JWAj(+J}Z8Ai?Y6*+#+st*uGdg6#v19+21Dz2`srtfJj zuOf=D?7;))!%e5XNJ>twxE;7{O%APJL(XA!m2;qIJ~DP>!}zJOZQ8nHqsUC?&8eD+ z-9bM>!rOt2&C0gA+W=AnC*%NR^I2idS}P{#pr}i(&Q#Z`4IIS_$GT@7C52jRcWMKA z%Fjyi>QB4Rp#{zjQs-rFk2S{XQULL9%Fv!C(JkQESVz*(RH~JTJ)J7C+=^^~0`ZOE zRXV`T)U=KU!+}em%dcw40f?IO#|9q~CxP{rOS#FU!8Ah z8@LjE>h``(w_0-Z8K8ymA1= z3Qov#$hKXQKSPw;)JI*X8)y#A=@`%4@-V0Kp=V(v&xC{o-IB60AMe8jqkk+t0FjfE zQ`svHA*9TQ0c59LvbL_w8{ZFi zv}_9vYH@KYNYB{;cceYn_?BS;>o*s8lh~&WW<4!%?MSfRS_OtIvv}$)? zGO7zloMbxNXxXIm?mg1L4mM5*lKT#h$m*Ke56@Un>@>E~LkXjg>JD|W5<3Ag^9#~l zH0`5gK^`&1^>s_@O-^^Bj{#D|vnU~ZyAlLRP9An=Px=TuhaGOq2o#o*v|&zIwiqZ# z@9Pad=4Hxmsd}07f@r#eFdaK!VOx=6J7;XGCnSiDf*vA8xb93zV0S4uH=KTYQ$tb= zgLGO5jSmPxE$G1ep2C^_q#3W>_(?9%6xbfAG+US03y|}uRkrU1%+wn!o z5Sr)}MtA@FXiY3&Nz7Gi>p|@(wT~JgFTzF2y)G)c!|3R^umJl|m*<>YeWyaqvDw}j zj&mATQ(rk1i70kwou47F3mSovQY2kJKx|dhFpxl-(6i$Z%57$zx7c;`+MW9-1gX=w zvs@JEWxX}AOBfDmt!Gxe_F#Wc(F_+Ui4LP>YD~Jm-9gJ<5XgEQMHD^6ZW#%~bZOGE zS{hlpY{~}Sr?kcrVWk{sn=vGa2iCpwC%F-Hw`<9;uZa^ui=mr88j~$=?tsSyp{n+S zFv*{A7vV8tc{9zEk~G-k<~=B_UhwVR6?Xaf!x8eNwpDzwhV5+ey)>kuX z?v4Mfa^b>Xs_)li*DS&sB5{B;hS@q8?A<)49mgCJhP$^Gb_=ql6r42I8A&u9Rww5~ z4B~Fhh9Fs11TO`7`8ku3lu}-HDG+u}o6wvUhGLEeg`;$8$Dp6*4bzj7ynFOP9*R56 z#fATo%vEs@78d)?hN|id3>y7%8-_pBJsd^Ba8FdT@|O! z%_WY5wKcuH!7gh|%w5DGk~F;Soj$veBYXJKTzGZao?MXL^&yZ*L>x(dU^~08k5Gto z0rUvLIuDeRd3kx@kiVDsRYLx?#P1%8_zS1sJ@v0+vjw`p(TOcu{|&KhDd%spW2+he zW^A?!=5N{Yd!_!j0Q^18-u+v4uzk?{Ej#{}9e;g-4Fib(cRnGz4ZkrkaGb$fTwI*1 ztE(gPRMt*}Z3u6R!x9e5RZF};LC8tw5MV+B-c76i& zvjx!o)iSBz*62<_ezdjTs6r@P^})lRSacU5v>&bc`Sa&e`lHTx*{`438@f0y?#<5M zwAE*w=6Du{^*md5q>F;sa4>O%-fWKAyDT7!gMHBDumVwe#ZrnR2vjA@Sj1Z#gN>&F zZ3gire+XMG$S(ZIAn5pcoH~W+OX4A;P($r4qW7+m>uxCXr-sj!G}3?)vR>~ z-`b+Q8#i#yliS5{#MRk5pbJSUM%+a#c~q_UxL9tiH_H{SH+6J$$UfwxS+kYpz%d9j zJ73?mxU94wy6v+;9v_mh3bS)h;manLJnMwAKQ~gueo@cK?v?M_ccAMFbc`z^1VHj5 z?BX~3#buKb%56XO*+vlWbmnwIfG8-I#su3_JPDLpP+cmT@QBE0ymj^PfhjES^u^h z3iP&~QwH}Kq&MjscLx_^VwxN9GnAC>?ruZs__(~1CcB&6<-nQoyvonl*3s1so}|Ao zlDodhn$T;(kMd&A=$r^_cozKML3sFS3A}n*Y_Q{sq)MQrR#?d7E*GFvhJWjSP>q1sO37J}Z zelZXYtZ4ms@*x-j=lA$5HpxOI&KVVb~bj|mjJ9jSa za>C)EVAu{hi#M<>#&g^88}(5X{<0~nl(}lgTt0NI(J*zwtHN1a28;uQ#3mQpLDiwX z564t4)Q6lJ42FfPJHCO{yh^G(dt1bQYQsv{wS1GA$jG-U9U21UkOc2i3y{Lzn1Oe* zg;qgtz2Y#pkDejet-DjC%Z{~NuaWSH8(zt~p}EPqjQh`5(SirVVL!F8QXmbymM7KF zTHf~9Fh0{L!K^PcmVCDa3A^R%GoZ|&sC`-+E{OQOKrcDhzinrjViOl$Xrx8c&l~W*jPUUA;Qle>?PFj$ z+_^7aytrZS;xbu0?M~bsTcdG%gIW~qT=SXZ)GdDN029Rxb)AuT?q}-<7cE=Hpiobl zR^I31GsW$5GXtgbP*u!Ofa8xjNid7+W`xTyjMgawcOReGlt21i}EAz)GFqw~f{EF?lN~2{F?lfm^eF8>ljHu5_w#h%iJ{|GjaBZlWzB=E(T)SoW;M>=)e=zD}zFsQKR+n9 zpIll6W*eFgvv=Y-Arcpdd(SCUl`vivx6wypt~s60Zn|x_!DoWS$&6jiuYhD7?Fh9t zh|9;=z4rb2?IC3`%DZxOG+7N3WZHP|4`%P>q1($7UoZhwEy~*R_XOJw<^y+Pu+Qp3 z)(oYN2Nj`&r6uI}R|;0kzwWAXz_w%jg^^bY8j}n2f%E<~cK~uPm>!QZS&J+;@CT7E z{Ew`LyDe>*H0M;`@lj0Rv=ysj>`d+2@P!D)y1eVTLGovki_*d$nQg-#d$)&Wj8*t}p})cK8BxJ_ZN@ z=Tc0Z-*irPU1g3cuzmglPy>c@bi-aP`&E4hf-iJ))}s%glTU8+Iz%&a6xKQjg9i-l?+4SgZk|#no>q? z_K6&Iq4Ma9y{V@`W+O-`9`1@b)UoGOv+<{-jTdKxrSSx3^H^fpMtNBr648AndFBH} z2o&QK#c{btop6$QU4px<1}cDj5g3pWM7Arty%cc1Qbh(I<8;ApFqU|9$h_`pd9C~$RUUXG0oTM68X8CP@7j}r>}wCX*P6aBTDq--_b3)~ zDp~Hr83C8(RYAWYJ^vfUX_|-rga}C*9WinP?_Qsz_dIKXp%umPWt@Pt|EICavEN#y z(}^7UK^w=8a(;Eabou^JYow64y?%yiqm_B-T#SdLu9LOdi&o!l+OqJn(ESOVC6}EK z*==JlcR3*+AR#Xd2hO8JFpE2G2wmwv=`8iJ3}P^_q#6llwyH#)uxRH^71;(3ln%n# zJEWe!aguZ=&JptM`X9u{h)RqPW^7mK(@ZV^CaMnXk2>&sa|ggekoYNtELqO_B~o|A z6HdPrTUD7#wWaw$rN$DQq6wDLpwslZ^gkMrm9Wu0`3}8QLnyk4FZA@Y7>7kEWOSi_ z@ktX9rb|T8;(Gc~yES0f>oPP=CtZ;z&PPnW#S^lc-W3~78-kk^zv0}3q&J5_EcSWAmv`jP6ud#!G}N-RKr zDaPd_-}q8chbvjEo+1v?l_!^z4)74IPq8^5Yu*f+0I^%;ez8O0`AgRB;aLg$-hgU4 RFjdTP*1+U+fxg|n{{cGtfAatU literal 0 HcmV?d00001 diff --git a/src/main/resources/base/media/firstpage_grid_item_all.png b/src/main/resources/base/media/firstpage_grid_item_all.png new file mode 100644 index 0000000000000000000000000000000000000000..fe47b459a86b896acc41c0c09ae53736ee686444 GIT binary patch literal 8674 zcmV<8Asya{P)_Ef@B8fA?e4zqM|+_C>JT8d5sV*_KX2d1IcM)$qk62W zRdx1lciU~VrG3x2`%#at)>`$gRcr4<%#8EhPObqP6QCu)7+}i+u(Afg0K~x39B^g? z^cElj$Q|zio&nf>ABPI%+++eY1Te7ETD;B>E};fj+a5m);7Sm-g0aOF&A>L0uje!v zXPB@GV1*bzpazGS@ucl>#26eT!tt&TWm^l(%m954U@p{e$AIg;+YN=bVIII5&@_PU zfr}P^ldIwSaStrc0+(EL-jJa^|64BTwgljMBD~!)ZU*pkMA+jx-gjXj{1(CR|8x8w zScd%XKy!b(+%#r9V;SEg!Z!hY6~H4zIP=r$=qn2}#htMagm?Bm{tbHU0eM9zuA=}} z_?I>*kBbjz;F+<9fjtD=Y6*V?;F;dy)6BTn7~KCN6!|4>uME%u;EOTAZ!uv%Gj1wC z;*1d^gDe2c_bG6kWCt@6%8`MTd-8R}ge$rZ9|qyWyn%0#!QDjoa|Vv$6#)H}0Qyz1 zc)w+QkQuua_r&XMhnt9S6B+ytfIkE9R{(zavh+S~ z=4B}~bZ8p^A7#dOY>z*-jNRd6X%;jVg$dBddHOpK-K!6B7H~p&Tn<``-Q44k!T1g{ zKI+>1@*wgu0X^z)yR|sLj6VS5VzIe?8&H4QNTi*IC76}H9t5wtHtq2|i>aY}EZ%pq zYb*CSfH7{ra7g6sLIeF;%eec@20q7(*FmucP>D<0C{rvbP%w>MG7ntcD}`!ssj5SN z<$;F8HoVTR<8!dMn}OH9R50{*L4Y-duacf1Lv z}}mEZ5nv+ROuBCu(zR4 zExpMMoTV9?S6W>&usKuG64qu~+(Lx!bPO!Ez(reL5W;hl=!p$Lk0!WcV}d&q$dzi= zD#*$;PBy%@%~0p+NsUVGjA}5_HD$1AT3Zx)Lwuf<7EPU3lI~1oZ?U*K!kuelT;U>= z4wrM;&IxE&L1VDP7~D(1m33o^nV+kPZWHH2_l4?CDgcJ0Q~;&{;lzMcCU;-ArKWnd zM3cHs3q!!-N{2-oFgd5~Ty1pIGw!n5CfBq{&-kP!6ai67rOkaQBvXT0vkGa<3gW?S4vgl71Pax=Y){hn>NK-b|D++$xoTNj&lOgK+41)Zxp zQ+Q}fMX9>JpnPiLDh6f<+<`e^$i=U(t?SqKR@=U=$0sH|(7nJ;M^=Z2jG`3w6TAnE z&+A4}HR?q;Q%TU&q{pS?4qY>F>(ne*PnJWQ0M$0YOq4k%I9(S$H@ipAJ#ws`nPuKc z1JmHW#P~vTWry%Qu|8a@4eGJic6f*x+Xn%tfEbd3f;a2yV|lh>5}pl@Ku(uA_5J=u|xck@{X7tF{i`E9mosdH<64&dib0^fKH_@^HM zM^~KsGM45DS1bcJUIV=G%3v1HY;0bxp5>V^t%X{XExt6f6TjHZ49fy_1A63+j3#*Z z={4Nln6msduzJ#}sFIT$CoNz7KJe{lBL}aTMJmh2PT1TT2dX=F*;e3P*8#7&G)U&u z#Ha_KfYtz87l8W?0)PDg@Wk_B1YLUwgh|I(9|POw3BPbH@S8s$IIxp=gJG?l3KeWJ z-?;X-Td}{H!##sQA3ZfF(Mvt>C>U1^orF@=+HB0W0pw3T0Q~QN&dfs(PH1fSmOd@J z6{ms@Iw9D(Ncf$%0&m$19A6Eq?nQtpw!vwTTYdlU-vIvG18%jOv2}r9h@+NeyW%wD z^tzL3!XNGj{>9EvHkA3%F%YV>CJA)q=n(KU6RriO7lG;d8t8EI$C&ZqsbPB|4-lcV zUwjm}<3Zq}dCxhEB2vTDXURSS;2gb(W2=m-mI?oHKd@~fXh&^AXIfzE9PmF60H3-K zSeS8A%pP1R$6XnUcd{M8KXM9q{i_N8;Wl9Rj+jgPqs$AizXqZ$?$-Yl9loSk#7g~Kx8fmlC zalFZu?I_vCSO${)Kv~Pa&5JX@rw;)C_3J^B=h`@s5YrSSq^v*pVb>CH@P~{)`V8>w zv7nKz4vzQ0mTk1vB5;k9!;X7=K-b4ULC1y($?rXOvc-pV^lZwN-b9+AI-n=MEJm zpPm6ea;IZjjKw*QP8`Gf7ISQ$2tIPwPE??)( zQXDWV6CtBuIc#%la~Fsk?iBdcm~ODTLP~(I$3D%5rwo#SGl1n`D1*l<~c1 z$ukkQGH}8&a$`g?Q3y-~*m42({48*K75LU;$OEr5iuHgGEI?>s>w#-01~;-xmTV-b z6l~wGYH*3lLYONF9*V?kJH>hAy)-8eF*MjJ2@Lj<)cHIai5>UBEo(hLc7yW7k+2{^ zVzVujJ*H&yvq_q6gV!X>h0r+X=IJBAPfiET>1@z4zq@uoNq6jP?X{+Hw)oXPnjh0m zeA?{1?eQxqh>mi!YQ{0dCkyG<4gzxLB zqvv)50eg_!l7v2PPe`Ym4=&-Fx#;(n2k*tR;lv zAfV*cy1g3{!p<#>EwhaABm&GqlU!4}G+24!9%5pt?jHL*&`wo|@Rp%#3$CbW94TCq zX|7ypX$TUtUM7O z(*Ulmdw2JkYb{3Oph;b1JAJa(oWS;4Anv^_0gNm9Z=n`26N?>ealHmP4HB1ovGl|$ z7fzX!Yzwnfz%_8rw%VYw$y{X0QkWw7Yi+jC8xzKkMaIon_dd*-G(+x7bt17L=^H>#)S9%s}0=xrVUT+#v;W6a`FzRch=z4zDC@7i))PSt9&vpzjJ*`)1s*m zU7$vnZgb2)+Z7?`dDf=_NEI4Q0iaNU1HuK2wk=rCHoRR|5_J3wVfS{6559ejS1&V; zujssjDr%=}sHrrhnmDi|7jpOZ8V0VO zj%liR%)pJ}v=nxlb3XH}*8)4Xu(zF#j-_HbPgGXCIGdNQG{EkFTem#5PI%K56I`{c z!^%c>oox*AxC>950rp(X_;!}qJ{x#N6*;h*tKQd=(!b0y82ZF2p>EO6in#@{>$Jav>j z_cY413~7yaill)~lHYj681J}t!^eq79icJg%W`V(;^z&K|Lz8U#}>kmjst)3wHf^5 zQw^5pIHej$-EL4<*2%Z=y*F)O-}NKEjyRr#GEDS@T%g{SZ5pSg#!r+@W4z~R8spp1 zx@&wGl>B|ZU4{bXEgyFJCOvn8AF+(7+RX^IEQ?-d0>64__>pegFgZ`x*&yp zbfawy4kd0HRZ%3##$eC3d5dc<>HPWe#Ds+jik*w#y3QI`dpzdqb&~q!e{Au84jCLh zMd1UrFu0+A2-3~ zt;J`;doRV#j2I891F?5ITYp?akG2j7MF1hI=}|Ak96>1lQsBa_-f>4*ng`x=RqvOM z9RGD~7F9-#)<2p&3h0=j=wT%%+mA+H*z{R8Zkw4POpHNaJfFI^UME!^7xz#&fI61Tga zlga3!=jBaDT$j@lJ$9yJBg&0*o~QuAUE31*L>O0}cc)cqSOPh&5BA=>EFB>C=~##h zC>d3Dp5%fpHbe`Oemt3SZGRn_w>v=$VSmGGCN$sYqI{fm#9Qnmq7mx5# z7Ny74<0i|`-PhtDzSnrK4)F>5t*U;o!5w3cE!2u(w1Ti~dIWhxM&eL@Xrk%JKXpb zGZSgmMOzpLpKb80^tjsiphiP#5WPEi`id*DS$txe(&6NVDfK*?K8f6Cn~Ksrt|P^%y#4A>C*QPngLBhih6g zG|_1ZuCLbv6>MmhS{noXq_T!mD>9)EAWP%5*08pR8RVjuS~EmzSS>Q7Yjm}wm{goY ztm4Wl)z~CkT-jq%pIi!smfdlO!!CMv2*6=&9yLW8ly-eC+oAATNOW8mjDdb2+vKT0 zx-uDcwvvsj-g}+K>~II<4Z< zo(HEaqde&ql4G+fr#wBisHvBQG`|+D2sD$^>N;cvJPw?d!I5P!ZJxw#r(~P#vWsw} zHBAL5S9sJoLE$7L6+a#vBQO;gfx17JFikK>6_!{?Sy*XK?DQm*maZyZsH#<+3u}40 z2dqB18i*PkkAiaLp|eSq^vbiMxwP?r;wRa_)G4VKtlR7>;9K8u9rj3$8l++m4T$cTs3u&51cl|5)q`ABY-Y?Ur% z)dVVK#A8-I6Noy&F>3+1!X#k!3|TzvQVom`U-sAl;j>eK)c{w3)vA*QpaL)PRzUIC z@>)^dR7PP3V-AJnD5vHATo5OZDs$uPn1~lhN}9{gYE7}oDF#-@W2z)~tzkvq1*-<1 z?Pjpi%{cZKpR?c|pV3)9g)P>zELW!o!BL>;2}>~qaCxmx57RqRXfrJTbD!hZR;2{z zGQ=ngFE7nR%Eth!fmhd9r1Y;oN`5cvh6s5bP=T+UM9*20^Bnw{K>Xb*%@W}DnI$}A zT0EN4n)HBEd2EViO>MdJ`mx=dEUxDQl~DvLR9GZb2GquIs*M>$MUI}TLo$k3Xk}s? z(j)E6ugHK84fa=Nj%3g~!N;Yaz<(Q+KGe9$MwrLX0agCHzV>#LKoGGQW$WoG2_XtOC z72q~6&-v9NRs)K#at=ir+_Bc+Zx;-B-T-bl-7&!JlVbo)aECECR!{)}QRN7xLD1l< ztpA)qLnYXSc^P_^B};gA)ia2`bA6UJ%PQ!sO_P@PR2Qki$+SQQN>t3ML34>S%tFKE z@eq}=TPIbTGj_Uw+_QdR6-944DbQb@oED)_DyXC)Qx0Ii0&U{b zmt=SL;l3ej&DRXc5feXy$4SRbujml0^fvLn?I6M&` zlc%0*i0}ypj@b%UI(%9qmbRtI2#*5gL-(SQdLf|3+~smOY5w@#j4yqgr&b_z>_a9h z-)EI(B?Y=w?28r|zy8*B-0(9UPOnkQ?{HDP<*mTC4mJ2Me>aO`r^vrTqBJ384tL=L zK-uj0pwcaG7~?nJwc*Q|bS6Gc95RyJqY;gvn4y#tX~Lt}BndZ9x6LLG-5(x!jf87Z zKf=4a72HkoJU1%?%~`6s5^$$t9&BcO@nOb+@4K{EO0Pgk?rfS+vINU=lZ{ch<*hyT zT|dTEm-SfNFli}CTv}%~wrv5PINadN54CvycplQi9wXP8gpji-m}{LRclh}?j(wRf zE2?Q^ftJuVlmRz{na1Q)v6!jk5Z~I;{&~VZL$4O0!#oVM3pRBp@b^r(wO&=x>x4tJ zulHaxW?IH%67nk~BgthbIs4jmA!|vp_n*hjat$fR{%X1PoGdKuF6- z11VuDP0YmA7cJ3gLbI&$J2KsL?465o`gtX_V=R67Aa38gp{i(2Wh@@j?I1 zGZ7ai9ojqJ!^es68v|pIxuaqpYDul&%vPx?)Eb??uW&6%MGDp0kxBu}xfPV|kVKZE zjhdSVUXXZs5&4O0RG}PM3(UEEsa2HQCvDAagMNd6$NkH(Hrd+xV(no`SD{!}c_RC7Y=yd-p}__xm>%6O$V7*cG>NoSmO zElCO(#mAy!k###Ez zk)5q#c4Q=>1lcGB6!Pv;C*lv{7!w?jY=>kZD3=rB=V|Fb%NXUq>2*LHB{hId$?j3) zwG?QYUYAtKj44wo4!%JtBmmvt&d`V4>ii3B=kz8==PZ%IZ4}lQ%hv^0USS=Kl17VY zYg=n#_NP%3k5PHXM&(X4>U_$tQ0sc>e|21!B~sbM1ap#{I#9~vknd>_Dbcpj{2~dS z4jIsGE*bV*Fg&N92eHBVIMEusvj^^P;`>R7Dl<~pYf`zIAwNQr0j+N+r+3;(BDtG9 z0~ZdGCYz{iS_KCZfPV0roLMTKpDSOOid4i+{$(9fbB7idigwKCNj433D8K9R1Gac) z*W*NYme<6cH_-kKT!BJ8%?7uR#(1Fbu~?{sZY;_ZDFryHuK?0e)G1j9GE_#^fNYPW z-7rzTjk2GHRCcHEd!iH&GGTO)*t`jYX=3zI=!s11q=_#tOvz=a_Wiw)@ zWsubB3}-2Y0v4*Sl-;R#ZtY0LMR6ahw9)>jxjy8mMEY9KMM#Vl@sWDZmpB@uStRuU*+4~9;Pw>7F;|lRyJk|(P5H>+7TR+-m zqmF0q4vBd7kDw5rXfl|->{yR~ zey|Cl-qx7s=V$4*SM4&tzuB{`=d)dqUlxk5W%zt+{L8%WDVU{tm_|REAl$eiB}pc8 z+Q#I!LADyqc+N-H()F5-<$6q0m91!-NOcGf#d`dGZcv+h+8MKt8K3XYi6y`Ac42=j zi|~CS?4R9&_Zh;$!tf<0p>jsFWRnDK$Uobcq*_U@ge()|i0SxJR*H=@A?ZEU5zUmB zW?i0%5>CcNG~uUEE%4{CIQk-dw)5Uzu^;z!^k|mwQEKqfKK{5b z+>iT)*T88qoOm>Bdy`0-C`YXyR z0Chha+)q6|VhGoFz}t<%&0zeT`{iQ+uG0PtZj}qDk1MeS!1#u7G2I?JZHL#H7MB>{>Mr~M`&Dk;)4jA7j3xH~d-V3)9N>U+ zc@*xwGf#1YLts1!iz8%k5RBv61pO574I6;}4~Iyf+pc=dPXGV_07*qoM6N<$g3TJ5 A`v3p{ literal 0 HcmV?d00001 diff --git a/src/main/resources/base/media/firstpage_news_arrow_right.png b/src/main/resources/base/media/firstpage_news_arrow_right.png new file mode 100644 index 0000000000000000000000000000000000000000..4c096f8c8597a77e50fca0b5dad426ea5a889282 GIT binary patch literal 373 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 literal 0 HcmV?d00001 diff --git a/src/main/resources/base/media/firstpage_search_icon.png b/src/main/resources/base/media/firstpage_search_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ca3798a912531dad0b047901011a391175e0deb1 GIT binary patch literal 1580 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 literal 0 HcmV?d00001 diff --git a/src/main/resources/base/media/icon_arrow_forward_24_new.svg b/src/main/resources/base/media/icon_arrow_forward_24_new.svg new file mode 100644 index 0000000..1bc3282 --- /dev/null +++ b/src/main/resources/base/media/icon_arrow_forward_24_new.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/base/media/icon_first_page_message.png b/src/main/resources/base/media/icon_first_page_message.png new file mode 100644 index 0000000000000000000000000000000000000000..c508d23cfd16e36ce3296bda46c1b5390abd3830 GIT binary patch literal 862 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTC#^NA%Cx&(BWL^R}E~ycoX}-P; zT0k}j17mw80}DtA5K93u0|WB{Mh0de%?J`(zyy~ySip>6gB0F2&*5QUU^?OH;uw;_ z`gVq8w`ib1>wOD1y^aMB@(rY!gPBE33mjvVbGP3(a=>H0@}d=w%r)R9XFS&ccuD5l2f3rOolgYapyXxG^eP>_KjMI;J;Q)pQV%{C_==&jh=KDd3 z2%fggc+G!2GVH;bN|8)1bwWB``Eo^nntw$*I33x;6LfsW?A#}=#fk#kTh98s)Hhv~ zES@!s;d=44Tl3<)51m#x`(8gU(m!Ela`FA2>L0%E+M!)8+iCl=eBFfSX2x?>54?RZ ztLD*t|HdOXkBE6sn4u%+aYXTftB=5R!HqL(=l$`1#8JaE zb?rI^(Nk4Iu1a5Yi}@n%x;=3{By@6p@_maX<&P#>&rwg(uD1CpenjvGPgZm=qn3`~ zF277c>n#$u&5mtOQ0bd*?6yF#^694eIX`{RxWp)*JY{h)^H}@?bqnU6J^SABH|Q_e z7#TGG#YXO)p4n0_^b)^nZ73E}*lgwhk-urxlVZ!Qg{>|ZeClmqT17evnt$^D@Or}Y zh<#gi9j5vmi4FfIx34Ai?H=!mZ?}ErTe8`^#qL<~qeN>C!o7TjdFk@MEF}DmW=cgkF go!BApSp|C?bLzA*=I5H+pFnBb)78&qol`;+0HO?bmjD0& literal 0 HcmV?d00001 diff --git a/src/main/resources/base/media/theme_icon_ad_close_float.png b/src/main/resources/base/media/theme_icon_ad_close_float.png new file mode 100644 index 0000000000000000000000000000000000000000..33eb904cdf072a1f7874362824cf5419ee322d2b GIT binary patch literal 523 zcmV+m0`&cfP)i_=_zLY+SA1GP~C{;qCV$|*=d-QT#lD!NJDY?Cw&E8(O zL-mN6BW9nu!|clYjJaff2v=1Cor?Dmx)xy#66TA~$Y+3z^g1eut;*)Jm}=$v`M z+@f$`&wz@gzkCKG5eHD;Z3;ssCMfGKGqnv+^BW03;4Kx0cSOx!gZL@)k$EVI?K0o7 zW;WY3$FnD2F%yA0@tMT-ghm5Rl4pnPht9@{!o(GyIO`}?9EteAS%fjlvk-v=mCNN5 z_jTPr492v%KpK-jrFae)TvGKx`b-k7xG@-0JzSF?tvJ@(T&N(xVAT-gYeh{9=X+5i zT#l|%9A!b*Rdtt(G!pT?Qaq;S7Pc{uB<17FG literal 0 HcmV?d00001 diff --git a/src/main/resources/base/media/theme_icon_ad_close_float_night.png b/src/main/resources/base/media/theme_icon_ad_close_float_night.png new file mode 100644 index 0000000000000000000000000000000000000000..dc5ca733478786582a07f98be73e86059f16275f GIT binary patch literal 487 zcmVM;60TnXskxF{K0(cE?F7Y$YmRw#4ps^0H3O7JYhLo^ z0|&r)7ZJhU4!#^S=jXX{&`*<4AxbMRaGN0FlfQWT3$!^hSO$X2h)9S$)OG7Ifhmzx z@*xYuPH!~b^B{a>!ix}6B010%6#t=aGRYtZW(B3Ar#}D!UBu_6<{2$K#Kr_dA9R}` z(n8=XgS@183%h6_aLOQ8q>3~JJJ>)LDSaLiadF0c&@S3LC%9y^qAy~d3Jh2FbsoqS z`{+324jc&RW%sUw#~(PjvNv=eFglO=^1ma~5y2IXOjeWzG|4_`=m&(BVyLfX)rozw deMlz;@eTX6@7FaBxxoMc002ovPDHLkV1mXH$2$N3 literal 0 HcmV?d00001 diff --git a/src/main/resources/base/media/theme_icon_first_page_hangqing_vp_add.png b/src/main/resources/base/media/theme_icon_first_page_hangqing_vp_add.png new file mode 100644 index 0000000000000000000000000000000000000000..8f7ccf16928a94a6c94601f6862721c7288ecec8 GIT binary patch literal 275 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz#^NA%Cx&(BWL^R}E~ycoX}-P; zT0k}j17mw80}DtA5K93u0|WB{Mh0de%?J`(zyz07Sip>6gA}f5OZp6?YCT;XLn1hn zQxxPg7`J9QY`xEL>G}!7u1*=>D_4wPHCz;s;BABw2`phc3!-{u6qv5C98&ne)^$)O zh(l~aQp8lRF1wPgC;A+xA3iPr!GwW}F(A1~rr}NYro{|X*iNu6)>SHU=5xK@pnfQj gy{GL!1_zj6VEDV5b4oy|Lzopr0D${RN&o-= literal 0 HcmV?d00001 diff --git a/src/main/resources/base/media/theme_icon_first_page_hangqing_vp_add_night.png b/src/main/resources/base/media/theme_icon_first_page_hangqing_vp_add_night.png new file mode 100644 index 0000000000000000000000000000000000000000..e6809c36a8967ec607ea1496287267b5fa075c9a GIT binary patch literal 280 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz#^NA%Cx&(BWL^R}E~ycoX}-P; zT0k}j17mw80}DtA5K93u0|WB{Mh0de%?J`(zyz07Sip>6gA}f5OZp6?nmk<`Ln1hn zQxt^%{Qn=%y&{x()n7Jc<;SK)JxNOL!lqw)tP)IE+?-*A#KNsomS4L$0)#o54@hgU z6?-T~G;}3~YH9wua8=2gEtXHe{=j7h4VJtIrAm38De;qKS$lSzJ+5zjOX-lj-I3FL k6-qLt2QoN%+Q0+@gX?agiEE~oIfGo~>FVdQ&MBb@0K4u;TL1t6 literal 0 HcmV?d00001 diff --git a/src/main/resources/base/media/theme_icon_new_feedback.png b/src/main/resources/base/media/theme_icon_new_feedback.png new file mode 100644 index 0000000000000000000000000000000000000000..5fddbc029b96fb9209ba7d9016480869a13b49fb GIT binary patch literal 556 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz#^NA%Cx&(BWL^R}6{!)PX}-P; zT0k}j0}G=R11m@g5Zf_I!`W_(8Vt-}aV7?a_DlvAs2V>Y4FVH@7^DY6GcRC-ss@TJ zV1lbwSip>6gS6Fg-vYUiv%n*=n1Mk|6@(c*gH%2MP4o41aSVyzd^>fc7qg>?>vfGK z9tRE`o8Z?{;<<9ZLXzsr%eQX)5*1`pY!Ug%kj<)H%__}(@t%{<#8ZcyxX<%i8ytCa zBsD(hZ1Z8CG_&t^?|6253Tdk)%y-~DsjP9^TR%MK;|b+G2Sh^@BbGkM+vhv8e<9QT z3qJMivo~ZNFyDXl2g|+xw^`1MYT1ADo?;ff)#|bA3yUddytGc=nOQnxMWedG{wb%= z^c&XtNn0eFthIUeeA&4U*Wy^M|J!!A{59_1xI2WazglBQ`R`lF;ooB~%=~^}@534A zvX-X5^T`A1Z;SRgzIDdw(?|EeHBHFc_muOz|7x8Vmwz#{Z{Xe~oO>a>go9ZlM7pv5 z{pRwy8}FyrPT4p;+j7bQaj*Ojobm>YwU6IF6iu3`GNF{6FYvl`!to=jp37du95}pZ sUf$qiO!|>lr2qY4FVH@7^DY6GcRC-ss@TJ zV1lbwSip>6gS6Fg-vYUiv%n*=n1Mk|6@(c*gH%2MO-u1~aSVyzd^>HU7qg>4+xw&g zN{rmn&9NLeW(LVSs!Uztmp%E~@(zU#4=(uwQ3p@O9XzIV#Lo4C($v=0#?R6>CqAC^ zR4;8(mbjhT_TA?H@9aOFw@72lgNBXFo|CRT;4teAe!kp!3#Yz8OZ19odqYpn4k(e? zs8*c6fa(4Q{qvQsGejl`);sKAeZ0uk;QfE!BaXTD&%d{&D8D$=xAuoc@5z;_4!wM@^4ng#&$sj@+H~@9noK#xG6zx2-lj?fPcj;WLVh zBPFBfzEx^+;D4gByWzfYUhboH4b|ZX=5g)*ceQS3m*To_`AdKK96deDLRxV8?wEDg zG-W>QabDvr{X}eU-{T)$J3_i^^ObKTyg6rF*I9Ajcw4~s>x`Rf4szH%l(lns_e1^2 z{f>n*Eey)}H~ec)d4F(QrQ92l(X|T-Lp% RWH1pF8J@0wF6*2UngIW8&_e(K literal 0 HcmV?d00001 diff --git a/src/main/resources/base/media/title_bar_refresh.png b/src/main/resources/base/media/title_bar_refresh.png new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/base/profile/main_pages.json b/src/main/resources/base/profile/main_pages.json new file mode 100644 index 0000000..71ee6d0 --- /dev/null +++ b/src/main/resources/base/profile/main_pages.json @@ -0,0 +1,7 @@ +{ + "src": [ + "pages/HotNewsPage", + "pages/HotNewsDetailPage", + "pages/GridNodeAllPage" + ] +} diff --git a/src/main/resources/dark/element/color.json b/src/main/resources/dark/element/color.json new file mode 100644 index 0000000..39c0557 --- /dev/null +++ b/src/main/resources/dark/element/color.json @@ -0,0 +1,12 @@ +{ + "color": [ + { + "name": "entry_navigation_bar_content_with_mask_color1", + "value": "#FFFFFFFF" + }, + { + "name": "solid_grey_900", + "value": "#3B3B3B" + } + ] +} diff --git a/src/main/resources/en_US/element/string.json b/src/main/resources/en_US/element/string.json new file mode 100644 index 0000000..9659948 --- /dev/null +++ b/src/main/resources/en_US/element/string.json @@ -0,0 +1,8 @@ +{ + "string": [ + { + "name": "page_show", + "value": "page from package" + } + ] +} \ No newline at end of file diff --git a/src/main/resources/light/element/color.json b/src/main/resources/light/element/color.json new file mode 100644 index 0000000..8d7e2d5 --- /dev/null +++ b/src/main/resources/light/element/color.json @@ -0,0 +1,56 @@ +{ + "color": [ + { + "name": "bg_white", + "value": "#ffffff" + }, + { + "name": "bg_transparent", + "value": "#00000000" + }, + { + "name": "bg_plate", + "value": "#f9f9f9" + }, + { + "name": "bg_orange", + "value": "#ff661a" + }, + { + "name": "bg_red", + "value": "#ff2436" + }, + { + "name": "bg_green", + "value": "#07ab4b" + }, + { + "name": "bg_tag", + "value": "#f5f5f5" + }, + { + "name": "bg_gray", + "value": "#0000000a" + }, + { + "name": "opacity_orange", + "value": "#1fff661a" + }, + { + "name": "opacity_red", + "value": "#1eff2436" + }, + { + "name": "text_red", + "value": "#ff2436" + }, + { + "name": "node_name_color", + "value": "#D6000000" + }, + { + "name": "text_gray_d6ffffff", + "value": "#D6FFFFFF" + } + ] +} \ No newline at end of file diff --git a/src/main/resources/rawfile/ai_score_background_images.json b/src/main/resources/rawfile/ai_score_background_images.json new file mode 100644 index 0000000..0436155 --- /dev/null +++ b/src/main/resources/rawfile/ai_score_background_images.json @@ -0,0 +1,24 @@ +{ + "light": { + "strong_bear": "https://s.thsi.cn/staticS3/mobileweb-upload-static-server.img/offline/pkg/11836707-a27a-4d72-b8aa-55a1e93d637e.png", + "medium_bear": "https://s.thsi.cn/staticS3/mobileweb-upload-static-server.img/offline/pkg/463f53d4-97c1-49b0-8a18-3d533c68c16c.png", + "weak_bear": "https://s.thsi.cn/staticS3/mobileweb-upload-static-server.img/offline/pkg/f33d6d26-b58d-4bfc-a272-c35c8fab1e7a.png", + "slight_bear": "https://s.thsi.cn/staticS3/mobileweb-upload-static-server.img/offline/pkg/3979188a-294e-45c5-a036-113c07c79f51.png", + "neutral": "https://s.thsi.cn/staticS3/mobileweb-upload-static-server.img/offline/pkg/2864a501-90c7-4d8f-bf68-f09822e5a384.png", + "slight_bull": "https://s.thsi.cn/staticS3/mobileweb-upload-static-server.img/offline/pkg/8ac9a979-5178-481a-8a45-247784e1c061.png", + "weak_bull": "https://s.thsi.cn/staticS3/mobileweb-upload-static-server.img/offline/pkg/80ea2a2a-f425-480e-8c9a-c7e240114d1e.png", + "medium_bull": "https://s.thsi.cn/staticS3/mobileweb-upload-static-server.img/offline/pkg/4a415e9f-5196-48e1-9c52-b9be780fc099.png", + "strong_bull": "https://s.thsi.cn/staticS3/mobileweb-upload-static-server.img/offline/pkg/dc8822ea-b594-41d7-94ca-d8399e120245.png" + }, + "dark": { + "strong_bear": "https://s.thsi.cn/staticS3/mobileweb-upload-static-server.img/offline/pkg/98c7b627-ed35-4303-9530-50bc687c1828.png", + "medium_bear": "https://s.thsi.cn/staticS3/mobileweb-upload-static-server.img/offline/pkg/b933cf0e-a358-466d-841a-2fc9bc574c75.png", + "weak_bear": "https://s.thsi.cn/staticS3/mobileweb-upload-static-server.img/offline/pkg/0b725128-f027-438b-a092-b828c3d173d9.png", + "slight_bear": "https://s.thsi.cn/staticS3/mobileweb-upload-static-server.img/offline/pkg/d217125a-b867-4d2c-ae18-6bdda9e31fe2.png", + "neutral": "https://s.thsi.cn/staticS3/mobileweb-upload-static-server.img/offline/pkg/32f99c85-9959-44af-9a55-2f48f0809caa.png", + "slight_bull": "https://s.thsi.cn/staticS3/mobileweb-upload-static-server.img/offline/pkg/943a5499-015e-4b62-ac12-132d8efdf89f.png", + "weak_bull": "https://s.thsi.cn/staticS3/mobileweb-upload-static-server.img/offline/pkg/a702e483-a569-4ab3-8408-ad7cbe25b51c.png", + "medium_bull": "https://s.thsi.cn/staticS3/mobileweb-upload-static-server.img/offline/pkg/7e1913db-8aff-4c14-8815-db88afedbaaa.png", + "strong_bull": "https://s.thsi.cn/staticS3/mobileweb-upload-static-server.img/offline/pkg/fba112c7-af5c-42f9-9962-4020d1db4f31.png" + } +} diff --git a/src/main/resources/rawfile/first_page_cards_config.json b/src/main/resources/rawfile/first_page_cards_config.json new file mode 100644 index 0000000..fb8cb8c --- /dev/null +++ b/src/main/resources/rawfile/first_page_cards_config.json @@ -0,0 +1,116 @@ +{ + "hotList": { + "card_id": 3948, + "card_key": "futuresHomepageHotNewsSingleCard", + "title_type": 1, + "is_toggle": true, + "render_key": "hot-list", + "card_size": 4, + "card_title": { + "value": "热点资讯", + "type": "text" + }, + "card_url": { + "ios": "client.html?action=ymtz^webid=2804^url=https://fupage.10jqka.com.cn/find-page/hot.html^ishiddenbar=1^mode=new", + "android": "client.html?action=ymtz^webid=2804^url=https://fupage.10jqka.com.cn/find-page/hot.html^ishiddenbar=1^mode=new" + }, + "explain_title": null, + "explain_message": null, + "mod_data": [ + { + "url": "https://ftapi.10jqka.com.cn/futgwapi/domain/news/content/hot_list/v1/list", + "param_list": [""], + "req_desc": "http_get" + } + ] + }, + "aiDiagnosis": { + "card_id": 6875, + "card_key": "futuresHomepageFundamentalAIDiagnosisCard", + "title_type": 1, + "is_toggle": false, + "render_key": "fundamental-ai-diagnosis", + "card_size": 4, + "card_title": { + "value": "AI诊期", + "type": "text" + }, + "card_url": { + "ios": "client.html?action=ymtz^webid=2804^url=https://fupage.10jqka.com.cn/industry-chain/diagnose.html^title=AI品种诊断", + "android": "client.html?action=ymtz^webid=2804^url=https://fupage.10jqka.com.cn/industry-chain/diagnose.html^title=AI品种诊断" + }, + "explain_title": null, + "explain_message": null, + "mod_data": [ + { + "url": "https://dq.10jqka.com.cn/fuyao/futures_homepage_comps/f10/v1/ai_explain", + "param_list": [""], + "req_desc": "http_get" + } + ] + }, + "aiRiseFall": { + "card_id": 4938, + "card_key": "futuresHomepageAIViewSingleCard", + "title_type": 1, + "is_toggle": false, + "render_key": "ai-view", + "card_size": 4, + "card_title": { + "value": "AI看涨跌", + "type": "text" + }, + "card_url": { + "ios": "client.html?action=ymtz^webid=2804^url=https://fupage.10jqka.com.cn/futures-frontend-kamis-renderer/index.0.3.6.html?token=KF9MTExODcB5^title=AI看涨跌", + "android": "client.html?action=ymtz^webid=2804^url=https://fupage.10jqka.com.cn/futures-frontend-kamis-renderer/index.0.3.6.html?token=KF9MTExODcB5^title=AI看涨跌" + }, + "explain_title": "AI看涨跌说明", + "explain_message": "1、【风险揭示及免责声明】\\n 本功能看涨、看跌的观点由AI推理,仅供参考。在任何情况下,本功能中的信息或所表述的意见,不构成对任何人的期货交易咨询建议。过去正确率不代表未来正确率,同花顺不对未来结果做承诺,交易者需自主决策,自行承担交易风险。\\n 2、【功能逻辑】\\n 通过AI能力,分析活跃的主力合约,AI会推理出具有看涨、看跌方向的合约进行展示。\\n 3、【是否正确的判别规则】\\n 方向看涨时,实际涨幅>=0%为正确;方向看跌时,实际涨幅<=0%为正确;其余均为错误。\\n 4、【实际涨幅】\\n 是固定以昨结算价为基准价计算的实时涨跌幅,并非预测的上涨幅度,也不随\"涨跌计算基准价设置\"功能而变化。\\n 5、【正确率】\\n (1)【近20日正确率】:有最新涨跌方向的交易日的前20个交易日,\"方向正确的合约\"占\"有方向的合约\"的占比。\\n (2)【昨日正确率】:有最新涨跌方向的交易日的前一个交易日,\"方向正确的合约\"占\"有方向的合约\"的占比。\\n (3)【品种正确率】:同一品种,近30次出现涨跌方向的正确率。\\n", + "mod_data": [ + { + "url": "https://ftapi.10jqka.com.cn/futgwapi/api/om/homepage/v2/component/trend_forecast", + "param_list": [""], + "req_desc": "http_get" + } + ] + }, + "commodityOptions": { + "card_id": 3954, + "card_key": "futuresHomepageCommodityOptionsSingleCard", + "title_type": 1, + "is_toggle": false, + "render_key": "commodity-options", + "card_size": 4, + "card_title": { + "value": "商品期权", + "type": "text" + }, + "card_url": { + "ios": "", + "android": "" + }, + "explain_title": "商品期权说明", + "explain_message": "热门:成交量最大的期权合约涨跌幅排行\\n临期期权:到期日10天内的成交量最大的期权合约涨跌幅排行\\n涨跌幅(昨收):以昨收价为基准价计算涨跌幅\\n", + "mod_data": [] + }, + "marketRanking": { + "card_id": 3952, + "card_key": "futuresHomepageMarketRankSingleCard", + "title_type": 1, + "is_toggle": true, + "render_key": "market-ranking", + "card_size": 4, + "card_title": { + "value": "市场排名", + "type": "text" + }, + "card_url": { + "ios": "", + "android": "" + }, + "explain_title": null, + "explain_message": null, + "mod_data": [], + "bury_point": "marketRank" + } +} diff --git a/src/main/resources/zh_CN/element/string.json b/src/main/resources/zh_CN/element/string.json new file mode 100644 index 0000000..9659948 --- /dev/null +++ b/src/main/resources/zh_CN/element/string.json @@ -0,0 +1,8 @@ +{ + "string": [ + { + "name": "page_show", + "value": "page from package" + } + ] +} \ No newline at end of file diff --git a/src/mock/mock-config.json5 b/src/mock/mock-config.json5 new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/src/mock/mock-config.json5 @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/src/ohosTest/ets/test/Ability.test.ets b/src/ohosTest/ets/test/Ability.test.ets new file mode 100644 index 0000000..85c78f6 --- /dev/null +++ b/src/ohosTest/ets/test/Ability.test.ets @@ -0,0 +1,35 @@ +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { describe, beforeAll, beforeEach, afterEach, afterAll, it, expect } from '@ohos/hypium'; + +export default function abilityTest() { + describe('ActsAbilityTest', () => { + // Defines a test suite. Two parameters are supported: test suite name and test suite function. + beforeAll(() => { + // Presets an action, which is performed only once before all test cases of the test suite start. + // This API supports only one parameter: preset action function. + }) + beforeEach(() => { + // Presets an action, which is performed before each unit test case starts. + // The number of execution times is the same as the number of test cases defined by **it**. + // This API supports only one parameter: preset action function. + }) + afterEach(() => { + // Presets a clear action, which is performed after each unit test case ends. + // The number of execution times is the same as the number of test cases defined by **it**. + // This API supports only one parameter: clear action function. + }) + afterAll(() => { + // Presets a clear action, which is performed after all test cases of the test suite end. + // This API supports only one parameter: clear action function. + }) + it('assertContain', 0, () => { + // Defines a test case. This API supports three parameters: test case name, filter parameter, and test case function. + hilog.info(0x0000, 'testTag', '%{public}s', 'it begin'); + let a = 'abc'; + let b = 'b'; + // Defines a variety of assertion methods, which are used to declare expected boolean conditions. + expect(a).assertContain(b); + expect(a).assertEqual(a); + }) + }) +} \ No newline at end of file diff --git a/src/ohosTest/ets/test/List.test.ets b/src/ohosTest/ets/test/List.test.ets new file mode 100644 index 0000000..794c7dc --- /dev/null +++ b/src/ohosTest/ets/test/List.test.ets @@ -0,0 +1,5 @@ +import abilityTest from './Ability.test'; + +export default function testsuite() { + abilityTest(); +} \ No newline at end of file diff --git a/src/ohosTest/ets/testability/TestAbility.ets b/src/ohosTest/ets/testability/TestAbility.ets new file mode 100644 index 0000000..3e04349 --- /dev/null +++ b/src/ohosTest/ets/testability/TestAbility.ets @@ -0,0 +1,47 @@ +import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit'; +import { abilityDelegatorRegistry } from '@kit.TestKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { window } from '@kit.ArkUI'; +import { Hypium } from '@ohos/hypium'; +import testsuite from '../test/List.test'; + +export default class TestAbility extends UIAbility { + onCreate(want: Want, launchParam: AbilityConstant.LaunchParam) { + hilog.info(0x0000, 'testTag', '%{public}s', 'TestAbility onCreate'); + hilog.info(0x0000, 'testTag', '%{public}s', 'want param:' + JSON.stringify(want) ?? ''); + hilog.info(0x0000, 'testTag', '%{public}s', 'launchParam:' + JSON.stringify(launchParam) ?? ''); + let abilityDelegator: abilityDelegatorRegistry.AbilityDelegator; + abilityDelegator = abilityDelegatorRegistry.getAbilityDelegator(); + let abilityDelegatorArguments: abilityDelegatorRegistry.AbilityDelegatorArgs; + abilityDelegatorArguments = abilityDelegatorRegistry.getArguments(); + hilog.info(0x0000, 'testTag', '%{public}s', 'start run testcase!!!'); + Hypium.hypiumTest(abilityDelegator, abilityDelegatorArguments, testsuite); + } + + onDestroy() { + hilog.info(0x0000, 'testTag', '%{public}s', 'TestAbility onDestroy'); + } + + onWindowStageCreate(windowStage: window.WindowStage) { + hilog.info(0x0000, 'testTag', '%{public}s', 'TestAbility onWindowStageCreate'); + windowStage.loadContent('testability/pages/Index', (err) => { + if (err.code) { + hilog.error(0x0000, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err) ?? ''); + return; + } + hilog.info(0x0000, 'testTag', 'Succeeded in loading the content.'); + }); + } + + onWindowStageDestroy() { + hilog.info(0x0000, 'testTag', '%{public}s', 'TestAbility onWindowStageDestroy'); + } + + onForeground() { + hilog.info(0x0000, 'testTag', '%{public}s', 'TestAbility onForeground'); + } + + onBackground() { + hilog.info(0x0000, 'testTag', '%{public}s', 'TestAbility onBackground'); + } +} \ No newline at end of file diff --git a/src/ohosTest/ets/testability/pages/Index.ets b/src/ohosTest/ets/testability/pages/Index.ets new file mode 100644 index 0000000..423b427 --- /dev/null +++ b/src/ohosTest/ets/testability/pages/Index.ets @@ -0,0 +1,17 @@ +@Entry +@Component +struct Index { + @State message: string = 'Hello World'; + + build() { + Row() { + Column() { + Text(this.message) + .fontSize(50) + .fontWeight(FontWeight.Bold) + } + .width('100%') + } + .height('100%') + } +} \ No newline at end of file diff --git a/src/ohosTest/ets/testrunner/OpenHarmonyTestRunner.ets b/src/ohosTest/ets/testrunner/OpenHarmonyTestRunner.ets new file mode 100644 index 0000000..713592f --- /dev/null +++ b/src/ohosTest/ets/testrunner/OpenHarmonyTestRunner.ets @@ -0,0 +1,90 @@ +import { abilityDelegatorRegistry, TestRunner } from '@kit.TestKit'; +import { UIAbility, Want } from '@kit.AbilityKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { resourceManager } from '@kit.LocalizationKit'; +import { util } from '@kit.ArkTS'; + +let abilityDelegator: abilityDelegatorRegistry.AbilityDelegator; +let abilityDelegatorArguments: abilityDelegatorRegistry.AbilityDelegatorArgs; +let jsonPath: string = 'mock/mock-config.json'; +let tag: string = 'testTag'; + +async function onAbilityCreateCallback(data: UIAbility) { + hilog.info(0x0000, 'testTag', 'onAbilityCreateCallback, data: ${}', JSON.stringify(data)); +} + +async function addAbilityMonitorCallback(err: BusinessError) { + hilog.info(0x0000, 'testTag', 'addAbilityMonitorCallback : %{public}s', JSON.stringify(err) ?? ''); +} + +export default class OpenHarmonyTestRunner implements TestRunner { + constructor() { + } + + onPrepare() { + hilog.info(0x0000, 'testTag', '%{public}s', 'OpenHarmonyTestRunner OnPrepare'); + } + + async onRun() { + let tag = 'testTag'; + hilog.info(0x0000, tag, '%{public}s', 'OpenHarmonyTestRunner onRun run'); + abilityDelegatorArguments = abilityDelegatorRegistry.getArguments() + abilityDelegator = abilityDelegatorRegistry.getAbilityDelegator() + let moduleName = abilityDelegatorArguments.parameters['-m']; + let context = abilityDelegator.getAppContext().getApplicationContext().createModuleContext(moduleName); + let mResourceManager = context.resourceManager; + await checkMock(abilityDelegator, mResourceManager); + const bundleName = abilityDelegatorArguments.bundleName; + const testAbilityName: string = 'TestAbility'; + let lMonitor: abilityDelegatorRegistry.AbilityMonitor = { + abilityName: testAbilityName, + onAbilityCreate: onAbilityCreateCallback, + moduleName: moduleName + }; + abilityDelegator.addAbilityMonitor(lMonitor, addAbilityMonitorCallback) + const want: Want = { + bundleName: bundleName, + abilityName: testAbilityName, + moduleName: moduleName + }; + abilityDelegator.startAbility(want, (err: BusinessError, data: void) => { + hilog.info(0x0000, tag, 'startAbility : err : %{public}s', JSON.stringify(err) ?? ''); + hilog.info(0x0000, tag, 'startAbility : data : %{public}s', JSON.stringify(data) ?? ''); + }) + hilog.info(0x0000, tag, '%{public}s', 'OpenHarmonyTestRunner onRun end'); + } +} + +async function checkMock(abilityDelegator: abilityDelegatorRegistry.AbilityDelegator, resourceManager: resourceManager.ResourceManager) { + let rawFile: Uint8Array; + try { + rawFile = resourceManager.getRawFileContentSync(jsonPath); + hilog.info(0x0000, tag, 'MockList file exists'); + let mockStr: string = util.TextDecoder.create('utf-8', { ignoreBOM: true }).decodeWithStream(rawFile); + let mockMap: Record = getMockList(mockStr); + try { + abilityDelegator.setMockList(mockMap) + } catch (error) { + let code = (error as BusinessError).code; + let message = (error as BusinessError).message; + hilog.error(0x0000, tag, `abilityDelegator.setMockList failed, error code: ${code}, message: ${message}.`); + } + } catch (error) { + let code = (error as BusinessError).code; + let message = (error as BusinessError).message; + hilog.error(0x0000, tag, `ResourceManager:callback getRawFileContent failed, error code: ${code}, message: ${message}.`); + } +} + +function getMockList(jsonStr: string) { + let jsonObj: Record = JSON.parse(jsonStr); + let map: Map = new Map(Object.entries(jsonObj)); + let mockList: Record = {}; + map.forEach((value: object, key: string) => { + let realValue: string = value['source'].toString(); + mockList[key] = realValue; + }); + hilog.info(0x0000, tag, '%{public}s', 'mock-json value:' + JSON.stringify(mockList) ?? ''); + return mockList; +} \ No newline at end of file diff --git a/src/ohosTest/module.json5 b/src/ohosTest/module.json5 new file mode 100644 index 0000000..569dd28 --- /dev/null +++ b/src/ohosTest/module.json5 @@ -0,0 +1,37 @@ +{ + "module": { + "name": "biz_firstpage_test", + "type": "feature", + "description": "$string:module_test_desc", + "mainElement": "TestAbility", + "deviceTypes": [ + "default", + "tablet" + ], + "deliveryWithInstall": true, + "installationFree": false, + "pages": "$profile:test_pages", + "abilities": [ + { + "name": "TestAbility", + "srcEntry": "./ets/testability/TestAbility.ets", + "description": "$string:TestAbility_desc", + "icon": "$media:icon", + "label": "$string:TestAbility_label", + "exported": true, + "startWindowIcon": "$media:icon", + "startWindowBackground": "$color:start_window_background", + "skills": [ + { + "actions": [ + "action.system.home" + ], + "entities": [ + "entity.system.home" + ] + } + ] + } + ] + } +} diff --git a/src/ohosTest/resources/base/element/color.json b/src/ohosTest/resources/base/element/color.json new file mode 100644 index 0000000..3c71296 --- /dev/null +++ b/src/ohosTest/resources/base/element/color.json @@ -0,0 +1,8 @@ +{ + "color": [ + { + "name": "start_window_background", + "value": "#FFFFFF" + } + ] +} \ No newline at end of file diff --git a/src/ohosTest/resources/base/element/string.json b/src/ohosTest/resources/base/element/string.json new file mode 100644 index 0000000..65d8fa5 --- /dev/null +++ b/src/ohosTest/resources/base/element/string.json @@ -0,0 +1,16 @@ +{ + "string": [ + { + "name": "module_test_desc", + "value": "test ability description" + }, + { + "name": "TestAbility_desc", + "value": "the test ability" + }, + { + "name": "TestAbility_label", + "value": "test label" + } + ] +} \ No newline at end of file diff --git a/src/ohosTest/resources/base/media/icon.png b/src/ohosTest/resources/base/media/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a39445dc87828b76fed6d2ec470dd455c45319e3 GIT binary patch literal 2777 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 literal 0 HcmV?d00001 diff --git a/src/ohosTest/resources/base/profile/test_pages.json b/src/ohosTest/resources/base/profile/test_pages.json new file mode 100644 index 0000000..b7e7343 --- /dev/null +++ b/src/ohosTest/resources/base/profile/test_pages.json @@ -0,0 +1,5 @@ +{ + "src": [ + "testability/pages/Index" + ] +} diff --git a/src/test/List.test.ets b/src/test/List.test.ets new file mode 100644 index 0000000..bb5b5c3 --- /dev/null +++ b/src/test/List.test.ets @@ -0,0 +1,5 @@ +import localUnitTest from './LocalUnit.test'; + +export default function testsuite() { + localUnitTest(); +} \ No newline at end of file diff --git a/src/test/LocalUnit.test.ets b/src/test/LocalUnit.test.ets new file mode 100644 index 0000000..165fc16 --- /dev/null +++ b/src/test/LocalUnit.test.ets @@ -0,0 +1,33 @@ +import { describe, beforeAll, beforeEach, afterEach, afterAll, it, expect } from '@ohos/hypium'; + +export default function localUnitTest() { + describe('localUnitTest', () => { + // Defines a test suite. Two parameters are supported: test suite name and test suite function. + beforeAll(() => { + // Presets an action, which is performed only once before all test cases of the test suite start. + // This API supports only one parameter: preset action function. + }); + beforeEach(() => { + // Presets an action, which is performed before each unit test case starts. + // The number of execution times is the same as the number of test cases defined by **it**. + // This API supports only one parameter: preset action function. + }); + afterEach(() => { + // Presets a clear action, which is performed after each unit test case ends. + // The number of execution times is the same as the number of test cases defined by **it**. + // This API supports only one parameter: clear action function. + }); + afterAll(() => { + // Presets a clear action, which is performed after all test cases of the test suite end. + // This API supports only one parameter: clear action function. + }); + it('assertContain', 0, () => { + // Defines a test case. This API supports three parameters: test case name, filter parameter, and test case function. + let a = 'abc'; + let b = 'b'; + // Defines a variety of assertion methods, which are used to declare expected boolean conditions. + expect(a).assertContain(b); + expect(a).assertEqual(a); + }); + }); +} \ No newline at end of file