commit af0025ce380facb4474f636daa27f6d9654bdf3f Author: clz Date: Sun Jul 26 01:10:20 2026 +0800 Initial commit: @b2c/first_page HarmonyOS stage-mode HAR ## 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) 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 0000000..96518ff Binary files /dev/null and b/src/main/resources/base/media/ads_popup_arrow_right.png differ diff --git a/src/main/resources/base/media/ai_diagnosis_txt_icon.svg b/src/main/resources/base/media/ai_diagnosis_txt_icon.svg new file mode 100644 index 0000000..07ef453 --- /dev/null +++ b/src/main/resources/base/media/ai_diagnosis_txt_icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + 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 0000000..3877b3a Binary files /dev/null and b/src/main/resources/base/media/c1_fallback.png differ 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 0000000..d1a5042 Binary files /dev/null and b/src/main/resources/base/media/firstpage_dialog_tanchuang_close.png differ diff --git a/src/main/resources/base/media/firstpage_fourgrid_bg.png b/src/main/resources/base/media/firstpage_fourgrid_bg.png new file mode 100644 index 0000000..c9c3cec Binary files /dev/null and b/src/main/resources/base/media/firstpage_fourgrid_bg.png differ 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 0000000..fe47b45 Binary files /dev/null and b/src/main/resources/base/media/firstpage_grid_item_all.png differ 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 0000000..4c096f8 Binary files /dev/null and b/src/main/resources/base/media/firstpage_news_arrow_right.png differ diff --git a/src/main/resources/base/media/firstpage_news_image.png b/src/main/resources/base/media/firstpage_news_image.png new file mode 100644 index 0000000..68ee6dc Binary files /dev/null and b/src/main/resources/base/media/firstpage_news_image.png differ diff --git a/src/main/resources/base/media/firstpage_pull_status_arrow.png b/src/main/resources/base/media/firstpage_pull_status_arrow.png new file mode 100644 index 0000000..3e6ddba Binary files /dev/null and b/src/main/resources/base/media/firstpage_pull_status_arrow.png differ 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 0000000..ca3798a Binary files /dev/null and b/src/main/resources/base/media/firstpage_search_icon.png differ diff --git a/src/main/resources/base/media/firstpage_top_default_bg.webp b/src/main/resources/base/media/firstpage_top_default_bg.webp new file mode 100644 index 0000000..218211f Binary files /dev/null and b/src/main/resources/base/media/firstpage_top_default_bg.webp differ 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 0000000..c508d23 Binary files /dev/null and b/src/main/resources/base/media/icon_first_page_message.png differ 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 0000000..33eb904 Binary files /dev/null and b/src/main/resources/base/media/theme_icon_ad_close_float.png differ 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 0000000..dc5ca73 Binary files /dev/null and b/src/main/resources/base/media/theme_icon_ad_close_float_night.png differ 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 0000000..8f7ccf1 Binary files /dev/null and b/src/main/resources/base/media/theme_icon_first_page_hangqing_vp_add.png differ 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 0000000..e6809c3 Binary files /dev/null and b/src/main/resources/base/media/theme_icon_first_page_hangqing_vp_add_night.png differ 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 0000000..5fddbc0 Binary files /dev/null and b/src/main/resources/base/media/theme_icon_new_feedback.png differ diff --git a/src/main/resources/base/media/theme_icon_new_feedback_night.png b/src/main/resources/base/media/theme_icon_new_feedback_night.png new file mode 100644 index 0000000..e02b1b0 Binary files /dev/null and b/src/main/resources/base/media/theme_icon_new_feedback_night.png differ 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 0000000..a39445d Binary files /dev/null and b/src/ohosTest/resources/base/media/icon.png differ 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