Compare commits
31 Commits
dc9cb595d0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2dd4c077af | |||
| fe3a2e6cc0 | |||
| adc8a1cfda | |||
| bed2e9a75b | |||
| aacb90d355 | |||
| 59999031eb | |||
| d25d18d029 | |||
| 6fbdf3150d | |||
| 32d066db69 | |||
| e2b98bc5c9 | |||
| 34beb5397b | |||
| 2a3b09236e | |||
| 7f38efa220 | |||
| 888f7e316c | |||
| 0bff3eeb6b | |||
| 2a8017860c | |||
| e9b6c7cbdf | |||
| 8e1a1f1eee | |||
| 6c338fc497 | |||
| 66151d78a0 | |||
| 63fcb9cbaa | |||
| e3dc1edb37 | |||
| 0b90b3fa21 | |||
| 7c807fcd30 | |||
| 2e9bd0c275 | |||
| 0be60b394c | |||
| bf6a38cab9 | |||
| 66e2ff073b | |||
| 5ee4f56041 | |||
| b81600b90e | |||
| b160360385 |
@@ -0,0 +1,148 @@
|
||||
# CardHarmonyOS
|
||||
|
||||
CardHarmonyOS 是期货首页卡片的 HarmonyOS ArkTS 示例工程,使用 ArkUI 声明式 UI
|
||||
实现 AI 观点、市场排名和 AI 选期三张业务卡片。首页入口为
|
||||
`entry/src/main/ets/pages/Index.ets`。
|
||||
|
||||
## 环境要求
|
||||
|
||||
- DevEco Studio(支持 HarmonyOS API 20 及以上)
|
||||
- HarmonyOS SDK 6.0.0 或更高版本
|
||||
- ohpm
|
||||
- HarmonyOS 手机或模拟器
|
||||
|
||||
项目目标 SDK 为 HarmonyOS 6.1.0(API 23),兼容 SDK 为 6.0.0(API 20)。
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 使用 DevEco Studio 打开仓库根目录。
|
||||
2. 执行 `ohpm install` 安装依赖。
|
||||
3. 在 **File > Project Structure > Signing Configs** 中配置本机调试签名。
|
||||
4. 选择 `entry` 模块和 `default` product。
|
||||
5. 连接设备或启动模拟器,点击 **Run** 构建并安装应用。
|
||||
|
||||
单元测试位于 `entry/src/test/`,设备测试位于 `entry/src/ohosTest/`,可在
|
||||
DevEco Studio 中右键测试文件执行。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
AppScope/ 应用级配置与资源
|
||||
entry/src/main/ets/
|
||||
├── pages/ 首页及详情页
|
||||
├── ai-view/ AI 观点卡片
|
||||
├── market-ranking/ 市场排名卡片
|
||||
├── ai-pick/ AI 选期卡片
|
||||
└── common/ 卡片容器、数据模型及行情字段常量
|
||||
entry/src/main/resources/ 文案、颜色、图片和页面配置
|
||||
doc/ HTTP 与 4106 行情接口文档
|
||||
```
|
||||
|
||||
业务卡片通常由 `Models`、`DataFetcher`、`NodeComponent` 和 Mock 数据组成。
|
||||
市场排名与 AI 选期分别通过独立的 `HqRequestClient` 构造行情订阅参数,共享
|
||||
`common/HqFieldId.ets` 中的 4106 字段 ID。
|
||||
|
||||
## MarketRanking 生命周期
|
||||
|
||||
`MarketRankingNodeComponent` 出现时读取卡片配置、注册 `tcpReconnect` 事件监听,并在
|
||||
`firstPageVisible` 为 `true` 时获取当前 Tab 的 contracts。contracts 返回后先生成占位列表,
|
||||
再由 `MarketRankingHqRequestClient` 发起行情订阅,行情回调会整体替换 `CardData`。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
appear["aboutToAppear"] --> config["读取卡片配置"]
|
||||
config --> listen["监听 tcpReconnect"]
|
||||
listen --> visible{"firstPageVisible?"}
|
||||
visible -- 否 --> idle["等待页面恢复可见"]
|
||||
visible -- 是 --> fetch["获取当前 Tab 的 contracts"]
|
||||
fetch --> placeholder["填充合约占位列表"]
|
||||
placeholder --> stillVisible{"页面仍然可见?"}
|
||||
stillVisible -- 否 --> idle
|
||||
stillVisible -- 是 --> subscribe["订阅行情"]
|
||||
subscribe --> update["行情回调更新 CardData"]
|
||||
update --> waiting["等待下一次行情推送"]
|
||||
waiting --> update
|
||||
|
||||
refresh["refreshTrigger 变化"] --> stop["停止旧行情订阅"]
|
||||
tab["Tab 切换"] --> stop
|
||||
stop --> clear["清空列表"]
|
||||
clear --> fetch
|
||||
|
||||
hidden["firstPageVisible = false"] --> stopOnly["停止行情订阅"]
|
||||
shown["firstPageVisible = true"] --> hasContracts{"已有 contracts?"}
|
||||
hasContracts -- 是 --> subscribe
|
||||
hasContracts -- 否 --> fetch
|
||||
|
||||
reconnect["tcpReconnect"] --> reconnectVisible{"页面可见?"}
|
||||
reconnectVisible -- 否 --> idle
|
||||
reconnectVisible -- 是 --> reconnectStop["停止旧行情订阅"]
|
||||
reconnectStop --> hasContracts
|
||||
|
||||
disappear["aboutToDisappear"] --> unlisten["解绑 tcpReconnect"]
|
||||
unlisten --> stopOnly
|
||||
```
|
||||
|
||||
运行期间按以下规则更新:
|
||||
|
||||
- `refreshTrigger`:由父组件通过 `@Link` 传入;变化后停止旧行情、重新获取 contracts,
|
||||
并在可见时重新订阅。
|
||||
- `firstPageVisible`:由父组件通过 `@Provide` 提供,组件通过 `@Consume` 监听,默认
|
||||
为 `true`。变为不可见时立即停止行情;恢复可见时优先使用已有 contracts 重新订阅,
|
||||
没有 contracts 才重新请求。
|
||||
- `tcpReconnect`:组件通过公共 `emitter` 监听。重连且页面可见时停止旧行情并重新订阅;
|
||||
页面不可见时忽略,恢复可见后再订阅。
|
||||
- Tab 切换:停止旧行情、清空列表、获取新 Tab 的 contracts,再订阅对应行情。
|
||||
|
||||
`aboutToDisappear` 会解绑 TCP 重连监听并停止行情订阅。HTTP 请求等待期间如果页面变为
|
||||
不可见,contracts 仍可更新,但不会创建行情订阅。
|
||||
|
||||
### 当前异常与恢复处理
|
||||
|
||||
| 异常或状态 | 处理方式 |
|
||||
| --- | --- |
|
||||
| HTTP 获取 contracts 失败 | `updateCardData` 捕获异常并清空列表 |
|
||||
| TCP 连接恢复 | 收到 `tcpReconnect` 后,仅在页面可见时停止旧行情并重新订阅 |
|
||||
| TCP 重连时没有 contracts | 重新请求 contracts,请求成功后再订阅行情 |
|
||||
| TCP 重连时页面不可见 | 忽略重连事件,页面恢复可见后再订阅 |
|
||||
| 页面变为不可见 | 立即停止行情订阅 |
|
||||
| 页面恢复可见 | 优先使用已有 contracts 恢复订阅;没有数据时重新请求 |
|
||||
| 手动刷新 | 停止旧行情、重新请求 contracts,并在可见时重新订阅 |
|
||||
| Tab 切换 | 停止旧行情、清空旧列表、请求新 Tab 数据并重新订阅 |
|
||||
| HTTP 返回时页面已隐藏 | 更新 contracts,但不建立行情订阅 |
|
||||
| 组件消失 | 解绑 TCP 重连监听并停止行情订阅 |
|
||||
|
||||
当前异常处理仍属于基础兜底:尚未实现 HTTP 自动重试、错误提示、请求取消、请求序号校验,
|
||||
以及行情订阅失败后的延迟重试。快速切换 Tab 时,较早发出的请求仍可能晚于新请求返回;
|
||||
首次加载使用的延迟任务也尚未在组件消失时取消。
|
||||
|
||||
## 卡片跳转链接
|
||||
|
||||
下表只记录页面跳转,不包含图片、HTTP 数据接口或行情接口。`{...}` 表示运行时参数。
|
||||
|
||||
| 卡片 | 触发入口 | 目标链接模板 | 当前本地路由 | 当前状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| MarketRanking | 点击合约 | `client://client.html?action=ymtz^webid=2205^stockcode={code}^marketid={market}` | `pages/Detail` | `clientUrl` 仅作为参数传入本地 Mock 页 |
|
||||
| MarketRanking | 点击标题 | `cardConfig.card_url.android`,为空时使用 `card_url.ios` | `pages/StrategyDetail` | 当前 Mock 配置为空,不跳转 |
|
||||
| AiPick | 点击合约 | `client://client.html?action=ymtz^webid=2205^stockcode={contract}^marketid={market}` | `pages/Detail` | `clientUrl` 仅作为参数传入本地 Mock 页 |
|
||||
| AiPick | 点击标题 | `cardConfig.card_url.android`,为空时使用 `card_url.ios` | `pages/StrategyDetail` | 当前 Mock 配置为空,不跳转 |
|
||||
| AiPick | 一句话定制策略 | `client://client.html?action=ymtz^ishiddenbar=1^mode=new^webid=2804^url=https://fupage.10jqka.com.cn/ai-web/ai-diagnosis-home.html?sync=1^title=AI选期` | `pages/StrategyDetail` | 尚未调用客户端能力 |
|
||||
| AiPick | 点击策略卡片 | `client://client.html?action=ymtz^ishiddenbar=1^mode=new^webid=2804^url=https://fupage.10jqka.com.cn/ai-web/ai-diagnosis-detail.html?id={id}&type={periodType}&name={name}^title=AI选期` | `pages/StrategyDetail` | `name` 使用 URL 编码;尚未调用客户端能力 |
|
||||
| AiPick | 点击策略标签 | `client://client.html?action=ymtz^ishiddenbar=1^mode=new^webid=2804^url=https://fupage.10jqka.com.cn/ai-web/ai-diagnosis-label.html?id={id}&type={periodType}&label={label}^title=AI选期` | `pages/StrategyDetail` | `label` 使用 URL 编码;尚未调用客户端能力 |
|
||||
|
||||
## 数据与接口
|
||||
|
||||
当前 HTTP Fetcher 仅构造请求参数,行情客户端也尚未连接真实客户端桥接层;页面使用 Mock
|
||||
数据并以 2.5 秒间隔模拟行情更新。接入真实服务前请阅读:
|
||||
|
||||
- [市场排名接口](doc/market-ranking-interfaces.md)
|
||||
- [AI 选期接口](doc/ai-pick-interfaces.md)
|
||||
|
||||
不要在源码或提交中新增 Cookie、Token、证书、私钥或本机签名密码。签名材料应通过
|
||||
DevEco Studio 在本机配置。
|
||||
|
||||
## 开发约定
|
||||
|
||||
- 使用 ArkTS 显式类型,组件采用 ArkUI 声明式写法。
|
||||
- 类型和类使用 PascalCase,变量和方法使用 camelCase,常量使用 UPPER_SNAKE_CASE。
|
||||
- 公共行情字段统一维护在 `HqFieldId`,避免在业务卡片中硬编码字段 ID。
|
||||
- 新增接口或修改 4106 参数时,同步更新 `doc/` 下对应文档。
|
||||
@@ -0,0 +1,86 @@
|
||||
# AI 选期接口文档
|
||||
|
||||
本文记录 `AiPickNodeComponent` 使用的策略 HTTP 接口及 4106 行情订阅协议。当前实现位于
|
||||
`entry/src/main/ets/ai-pick/AiPickDataFetcher.ets`。
|
||||
|
||||
## 策略 HTTP 接口
|
||||
|
||||
### 请求
|
||||
|
||||
```text
|
||||
GET {API_HOST}futgwapi/api/ai_diagnosis/home_strategy/v1/user_data?_=TIMESTAMP
|
||||
```
|
||||
|
||||
| 构建模式 | API_HOST |
|
||||
| --- | --- |
|
||||
| Debug | `https://futures-test.10jqka.com.cn/` |
|
||||
| Release | `https://ftapi.10jqka.com.cn/` |
|
||||
|
||||
请求头为 `Content-Type: application/json` 和客户端 Cookie。`_` 是毫秒时间戳,用于避免缓存。
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": [
|
||||
{
|
||||
"type": "custom_strategy",
|
||||
"custom_strategy": {
|
||||
"custom_strategy": {
|
||||
"id": "1",
|
||||
"strategy_name": "策略名",
|
||||
"user_question": "策略描述",
|
||||
"strategy_type": "custom_strategy",
|
||||
"tag_list": ["自编"]
|
||||
},
|
||||
"detail_list": [
|
||||
{
|
||||
"contract": "CU2501",
|
||||
"market": "70",
|
||||
"contract_name": "沪铜2501",
|
||||
"main_contract_type": "1"
|
||||
}
|
||||
],
|
||||
"field_list": [],
|
||||
"origin_result_list": [],
|
||||
"detail_count": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
HTTP 数据先生成 Tab 和合约列表;最新价、涨跌幅由行情推送补齐。
|
||||
|
||||
## 4106 行情订阅
|
||||
|
||||
```json
|
||||
{
|
||||
"protocolId": "4106",
|
||||
"pageId": "2201",
|
||||
"onlineId": "contractHQData",
|
||||
"columnOrder": "55|10|4|36103|34818|6|7|66|34877",
|
||||
"requestDic": "sortid=-1\r\nsortorder=0\r\npush=1\r\ndataitem=55,10,4,36103,34818,6,7,66,34877,\r\ncodelist=70(CU2501,);\r\nscenario=qht_qihuo_sort\r\nprecision=1\r\npushtime=2.5\r\n"
|
||||
}
|
||||
```
|
||||
|
||||
合约按市场分组写入 `codelist`,`push=1` 表示持续推送,推送间隔为 2.5 秒。关键字段:
|
||||
|
||||
| 字段 | ID |
|
||||
| --- | ---: |
|
||||
| 名称 | 55 |
|
||||
| 最新价 | 10 |
|
||||
| 合约代码 | 4 |
|
||||
| 市场 | 36103 |
|
||||
| 涨跌幅 | 34818 |
|
||||
| 昨收 | 6 |
|
||||
| 开盘价 | 7 |
|
||||
| 昨结算 | 66 |
|
||||
| 主力标记 | 34877 |
|
||||
|
||||
## 当前 Mock 行为
|
||||
|
||||
- HTTP 使用 `@ohos.net.http` 构造请求并打印参数,但不调用 `request()`。
|
||||
- Fetcher 延迟 300ms 返回 Mock 策略数据,不读取或更新卡片缓存。
|
||||
- 行情订阅打印完整 4106 参数,每 2500ms 生成一次行情,并整体替换 `CardData`。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 市场排名卡片设计
|
||||
|
||||
市场排名卡片采用视图、HTTP 数据和行情订阅分层设计:
|
||||
|
||||
- `MarketRankingNodeComponent`:负责 UI、Tab 切换、页面可见性和订阅生命周期。
|
||||
- `MarketRankingDataFetcher`:根据当前排名类型获取 contracts,确定展示的合约及顺序。
|
||||
- `MarketRankingHqRequestClient`:使用 contracts 构建 4106 参数并订阅行情。
|
||||
- `MarketRankingTableData`:对齐真实行情返回结构,通过 `tableRows` 和
|
||||
`row.get(fieldId)` 读取字段。
|
||||
- `HqFieldId`:统一维护行情字段 ID,避免业务代码硬编码。
|
||||
|
||||
## 数据流
|
||||
|
||||
```text
|
||||
切换 Tab
|
||||
→ HTTP 获取 contracts
|
||||
→ 生成合约占位列表
|
||||
→ 发起 4106 行情订阅
|
||||
→ TableData 转换为 CardData
|
||||
→ 更新 UI
|
||||
```
|
||||
|
||||
HTTP 只负责返回 contracts;最新价、涨跌幅、成交额等动态字段由行情订阅补充。
|
||||
|
||||
## 生命周期
|
||||
|
||||
- 页面可见时获取数据并订阅行情。
|
||||
- 页面隐藏时停止行情订阅。
|
||||
- `refreshTrigger` 变化或 Tab 切换时重新获取 contracts,并在可见时重新订阅。
|
||||
- TCP 重连且页面可见时,停止旧订阅并重新订阅行情。
|
||||
- 组件消失时解绑 TCP 事件并释放行情订阅。
|
||||
|
||||
当前 HTTP 和行情连接均为 Mock。接入真实环境时,主要替换 `MarketRankingDataFetcher`
|
||||
和 `MarketRankingHqRequestClient` 的内部实现,视图层数据结构和生命周期保持不变。
|
||||
真实 HTTP 接入后还应增加请求版本校验或取消机制,避免快速切换 Tab 时较早的响应覆盖当前状态。
|
||||
|
||||
具体 HTTP 与 4106 参数见 [市场排名接口文档](market-ranking-interfaces.md)。
|
||||
@@ -0,0 +1,105 @@
|
||||
# 市场排名接口文档
|
||||
|
||||
本文记录 `MarketRankingNodeComponent` 卡片使用的合约榜单 HTTP 接口及 4106 行情订阅协议。HTTP
|
||||
实现位于 `MarketRankingDataFetcher.ets`,行情实现位于 `MarketRankingHqRequestClient.ets`。
|
||||
|
||||
## 合约榜单 HTTP 接口
|
||||
|
||||
### 请求
|
||||
|
||||
```text
|
||||
GET {API_HOST}futgwapi/api/market/homepage/v1/recommend_futures
|
||||
```
|
||||
|
||||
| 构建模式 | API_HOST |
|
||||
| --- | --- |
|
||||
| Debug | `https://futures-test.10jqka.com.cn/` |
|
||||
| Release | `https://ftapi.10jqka.com.cn/` |
|
||||
|
||||
查询参数:
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `quote_type` | string | 当前排行榜维度 |
|
||||
| `number` | number | 返回数量,固定为 `50` |
|
||||
|
||||
`quote_type` 示例:
|
||||
|
||||
- 涨幅:`rise_percent`
|
||||
- 跌幅:`fall_percent`
|
||||
- 成交额:`turnover`
|
||||
- 日增仓:`increase_position`
|
||||
- 5 分钟涨速:`five_minute_rise_percent`
|
||||
- 10 分钟跌速:`ten_minute_fall_percent`
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": [
|
||||
{
|
||||
"contract_code": "CU2501",
|
||||
"contract_name": "沪铜2501",
|
||||
"market": "70"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
该接口只确定榜单合约及顺序;价格、涨跌幅等数据由行情订阅补充。
|
||||
|
||||
## 4106 行情订阅
|
||||
|
||||
行情通过客户端统一行情协议订阅,不是普通 HTTP 请求。
|
||||
|
||||
```json
|
||||
{
|
||||
"protocolId": "4106",
|
||||
"pageId": "2201",
|
||||
"onlineId": "marketRankingData",
|
||||
"columnOrder": "55|10|4|36103|34818|6|7|66",
|
||||
"requestDic": "sortid=34818\r\nsortorder=0\r\npush=1\r\ndataitem=55,10,4,36103,34818,6,7,66,\r\ncodelist=70(CU2501,RB2505,);\r\nscenario=qht_qihuo_sort\r\nprecision=1\r\npushtime=2.5\r\n"
|
||||
}
|
||||
```
|
||||
|
||||
关键参数:
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `sortid` | 当前 Tab 对应的行情字段 ID |
|
||||
| `sortorder` | 跌幅、跌速为 `1`,其余为 `0` |
|
||||
| `push` | `1` 表示持续推送 |
|
||||
| `dataitem` | 本次需要返回的字段 ID |
|
||||
| `codelist` | 按市场分组的合约列表 |
|
||||
| `pushtime` | 推送间隔,`2.5` 秒 |
|
||||
|
||||
### 行情字段
|
||||
|
||||
| 字段 | ID | 使用场景 |
|
||||
| --- | ---: | --- |
|
||||
| `name` | 55 | 始终 |
|
||||
| `price` | 10 | 始终 |
|
||||
| `code` | 4 | 始终 |
|
||||
| `market` | 36103 | 始终 |
|
||||
| `price_chg` | 34818 | 始终 |
|
||||
| `pre_price` | 6 | 始终 |
|
||||
| `open_price` | 7 | 始终 |
|
||||
| `pre_settle_price` | 66 | 始终 |
|
||||
| `turnover` | 19 | 成交额 |
|
||||
| `incre_posit` | 34355 | 日增仓 |
|
||||
| `chg_speed_1min` | 34874 | 1 分钟涨跌速 |
|
||||
| `chg_speed_5min` | 34325 | 5 分钟涨跌速 |
|
||||
| `chg_speed_10min` | 34875 | 10 分钟涨跌速 |
|
||||
| `chg_speed_15min` | 34876 | 15 分钟涨跌速 |
|
||||
|
||||
鸿蒙原始推送为 `TableData` 行式结构。`tableRows` 中每个 Row 代表一份合约行情,通过
|
||||
`row.get(字段 ID)` 读取对应值。`MarketRankingHqRequestClient` 将其转换为 `RankItem[]`,再通过
|
||||
完整替换 `CardData` 刷新 UI。
|
||||
|
||||
## 当前 Mock 行为
|
||||
|
||||
- HTTP:使用 `@ohos.net.http` 构造 GET URL 和请求选项,但不调用 `request()`。
|
||||
- 行情:构造并输出完整 4106 参数,不连接真实行情桥;Mock 使用 `tableRows + row.get(id)` 模拟回调。
|
||||
- Mock 行情每 `2500ms` 推送一次,与 `pushtime=2.5` 保持一致。
|
||||
- 每次请求及订阅参数均使用 `[MarketRanking]` 前缀输出到控制台。
|
||||
@@ -1,256 +0,0 @@
|
||||
import { PickContractItem, PickTabData } from './AiPickTypes';
|
||||
import { AiPickModel } from './AiPickModel';
|
||||
import { cellColor, cellValue, formatColName } from './AiPickUtils';
|
||||
import { MAX_ITEMS } from './AiPickConstant';
|
||||
|
||||
// @Builder 按引用传递要求单参数 + 对象字面量,用此 interface 包装 tabIdx
|
||||
interface StrategyDescParams {
|
||||
tabIdx: number;
|
||||
}
|
||||
|
||||
@Component
|
||||
export struct AiPick {
|
||||
@State vm: AiPickModel = new AiPickModel();
|
||||
@State tabIdx: number = 0;
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.vm.loadData();
|
||||
}
|
||||
|
||||
// 按引用传递:单参数对象字面量,$$.tabIdx 变化时内部 UI 自动刷新
|
||||
@Builder
|
||||
StrategyDesc($$: StrategyDescParams) {
|
||||
// 图片:imgs[0] 白天,imgs[1] 夜间;对应 Vue 端 desc-text.vue 的 imgSrc computed
|
||||
// 外层整体点击跳 jumpToDetail2,tag 点击阻止冒泡后跳 jumpToLabel(对应 @click.stop)
|
||||
Row() {
|
||||
// 左侧:问句 + 标签
|
||||
Column({ space: 8 }) {
|
||||
if (this.vm.tabs[$$.tabIdx].strategy.detail) {
|
||||
Text(this.vm.tabs[$$.tabIdx].strategy.detail)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
|
||||
if (this.vm.tabs[$$.tabIdx].strategy.tagList.length > 0) {
|
||||
Row({ space: 8 }) {
|
||||
ForEach(this.vm.tabs[$$.tabIdx].strategy.tagList, (tag: string) => {
|
||||
Text(tag)
|
||||
.fontSize(11)
|
||||
.fontColor($r('app.color.text_blue'))
|
||||
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
|
||||
.borderRadius(4)
|
||||
.border({ width: 0.5, color: $r('app.color.text_blue') })
|
||||
.onClick((event: ClickEvent) => {
|
||||
|
||||
this.vm.jumpToLabel(
|
||||
this.vm.tabs[$$.tabIdx].strategy.id,
|
||||
this.vm.tabs[$$.tabIdx].strategy.periodType,
|
||||
tag,
|
||||
);
|
||||
})
|
||||
}, (tag: string) => tag)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 右侧:策略图片(仅系统策略有图,对应 Vue 端 desc-text.vue 右侧 image)
|
||||
// 对应 Vue 端 v-if="isTrueArray(tabInfo.imgs) && imgSrc":
|
||||
// imgs 长度>0 且当前选中位(imgs[0],暂无主题切换)非空才渲染
|
||||
if (this.vm.tabs[$$.tabIdx].strategy.imgs.length > 0 && this.vm.tabs[$$.tabIdx].strategy.imgs[0]) {
|
||||
Image(this.vm.tabs[$$.tabIdx].strategy.imgs[0])
|
||||
.width(104)
|
||||
.height(66)
|
||||
.objectFit(ImageFit.Contain)
|
||||
.margin({ left: 12 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 16 })
|
||||
.alignItems(VerticalAlign.Top)
|
||||
.onClick(() => {
|
||||
this.vm.jumpToDetail2(
|
||||
this.vm.tabs[$$.tabIdx].strategy.id,
|
||||
this.vm.tabs[$$.tabIdx].strategy.periodType,
|
||||
this.vm.tabs[$$.tabIdx].strategy.name,
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
// 数据未就绪(加载中/加载失败)时不渲染依赖 tabs[tabIdx] 的内容,避免越界
|
||||
if (this.vm.tabs.length === 0) {
|
||||
Row() {
|
||||
Text('AI选期')
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ bottom: 16 })
|
||||
|
||||
Text(this.vm.dataError ? '数据加载失败' : '加载中...')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_tertiary'))
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
.padding({ top: 24, bottom: 24 })
|
||||
} else {
|
||||
this.MainContent()
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
@Builder
|
||||
MainContent() {
|
||||
Column() {
|
||||
// ── 标题栏 ──────────────────────────────────────────────
|
||||
// 对应 Vue 端 card-title 组件:点击跳转 cardData.card_url(卡片配置的通用链接),
|
||||
// 与"一句话定制策略"(jumpToNewHome)无关;mockFloorData 中 ai-pick 的 card_url 为空,
|
||||
// 故标题栏本身不绑定跳转行为,也不显示右箭头
|
||||
Row() {
|
||||
Text('AI选期')
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ bottom: 16 })
|
||||
|
||||
// ── Tab 栏 ──────────────────────────────────────────────
|
||||
Scroll() {
|
||||
Row({ space: 8 }) {
|
||||
ForEach(this.vm.tabs, (tab: PickTabData, index: number) => {
|
||||
Text(tab.title)
|
||||
.fontSize(14)
|
||||
.fontWeight(this.tabIdx === index ? FontWeight.Bold : FontWeight.Normal)
|
||||
.fontColor(this.tabIdx === index
|
||||
? $r('app.color.text_blue')
|
||||
: $r('app.color.text_secondary'))
|
||||
.constraintSize({ minWidth: 80 })
|
||||
.textAlign(TextAlign.Center)
|
||||
.padding({ top: 6, bottom: 6, left: 12, right: 12 })
|
||||
.borderRadius(4)
|
||||
.backgroundColor(this.tabIdx === index
|
||||
? $r('app.color.pill_active_bg')
|
||||
: $r('app.color.pill_inactive_bg'))
|
||||
.onClick(() => {
|
||||
this.tabIdx = index;
|
||||
})
|
||||
}, (tab: PickTabData) => tab.strategy.id)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.scrollable(ScrollDirection.Horizontal)
|
||||
.scrollBar(BarState.Off)
|
||||
.align(Alignment.Start)
|
||||
|
||||
// ── 策略问句(按引用传递,tabIdx 变化时自动刷新)────────
|
||||
this.StrategyDesc({ tabIdx: this.tabIdx })
|
||||
|
||||
// ── 表头 ────────────────────────────────────────────────
|
||||
Row() {
|
||||
Text(formatColName(this.vm.tabs[this.tabIdx].keysList[0]))
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_tertiary'))
|
||||
.width('35%')
|
||||
Text(formatColName(this.vm.tabs[this.tabIdx].keysList[1]))
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_tertiary'))
|
||||
.layoutWeight(1)
|
||||
.textAlign(TextAlign.End)
|
||||
Text(formatColName(this.vm.tabs[this.tabIdx].keysList[2]))
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_tertiary'))
|
||||
.layoutWeight(1)
|
||||
.textAlign(TextAlign.End)
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 12 })
|
||||
.padding({ bottom: 4 })
|
||||
.border({ width: { bottom: 0.5 }, color: $r('app.color.divider_color') })
|
||||
|
||||
// ── 合约列表 ────────────────────────────────────────────
|
||||
ForEach(
|
||||
this.vm.tabs[this.tabIdx].items.slice(0, MAX_ITEMS),
|
||||
(item: PickContractItem, index: number) => {
|
||||
Row() {
|
||||
Row({ space: 4 }) {
|
||||
Text(item.name)
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
if (item.isMain) {
|
||||
Text('主')
|
||||
.fontSize(11)
|
||||
.fontColor($r('app.color.color_orange'))
|
||||
.width(16)
|
||||
.height(16)
|
||||
.textAlign(TextAlign.Center)
|
||||
.borderRadius(4)
|
||||
.backgroundColor($r('app.color.bg_orange'))
|
||||
}
|
||||
}
|
||||
.width('35%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
Text(cellValue(
|
||||
this.vm.tabs[this.tabIdx].keysList[1],
|
||||
item,
|
||||
this.vm.tabs[this.tabIdx].fieldList,
|
||||
))
|
||||
.fontSize(16)
|
||||
.fontColor(cellColor(this.vm.tabs[this.tabIdx].keysList[1], item))
|
||||
.layoutWeight(1)
|
||||
.textAlign(TextAlign.End)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
|
||||
Text(cellValue(
|
||||
this.vm.tabs[this.tabIdx].keysList[2],
|
||||
item,
|
||||
this.vm.tabs[this.tabIdx].fieldList,
|
||||
))
|
||||
.fontSize(16)
|
||||
.fontColor(cellColor(this.vm.tabs[this.tabIdx].keysList[2], item))
|
||||
.layoutWeight(1)
|
||||
.textAlign(TextAlign.End)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 12, bottom: 12 })
|
||||
.border(index === Math.min(MAX_ITEMS, this.vm.tabs[this.tabIdx].items.length) - 1
|
||||
? undefined
|
||||
: { width: { bottom: 0.5 }, color: $r('app.color.divider_color') })
|
||||
.onClick(() => {
|
||||
this.vm.jumpToDetail(item.contract, item.market);
|
||||
})
|
||||
},
|
||||
(item: PickContractItem) => item.contract + item.market,
|
||||
)
|
||||
|
||||
// ── 一句话定制策略 ──────────────────────────────────────
|
||||
Text('一句话定制策略 ›')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_tertiary'))
|
||||
.textAlign(TextAlign.Center)
|
||||
.width('100%')
|
||||
.margin({ top: 12 })
|
||||
.onClick(() => {
|
||||
this.vm.jumpToNewHome(
|
||||
this.vm.tabs[this.tabIdx].strategy.id,
|
||||
this.vm.tabs[this.tabIdx].strategy.type,
|
||||
);
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
// AI选期接口层:负责从网络获取原始数据并解析为视图数据
|
||||
// 对应 Vue 端 index.vue 里的 getData()(http() 封装 + 解析流程)
|
||||
// 目前网络请求未接入,直接返回 Mock 数据;接入时替换 fetchAiPickTabs 内部实现即可。
|
||||
import {
|
||||
PickContractItem,
|
||||
PickStrategy,
|
||||
PickTabData,
|
||||
RawPickTabItem,
|
||||
RawStrategyDetail,
|
||||
RawStrategyItem,
|
||||
} from './AiPickTypes';
|
||||
import { getResData, getTableList, ResData } from './AiPickUtils';
|
||||
import { MOCK_RAW_TABS } from './AiPickMock';
|
||||
import { MAX_ITEMS, MAX_TABS, MAX_TAGS } from './AiPickConstant';
|
||||
|
||||
// 新版接口地址(isCustomVersion=true):
|
||||
// {API_HOST}futgwapi/api/ai_diagnosis/home_strategy/v1/user_data
|
||||
// 旧版接口地址(isCustomVersion=false):
|
||||
// {API_HOST}futgwapi/api/ai_diagnosis/system_strategy/v1/home_data
|
||||
|
||||
// 模拟网络延迟(毫秒),验证 loading 状态和异步流程用;接入真实接口后删除
|
||||
const MOCK_DELAY_MS = 300;
|
||||
|
||||
// 对应 Vue 端 index.vue 中 formatTabData 的单项处理(新版 isCustomVersion=true)
|
||||
function parseRawTab(item: RawPickTabItem): PickTabData {
|
||||
const isSystemPeriod = item.type === 'system_period';
|
||||
const strategyItem: RawStrategyItem = (
|
||||
isSystemPeriod ? item.system_strategy : item.custom_strategy
|
||||
) ?? {};
|
||||
|
||||
const strategyDetail: RawStrategyDetail = (
|
||||
isSystemPeriod
|
||||
? strategyItem.system_strategy
|
||||
: strategyItem.custom_strategy
|
||||
) ?? { id: '', strategy_name: '', strategy_type: '' };
|
||||
|
||||
const resData: ResData = getResData(strategyItem);
|
||||
const allItems: PickContractItem[] = getTableList(resData.list, resData.resultValueList);
|
||||
// count 取自 strategyItem 层级(与 detail_list/field_list 同级),
|
||||
// 对应 Vue 端 strategyItem['detail_count'],不是嵌套的策略详情对象里的字段
|
||||
const count = strategyItem.detail_count ?? 0;
|
||||
|
||||
// Tab 标题:策略名 + "(count个)"(isCustomVersion 全局为 true 时,系统策略 Tab 也会拼此后缀)
|
||||
const title = `${strategyDetail.strategy_name}(${count}个)`;
|
||||
|
||||
// 图片:系统策略有 white_img/black_img,自定义策略为空
|
||||
// 保留两个位置(哪怕为空字符串),对应 Vue 端 [white_img, black_img] 不做过滤
|
||||
const imgs: string[] = isSystemPeriod
|
||||
? [strategyDetail.white_img ?? '', strategyDetail.black_img ?? '']
|
||||
: [];
|
||||
|
||||
const strategy: PickStrategy = {
|
||||
id: strategyDetail.id,
|
||||
name: strategyDetail.strategy_name,
|
||||
// 对应 Vue 端 user_question || content:空字符串也会 fallback
|
||||
detail: strategyDetail.user_question || strategyDetail.content || '',
|
||||
// 对应 Vue 端 getDefaultValue(strategy_type, 'custom_strategy')
|
||||
type: strategyDetail.strategy_type || 'custom_strategy',
|
||||
periodType: item.type,
|
||||
// 对应 Vue 端默认值 ['自编'](非空数组不会被替换,因为空数组本身是 truthy)
|
||||
tagList: (strategyDetail.tag_list ?? ['自编']).slice(0, MAX_TAGS),
|
||||
imgs,
|
||||
count,
|
||||
};
|
||||
|
||||
const tabData: PickTabData = {
|
||||
title,
|
||||
strategy,
|
||||
items: allItems.slice(0, MAX_ITEMS),
|
||||
keysList: resData.showKeyList,
|
||||
fieldList: resData.fieldList,
|
||||
};
|
||||
return tabData;
|
||||
}
|
||||
|
||||
// 获取 AI 选期视图数据(原始网络数据 + 解析为 PickTabData[])
|
||||
// TODO: 接入真实接口后,改为调用 @ohos.net.http 请求上述 URL,
|
||||
// 解析 JSON 响应体为 RawPickTabItem[] 后传入 parseRawTab
|
||||
export function fetchAiPickTabs(): Promise<PickTabData[]> {
|
||||
return new Promise<PickTabData[]>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(MOCK_RAW_TABS.slice(0, MAX_TABS).map(parseRawTab));
|
||||
}, MOCK_DELAY_MS);
|
||||
});
|
||||
}
|
||||
@@ -1,22 +1,41 @@
|
||||
// AI选期卡片常量配置,供 AiPickModel / AiPickUtils / AiPick 共用
|
||||
// AI选期卡片常量配置,供 DataFetcher、Utils 和 NodeComponent 共用。
|
||||
|
||||
// 最多展示的策略 Tab 数(对应 Vue 端 getData 里 .slice(0, 5))
|
||||
// 最多展示的策略 Tab 数
|
||||
export const MAX_TABS = 5;
|
||||
|
||||
// 每个 Tab 最多展示的合约条数(对应 Vue 端 tabDataList 里 .slice(0, 3))
|
||||
// 每个 Tab 最多展示的合约条数
|
||||
export const MAX_ITEMS = 3;
|
||||
|
||||
// 策略标签最多展示条数(对应 Vue 端 tagList.slice(0, 3))
|
||||
// 策略标签最多展示条数
|
||||
export const MAX_TAGS = 3;
|
||||
|
||||
// 表格列数:合约名称 + 最多 2 个数据列(对应 Vue 端 getShowKeyList 的 slice(0, 3))
|
||||
// 表格列数:合约名称 + 最多 2 个数据列
|
||||
export const MAX_SHOW_KEYS = 3;
|
||||
|
||||
// 自定义 DOUBLE 字段最多取前 2 个用于表头(对应 Vue 端 showKey = doubleListKey.slice(0, 2))
|
||||
// 自定义 DOUBLE 字段最多取前 2 个用于表头
|
||||
export const MAX_DOUBLE_KEYS = 2;
|
||||
|
||||
// 字段黑名单:过滤 field_list 中不应展示的字段(对应 Vue 端 utils.ts 的 blackList)
|
||||
export const RIGHT_ARROW_LIGHT =
|
||||
'https://u.thsi.cn/imgsrc/bbs/8c8290d904cd89250d4e828da5f1010c_300_230.png';
|
||||
export const RIGHT_ARROW_DARK =
|
||||
'https://u.thsi.cn/imgsrc/bbs/f5ff0a4fcd103d7b3da180ad90d9db23_300_230.png';
|
||||
export const TITLE_ARROW_LIGHT =
|
||||
'https://s.thsi.cn/staticS3/mobileweb-upload-static-server.img/offline/pkg/e6b471c4-b9a6-4c73-a2e4-7c3176308daf.png';
|
||||
export const TITLE_ARROW_DARK =
|
||||
'https://s.thsi.cn/staticS3/mobileweb-upload-static-server.img/offline/pkg/f90c3c71-143d-45a2-af8f-d4b4c5d8dd1d.png';
|
||||
export const INFO_IMAGE_LIGHT =
|
||||
'https://u.thsi.cn/imgsrc/bbs/e1e9b6d81b9f4f6fa817cbeb71e98ff1.png';
|
||||
export const INFO_IMAGE_DARK =
|
||||
'https://u.thsi.cn/imgsrc/bbs/19f7f20bcf800ec2b8e9fc0fa0d73369.png';
|
||||
// 字段黑名单:过滤 field_list 中不应展示的字段
|
||||
export const BLACK_LIST: string[] = [
|
||||
'合约代码', '合约简称', '收盘价', '最新价', '涨跌幅', 'code',
|
||||
'market_id', 'market_code', '最新涨跌幅',
|
||||
'合约代码',
|
||||
'合约简称',
|
||||
'收盘价',
|
||||
'最新价',
|
||||
'涨跌幅',
|
||||
'code',
|
||||
'market_id',
|
||||
'market_code',
|
||||
'最新涨跌幅',
|
||||
];
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// AI 选期数据层:负责网络请求及原始数据到视图数据的转换。
|
||||
import http from '@ohos.net.http';
|
||||
import allCardsMock from '../common/mock/AllCardsMock.json';
|
||||
import {
|
||||
PickContractItem,
|
||||
PickStrategy,
|
||||
PickTabData,
|
||||
RawPickTabItem,
|
||||
RawStrategyDetail,
|
||||
RawStrategyItem,
|
||||
} from './AiPickModels';
|
||||
import {
|
||||
AllCardsCard,
|
||||
AllCardsFloor,
|
||||
AllCardsModData,
|
||||
AllCardsResponse,
|
||||
} from '../common/AllCardsModels';
|
||||
import { getResData, getTableList, ResData } from './AiPickUtils';
|
||||
import { MOCK_RAW_TABS } from './AiPickMock';
|
||||
import { MAX_ITEMS, MAX_TABS, MAX_TAGS } from './AiPickConstant';
|
||||
|
||||
// 模拟网络延迟(毫秒),验证 loading 状态和异步流程用;接入真实接口后删除
|
||||
const MOCK_DELAY_MS = 300;
|
||||
const EMPTY_RAW_STRATEGY_ITEM: RawStrategyItem = {};
|
||||
const EMPTY_RAW_STRATEGY_DETAIL: RawStrategyDetail = {
|
||||
id: '',
|
||||
strategy_name: '',
|
||||
strategy_type: '',
|
||||
};
|
||||
|
||||
// 对应 Vue 端 index.vue 中 formatTabData 的单项处理(新版 isCustomVersion=true)
|
||||
function parseRawTab(item: RawPickTabItem): PickTabData {
|
||||
const isSystemPeriod = item.type === 'system_period';
|
||||
const strategyItem: RawStrategyItem = (
|
||||
isSystemPeriod ? item.system_strategy : item.custom_strategy
|
||||
) ?? EMPTY_RAW_STRATEGY_ITEM;
|
||||
|
||||
const strategyDetail: RawStrategyDetail = (
|
||||
isSystemPeriod
|
||||
? strategyItem.system_strategy
|
||||
: strategyItem.custom_strategy
|
||||
) ?? EMPTY_RAW_STRATEGY_DETAIL;
|
||||
|
||||
const resData: ResData = getResData(strategyItem);
|
||||
const allItems: PickContractItem[] = getTableList(resData.list, resData.resultValueList);
|
||||
// count 取自 strategyItem 层级(与 detail_list/field_list 同级),
|
||||
// 对应 Vue 端 strategyItem['detail_count'],不是嵌套的策略详情对象里的字段
|
||||
const count = strategyItem.detail_count ?? 0;
|
||||
|
||||
// 图片:系统策略有 white_img/black_img,自定义策略为空
|
||||
// 保留两个位置(哪怕为空字符串),对应 Vue 端 [white_img, black_img] 不做过滤
|
||||
const imgs: string[] = isSystemPeriod
|
||||
? [strategyDetail.white_img ?? '', strategyDetail.black_img ?? '']
|
||||
: [];
|
||||
|
||||
const strategy: PickStrategy = {
|
||||
id: strategyDetail.id,
|
||||
name: strategyDetail.strategy_name,
|
||||
detail: strategyDetail.user_question || strategyDetail.content || '',
|
||||
type: strategyDetail.strategy_type || 'custom_strategy',
|
||||
periodType: item.type,
|
||||
tagList: (strategyDetail.tag_list ?? ['自编']).slice(0, MAX_TAGS),
|
||||
imgs,
|
||||
count,
|
||||
};
|
||||
|
||||
const tabData: PickTabData = {
|
||||
strategy,
|
||||
items: allItems.slice(0, MAX_ITEMS),
|
||||
keysList: resData.showKeyList,
|
||||
fieldList: resData.fieldList,
|
||||
};
|
||||
return tabData;
|
||||
}
|
||||
|
||||
// 把 RawPickTabItem[] 解析成 PickTabData[](上限 MAX_TABS)
|
||||
function parseRawTabs(rawTabs: RawPickTabItem[]): PickTabData[] {
|
||||
return rawTabs.slice(0, MAX_TABS)
|
||||
.map((item: RawPickTabItem): PickTabData => parseRawTab(item));
|
||||
}
|
||||
|
||||
export class AiPickDataFetcher {
|
||||
private static readonly instance: AiPickDataFetcher = new AiPickDataFetcher();
|
||||
|
||||
private constructor() {
|
||||
}
|
||||
|
||||
static getInstance(): AiPickDataFetcher {
|
||||
return AiPickDataFetcher.instance;
|
||||
}
|
||||
|
||||
// 从公共全部卡片 Mock 中取得 AI 选期卡片配置。
|
||||
fetchCardConfig(): AllCardsCard | undefined {
|
||||
const response = allCardsMock as AllCardsResponse;
|
||||
for (const floor of response.data) {
|
||||
const card = this.findAiPickCard(floor);
|
||||
if (card !== undefined) {
|
||||
return card;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 模拟 HTTP 请求并返回最新的卡片数据。
|
||||
fetchCardData(): Promise<PickTabData[]> {
|
||||
const cardConfig = this.fetchCardConfig();
|
||||
if (cardConfig === undefined) {
|
||||
return Promise.resolve<PickTabData[]>([]);
|
||||
}
|
||||
const modData: AllCardsModData | undefined = cardConfig.mod_data[0];
|
||||
const configuredUrl = modData?.url ?? '';
|
||||
if (configuredUrl === '') {
|
||||
return Promise.resolve<PickTabData[]>([]);
|
||||
}
|
||||
const requestUrl = this.buildRequestUrl(configuredUrl, Date.now());
|
||||
const requestHeader: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Cookie': '',
|
||||
};
|
||||
const requestOptions: http.HttpRequestOptions = {
|
||||
method: http.RequestMethod.GET,
|
||||
header: requestHeader,
|
||||
connectTimeout: 60000,
|
||||
readTimeout: 60000,
|
||||
};
|
||||
const httpRequest = http.createHttp();
|
||||
console.info(
|
||||
`[AiPick] request: url=${requestUrl}, options=${JSON.stringify(requestOptions)}`,
|
||||
);
|
||||
// 当前只模拟真实请求构造,不调用 httpRequest.request()。
|
||||
httpRequest.destroy();
|
||||
return new Promise<PickTabData[]>((resolve: (value: PickTabData[]) => void) => {
|
||||
setTimeout(() => {
|
||||
const tabs = parseRawTabs(MOCK_RAW_TABS);
|
||||
resolve(tabs);
|
||||
}, MOCK_DELAY_MS);
|
||||
});
|
||||
}
|
||||
|
||||
private buildRequestUrl(configuredUrl: string, timestamp: number): string {
|
||||
const separator = configuredUrl.includes('?') ? '&' : '?';
|
||||
return `${configuredUrl}${separator}_=${timestamp}`;
|
||||
}
|
||||
|
||||
private findAiPickCard(floor: AllCardsFloor): AllCardsCard | undefined {
|
||||
return floor.card_list.find(
|
||||
(card: AllCardsCard): boolean => card.render_key === 'ai-pick',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
AiPickHqData,
|
||||
AiPickHqRequestParams,
|
||||
CardData,
|
||||
PickContractItem,
|
||||
PickTabData,
|
||||
} from './AiPickModels';
|
||||
import { HqFieldId } from '../common/HqFieldId';
|
||||
|
||||
const MOCK_QUOTE_INTERVAL_MS = 2500;
|
||||
|
||||
export type AiPickCardDataListener = (cardData: CardData) => void;
|
||||
|
||||
// AI 选期行情请求客户端:负责合约去重、4106 参数、订阅和行情数据合并。
|
||||
export class AiPickHqRequestClient {
|
||||
private static readonly instance: AiPickHqRequestClient = new AiPickHqRequestClient();
|
||||
|
||||
private constructor() {
|
||||
}
|
||||
|
||||
static getInstance(): AiPickHqRequestClient {
|
||||
return AiPickHqRequestClient.instance;
|
||||
}
|
||||
|
||||
// 模拟共享 4106 行情订阅:立即推送一次,之后每 2.5 秒更新完整卡片数据。
|
||||
subscribeHq(cardData: CardData, listener: AiPickCardDataListener): () => void {
|
||||
const requestParams = this.buildHqRequestParams(cardData);
|
||||
console.info(`[AiPick] subscribeHq params: ${JSON.stringify(requestParams)}`);
|
||||
// todo: 接入真实的行情订阅。
|
||||
const contracts = this.getUniqueContracts(cardData);
|
||||
let tick = 0;
|
||||
const emit = (): void => {
|
||||
tick += 1;
|
||||
const hqData = this.createMockHqData(contracts, tick);
|
||||
listener(this.mergeHqData(cardData, hqData));
|
||||
};
|
||||
|
||||
emit();
|
||||
const timer = setInterval(emit, MOCK_QUOTE_INTERVAL_MS);
|
||||
return (): void => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}
|
||||
|
||||
// 对齐 Vue buildHqParams,生成 AI 选期共享行情连接的完整请求参数。
|
||||
buildHqRequestParams(cardData: CardData): AiPickHqRequestParams {
|
||||
const dataItems: number[] = [
|
||||
HqFieldId.DATA_ID_NAME,
|
||||
HqFieldId.DATA_ID_PRICE,
|
||||
HqFieldId.DATA_ID_CODE,
|
||||
HqFieldId.DATA_ID_MARKET,
|
||||
HqFieldId.DATA_ID_PRICE_CHG,
|
||||
HqFieldId.DATA_ID_PRE_PRICE,
|
||||
HqFieldId.DATA_ID_OPEN_PRICE,
|
||||
HqFieldId.DATA_ID_PRE_SETTLE_PRICE,
|
||||
HqFieldId.DATA_ID_PRICE_CHG_YTD,
|
||||
];
|
||||
const requestDic = `sortid=-1\r\n` +
|
||||
`sortorder=0\r\n` +
|
||||
`push=1\r\n` +
|
||||
`dataitem=${dataItems.map((item: number): string => `${item},`).join('')}\r\n` +
|
||||
`codelist=${this.formatCodeList(cardData)}\r\n` +
|
||||
`scenario=qht_qihuo_sort\r\n` +
|
||||
`precision=1\r\n` +
|
||||
`pushtime=2.5\r\n`;
|
||||
return {
|
||||
protocolId: '4106',
|
||||
pageId: '2201',
|
||||
onlineId: 'contractHQData',
|
||||
columnOrder: dataItems.join('|'),
|
||||
requestDic: requestDic,
|
||||
};
|
||||
}
|
||||
|
||||
private getUniqueContracts(cardData: CardData): PickContractItem[] {
|
||||
const contracts: PickContractItem[] = [];
|
||||
const contractKeys: string[] = [];
|
||||
cardData.tabs.forEach((tab: PickTabData): void => {
|
||||
tab.items.forEach((item: PickContractItem): void => {
|
||||
const contractKey = `${item.contract}_${item.market}`;
|
||||
if (!contractKeys.includes(contractKey)) {
|
||||
contractKeys.push(contractKey);
|
||||
contracts.push(item);
|
||||
}
|
||||
});
|
||||
});
|
||||
return contracts;
|
||||
}
|
||||
|
||||
private formatCodeList(cardData: CardData): string {
|
||||
const contracts = this.getUniqueContracts(cardData);
|
||||
const markets: string[] = [];
|
||||
const codeGroups: string[][] = [];
|
||||
contracts.forEach((contract: PickContractItem): void => {
|
||||
const marketIndex = markets.indexOf(contract.market);
|
||||
if (marketIndex >= 0) {
|
||||
codeGroups[marketIndex].push(contract.contract);
|
||||
} else {
|
||||
markets.push(contract.market);
|
||||
codeGroups.push([contract.contract]);
|
||||
}
|
||||
});
|
||||
return markets.map((market: string, index: number): string =>
|
||||
`${market}(${codeGroups[index].map((code: string): string => `${code},`).join('')});`
|
||||
).join('');
|
||||
}
|
||||
|
||||
private createMockHqData(contracts: PickContractItem[], tick: number): AiPickHqData {
|
||||
const data: AiPickHqData = {
|
||||
code: [],
|
||||
market: [],
|
||||
price: [],
|
||||
priceChg: [],
|
||||
};
|
||||
contracts.forEach((contract: PickContractItem, index: number): void => {
|
||||
data.code.push(contract.contract);
|
||||
data.market.push(contract.market);
|
||||
data.price.push(100 + index * 10 + tick * 0.1);
|
||||
data.priceChg.push(((tick + index) % 5 - 2) * 0.35);
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
private mergeHqData(cardData: CardData, hqData: AiPickHqData): CardData {
|
||||
const tabs = cardData.tabs.map((tab: PickTabData): PickTabData => {
|
||||
const items = tab.items.map((item: PickContractItem): PickContractItem => {
|
||||
let hqIndex = -1;
|
||||
for (let index = 0; index < hqData.code.length; index += 1) {
|
||||
if (hqData.code[index] === item.contract && hqData.market[index] === item.market) {
|
||||
hqIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const nextItem: PickContractItem = {
|
||||
contract: item.contract,
|
||||
market: item.market,
|
||||
name: item.name,
|
||||
price: hqIndex >= 0 ? hqData.price[hqIndex] : undefined,
|
||||
priceChg: hqIndex >= 0 ? hqData.priceChg[hqIndex] : undefined,
|
||||
isMain: item.isMain,
|
||||
extraFields: item.extraFields,
|
||||
};
|
||||
return nextItem;
|
||||
});
|
||||
const nextTab: PickTabData = {
|
||||
strategy: tab.strategy,
|
||||
items: items,
|
||||
keysList: tab.keysList,
|
||||
fieldList: tab.fieldList,
|
||||
};
|
||||
return nextTab;
|
||||
});
|
||||
const nextCardData: CardData = {
|
||||
tabs: tabs,
|
||||
};
|
||||
return nextCardData;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RawPickTabItem } from './AiPickTypes';
|
||||
import { RawPickTabItem } from './AiPickModels';
|
||||
|
||||
// 完全模拟 user_data 接口响应(新版,isCustomVersion=true)
|
||||
// 数组每项对应一个策略 Tab,最多 5 项,这里给 3 项
|
||||
@@ -25,47 +25,45 @@ export const MOCK_RAW_TABS: RawPickTabItem[] = [
|
||||
{ contract: 'ZN2506', market: '70', contract_name: '沪锌2506', main_contract_type: '1' },
|
||||
],
|
||||
field_list: [
|
||||
{ key: '仓单单日增减量', type: 'DOUBLE', unit: '张' },
|
||||
{ key: '成交量', type: 'DOUBLE', unit: '手' },
|
||||
{ key: '持仓量', type: 'DOUBLE', unit: '手' },
|
||||
{ key: '合约代码', type: 'STRING', unit: '' }, // 黑名单,不展示
|
||||
{ key: '最新涨跌幅', type: 'DOUBLE', unit: '%' }, // 黑名单,不展示
|
||||
],
|
||||
origin_result_list: [
|
||||
{ '成交量': 123000, '持仓量': 456000, '合约代码': 'CU2506', '最新涨跌幅': 1.85 },
|
||||
{ '成交量': 31000, '持仓量': 182000, '合约代码': 'CU2507', '最新涨跌幅': 1.62 },
|
||||
{ '成交量': 57000, '持仓量': 224000, '合约代码': 'NI2506', '最新涨跌幅': 0.94 },
|
||||
{ '成交量': 44000, '持仓量': 198000, '合约代码': 'AL2506', '最新涨跌幅': 0.76 },
|
||||
{ '成交量': 28000, '持仓量': 143000, '合约代码': 'ZN2506', '最新涨跌幅': 0.61 },
|
||||
{ '仓单单日增减量': 1250, '成交量': 123000, '持仓量': 456000, '合约代码': 'CU2506', '最新涨跌幅': 1.85 },
|
||||
{ '仓单单日增减量': -860, '成交量': 31000, '持仓量': 182000, '合约代码': 'CU2507', '最新涨跌幅': 1.62 },
|
||||
{ '仓单单日增减量': 420, '成交量': 57000, '持仓量': 224000, '合约代码': 'NI2506', '最新涨跌幅': 0.94 },
|
||||
{ '仓单单日增减量': -315, '成交量': 44000, '持仓量': 198000, '合约代码': 'AL2506', '最新涨跌幅': 0.76 },
|
||||
{ '仓单单日增减量': 980, '成交量': 28000, '持仓量': 143000, '合约代码': 'ZN2506', '最新涨跌幅': 0.61 },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// ── Tab 2:系统周期策略(type = 'system_period')──────────────────
|
||||
// ── Tab 2:只有1条数据的系统策略 ──────────────────
|
||||
{
|
||||
type: 'system_period',
|
||||
system_strategy: {
|
||||
system_strategy: {
|
||||
id: 'strat_002',
|
||||
strategy_name: '黑色系超跌反弹',
|
||||
content: '铁矿石、螺纹钢跌幅已达技术支撑位,RSI 超卖信号显现',
|
||||
content: '铁矿石跌幅已达技术支撑位,RSI 超卖信号显现',
|
||||
strategy_type: 'system_period',
|
||||
tag_list: ['反弹', '黑色系', '超卖'],
|
||||
white_img: 'https://s.thsi.cn/js/m/upload/yyzt/1721828556_3492.png',
|
||||
black_img: 'https://s.thsi.cn/js/m/upload/yyzt/1721828547_6904.png',
|
||||
},
|
||||
detail_count: 3,
|
||||
detail_count: 1,
|
||||
detail_list: [
|
||||
{ contract: 'RB2510', market: '70', contract_name: '螺纹钢2510', main_contract_type: '1' },
|
||||
{ contract: 'I2509', market: '70', contract_name: '铁矿石2509', main_contract_type: '1' },
|
||||
{ contract: 'HC2510', market: '70', contract_name: '热卷2510', main_contract_type: '0' },
|
||||
],
|
||||
// 系统策略无自定义 DOUBLE 字段
|
||||
field_list: [],
|
||||
origin_result_list: [],
|
||||
},
|
||||
},
|
||||
|
||||
// ── Tab 3:自定义策略(type = 'custom_strategy')──────────────────
|
||||
// ── Tab 3:只有2条数据的自定义策略 ──────────────────
|
||||
{
|
||||
type: 'custom_strategy',
|
||||
custom_strategy: {
|
||||
@@ -76,12 +74,10 @@ export const MOCK_RAW_TABS: RawPickTabItem[] = [
|
||||
strategy_type: 'custom_strategy',
|
||||
tag_list: ['季节性', '农产品'],
|
||||
},
|
||||
detail_count: 4,
|
||||
detail_count: 2,
|
||||
detail_list: [
|
||||
{ contract: 'M2509', market: '70', contract_name: '豆粕2509', main_contract_type: '1' },
|
||||
{ contract: 'RM2509', market: '70', contract_name: '菜粕2509', main_contract_type: '1' },
|
||||
{ contract: 'Y2509', market: '70', contract_name: '豆油2509', main_contract_type: '1' },
|
||||
{ contract: 'OI2509', market: '70', contract_name: '菜油2509', main_contract_type: '1' },
|
||||
{ contract: 'RM2509', market: '70', contract_name: '菜粕2509', main_contract_type: '0' },
|
||||
],
|
||||
field_list: [
|
||||
{ key: '成交量', type: 'DOUBLE', unit: '手' },
|
||||
@@ -90,8 +86,6 @@ export const MOCK_RAW_TABS: RawPickTabItem[] = [
|
||||
origin_result_list: [
|
||||
{ '成交量': 986000, '持仓量': 3102000 },
|
||||
{ '成交量': 324000, '持仓量': 1258000 },
|
||||
{ '成交量': 189000, '持仓量': 683000 },
|
||||
{ '成交量': 142000, '持仓量': 521000 },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import router from '@ohos.router';
|
||||
import { PickTabData } from './AiPickTypes';
|
||||
import { fetchAiPickTabs } from './AiPickApi';
|
||||
|
||||
@Observed
|
||||
export class AiPickModel {
|
||||
tabs: PickTabData[] = [];
|
||||
// 数据是否已就绪(对应 Vue 端 dataReady),View 层可据此展示 loading/骨架屏
|
||||
dataReady: boolean = false;
|
||||
// 请求是否失败(对应 Vue 端 dataError)
|
||||
dataError: boolean = false;
|
||||
|
||||
// 拉取并解析视图数据
|
||||
async loadData(): Promise<void> {
|
||||
try {
|
||||
this.tabs = await fetchAiPickTabs();
|
||||
this.dataError = false;
|
||||
} catch (e) {
|
||||
this.tabs = [];
|
||||
this.dataError = true;
|
||||
} finally {
|
||||
this.dataReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
jumpToDetail(contract: string, market: string): void {
|
||||
router.pushUrl({
|
||||
url: 'pages/Detail',
|
||||
params: { code: contract, market: market },
|
||||
});
|
||||
}
|
||||
|
||||
jumpToNewHome(strategyId: string, type: string): void {
|
||||
router.pushUrl({
|
||||
url: 'pages/StrategyDetail',
|
||||
params: { type: 'home', id: strategyId, periodType: type },
|
||||
});
|
||||
}
|
||||
|
||||
jumpToDetail2(strategyId: string, periodType: string, name: string): void {
|
||||
router.pushUrl({
|
||||
url: 'pages/StrategyDetail',
|
||||
params: { type: 'detail', id: strategyId, periodType, name },
|
||||
});
|
||||
}
|
||||
|
||||
jumpToLabel(strategyId: string, periodType: string, label: string): void {
|
||||
router.pushUrl({
|
||||
url: 'pages/StrategyDetail',
|
||||
params: { type: 'label', id: strategyId, periodType, label },
|
||||
});
|
||||
}
|
||||
}
|
||||
+22
-5
@@ -23,8 +23,6 @@ export interface PickFieldItem {
|
||||
|
||||
// 单个 Tab 的完整数据
|
||||
export interface PickTabData {
|
||||
// Tab 标题(策略名,含"(N个)"后缀)
|
||||
title: string;
|
||||
// 问句信息(用于 desc-text 区域)
|
||||
strategy: PickStrategy;
|
||||
// 合约行列表(最多 3 条)
|
||||
@@ -36,6 +34,26 @@ export interface PickTabData {
|
||||
fieldList: PickFieldItem[];
|
||||
}
|
||||
|
||||
// 当前 AI 选期卡片的完整展示数据;请求完成后整体替换以触发 UI 刷新。
|
||||
export interface CardData {
|
||||
tabs: PickTabData[];
|
||||
}
|
||||
|
||||
export interface AiPickHqData {
|
||||
code: string[];
|
||||
market: string[];
|
||||
price: number[];
|
||||
priceChg: number[];
|
||||
}
|
||||
|
||||
export interface AiPickHqRequestParams {
|
||||
protocolId: string;
|
||||
pageId: string;
|
||||
onlineId: string;
|
||||
columnOrder: string;
|
||||
requestDic: string;
|
||||
}
|
||||
|
||||
// 策略摘要(问句 + 标签 + 图片,对应 desc-text.vue)
|
||||
export interface PickStrategy {
|
||||
id: string;
|
||||
@@ -55,15 +73,14 @@ export interface PickStrategy {
|
||||
}
|
||||
|
||||
// ============ 后端接口原始响应类型 ============
|
||||
// 对应接口:futgwapi/api/ai_diagnosis/home_strategy/v1/user_data(新版自定义)
|
||||
// futgwapi/api/ai_diagnosis/system_strategy/v1/home_data(旧版系统)
|
||||
// 对应接口:futgwapi/api/ai_diagnosis/home_strategy/v1/user_data
|
||||
|
||||
export interface RawStrategyDetail {
|
||||
id: string;
|
||||
strategy_name: string;
|
||||
// 新版自定义策略
|
||||
user_question?: string;
|
||||
// 旧版系统策略
|
||||
// 系统策略
|
||||
content?: string;
|
||||
strategy_type: string;
|
||||
tag_list?: string[];
|
||||
@@ -0,0 +1,662 @@
|
||||
import router from '@ohos.router';
|
||||
import {
|
||||
CardData,
|
||||
PickContractItem,
|
||||
PickStrategy,
|
||||
PickTabData,
|
||||
} from './AiPickModels';
|
||||
import { AllCardsCard } from '../common/AllCardsModels';
|
||||
import { AiPickDataFetcher } from './AiPickDataFetcher';
|
||||
import { AiPickHqRequestClient } from './AiPickHqRequestClient';
|
||||
import { cellColor, cellValue, formatColName } from './AiPickUtils';
|
||||
import {
|
||||
emitter,
|
||||
EVENT_TCP_RECONNECT,
|
||||
EventListener,
|
||||
} from '../common/EventEmitter';
|
||||
import {
|
||||
INFO_IMAGE_DARK,
|
||||
INFO_IMAGE_LIGHT,
|
||||
RIGHT_ARROW_DARK,
|
||||
RIGHT_ARROW_LIGHT,
|
||||
TITLE_ARROW_DARK,
|
||||
TITLE_ARROW_LIGHT,
|
||||
} from './AiPickConstant';
|
||||
|
||||
interface StrategyDescParams {
|
||||
tabIdx: number;
|
||||
}
|
||||
|
||||
@Component
|
||||
export struct AiPickNodeComponent {
|
||||
// 外部状态
|
||||
@Link @Watch('onRefreshTriggerChange') refreshTrigger: number;
|
||||
@Consume('firstPageVisible') @Watch('onFirstPageVisibleChange')
|
||||
firstPageVisible: boolean = true;
|
||||
@StorageLink('enableDarkMode') isDarkMode: boolean = false;
|
||||
|
||||
// 卡片业务数据
|
||||
@State cardConfig: AllCardsCard | undefined = undefined;
|
||||
@State cardData: CardData = { tabs: [] };
|
||||
|
||||
// Tab 状态及滚动区域
|
||||
@State tabIdx: number = 0;
|
||||
|
||||
private getUrl(resource: Resource): string {
|
||||
return getContext(this).resourceManager.getStringSync(resource);
|
||||
}
|
||||
@State tabViewportWidth: number = 0;
|
||||
@State tabContentWidth: number = 0;
|
||||
@State tabScrollOffset: number = 0;
|
||||
|
||||
// 弹层状态
|
||||
@State showExplainSheet: boolean = false;
|
||||
|
||||
// 行情订阅与事件状态
|
||||
private hqUnsubscribe: (() => void) | undefined;
|
||||
private tcpReconnectListener: EventListener = (): void => {
|
||||
this.onTcpReconnect();
|
||||
};
|
||||
|
||||
// ==================== 组件生命周期 ====================
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.updateCardConfig();
|
||||
emitter.on(EVENT_TCP_RECONNECT, this.tcpReconnectListener);
|
||||
if (!this.firstPageVisible) {
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.updateCardData();
|
||||
}, 600);
|
||||
}
|
||||
|
||||
aboutToDisappear(): void {
|
||||
emitter.off(EVENT_TCP_RECONNECT, this.tcpReconnectListener);
|
||||
this.stopSubscribeHq();
|
||||
}
|
||||
|
||||
// ==================== 配置与刷新 ====================
|
||||
|
||||
// 从首页统一 Mock 配置中读取 AI Pick 配置。
|
||||
private updateCardConfig(): void {
|
||||
this.cardConfig = AiPickDataFetcher.getInstance().fetchCardConfig();
|
||||
}
|
||||
|
||||
// refreshTrigger 变化时重新获取策略数据,并在页面可见时重建行情订阅。
|
||||
onRefreshTriggerChange(): void {
|
||||
this.updateCardData();
|
||||
}
|
||||
|
||||
// ==================== 可见性与 TCP 重连 ====================
|
||||
|
||||
// 页面不可见时停止行情;恢复可见后使用已有策略数据重建订阅。
|
||||
onFirstPageVisibleChange(): void {
|
||||
if (!this.firstPageVisible) {
|
||||
this.stopSubscribeHq();
|
||||
return;
|
||||
}
|
||||
if (this.cardData.tabs.length > 0) {
|
||||
this.subscribeHq();
|
||||
return;
|
||||
}
|
||||
this.updateCardData();
|
||||
}
|
||||
|
||||
// 收到 TCP 重连事件后,仅在页面可见时重新订阅行情。
|
||||
private onTcpReconnect(): void {
|
||||
if (!this.firstPageVisible) {
|
||||
return;
|
||||
}
|
||||
this.stopSubscribeHq();
|
||||
if (this.cardData.tabs.length > 0) {
|
||||
this.subscribeHq();
|
||||
return;
|
||||
}
|
||||
this.updateCardData();
|
||||
}
|
||||
|
||||
// ==================== 数据请求与行情订阅 ====================
|
||||
|
||||
// 请求策略和合约数据,成功后重建行情订阅。
|
||||
private async updateCardData(): Promise<void> {
|
||||
this.stopSubscribeHq();
|
||||
this.cardData = { tabs: [] };
|
||||
const fetcher = AiPickDataFetcher.getInstance();
|
||||
try {
|
||||
const tabs = await fetcher.fetchCardData();
|
||||
this.cardData = { tabs };
|
||||
if (tabs.length > 0 && this.firstPageVisible) {
|
||||
this.subscribeHq();
|
||||
}
|
||||
} catch {
|
||||
this.stopSubscribeHq();
|
||||
this.cardData = { tabs: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// 使用当前 CardData 的全部 Tab 合约订阅行情,推送后整体更新 CardData。
|
||||
private subscribeHq(): void {
|
||||
this.stopSubscribeHq();
|
||||
this.hqUnsubscribe = AiPickHqRequestClient.getInstance().subscribeHq(
|
||||
this.cardData,
|
||||
(cardData: CardData): void => {
|
||||
this.cardData = cardData;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private stopSubscribeHq(): void {
|
||||
if (this.hqUnsubscribe !== undefined) {
|
||||
this.hqUnsubscribe();
|
||||
this.hqUnsubscribe = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 页面跳转 ====================
|
||||
|
||||
// 跳转到分时详情页。
|
||||
// todo: 后续跳转为真实的客户端页面。
|
||||
private jumpToDetail(item: PickContractItem): void {
|
||||
const clientUrl =
|
||||
`client://client.html?action=ymtz^webid=2205^stockcode=${item.contract}^marketid=${item.market}`;
|
||||
console.info(`[AiPick] detail url: ${clientUrl}`);
|
||||
// 当前仍跳转本地 Mock 页面;接入客户端能力后改为使用 clientUrl。
|
||||
router.pushUrl({
|
||||
url: 'pages/Detail',
|
||||
params: {
|
||||
code: item.contract,
|
||||
market: item.market,
|
||||
clientUrl: clientUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 跳转到卡片配置中的标题地址。
|
||||
// todo: 后续跳转为真实的客户端页面。
|
||||
private jumpToCardTitle(): void {
|
||||
const androidUrl = this.cardConfig?.card_url.android ?? '';
|
||||
const clientUrl = androidUrl !== '' ? androidUrl : this.cardConfig?.card_url.ios ?? '';
|
||||
if (clientUrl === '') {
|
||||
return;
|
||||
}
|
||||
console.info(`[AiPick] card title url: ${clientUrl}`);
|
||||
// 当前仍跳转本地 Mock 页面;接入客户端能力后改为使用 clientUrl。
|
||||
router.pushUrl({
|
||||
url: 'pages/StrategyDetail',
|
||||
params: {
|
||||
type: 'home',
|
||||
clientUrl: clientUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 跳转到一句话定制策略主页。
|
||||
// todo: 后续跳转为真实的客户端页面。
|
||||
private jumpToNewHome(): void {
|
||||
const pageUrl: string = this.getUrl($r('app.string.ai_pick_home_url'));
|
||||
const clientUrl =
|
||||
`client://client.html?action=ymtz^ishiddenbar=1^mode=new^webid=2804^url=${pageUrl}^title=AI选期`;
|
||||
console.info(`[AiPick] home url: ${clientUrl}`);
|
||||
// 当前仍跳转本地 Mock 页面;接入客户端能力后改为使用 clientUrl。
|
||||
router.pushUrl({
|
||||
url: 'pages/StrategyDetail',
|
||||
params: {
|
||||
type: 'home',
|
||||
clientUrl: clientUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 跳转到策略详情页。
|
||||
// todo: 后续跳转为真实的客户端页面。
|
||||
private jumpToStrategyDetail(strategy: PickStrategy): void {
|
||||
const pageUrl: string = this.getUrl($r('app.string.ai_pick_strategy_detail_url')) +
|
||||
`?id=${strategy.id}&type=${strategy.periodType}&name=${encodeURIComponent(strategy.name)}`;
|
||||
const clientUrl =
|
||||
`client://client.html?action=ymtz^ishiddenbar=1^mode=new^webid=2804^url=${pageUrl}^title=AI选期`;
|
||||
console.info(`[AiPick] strategy detail url: ${clientUrl}`);
|
||||
// 当前仍跳转本地 Mock 页面;接入客户端能力后改为使用 clientUrl。
|
||||
router.pushUrl({
|
||||
url: 'pages/StrategyDetail',
|
||||
params: {
|
||||
type: 'detail',
|
||||
id: strategy.id,
|
||||
periodType: strategy.periodType,
|
||||
name: strategy.name,
|
||||
clientUrl: clientUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 跳转到策略标签页。
|
||||
// todo: 后续跳转为真实的客户端页面。
|
||||
private jumpToLabel(strategy: PickStrategy, label: string): void {
|
||||
const pageUrl: string = this.getUrl($r('app.string.ai_pick_strategy_label_url')) +
|
||||
`?id=${strategy.id}&type=${strategy.periodType}&label=${encodeURIComponent(label)}`;
|
||||
const clientUrl =
|
||||
`client://client.html?action=ymtz^ishiddenbar=1^mode=new^webid=2804^url=${pageUrl}^title=AI选期`;
|
||||
console.info(`[AiPick] label url: ${clientUrl}`);
|
||||
// 当前仍跳转本地 Mock 页面;接入客户端能力后改为使用 clientUrl。
|
||||
router.pushUrl({
|
||||
url: 'pages/StrategyDetail',
|
||||
params: {
|
||||
type: 'label',
|
||||
id: strategy.id,
|
||||
periodType: strategy.periodType,
|
||||
label: label,
|
||||
clientUrl: clientUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== UI 构建 ====================
|
||||
|
||||
private showTabGradient(): boolean {
|
||||
const maxOffset = this.tabContentWidth - this.tabViewportWidth;
|
||||
return maxOffset > 0 && this.tabScrollOffset < maxOffset;
|
||||
}
|
||||
|
||||
@Builder
|
||||
CardTitle() {
|
||||
Row() {
|
||||
Text(this.cardConfig?.card_title.value || $r('app.string.ai_pick_title'))
|
||||
.fontSize(18)
|
||||
.fontWeight(500)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.maxLines(1)
|
||||
|
||||
if ((this.cardConfig?.card_url.android ?? '') !== '' ||
|
||||
(this.cardConfig?.card_url.ios ?? '') !== '') {
|
||||
Image(this.isDarkMode ? TITLE_ARROW_DARK : TITLE_ARROW_LIGHT)
|
||||
.width(20)
|
||||
.height(20)
|
||||
}
|
||||
|
||||
if ((this.cardConfig?.explain_message ?? '') !== '') {
|
||||
Image(this.isDarkMode ? INFO_IMAGE_DARK : INFO_IMAGE_LIGHT)
|
||||
.width(16)
|
||||
.height(16)
|
||||
.margin({ left: 8 })
|
||||
.onClick((event: ClickEvent) => {
|
||||
this.showExplainSheet = true;
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.onClick(() => {
|
||||
this.jumpToCardTitle();
|
||||
})
|
||||
}
|
||||
|
||||
@Builder
|
||||
ExplainSheet() {
|
||||
Column() {
|
||||
Text(this.cardConfig?.explain_title ||
|
||||
this.cardConfig?.card_title.value ||
|
||||
$r('app.string.ai_pick_title'))
|
||||
.width('100%')
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.textAlign(TextAlign.Center)
|
||||
.margin({ bottom: 16 })
|
||||
|
||||
Scroll() {
|
||||
Text(this.cardConfig?.explain_message ?? '')
|
||||
.width('100%')
|
||||
.fontSize(16)
|
||||
.lineHeight(24)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
}
|
||||
.width('100%')
|
||||
.scrollBar(BarState.Off)
|
||||
|
||||
Text($r('app.string.card_explain_confirm'))
|
||||
.width('100%')
|
||||
.height(44)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(Color.White)
|
||||
.textAlign(TextAlign.Center)
|
||||
.backgroundColor($r('app.color.text_blue'))
|
||||
.borderRadius(4)
|
||||
.margin({ top: 16 })
|
||||
.onClick(() => {
|
||||
this.showExplainSheet = false;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 16, bottom: 18 })
|
||||
}
|
||||
|
||||
@Builder
|
||||
StrategyDesc($$: StrategyDescParams) {
|
||||
// 图片:imgs[0] 白天,imgs[1] 夜间
|
||||
Row() {
|
||||
// 左侧:问句 + 标签
|
||||
Column({ space: 4 }) {
|
||||
if (this.cardData.tabs[$$.tabIdx].strategy.detail) {
|
||||
Text(this.cardData.tabs[$$.tabIdx].strategy.detail)
|
||||
.fontSize(14)
|
||||
.lineHeight(21)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
|
||||
if (this.cardData.tabs[$$.tabIdx].strategy.tagList.length > 0) {
|
||||
Row({ space: 4 }) {
|
||||
ForEach(this.cardData.tabs[$$.tabIdx].strategy.tagList, (tag: string) => {
|
||||
Row() {
|
||||
Text(tag)
|
||||
.fontSize(11)
|
||||
.fontColor($r('app.color.text_blue'))
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.height(18)
|
||||
.padding({ left: 3, right: 3 })
|
||||
.borderRadius(2)
|
||||
.border({ width: 0.5, color: $r('app.color.text_blue') })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.onClick(() => {
|
||||
this.jumpToLabel(
|
||||
this.cardData.tabs[$$.tabIdx].strategy,
|
||||
tag,
|
||||
);
|
||||
})
|
||||
}, (tag: string) => tag)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 右侧:策略图片(仅系统策略有图,右侧 image)
|
||||
// imgs 长度>0 且当前选中位(imgs[0],暂无主题切换)非空才渲染
|
||||
if ((this.cardData.tabs[$$.tabIdx].strategy.imgs[this.isDarkMode ? 1 : 0] ?? '') !== '') {
|
||||
Image(this.cardData.tabs[$$.tabIdx].strategy.imgs[this.isDarkMode ? 1 : 0] ?? '')
|
||||
.width(104)
|
||||
.height(66)
|
||||
.objectFit(ImageFit.Contain)
|
||||
.margin({ left: 10 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height(86)
|
||||
.padding({ top: 10, bottom: 10 })
|
||||
.alignItems(VerticalAlign.Top)
|
||||
.onClick(() => {
|
||||
this.jumpToStrategyDetail(
|
||||
this.cardData.tabs[$$.tabIdx].strategy,
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
// 数据未就绪时不渲染依赖 tabs[tabIdx] 的内容,避免越界。
|
||||
if (this.cardData.tabs.length === 0) {
|
||||
this.CardTitle()
|
||||
|
||||
Column() {
|
||||
Text($r('app.string.ai_pick_loading'))
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_grey'))
|
||||
}
|
||||
.width('100%')
|
||||
.height(321)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
} else {
|
||||
this.MainContent()
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
.borderRadius(4)
|
||||
.padding({ left: 10, right: 10, bottom: 10 })
|
||||
.bindSheet(this.showExplainSheet, this.ExplainSheet, {
|
||||
height: SheetSize.FIT_CONTENT,
|
||||
dragBar: true,
|
||||
showClose: false,
|
||||
maskColor: '#99000000', // todo: 后续替换
|
||||
onDisappear: () => {
|
||||
this.showExplainSheet = false;
|
||||
},
|
||||
radius: {
|
||||
topLeft: 10,
|
||||
topRight: 10,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@Builder
|
||||
MainContent() {
|
||||
Column() {
|
||||
// ── 标题栏 ──────────────────────────────────────────────
|
||||
this.CardTitle()
|
||||
|
||||
// ── Tab 栏 ──────────────────────────────────────────────
|
||||
Stack({ alignContent: Alignment.End }) {
|
||||
Scroll() {
|
||||
Row({ space: 8 }) {
|
||||
ForEach(this.cardData.tabs, (tab: PickTabData, index: number) => {
|
||||
Text(`${tab.strategy.name}(${tab.strategy.count})`)
|
||||
.fontSize(14)
|
||||
.fontWeight(this.tabIdx === index ? FontWeight.Medium : FontWeight.Normal)
|
||||
.fontColor(this.tabIdx === index
|
||||
? $r('app.color.text_blue')
|
||||
: $r('app.color.text_secondary'))
|
||||
.constraintSize({ minWidth: 66 })
|
||||
.maxLines(1)
|
||||
.textAlign(TextAlign.Center)
|
||||
.height(30)
|
||||
.padding({ left: 8, right: 8 })
|
||||
.borderRadius(4)
|
||||
.backgroundColor(this.tabIdx === index
|
||||
? $r('app.color.pill_active_bg')
|
||||
: $r('app.color.pill_inactive_bg'))
|
||||
.onClick(() => {
|
||||
this.tabIdx = index;
|
||||
})
|
||||
}, (tab: PickTabData) => tab.strategy.id)
|
||||
}
|
||||
.onAreaChange((oldValue: Area, newValue: Area) => {
|
||||
this.tabContentWidth = Number(newValue.width);
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height(30)
|
||||
.padding({ right: 10 })
|
||||
.scrollable(ScrollDirection.Horizontal)
|
||||
.scrollBar(BarState.Off)
|
||||
.align(Alignment.Start)
|
||||
.onScroll((xOffset: number, _yOffset: number) => {
|
||||
this.tabScrollOffset = xOffset;
|
||||
})
|
||||
|
||||
if (this.showTabGradient()) {
|
||||
Row()
|
||||
.width(24)
|
||||
.height(30)
|
||||
.linearGradient({
|
||||
angle: 90,
|
||||
colors: [[$r('app.color.tab_gradient_start'), 0],
|
||||
[$r('app.color.card_bg'), 1]],
|
||||
})
|
||||
.hitTestBehavior(HitTestMode.None)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height(30)
|
||||
.onAreaChange((oldValue: Area, newValue: Area) => {
|
||||
this.tabViewportWidth = Number(newValue.width);
|
||||
})
|
||||
|
||||
// ── 策略问句(按引用传递,tabIdx 变化时自动刷新)────────
|
||||
if (this.cardData.tabs[this.tabIdx].items.length > 0) {
|
||||
this.StrategyDesc({ tabIdx: this.tabIdx })
|
||||
}
|
||||
|
||||
if (this.cardData.tabs[this.tabIdx].items.length === 0) {
|
||||
this.EmptyState($r('app.string.ai_pick_no_contract'))
|
||||
} else {
|
||||
Column() {
|
||||
// ── 表头 ────────────────────────────────────────────
|
||||
Row({ space: 8 }) {
|
||||
Text(formatColName(this.cardData.tabs[this.tabIdx].keysList[0]))
|
||||
.fontSize(14)
|
||||
.maxFontSize(14)
|
||||
.minFontSize(9)
|
||||
.maxLines(1)
|
||||
.heightAdaptivePolicy(TextHeightAdaptivePolicy.MIN_FONT_SIZE_FIRST)
|
||||
.fontColor($r('app.color.text_tertiary'))
|
||||
.width('35%')
|
||||
Text(formatColName(this.cardData.tabs[this.tabIdx].keysList[1]))
|
||||
.fontSize(14)
|
||||
.maxFontSize(14)
|
||||
.minFontSize(9)
|
||||
.maxLines(1)
|
||||
.heightAdaptivePolicy(TextHeightAdaptivePolicy.MIN_FONT_SIZE_FIRST)
|
||||
.fontColor($r('app.color.text_tertiary'))
|
||||
.layoutWeight(1)
|
||||
.textAlign(TextAlign.End)
|
||||
Text(formatColName(this.cardData.tabs[this.tabIdx].keysList[2]))
|
||||
.fontSize(14)
|
||||
.maxFontSize(14)
|
||||
.minFontSize(9)
|
||||
.maxLines(1)
|
||||
.heightAdaptivePolicy(TextHeightAdaptivePolicy.MIN_FONT_SIZE_FIRST)
|
||||
.fontColor($r('app.color.text_tertiary'))
|
||||
.layoutWeight(1)
|
||||
.textAlign(TextAlign.End)
|
||||
}
|
||||
.width('100%')
|
||||
.height(40)
|
||||
|
||||
// ── 合约列表 ────────────────────────────────────────
|
||||
ForEach(
|
||||
this.cardData.tabs[this.tabIdx].items,
|
||||
(item: PickContractItem) => {
|
||||
Row({ space: 8 }) {
|
||||
Row({ space: 5 }) {
|
||||
Text(item.name)
|
||||
.fontSize(16)
|
||||
.maxFontSize(16)
|
||||
.minFontSize(9)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
if (item.isMain) {
|
||||
Text($r('app.string.ai_pick_main_contract'))
|
||||
.fontSize(11)
|
||||
.fontColor($r('app.color.color_orange'))
|
||||
.width(16)
|
||||
.height(16)
|
||||
.textAlign(TextAlign.Center)
|
||||
.borderRadius(2)
|
||||
.backgroundColor($r('app.color.bg_orange'))
|
||||
}
|
||||
}
|
||||
.width('35%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
Text(cellValue(
|
||||
this.cardData.tabs[this.tabIdx].keysList[1],
|
||||
item,
|
||||
this.cardData.tabs[this.tabIdx].fieldList,
|
||||
))
|
||||
.fontSize(16)
|
||||
.maxFontSize(16)
|
||||
.minFontSize(9)
|
||||
.fontWeight(500)
|
||||
.fontFamily('monospace')
|
||||
.fontColor(cellColor(this.cardData.tabs[this.tabIdx].keysList[1], item))
|
||||
.layoutWeight(1)
|
||||
.textAlign(TextAlign.End)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
|
||||
Text(cellValue(
|
||||
this.cardData.tabs[this.tabIdx].keysList[2],
|
||||
item,
|
||||
this.cardData.tabs[this.tabIdx].fieldList,
|
||||
))
|
||||
.fontSize(16)
|
||||
.maxFontSize(16)
|
||||
.minFontSize(9)
|
||||
.fontWeight(500)
|
||||
.fontFamily('monospace')
|
||||
.fontColor(cellColor(this.cardData.tabs[this.tabIdx].keysList[2], item))
|
||||
.layoutWeight(1)
|
||||
.textAlign(TextAlign.End)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.width('100%')
|
||||
.height(45)
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.border({ width: { top: 1 }, color: $r('app.color.divider_color') })
|
||||
.onClick(() => {
|
||||
this.jumpToDetail(item);
|
||||
})
|
||||
},
|
||||
// 行情字段参与 key,确保整体替换 CardData 后列表行同步刷新。
|
||||
(item: PickContractItem) =>
|
||||
`${item.contract}_${item.market}_${item.price ?? ''}_${item.priceChg ?? ''}`,
|
||||
)
|
||||
|
||||
Divider()
|
||||
.width('100%')
|
||||
.strokeWidth(1)
|
||||
.color($r('app.color.divider_color'))
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
// ── 一句话定制策略 ──────────────────────────────────────
|
||||
Row({ space: 2 }) {
|
||||
Text($r('app.string.ai_pick_custom_strategy'))
|
||||
.fontSize(14)
|
||||
.lineHeight(20)
|
||||
.fontColor($r('app.color.text_tertiary'))
|
||||
Image(this.isDarkMode ? RIGHT_ARROW_DARK : RIGHT_ARROW_LIGHT)
|
||||
.width(14)
|
||||
.height(14)
|
||||
.margin({ top: 2 })
|
||||
}
|
||||
.width('100%')
|
||||
.height(30)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.onClick(() => {
|
||||
this.jumpToNewHome();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
@Builder
|
||||
EmptyState(message: ResourceStr) {
|
||||
Column() {
|
||||
Image(this.isDarkMode ?
|
||||
$r('app.string.ai_pick_empty_image_dark_url') :
|
||||
$r('app.string.ai_pick_empty_image_light_url'))
|
||||
.width(100)
|
||||
.height(100)
|
||||
Text(message)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_grey'))
|
||||
}
|
||||
.width('100%')
|
||||
.height(261)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,13 +6,13 @@ import {
|
||||
RawFieldItem,
|
||||
RawResultItem,
|
||||
RawStrategyItem,
|
||||
} from './AiPickTypes';
|
||||
} from './AiPickModels';
|
||||
import { BLACK_LIST, MAX_DOUBLE_KEYS, MAX_SHOW_KEYS } from './AiPickConstant';
|
||||
import { formatPercent, riseFallColor } from '../common/NumberFormat';
|
||||
|
||||
// 对应 filterFieldKey:过滤黑名单 + 期货@前缀黑名单 + 涨跌幅[正则
|
||||
export function filterFieldKey(key: string): boolean {
|
||||
const extBlackList = BLACK_LIST.map(v => `期货@${v}`);
|
||||
const extBlackList = BLACK_LIST.map((value: string): string => `期货@${value}`);
|
||||
const inBlack = BLACK_LIST.includes(key) || extBlackList.includes(key);
|
||||
const matchRegExp = key.startsWith('涨跌幅[') || key.startsWith('期货@涨跌幅[');
|
||||
return !inBlack && !matchRegExp;
|
||||
@@ -65,11 +65,11 @@ export function getResultValueList(
|
||||
resultList: RawResultItem[],
|
||||
doubleListKey: string[],
|
||||
): Record<string, string>[] {
|
||||
return resultList.map(v => {
|
||||
return resultList.map((value: RawResultItem): Record<string, string> => {
|
||||
const res: Record<string, string> = {};
|
||||
for (const key of doubleListKey) {
|
||||
if (v[key] !== undefined) {
|
||||
res[key] = formatBigData(v[key] as number | string);
|
||||
if (value[key] !== undefined) {
|
||||
res[key] = formatBigData(value[key] as number | string);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
@@ -94,13 +94,13 @@ export function getResData(strategyItem: RawStrategyItem): ResData {
|
||||
(v: RawFieldItem) => v.type === 'DOUBLE' && filterFieldKey(v.key),
|
||||
);
|
||||
// 全部 DOUBLE key 用于格式化 resultValueList(对应 Vue 端逻辑)
|
||||
const doubleListKey: string[] = doubleList.map((v: RawFieldItem) => v.key);
|
||||
const doubleListKey: string[] = doubleList.map((v: RawFieldItem): string => v.key);
|
||||
// 只取前 2 个给 showKeyList(对应 Vue 端 showKey = doubleListKey.slice(0, 2))
|
||||
const showKey: string[] = doubleListKey.slice(0, MAX_DOUBLE_KEYS);
|
||||
const showKeyList: string[] = getShowKeyList(showKey);
|
||||
|
||||
// PickFieldItem 只保留 View 层需要的 key + unit
|
||||
const fieldList: PickFieldItem[] = doubleList.map((v: RawFieldItem) => {
|
||||
const fieldList: PickFieldItem[] = doubleList.map((v: RawFieldItem): PickFieldItem => {
|
||||
const fi: PickFieldItem = { key: v.key, unit: v.unit ?? '' };
|
||||
return fi;
|
||||
});
|
||||
@@ -116,7 +116,7 @@ export function getTableList(
|
||||
list: RawDetailItem[],
|
||||
resultValueList: Record<string, string>[],
|
||||
): PickContractItem[] {
|
||||
return list.map((item: RawDetailItem, index: number) => {
|
||||
return list.map((item: RawDetailItem, index: number): PickContractItem => {
|
||||
const extraFields: Record<string, string> = resultValueList[index] ?? {};
|
||||
const ci: PickContractItem = {
|
||||
contract: item.contract,
|
||||
@@ -133,12 +133,14 @@ export function getTableList(
|
||||
|
||||
// 对应 formatName:去除"期货@"前缀和"[...]"括号内容
|
||||
export function formatColName(name: string): string {
|
||||
return name.replace(/期货@/g, '').replace(/\[.*?\]/g, '');
|
||||
const prefixPattern = new RegExp('期货@', 'g');
|
||||
const bracketPattern = new RegExp('\\[.*?\\]', 'g');
|
||||
return name.replace(prefixPattern, '').replace(bracketPattern, '');
|
||||
}
|
||||
|
||||
// 对应 getUnit:从 fieldList 里按 key 查单位
|
||||
export function getUnit(key: string, fieldList: PickFieldItem[]): string {
|
||||
const item = fieldList.find(f => f.key === key);
|
||||
const item = fieldList.find((field: PickFieldItem): boolean => field.key === key);
|
||||
return item ? item.unit : '';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// 首页全部卡片接口中的标题配置。
|
||||
export interface AllCardsCardTitle {
|
||||
value: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
// 首页全部卡片接口中的平台跳转地址。
|
||||
export interface AllCardsCardUrl {
|
||||
ios: string;
|
||||
android: string;
|
||||
}
|
||||
|
||||
// HTTP 类卡片使用的模块请求配置。
|
||||
export interface AllCardsModData {
|
||||
url?: string;
|
||||
param_list?: string[];
|
||||
req_desc?: string;
|
||||
}
|
||||
|
||||
export interface AllCardsFloor {
|
||||
card_list: AllCardsCard[];
|
||||
}
|
||||
|
||||
export interface AllCardsResponse {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: AllCardsFloor[];
|
||||
}
|
||||
|
||||
// 首页全部卡片接口中的单张卡片配置。
|
||||
export interface AllCardsCard {
|
||||
card_id: number;
|
||||
card_key: string;
|
||||
title_type: number;
|
||||
is_toggle: number | null;
|
||||
render_key: string;
|
||||
card_size: number;
|
||||
explain_title: string | null;
|
||||
explain_message: string | null;
|
||||
card_title: AllCardsCardTitle;
|
||||
card_url: AllCardsCardUrl;
|
||||
background_gradient: Object | null;
|
||||
data_completed: boolean | null;
|
||||
mod_data: AllCardsModData[];
|
||||
config: Object | null;
|
||||
bury_point: string;
|
||||
check_block: number;
|
||||
}
|
||||
@@ -11,9 +11,9 @@ export struct Card {
|
||||
this.content()
|
||||
}
|
||||
.width('100%')
|
||||
.padding(20)
|
||||
.borderRadius(12)
|
||||
.padding(10)
|
||||
.borderRadius(6)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
.shadow({ radius: 12, color: '#1A000000', offsetX: 0, offsetY: 4 })
|
||||
.clip(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
export const EVENT_TCP_RECONNECT = 'tcpReconnect';
|
||||
|
||||
export type EventListener = () => void;
|
||||
|
||||
// 模拟客户端事件总线,后续可在真实 TCP 重连回调中调用 emit。
|
||||
export class EventEmitter {
|
||||
private static readonly instance: EventEmitter = new EventEmitter();
|
||||
private listeners: Map<string, EventListener[]> = new Map<string, EventListener[]>();
|
||||
|
||||
private constructor() {
|
||||
}
|
||||
|
||||
static getInstance(): EventEmitter {
|
||||
return EventEmitter.instance;
|
||||
}
|
||||
|
||||
on(eventName: string, listener: EventListener): void {
|
||||
const eventListeners = this.listeners.get(eventName);
|
||||
if (eventListeners === undefined) {
|
||||
this.listeners.set(eventName, [listener]);
|
||||
return;
|
||||
}
|
||||
if (!eventListeners.includes(listener)) {
|
||||
eventListeners.push(listener);
|
||||
}
|
||||
}
|
||||
|
||||
off(eventName: string, listener: EventListener): void {
|
||||
const eventListeners = this.listeners.get(eventName);
|
||||
if (eventListeners === undefined) {
|
||||
return;
|
||||
}
|
||||
const listenerIndex = eventListeners.indexOf(listener);
|
||||
if (listenerIndex >= 0) {
|
||||
eventListeners.splice(listenerIndex, 1);
|
||||
}
|
||||
if (eventListeners.length === 0) {
|
||||
this.listeners.delete(eventName);
|
||||
}
|
||||
}
|
||||
|
||||
emit(eventName: string): void {
|
||||
const eventListeners = this.listeners.get(eventName);
|
||||
if (eventListeners === undefined) {
|
||||
return;
|
||||
}
|
||||
eventListeners.slice().forEach((listener: EventListener): void => {
|
||||
listener();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const emitter: EventEmitter = EventEmitter.getInstance();
|
||||
@@ -0,0 +1,21 @@
|
||||
// 4106 行情协议通用字段 ID。
|
||||
export class HqFieldId {
|
||||
static readonly DATA_ID_CODE: 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_PRE_SETTLE_PRICE: number = 66; // 昨日结算价
|
||||
static readonly DATA_ID_CHG_SPEED_5MIN: number = 34325; // 5 分钟涨跌速
|
||||
static readonly DATA_ID_INCRE_POSIT: number = 34355; // 日增仓
|
||||
static readonly DATA_ID_PRICE_CHG: 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_PRICE_CHG_YTD: number = 34877; // 昨收涨跌幅
|
||||
static readonly DATA_ID_MARKET: number = 36103; // 市场 ID
|
||||
|
||||
private constructor() {
|
||||
}
|
||||
}
|
||||
@@ -30,5 +30,14 @@ export function formatGreatNumber(value?: number): string {
|
||||
|
||||
// 涨红跌绿:按数值正负取色,未处理无效值时的灰色分支
|
||||
export function riseFallColor(value?: number): Resource {
|
||||
return (value ?? 0) >= 0 ? $r('app.color.color_rise') : $r('app.color.color_fall');
|
||||
if (value === undefined) {
|
||||
return $r('app.color.text_grey');
|
||||
}
|
||||
if (value > 0) {
|
||||
return $r('app.color.color_rise');
|
||||
}
|
||||
if (value < 0) {
|
||||
return $r('app.color.color_fall');
|
||||
}
|
||||
return $r('app.color.text_primary');
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,37 @@
|
||||
import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
|
||||
import { AbilityConstant, Configuration, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
|
||||
import { resourceManager } from '@kit.LocalizationKit';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { window } from '@kit.ArkUI';
|
||||
|
||||
const DOMAIN = 0x0000;
|
||||
|
||||
export default class EntryAbility extends UIAbility {
|
||||
private updateDarkMode(isDarkMode: boolean): void {
|
||||
AppStorage.setOrCreate<boolean>('enableDarkMode', isDarkMode);
|
||||
}
|
||||
|
||||
private syncDarkModeFromResources(): void {
|
||||
const configuration: resourceManager.Configuration =
|
||||
this.context.resourceManager.getConfigurationSync();
|
||||
this.updateDarkMode(configuration.colorMode === resourceManager.ColorMode.DARK);
|
||||
}
|
||||
|
||||
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
|
||||
try {
|
||||
this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
|
||||
} catch (err) {
|
||||
hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err));
|
||||
}
|
||||
this.syncDarkModeFromResources();
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
|
||||
}
|
||||
|
||||
onConfigurationUpdate(newConfig: Configuration): void {
|
||||
if (newConfig.colorMode !== undefined) {
|
||||
this.updateDarkMode(newConfig.colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK);
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(): void {
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onDestroy');
|
||||
}
|
||||
@@ -37,7 +55,7 @@ export default class EntryAbility extends UIAbility {
|
||||
}
|
||||
|
||||
onForeground(): void {
|
||||
// Ability has brought to foreground
|
||||
this.syncDarkModeFromResources();
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
import { PeriodRange, RankItem, TabMetric } from './MarketRankingTypes';
|
||||
import { MarketRankingModel } from './MarketRankingModel';
|
||||
import { formatGreatNumber, formatPercent, riseFallColor } from '../common/NumberFormat';
|
||||
import { PERIOD_RANGES, TAB_METRICS } from './MarketRankingConstant';
|
||||
|
||||
const NORMAL_SIZE = 3;
|
||||
const MAX_SIZE = 5;
|
||||
|
||||
@Component
|
||||
export struct MarketRanking {
|
||||
@State vm: MarketRankingModel = new MarketRankingModel();
|
||||
// Tab 选中/展开状态是纯 UI 交互状态,留在 View 里
|
||||
@State primaryIdx: number = 0;
|
||||
@State secondaryIdx: number = 0;
|
||||
@State unfolded: boolean = false;
|
||||
|
||||
// 第三列标题随 Tab 变化
|
||||
private thirdColumnTitle(): string {
|
||||
const metric = TAB_METRICS[this.primaryIdx];
|
||||
if (metric.hasChildren) {
|
||||
return `${PERIOD_RANGES[this.secondaryIdx].label}${metric.label}`;
|
||||
}
|
||||
if (metric.id === 'rise' || metric.id === 'fall') {
|
||||
return '涨跌幅';
|
||||
}
|
||||
return metric.label;
|
||||
}
|
||||
|
||||
// 成交额/日增仓列用中性色,其余按涨跌上色
|
||||
private isNeutralColumn(): boolean {
|
||||
const id = TAB_METRICS[this.primaryIdx].id;
|
||||
return id === 'turnOver' || id === 'increPosit';
|
||||
}
|
||||
|
||||
// 第三列展示值:按当前一级 Tab 从对应原始字段取值并格式化
|
||||
private thirdColumnValue(item: RankItem): string {
|
||||
const id = TAB_METRICS[this.primaryIdx].id;
|
||||
if (id === 'turnOver') {
|
||||
return formatGreatNumber(item.turnover);
|
||||
}
|
||||
if (id === 'increPosit') {
|
||||
return formatGreatNumber(item.incre_posit);
|
||||
}
|
||||
if (id === 'riseSpeed' || id === 'fallSpeed') {
|
||||
return formatPercent(item.chg_speed);
|
||||
}
|
||||
return formatPercent(item.price_chg);
|
||||
}
|
||||
|
||||
// 第三列取色:成交额/日增仓走中性色,其余按对应原始字段的正负取色
|
||||
private thirdColumnColor(item: RankItem): Resource {
|
||||
if (this.isNeutralColumn()) {
|
||||
return $r('app.color.text_primary');
|
||||
}
|
||||
const id = TAB_METRICS[this.primaryIdx].id;
|
||||
const raw = id === 'riseSpeed' || id === 'fallSpeed' ? item.chg_speed : item.price_chg;
|
||||
return riseFallColor(raw);
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
// 标题栏:标题 + 展开/收起胶囊紧挨着,都在左侧
|
||||
Row() {
|
||||
Text('市场排名')
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(this.unfolded ? '收起' : '展开')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_tertiary'))
|
||||
.height(20)
|
||||
.padding({ left: 8, right: 8 })
|
||||
.borderRadius(11)
|
||||
.backgroundColor($r('app.color.pill_inactive_bg'))
|
||||
.textAlign(TextAlign.Center)
|
||||
.margin({ left: 16 })
|
||||
.onClick(() => {
|
||||
this.unfolded = !this.unfolded;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ bottom: 16 })
|
||||
|
||||
// 一级 Tab(胶囊按钮,可横向滚动)
|
||||
Scroll() {
|
||||
Row({ space: 8 }) {
|
||||
ForEach(TAB_METRICS, (metric: TabMetric, index: number) => {
|
||||
Text(metric.label)
|
||||
.fontSize(14)
|
||||
.fontWeight(this.primaryIdx === index ? FontWeight.Bold : FontWeight.Normal)
|
||||
.fontColor(this.primaryIdx === index ? $r('app.color.text_blue') : $r('app.color.text_secondary'))
|
||||
.constraintSize({ minWidth: 63 })
|
||||
.textAlign(TextAlign.Center)
|
||||
.padding({ top: 6, bottom: 6, left: 12, right: 12 })
|
||||
.borderRadius(4)
|
||||
.backgroundColor(this.primaryIdx === index
|
||||
? $r('app.color.pill_active_bg')
|
||||
: $r('app.color.pill_inactive_bg'))
|
||||
.onClick(() => {
|
||||
this.primaryIdx = index;
|
||||
this.secondaryIdx = 0;
|
||||
})
|
||||
}, (metric: TabMetric) => metric.id)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.scrollable(ScrollDirection.Horizontal)
|
||||
.scrollBar(BarState.Off)
|
||||
.align(Alignment.Start)
|
||||
|
||||
// 二级 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.divider_color'))
|
||||
.margin({ left: 8, right: 8 })
|
||||
}
|
||||
Text(period.label)
|
||||
.fontSize(12)
|
||||
.fontWeight(this.secondaryIdx === index ? FontWeight.Bold : FontWeight.Normal)
|
||||
.fontColor(this.secondaryIdx === index
|
||||
? $r('app.color.text_primary')
|
||||
: $r('app.color.text_tertiary'))
|
||||
.padding({ top: 4, bottom: 4 })
|
||||
.onClick(() => {
|
||||
this.secondaryIdx = index;
|
||||
})
|
||||
}, (period: PeriodRange) => period.id)
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.End)
|
||||
.margin({ top: 8 })
|
||||
}
|
||||
|
||||
// 表头
|
||||
Row() {
|
||||
Text('合约名称').fontSize(12).fontColor($r('app.color.text_tertiary')).width('35%')
|
||||
Text('最新价').fontSize(12).fontColor($r('app.color.text_tertiary')).layoutWeight(1).textAlign(TextAlign.End)
|
||||
Text(this.thirdColumnTitle())
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_tertiary'))
|
||||
.layoutWeight(1)
|
||||
.textAlign(TextAlign.End)
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 10 })
|
||||
.padding({ bottom: 2 })
|
||||
.border({ width: { bottom: 0.5 }, color: $r('app.color.divider_color') })
|
||||
|
||||
// 表格列表
|
||||
ForEach(
|
||||
this.vm.currentList(this.primaryIdx, this.secondaryIdx).slice(0, this.unfolded ? MAX_SIZE : NORMAL_SIZE),
|
||||
(item: RankItem, index: number) => {
|
||||
Row() {
|
||||
Text(item.name).fontSize(16).fontColor($r('app.color.text_primary')).width('35%')
|
||||
Text(`${item.price}`)
|
||||
.fontSize(16)
|
||||
.fontColor(riseFallColor(item.price_chg))
|
||||
.layoutWeight(1)
|
||||
.textAlign(TextAlign.End)
|
||||
Text(this.thirdColumnValue(item))
|
||||
.fontSize(16)
|
||||
.fontColor(this.thirdColumnColor(item))
|
||||
.layoutWeight(1)
|
||||
.textAlign(TextAlign.End)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 12, bottom: 12 })
|
||||
.border(index === Math.min(
|
||||
this.unfolded ? MAX_SIZE : NORMAL_SIZE,
|
||||
this.vm.currentList(this.primaryIdx, this.secondaryIdx).length,
|
||||
) - 1
|
||||
? undefined
|
||||
: { width: { bottom: 0.5 }, color: $r('app.color.divider_color') })
|
||||
.onClick(() => {
|
||||
this.vm.jumpToDetail(item);
|
||||
})
|
||||
},
|
||||
(item: RankItem) => item.code,
|
||||
)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,29 @@
|
||||
import { PeriodRange, TabMetric } from './MarketRankingTypes';
|
||||
import { PeriodRange, TabMetric } from './MarketRankingModels';
|
||||
import { HqFieldId } from '../common/HqFieldId';
|
||||
|
||||
// 一级 Tab 配置:涨幅/跌幅/涨速/跌速/成交额/日增仓,hqId/hqName 用于后续接入行情推送
|
||||
export const TITLE_ARROW_LIGHT =
|
||||
'https://s.thsi.cn/staticS3/mobileweb-upload-static-server.img/offline/pkg/e6b471c4-b9a6-4c73-a2e4-7c3176308daf.png';
|
||||
export const TITLE_ARROW_DARK =
|
||||
'https://s.thsi.cn/staticS3/mobileweb-upload-static-server.img/offline/pkg/f90c3c71-143d-45a2-af8f-d4b4c5d8dd1d.png';
|
||||
export const INFO_IMAGE_LIGHT =
|
||||
'https://u.thsi.cn/imgsrc/bbs/e1e9b6d81b9f4f6fa817cbeb71e98ff1.png';
|
||||
export const INFO_IMAGE_DARK =
|
||||
'https://u.thsi.cn/imgsrc/bbs/19f7f20bcf800ec2b8e9fc0fa0d73369.png';
|
||||
|
||||
// 一级 Tab 配置:涨幅/跌幅/涨速/跌速/成交额/日增仓。
|
||||
export const TAB_METRICS: TabMetric[] = [
|
||||
{ id: 'rise', label: '涨幅', stat: 'rise', api: 'rise_percent', hqId: '34818', hqName: 'price_chg', index: 0, hasChildren: false },
|
||||
{ id: 'fall', label: '跌幅', stat: 'fall', api: 'fall_percent', hqId: '34818', hqName: 'price_chg', index: 1, hasChildren: false },
|
||||
{ id: 'riseSpeed', label: '涨速', stat: 'riseSpeed', api: 'rise_percent', hqId: '34821', hqName: 'chg_speed', index: 2, hasChildren: true },
|
||||
{ id: 'fallSpeed', label: '跌速', stat: 'fallSpeed', api: 'fall_percent', hqId: '34821', hqName: 'chg_speed', index: 3, hasChildren: true },
|
||||
{ id: 'turnOver', label: '成交额', stat: 'turnOver', api: 'turnover', hqId: '19', hqName: 'turnover', index: 4, hasChildren: false },
|
||||
{ id: 'increPosit', label: '日增仓', stat: 'increPosit', api: 'increase_position', hqId: '34355', hqName: 'incre_posit', index: 5, hasChildren: false },
|
||||
{ id: 'rise', label: $r('app.string.market_ranking_tab_rise'), api: 'rise_percent', hqId: HqFieldId.DATA_ID_PRICE_CHG, hasChildren: false },
|
||||
{ id: 'fall', label: $r('app.string.market_ranking_tab_fall'), api: 'fall_percent', hqId: HqFieldId.DATA_ID_PRICE_CHG, hasChildren: false },
|
||||
{ id: 'riseSpeed', label: $r('app.string.market_ranking_tab_rise_speed'), api: 'rise_percent', hqId: HqFieldId.DATA_ID_CHG_SPEED_1MIN, hasChildren: true },
|
||||
{ id: 'fallSpeed', label: $r('app.string.market_ranking_tab_fall_speed'), api: 'fall_percent', hqId: HqFieldId.DATA_ID_CHG_SPEED_1MIN, hasChildren: true },
|
||||
{ id: 'turnOver', label: $r('app.string.market_ranking_tab_turnover'), api: 'turnover', hqId: HqFieldId.DATA_ID_TURNOVER, hasChildren: false },
|
||||
{ id: 'increPosit', label: $r('app.string.market_ranking_tab_increase_position'), api: 'increase_position', hqId: HqFieldId.DATA_ID_INCRE_POSIT, hasChildren: false },
|
||||
];
|
||||
|
||||
// 二级 Tab 配置:仅涨速/跌速下出现,按周期筛选
|
||||
export const PERIOD_RANGES: PeriodRange[] = [
|
||||
{ id: '1min', label: '1分钟', stat: 'min1', api: 'one_minute', hqId: '34874', index: 0 },
|
||||
{ id: '5min', label: '5分钟', stat: 'min5', api: 'five_minute', hqId: '34325', index: 1 },
|
||||
{ id: '10min', label: '10分钟', stat: 'min10', api: 'ten_minute', hqId: '34875', index: 2 },
|
||||
{ id: '15min', label: '15分钟', stat: 'min15', api: 'fifteen_minute', hqId: '34876', index: 3 },
|
||||
{ id: '1min', label: $r('app.string.market_ranking_period_1min'), api: 'one_minute', hqId: HqFieldId.DATA_ID_CHG_SPEED_1MIN },
|
||||
{ id: '5min', label: $r('app.string.market_ranking_period_5min'), api: 'five_minute', hqId: HqFieldId.DATA_ID_CHG_SPEED_5MIN },
|
||||
{ id: '10min', label: $r('app.string.market_ranking_period_10min'), api: 'ten_minute', hqId: HqFieldId.DATA_ID_CHG_SPEED_10MIN },
|
||||
{ id: '15min', label: $r('app.string.market_ranking_period_15min'), api: 'fifteen_minute', hqId: HqFieldId.DATA_ID_CHG_SPEED_15MIN },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import http from '@ohos.net.http';
|
||||
import BuildProfile from 'BuildProfile';
|
||||
import allCardsMock from '../common/mock/AllCardsMock.json';
|
||||
import {
|
||||
AllCardsCard,
|
||||
AllCardsFloor,
|
||||
AllCardsResponse,
|
||||
} from '../common/AllCardsModels';
|
||||
import {
|
||||
RankItem,
|
||||
RecommendFuturesItem,
|
||||
} from './MarketRankingModels';
|
||||
import { MOCK_RANK_LISTS, MOCK_SPEED_LISTS } from './MarketRankingMock';
|
||||
import { PERIOD_RANGES, TAB_METRICS } from './MarketRankingConstant';
|
||||
|
||||
const MOCK_REQUEST_DELAY_MS = 300;
|
||||
const TEST_API_HOST = 'https://futures-test.10jqka.com.cn/';
|
||||
const RELEASE_API_HOST = 'https://ftapi.10jqka.com.cn/';
|
||||
const RECOMMEND_FUTURES_API_PATH = 'futgwapi/api/market/homepage/v1/recommend_futures';
|
||||
const RECOMMEND_FUTURES_NUMBER = 50;
|
||||
|
||||
export class MarketRankingDataFetcher {
|
||||
private static readonly instance: MarketRankingDataFetcher = new MarketRankingDataFetcher();
|
||||
|
||||
private constructor() {
|
||||
}
|
||||
|
||||
static getInstance(): MarketRankingDataFetcher {
|
||||
return MarketRankingDataFetcher.instance;
|
||||
}
|
||||
|
||||
// 从公共全部卡片 Mock 中取得市场排名卡片配置。
|
||||
fetchCardConfig(): AllCardsCard | undefined {
|
||||
const response = allCardsMock as AllCardsResponse;
|
||||
for (const floor of response.data) {
|
||||
const card = this.findMarketRankingCard(floor);
|
||||
if (card !== undefined) {
|
||||
return card;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 市场排名合约列表接口(todo: 当前暂用 Mock,后续只替换本方法内部实现):
|
||||
* GET {API_HOST}futgwapi/api/market/homepage/v1/recommend_futures
|
||||
* dev/test API_HOST: https://futures-test.10jqka.com.cn/
|
||||
* release API_HOST: https://ftapi.10jqka.com.cn/
|
||||
*
|
||||
* Query:
|
||||
* - quote_type: 当前一级/二级 Tab 对应的排序维度
|
||||
* - number: 固定 50
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* code: 0,
|
||||
* data: [{ contract_code: string, contract_name: string, market: string }]
|
||||
* }
|
||||
*
|
||||
* 该接口只返回榜单合约及顺序;最新价、涨跌幅等展示数据由行情订阅提供。
|
||||
*/
|
||||
async fetchRecommendFutures(primaryIdx: number, secondaryIdx: number): Promise<RecommendFuturesItem[]> {
|
||||
const quoteType = this.getQuoteType(primaryIdx, secondaryIdx);
|
||||
const requestUrl = this.buildRecommendFuturesUrl(quoteType);
|
||||
const requestOptions: http.HttpRequestOptions = {
|
||||
method: http.RequestMethod.GET,
|
||||
connectTimeout: 60000,
|
||||
readTimeout: 60000,
|
||||
};
|
||||
const httpRequest = http.createHttp();
|
||||
console.info(
|
||||
`[MarketRanking] recommendFutures request: url=${requestUrl}, options=${JSON.stringify(requestOptions)}`,
|
||||
);
|
||||
// 当前只模拟真实请求构造,不调用 httpRequest.request()。
|
||||
httpRequest.destroy();
|
||||
return new Promise<RecommendFuturesItem[]>((resolve: (value: RecommendFuturesItem[]) => void) => {
|
||||
setTimeout(() => {
|
||||
const data = this.mockRankList(quoteType).map((item: RankItem): RecommendFuturesItem => ({
|
||||
contract_code: item.code,
|
||||
contract_name: item.name,
|
||||
market: item.market,
|
||||
}));
|
||||
resolve(data);
|
||||
}, MOCK_REQUEST_DELAY_MS);
|
||||
});
|
||||
}
|
||||
|
||||
private findMarketRankingCard(floor: AllCardsFloor): AllCardsCard | undefined {
|
||||
return floor.card_list.find((card: AllCardsCard): boolean =>
|
||||
card.render_key === 'market-ranking'
|
||||
);
|
||||
}
|
||||
|
||||
private buildRecommendFuturesUrl(quoteType: string): string {
|
||||
const apiHost = BuildProfile.DEBUG ? TEST_API_HOST : RELEASE_API_HOST;
|
||||
return `${apiHost}${RECOMMEND_FUTURES_API_PATH}` +
|
||||
`?quote_type=${encodeURIComponent(quoteType)}&number=${RECOMMEND_FUTURES_NUMBER}`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private mockRankList(quoteType: string): RankItem[] {
|
||||
switch (quoteType) {
|
||||
case 'rise_percent':
|
||||
return MOCK_RANK_LISTS[0];
|
||||
case 'fall_percent':
|
||||
return MOCK_RANK_LISTS[1];
|
||||
case 'turnover':
|
||||
return MOCK_RANK_LISTS[4];
|
||||
case 'increase_position':
|
||||
return MOCK_RANK_LISTS[5];
|
||||
case 'one_minute_rise_percent':
|
||||
return MOCK_SPEED_LISTS[0][0];
|
||||
case 'five_minute_rise_percent':
|
||||
return MOCK_SPEED_LISTS[0][1];
|
||||
case 'ten_minute_rise_percent':
|
||||
return MOCK_SPEED_LISTS[0][2];
|
||||
case 'fifteen_minute_rise_percent':
|
||||
return MOCK_SPEED_LISTS[0][3];
|
||||
case 'one_minute_fall_percent':
|
||||
return MOCK_SPEED_LISTS[1][0];
|
||||
case 'five_minute_fall_percent':
|
||||
return MOCK_SPEED_LISTS[1][1];
|
||||
case 'ten_minute_fall_percent':
|
||||
return MOCK_SPEED_LISTS[1][2];
|
||||
case 'fifteen_minute_fall_percent':
|
||||
return MOCK_SPEED_LISTS[1][3];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
CardData,
|
||||
RankItem,
|
||||
RecommendFuturesItem,
|
||||
} from './MarketRankingModels';
|
||||
|
||||
export function cardDataToContracts(cardData: CardData): RecommendFuturesItem[] {
|
||||
return cardData.tableList.map((item: RankItem): RecommendFuturesItem => ({
|
||||
contract_code: item.code,
|
||||
contract_name: item.name,
|
||||
market: item.market,
|
||||
}));
|
||||
}
|
||||
|
||||
export function contractsToPlaceholderCardData(contracts: RecommendFuturesItem[]): CardData {
|
||||
const cardData: CardData = {
|
||||
tableList: contracts.map((contract: RecommendFuturesItem): RankItem => ({
|
||||
code: contract.contract_code,
|
||||
market: contract.market,
|
||||
name: contract.contract_name,
|
||||
})),
|
||||
};
|
||||
return cardData;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import {
|
||||
CardData,
|
||||
MarketRankingHqValue,
|
||||
MarketRankingHqRequestParams,
|
||||
MarketRankingTableData,
|
||||
MarketRankingTableRow,
|
||||
RankItem,
|
||||
RecommendFuturesItem,
|
||||
} from './MarketRankingModels';
|
||||
import {
|
||||
PERIOD_RANGES,
|
||||
TAB_METRICS,
|
||||
} from './MarketRankingConstant';
|
||||
import { HqFieldId } from '../common/HqFieldId';
|
||||
|
||||
const MOCK_QUOTE_INTERVAL_MS = 2500;
|
||||
|
||||
export type MarketRankingCardDataListener = (cardData: CardData) => void;
|
||||
|
||||
// 市场排名行情请求客户端:负责构造 4106 参数、管理订阅及转换行情数据。
|
||||
export class MarketRankingHqRequestClient {
|
||||
private static readonly instance: MarketRankingHqRequestClient =
|
||||
new MarketRankingHqRequestClient();
|
||||
|
||||
private constructor() {
|
||||
}
|
||||
|
||||
static getInstance(): MarketRankingHqRequestClient {
|
||||
return MarketRankingHqRequestClient.instance;
|
||||
}
|
||||
|
||||
// 模拟行情订阅:先推送一次,之后每 2.5 秒更新完整卡片数据。
|
||||
subscribeHq(
|
||||
contracts: RecommendFuturesItem[],
|
||||
primaryIdx: number,
|
||||
secondaryIdx: number,
|
||||
listener: MarketRankingCardDataListener,
|
||||
): () => void {
|
||||
const requestParams = this.buildHqRequestParams(contracts, primaryIdx, secondaryIdx);
|
||||
console.info(`[MarketRanking] subscribeHq params: ${JSON.stringify(requestParams)}`);
|
||||
// todo: 接入真实的行情订阅。
|
||||
const quoteType = this.getQuoteType(primaryIdx, secondaryIdx);
|
||||
let tick = 0;
|
||||
const emit = (): void => {
|
||||
tick += 1;
|
||||
const tableData = this.createMockTableData(contracts, quoteType, tick);
|
||||
listener(this.mergeTableData(tableData, this.getSortId(primaryIdx, secondaryIdx)));
|
||||
};
|
||||
|
||||
emit();
|
||||
const timer = setInterval(emit, MOCK_QUOTE_INTERVAL_MS);
|
||||
return (): void => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}
|
||||
|
||||
// 对齐 Vue generateHqParam,生成 4106 行情订阅所需的完整请求参数。
|
||||
buildHqRequestParams(
|
||||
contracts: RecommendFuturesItem[],
|
||||
primaryIdx: number,
|
||||
secondaryIdx: number,
|
||||
): MarketRankingHqRequestParams {
|
||||
const sortId = this.getSortId(primaryIdx, secondaryIdx);
|
||||
const metric = TAB_METRICS[primaryIdx];
|
||||
const sortOrder = metric.id === 'fall' || metric.id === 'fallSpeed' ? '1' : '0';
|
||||
const dataItems: number[] = [
|
||||
HqFieldId.DATA_ID_NAME,
|
||||
HqFieldId.DATA_ID_PRICE,
|
||||
HqFieldId.DATA_ID_CODE,
|
||||
HqFieldId.DATA_ID_MARKET,
|
||||
HqFieldId.DATA_ID_PRICE_CHG,
|
||||
HqFieldId.DATA_ID_PRE_PRICE,
|
||||
HqFieldId.DATA_ID_OPEN_PRICE,
|
||||
HqFieldId.DATA_ID_PRE_SETTLE_PRICE,
|
||||
];
|
||||
if (!dataItems.includes(sortId)) {
|
||||
dataItems.push(sortId);
|
||||
}
|
||||
const columnOrder = dataItems.join('|');
|
||||
const requestDic = `sortid=${sortId}\r\n` +
|
||||
`sortorder=${sortOrder}\r\n` +
|
||||
`push=1\r\n` +
|
||||
`dataitem=${dataItems.map((item: number): string => `${item},`).join('')}\r\n` +
|
||||
`codelist=${this.formatCodeList(contracts)}\r\n` +
|
||||
`scenario=qht_qihuo_sort\r\n` +
|
||||
`precision=1\r\n` +
|
||||
`pushtime=2.5\r\n`;
|
||||
return {
|
||||
protocolId: '4106',
|
||||
// HarmonyOS 当前沿用 Android 行情页面标识。
|
||||
pageId: '2201',
|
||||
onlineId: 'marketRankingData',
|
||||
columnOrder: columnOrder,
|
||||
requestDic: requestDic,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private getSortId(primaryIdx: number, secondaryIdx: number): number {
|
||||
const metric = TAB_METRICS[primaryIdx];
|
||||
return metric.hasChildren ? PERIOD_RANGES[secondaryIdx].hqId : metric.hqId;
|
||||
}
|
||||
|
||||
// 按市场拼成 4106 所需格式,例如 70(CU2501,RB2505,);。
|
||||
private formatCodeList(contracts: RecommendFuturesItem[]): string {
|
||||
const markets: string[] = [];
|
||||
const codeGroups: string[][] = [];
|
||||
contracts.forEach((contract: RecommendFuturesItem): void => {
|
||||
const marketIndex = 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('');
|
||||
}
|
||||
|
||||
// 模拟真实鸿蒙行情 TableData,每个 Row 通过字段 ID 读取数据。
|
||||
private createMockTableData(
|
||||
contracts: RecommendFuturesItem[],
|
||||
quoteType: string,
|
||||
tick: number,
|
||||
): MarketRankingTableData {
|
||||
const tableData = new MarketRankingTableData();
|
||||
contracts.forEach((contract: RecommendFuturesItem, index: number): void => {
|
||||
const price = 100 + index * 10 + tick * 0.1;
|
||||
const priceChg = ((tick + index) % 5 - 2) * 0.35;
|
||||
const row = new MarketRankingTableRow();
|
||||
row.set(HqFieldId.DATA_ID_CODE, contract.contract_code);
|
||||
row.set(HqFieldId.DATA_ID_MARKET, contract.market);
|
||||
row.set(HqFieldId.DATA_ID_NAME, contract.contract_name);
|
||||
row.set(HqFieldId.DATA_ID_PRICE, price);
|
||||
row.set(HqFieldId.DATA_ID_PRICE_CHG, priceChg);
|
||||
row.set(HqFieldId.DATA_ID_PRE_PRICE, price - 0.5);
|
||||
row.set(HqFieldId.DATA_ID_OPEN_PRICE, price - 0.2);
|
||||
row.set(HqFieldId.DATA_ID_PRE_SETTLE_PRICE, price - 0.4);
|
||||
if (quoteType === 'turnover') {
|
||||
row.set(HqFieldId.DATA_ID_TURNOVER, 100000 + index * 5000 + tick * 100);
|
||||
}
|
||||
if (quoteType === 'increase_position') {
|
||||
row.set(HqFieldId.DATA_ID_INCRE_POSIT, 1000 + index * 100 + tick * 10);
|
||||
}
|
||||
const speed = priceChg / 2;
|
||||
if (quoteType === 'one_minute_rise_percent' || quoteType === 'one_minute_fall_percent') {
|
||||
row.set(HqFieldId.DATA_ID_CHG_SPEED_1MIN, speed);
|
||||
} else if (quoteType === 'five_minute_rise_percent' ||
|
||||
quoteType === 'five_minute_fall_percent') {
|
||||
row.set(HqFieldId.DATA_ID_CHG_SPEED_5MIN, speed);
|
||||
} else if (quoteType === 'ten_minute_rise_percent' ||
|
||||
quoteType === 'ten_minute_fall_percent') {
|
||||
row.set(HqFieldId.DATA_ID_CHG_SPEED_10MIN, speed);
|
||||
} else if (quoteType === 'fifteen_minute_rise_percent' ||
|
||||
quoteType === 'fifteen_minute_fall_percent') {
|
||||
row.set(HqFieldId.DATA_ID_CHG_SPEED_15MIN, speed);
|
||||
}
|
||||
tableData.addRow(row);
|
||||
});
|
||||
return tableData;
|
||||
}
|
||||
|
||||
// 遍历 tableRows,通过字段 ID 转换为 View 使用的行数据。
|
||||
private mergeTableData(data: MarketRankingTableData, sortFieldId: number): CardData {
|
||||
const tableList = data.tableRows.map((row: MarketRankingTableRow): RankItem => {
|
||||
const item: RankItem = {
|
||||
code: this.getString(row, HqFieldId.DATA_ID_CODE),
|
||||
market: this.getString(row, HqFieldId.DATA_ID_MARKET),
|
||||
name: this.getString(row, HqFieldId.DATA_ID_NAME),
|
||||
};
|
||||
item.price = this.getNumber(row, HqFieldId.DATA_ID_PRICE);
|
||||
item.price_chg = this.getNumber(row, HqFieldId.DATA_ID_PRICE_CHG);
|
||||
if (sortFieldId === HqFieldId.DATA_ID_TURNOVER) {
|
||||
item.turnover = this.getNumber(row, HqFieldId.DATA_ID_TURNOVER);
|
||||
} else if (sortFieldId === HqFieldId.DATA_ID_INCRE_POSIT) {
|
||||
item.incre_posit = this.getNumber(row, HqFieldId.DATA_ID_INCRE_POSIT);
|
||||
} else if (sortFieldId !== HqFieldId.DATA_ID_PRICE_CHG) {
|
||||
item.chg_speed = this.getNumber(row, sortFieldId);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
return { tableList: tableList };
|
||||
}
|
||||
|
||||
private getString(row: MarketRankingTableRow, fieldId: number): string {
|
||||
const value: MarketRankingHqValue | undefined = row.get(fieldId);
|
||||
return value === undefined ? '' : `${value}`;
|
||||
}
|
||||
|
||||
private getNumber(row: MarketRankingTableRow, fieldId: number): number | undefined {
|
||||
const value: MarketRankingHqValue | undefined = row.get(fieldId);
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && value !== '') {
|
||||
const result = parseFloat(value);
|
||||
return Number.isNaN(result) ? undefined : result;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RankItem } from './MarketRankingTypes';
|
||||
import { RankItem } from './MarketRankingModels';
|
||||
|
||||
export const MOCK_RANK_LISTS: RankItem[][] = [
|
||||
// rise 涨幅
|
||||
@@ -26,12 +26,16 @@ export const MOCK_RANK_LISTS: RankItem[][] = [
|
||||
{ code: 'RB2505', market: '70', name: '螺纹钢2505', price: 3452, price_chg: 2.15, turnover: 1286000 },
|
||||
{ code: 'M2505', market: '70', name: '豆粕2505', price: 2986, price_chg: 1.87, turnover: 963000 },
|
||||
{ code: 'I2505', market: '70', name: '铁矿石2505', price: 812, price_chg: -2.86, turnover: 841000 },
|
||||
{ code: 'AU2506', market: '70', name: '沪金2506', price: 598.4, price_chg: 1.52, turnover: 765000 },
|
||||
{ code: 'P2505', market: '70', name: '棕榈油2505', price: 7654, price_chg: -2.13, turnover: 628000 },
|
||||
],
|
||||
// increPosit 日增仓(原始数值,未换算单位)
|
||||
[
|
||||
{ code: 'CU2501', market: '70', name: '沪铜2501', price: 68520, price_chg: 3.21, incre_posit: 12000 },
|
||||
{ code: 'AU2506', market: '70', name: '沪金2506', price: 598.4, price_chg: 1.52, incre_posit: 9000 },
|
||||
{ code: 'P2505', market: '70', name: '棕榈油2505', price: 7654, price_chg: -2.13, incre_posit: -6000 },
|
||||
{ code: 'RB2505', market: '70', name: '螺纹钢2505', price: 3452, price_chg: 2.15, incre_posit: 5200 },
|
||||
{ code: 'M2505', market: '70', name: '豆粕2505', price: 2986, price_chg: 1.87, incre_posit: 3800 },
|
||||
],
|
||||
];
|
||||
|
||||
@@ -45,24 +49,32 @@ export const MOCK_SPEED_LISTS: RankItem[][][] = [
|
||||
{ code: 'CU2501', market: '70', name: '沪铜2501', price: 68520, price_chg: 3.21, chg_speed: 0.85 },
|
||||
{ code: 'AU2506', market: '70', name: '沪金2506', price: 598.4, price_chg: 1.52, chg_speed: 0.62 },
|
||||
{ code: 'SR2505', market: '70', name: '白糖2505', price: 6234, price_chg: 1.1, chg_speed: 0.41 },
|
||||
{ code: 'RB2505', market: '70', name: '螺纹钢2505', price: 3452, price_chg: 2.15, chg_speed: 0.36 },
|
||||
{ code: 'M2505', market: '70', name: '豆粕2505', price: 2986, price_chg: 1.87, chg_speed: 0.29 },
|
||||
],
|
||||
// 5分钟
|
||||
[
|
||||
{ code: 'AU2506', market: '70', name: '沪金2506', price: 598.4, price_chg: 1.52, chg_speed: 1.34 },
|
||||
{ code: 'CU2501', market: '70', name: '沪铜2501', price: 68520, price_chg: 3.21, chg_speed: 1.02 },
|
||||
{ code: 'M2505', market: '70', name: '豆粕2505', price: 2986, price_chg: 1.87, chg_speed: 0.78 },
|
||||
{ code: 'RB2505', market: '70', name: '螺纹钢2505', price: 3452, price_chg: 2.15, chg_speed: 0.63 },
|
||||
{ code: 'SR2505', market: '70', name: '白糖2505', price: 6234, price_chg: 1.1, chg_speed: 0.51 },
|
||||
],
|
||||
// 10分钟
|
||||
[
|
||||
{ code: 'M2505', market: '70', name: '豆粕2505', price: 2986, price_chg: 1.87, chg_speed: 2.15 },
|
||||
{ code: 'SR2505', market: '70', name: '白糖2505', price: 6234, price_chg: 1.1, chg_speed: 1.76 },
|
||||
{ code: 'RB2505', market: '70', name: '螺纹钢2505', price: 3452, price_chg: 2.15, chg_speed: 1.3 },
|
||||
{ code: 'CU2501', market: '70', name: '沪铜2501', price: 68520, price_chg: 3.21, chg_speed: 1.16 },
|
||||
{ code: 'AU2506', market: '70', name: '沪金2506', price: 598.4, price_chg: 1.52, chg_speed: 0.92 },
|
||||
],
|
||||
// 15分钟
|
||||
[
|
||||
{ code: 'RB2505', market: '70', name: '螺纹钢2505', price: 3452, price_chg: 2.15, chg_speed: 2.68 },
|
||||
{ code: 'CU2501', market: '70', name: '沪铜2501', price: 68520, price_chg: 3.21, chg_speed: 2.21 },
|
||||
{ code: 'AU2506', market: '70', name: '沪金2506', price: 598.4, price_chg: 1.52, chg_speed: 1.85 },
|
||||
{ code: 'M2505', market: '70', name: '豆粕2505', price: 2986, price_chg: 1.87, chg_speed: 1.61 },
|
||||
{ code: 'SR2505', market: '70', name: '白糖2505', price: 6234, price_chg: 1.1, chg_speed: 1.42 },
|
||||
],
|
||||
],
|
||||
// fallSpeed 跌速
|
||||
@@ -72,24 +84,32 @@ export const MOCK_SPEED_LISTS: RankItem[][][] = [
|
||||
{ code: 'I2505', market: '70', name: '铁矿石2505', price: 812, price_chg: -2.86, chg_speed: -0.73 },
|
||||
{ code: 'FG2505', market: '70', name: '玻璃2505', price: 1289, price_chg: -1.34, chg_speed: -0.55 },
|
||||
{ code: 'SM2505', market: '70', name: '锰硅2505', price: 5876, price_chg: -0.92, chg_speed: -0.38 },
|
||||
{ code: 'JD2505', market: '70', name: '鸡蛋2505', price: 3568, price_chg: -1.75, chg_speed: -0.31 },
|
||||
{ code: 'P2505', market: '70', name: '棕榈油2505', price: 7654, price_chg: -2.13, chg_speed: -0.25 },
|
||||
],
|
||||
// 5分钟
|
||||
[
|
||||
{ code: 'FG2505', market: '70', name: '玻璃2505', price: 1289, price_chg: -1.34, chg_speed: -1.12 },
|
||||
{ code: 'SM2505', market: '70', name: '锰硅2505', price: 5876, price_chg: -0.92, chg_speed: -0.89 },
|
||||
{ code: 'I2505', market: '70', name: '铁矿石2505', price: 812, price_chg: -2.86, chg_speed: -0.64 },
|
||||
{ code: 'JD2505', market: '70', name: '鸡蛋2505', price: 3568, price_chg: -1.75, chg_speed: -0.52 },
|
||||
{ code: 'P2505', market: '70', name: '棕榈油2505', price: 7654, price_chg: -2.13, chg_speed: -0.43 },
|
||||
],
|
||||
// 10分钟
|
||||
[
|
||||
{ code: 'SM2505', market: '70', name: '锰硅2505', price: 5876, price_chg: -0.92, chg_speed: -1.87 },
|
||||
{ code: 'JD2505', market: '70', name: '鸡蛋2505', price: 3568, price_chg: -1.75, chg_speed: -1.45 },
|
||||
{ code: 'FG2505', market: '70', name: '玻璃2505', price: 1289, price_chg: -1.34, chg_speed: -1.06 },
|
||||
{ code: 'I2505', market: '70', name: '铁矿石2505', price: 812, price_chg: -2.86, chg_speed: -0.91 },
|
||||
{ code: 'P2505', market: '70', name: '棕榈油2505', price: 7654, price_chg: -2.13, chg_speed: -0.76 },
|
||||
],
|
||||
// 15分钟
|
||||
[
|
||||
{ code: 'JD2505', market: '70', name: '鸡蛋2505', price: 3568, price_chg: -1.75, chg_speed: -2.34 },
|
||||
{ code: 'I2505', market: '70', name: '铁矿石2505', price: 812, price_chg: -2.86, chg_speed: -1.98 },
|
||||
{ code: 'SM2505', market: '70', name: '锰硅2505', price: 5876, price_chg: -0.92, chg_speed: -1.52 },
|
||||
{ code: 'FG2505', market: '70', name: '玻璃2505', price: 1289, price_chg: -1.34, chg_speed: -1.31 },
|
||||
{ code: 'P2505', market: '70', name: '棕榈油2505', price: 7654, price_chg: -2.13, chg_speed: -1.17 },
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import router from '@ohos.router';
|
||||
import { PeriodRange, RankItem, TabMetric } from './MarketRankingTypes';
|
||||
import { TAB_METRICS, PERIOD_RANGES } from './MarketRankingConstant';
|
||||
import { MOCK_RANK_LISTS, MOCK_SPEED_LISTS } from './MarketRankingMock';
|
||||
|
||||
// 状态持有 + 编排类;
|
||||
@Observed
|
||||
export class MarketRankingModel {
|
||||
private tabMetrics: TabMetric[] = TAB_METRICS;
|
||||
private periodRanges: PeriodRange[] = PERIOD_RANGES;
|
||||
rankLists: RankItem[][] = MOCK_RANK_LISTS;
|
||||
speedLists: RankItem[][][] = MOCK_SPEED_LISTS;
|
||||
|
||||
// 按当前一级/二级 Tab 取展示列表:View 只需直接调用,不用自己判断 rankLists/speedLists 分支
|
||||
currentList(primaryIdx: number, secondaryIdx: number): RankItem[] {
|
||||
if (this.tabMetrics[primaryIdx].hasChildren) {
|
||||
const speedGroupIdx = primaryIdx === 2 ? 0 : 1;
|
||||
return this.speedLists[speedGroupIdx][secondaryIdx];
|
||||
}
|
||||
return this.rankLists[primaryIdx];
|
||||
}
|
||||
|
||||
// 跳转到分时详情页
|
||||
jumpToDetail(item: RankItem): void {
|
||||
router.pushUrl({
|
||||
url: 'pages/Detail',
|
||||
params: { code: item.code, market: item.market },
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: 接入后端接口后,在此方法内按 (primaryIdx, secondaryIdx) 发起请求并更新 rankLists/speedLists
|
||||
async updateRankList(primaryIdx: number, secondaryIdx: number): Promise<void> {
|
||||
}
|
||||
}
|
||||
+45
-11
@@ -1,10 +1,10 @@
|
||||
// ============ 视图数据类型(合并接口 + 行情后,供 View 消费) ============
|
||||
// 保留原始字段,不在数据层预先格式化/预先判断涨跌色,格式化和取色都留给 View 层做。
|
||||
// 保留原始字段,不在数据层预先格式化/预先判断涨跌色;HTTP 占位行允许行情字段暂缺。
|
||||
export interface RankItem {
|
||||
code: string;
|
||||
market: string;
|
||||
name: string;
|
||||
price: number;
|
||||
price?: number;
|
||||
// 涨跌幅(%数值,如 3.21 表示 +3.21%)。行情推送里固定字段,任何 Tab 下都存在,
|
||||
// 用于「最新价」列的取色,也是 涨幅/跌幅 Tab 第三列的数据来源。
|
||||
price_chg?: number;
|
||||
@@ -16,24 +16,57 @@ export interface RankItem {
|
||||
incre_posit?: number;
|
||||
}
|
||||
|
||||
// 当前市场排名卡片的完整展示数据;网络/行情更新后整体替换以触发 UI 刷新。
|
||||
export interface CardData {
|
||||
tableList: RankItem[];
|
||||
}
|
||||
|
||||
export type MarketRankingHqValue = string | number;
|
||||
|
||||
// 对齐真实鸿蒙行情返回行:通过行情字段 ID 获取当前合约的值。
|
||||
export class MarketRankingTableRow {
|
||||
private values: Map<number, MarketRankingHqValue> =
|
||||
new Map<number, MarketRankingHqValue>();
|
||||
|
||||
set(fieldId: number, value: MarketRankingHqValue): void {
|
||||
this.values.set(fieldId, value);
|
||||
}
|
||||
|
||||
get(fieldId: number): MarketRankingHqValue | undefined {
|
||||
return this.values.get(fieldId);
|
||||
}
|
||||
}
|
||||
|
||||
// 对齐真实鸿蒙行情 TableData:每个 tableRows 元素代表一份合约行情。
|
||||
export class MarketRankingTableData {
|
||||
tableRows: MarketRankingTableRow[] = [];
|
||||
|
||||
addRow(row: MarketRankingTableRow): void {
|
||||
this.tableRows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
export interface MarketRankingHqRequestParams {
|
||||
protocolId: string;
|
||||
pageId: string;
|
||||
onlineId: string;
|
||||
columnOrder: string;
|
||||
requestDic: string;
|
||||
}
|
||||
|
||||
export interface TabMetric {
|
||||
id: string;
|
||||
label: string;
|
||||
stat: string; // 埋点用
|
||||
label: ResourceStr;
|
||||
api: string; // HTTP 接口 quote_type 参数用
|
||||
hqId: string; // 行情订阅 sortid 用
|
||||
hqName: string; // 推送字段映射用
|
||||
index: number;
|
||||
hqId: number; // 行情订阅 sortid 用
|
||||
hasChildren: boolean; // Vue 用 children 数组本身判断,这里简化为布尔标记
|
||||
}
|
||||
|
||||
export interface PeriodRange {
|
||||
id: string;
|
||||
label: string;
|
||||
stat: string;
|
||||
label: ResourceStr;
|
||||
api: string;
|
||||
hqId: string;
|
||||
index: number;
|
||||
hqId: number;
|
||||
}
|
||||
|
||||
// ============ 后端接口原始响应类型(recommend_futures) ============
|
||||
@@ -42,6 +75,7 @@ export interface PeriodRange {
|
||||
export interface RecommendFuturesItem {
|
||||
contract_code: string;
|
||||
contract_name: string;
|
||||
market: string;
|
||||
}
|
||||
|
||||
export interface RecommendFuturesResponse {
|
||||
@@ -0,0 +1,667 @@
|
||||
import router from '@ohos.router';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { preferences } from '@kit.ArkData';
|
||||
import {
|
||||
CardData,
|
||||
PeriodRange,
|
||||
RankItem, RecommendFuturesItem, TabMetric,
|
||||
} from './MarketRankingModels';
|
||||
import { MarketRankingDataFetcher } from './MarketRankingDataFetcher';
|
||||
import {
|
||||
cardDataToContracts,
|
||||
contractsToPlaceholderCardData,
|
||||
} from './MarketRankingDataMapper';
|
||||
import { MarketRankingHqRequestClient } from './MarketRankingHqRequestClient';
|
||||
import { formatGreatNumber, formatPercent, riseFallColor } from '../common/NumberFormat';
|
||||
import { AllCardsCard } from '../common/AllCardsModels';
|
||||
import {
|
||||
emitter,
|
||||
EVENT_TCP_RECONNECT,
|
||||
EventListener,
|
||||
} from '../common/EventEmitter';
|
||||
import {
|
||||
INFO_IMAGE_DARK,
|
||||
INFO_IMAGE_LIGHT,
|
||||
PERIOD_RANGES,
|
||||
TAB_METRICS,
|
||||
TITLE_ARROW_DARK,
|
||||
TITLE_ARROW_LIGHT,
|
||||
} from './MarketRankingConstant';
|
||||
|
||||
const NORMAL_SIZE = 3;
|
||||
const MAX_SIZE = 5;
|
||||
const SP_FILE_NAME_FIRST_PAGE_MARKET_RANKING = 'sp_file_name_first_page_market_ranking';
|
||||
const SP_KEY_FIRST_PAGE_MARKET_RANKING_EXPAND = 'sp_key_first_page_market_ranking_expand';
|
||||
|
||||
@Component
|
||||
export struct MarketRankingNodeComponent {
|
||||
@State cardConfig: AllCardsCard | undefined = undefined;
|
||||
@State cardData: CardData = { tableList: [] };
|
||||
private contracts: RecommendFuturesItem[] = [];
|
||||
private tabDataCache: Map<string, CardData> = new Map<string, CardData>();
|
||||
private dataRequestVersion: number = 0;
|
||||
// Tab 选中/展开状态是纯 UI 交互状态,留在 View 里
|
||||
@State primaryIdx: number = 0;
|
||||
@State secondaryIdx: number = 0;
|
||||
@State unfolded: boolean = false;
|
||||
@State tabViewportWidth: number = 0;
|
||||
@State tabContentWidth: number = 0;
|
||||
@State tabScrollOffset: number = 0;
|
||||
@State showExplainSheet: boolean = false;
|
||||
@Link @Watch('onRefreshTriggerChange') refreshTrigger: number; //todo
|
||||
@Consume('firstPageVisible') @Watch('onFirstPageVisibleChange') firstPageVisible: boolean = true;
|
||||
@StorageLink('enableDarkMode') isDarkMode: boolean = false; //todo
|
||||
private hqUnsubscribe: (() => void) | undefined;
|
||||
private tcpReconnectListener: EventListener = (): void => {
|
||||
this.onTcpReconnect();
|
||||
};
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.initUnfoldedState();
|
||||
this.updateCardConfig();
|
||||
emitter.on(EVENT_TCP_RECONNECT, this.tcpReconnectListener);
|
||||
if (!this.firstPageVisible) {
|
||||
return;
|
||||
}
|
||||
// Mock 网络延迟:模拟真实接口返回前的等待过程,接入真实请求后移除该 setTimeout。
|
||||
setTimeout(() => {
|
||||
this.updateCardData(this.primaryIdx, this.secondaryIdx);
|
||||
}, 600);
|
||||
}
|
||||
|
||||
aboutToDisappear(): void {
|
||||
this.dataRequestVersion += 1;
|
||||
emitter.off(EVENT_TCP_RECONNECT, this.tcpReconnectListener);
|
||||
this.stopSubscribeHq();
|
||||
}
|
||||
|
||||
// 只负责从公共 Mock 获取并更新卡片配置。
|
||||
private updateCardConfig(): void {
|
||||
this.cardConfig = MarketRankingDataFetcher.getInstance().fetchCardConfig();
|
||||
}
|
||||
|
||||
// refreshTrigger 变化时重新获取当前 Tab 的合约,并在请求完成后重建行情订阅。
|
||||
onRefreshTriggerChange(): void {
|
||||
this.updateCardData(this.primaryIdx, this.secondaryIdx);
|
||||
}
|
||||
|
||||
// 页面不可见时停止行情;恢复可见后使用已有合约重建订阅。
|
||||
onFirstPageVisibleChange(): void {
|
||||
if (!this.firstPageVisible) {
|
||||
this.stopSubscribeHq();
|
||||
return;
|
||||
}
|
||||
if (this.cardData.tableList.length > 0) {
|
||||
this.subscribeHq(this.primaryIdx, this.secondaryIdx);
|
||||
return;
|
||||
}
|
||||
this.updateCardData(this.primaryIdx, this.secondaryIdx);
|
||||
}
|
||||
|
||||
// 收到 TCP 重连事件后,仅在页面可见时重新订阅行情。
|
||||
private onTcpReconnect(): void {
|
||||
if (!this.firstPageVisible) {
|
||||
return;
|
||||
}
|
||||
this.stopSubscribeHq();
|
||||
if (this.cardData.tableList.length > 0) {
|
||||
this.subscribeHq(this.primaryIdx, this.secondaryIdx);
|
||||
return;
|
||||
}
|
||||
this.updateCardData(this.primaryIdx, this.secondaryIdx);
|
||||
}
|
||||
|
||||
@Builder
|
||||
CardTitle() {
|
||||
Row() {
|
||||
Row() {
|
||||
Text(this.cardConfig?.card_title.value || $r('app.string.market_ranking_title'))
|
||||
.fontSize(18)
|
||||
.fontWeight(500)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.maxLines(1)
|
||||
|
||||
if ((this.cardConfig?.card_url.android ?? '') !== '' ||
|
||||
(this.cardConfig?.card_url.ios ?? '') !== '') {
|
||||
Image(this.isDarkMode ? TITLE_ARROW_DARK : TITLE_ARROW_LIGHT)
|
||||
.width(20)
|
||||
.height(20)
|
||||
}
|
||||
|
||||
if ((this.cardConfig?.explain_message ?? '') !== '') {
|
||||
Image(this.isDarkMode ? INFO_IMAGE_DARK : INFO_IMAGE_LIGHT)
|
||||
.width(16)
|
||||
.height(16)
|
||||
.margin({ left: 8 })
|
||||
.onClick(() => {
|
||||
this.showExplainSheet = true;
|
||||
})
|
||||
}
|
||||
}
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.height('100%')
|
||||
.onClick(() => {
|
||||
this.jumpToCardTitle();
|
||||
})
|
||||
|
||||
Row() {
|
||||
Text(this.unfolded
|
||||
? $r('app.string.market_ranking_collapse')
|
||||
: $r('app.string.market_ranking_expand'))
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_tertiary'))
|
||||
.height(16)
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
}
|
||||
.width(40)
|
||||
.height(20)
|
||||
.borderRadius(10)
|
||||
.backgroundColor($r('app.color.pill_inactive_bg'))
|
||||
.margin({ left: 8 })
|
||||
.onClick(() => {
|
||||
this.toggleUnfolded();
|
||||
})
|
||||
|
||||
Blank()
|
||||
}
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
|
||||
@Builder
|
||||
ExplainSheet() {
|
||||
Column() {
|
||||
Text(this.cardConfig?.explain_title ||
|
||||
this.cardConfig?.card_title.value ||
|
||||
$r('app.string.market_ranking_title'))
|
||||
.width('100%')
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.textAlign(TextAlign.Center)
|
||||
.margin({ bottom: 16 })
|
||||
|
||||
Scroll() {
|
||||
Text(this.cardConfig?.explain_message ?? '')
|
||||
.width('100%')
|
||||
.fontSize(16)
|
||||
.lineHeight(24)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
}
|
||||
.width('100%')
|
||||
.scrollBar(BarState.Off)
|
||||
|
||||
Text($r('app.string.card_explain_confirm'))
|
||||
.width('100%')
|
||||
.height(44)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(Color.White)
|
||||
.textAlign(TextAlign.Center)
|
||||
.backgroundColor($r('app.color.text_blue'))
|
||||
.borderRadius(4)
|
||||
.margin({ top: 16 })
|
||||
.onClick(() => {
|
||||
this.showExplainSheet = false;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 16, bottom: 18 })
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
this.CardTitle()
|
||||
|
||||
// 一级 Tab(胶囊按钮,可横向滚动)
|
||||
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.text_blue')
|
||||
: $r('app.color.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.pill_active_bg')
|
||||
: $r('app.color.pill_inactive_bg'))
|
||||
.borderRadius(4)
|
||||
.onClick(() => {
|
||||
this.primaryIdx = index;
|
||||
this.secondaryIdx = 0;
|
||||
this.updateCardData(index, 0);
|
||||
})
|
||||
}, (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: [[$r('app.color.tab_gradient_start'), 0], [$r('app.color.card_bg'), 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.divider_color'))
|
||||
.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.text_primary')
|
||||
: $r('app.color.text_tertiary'))
|
||||
.padding({ top: 4, bottom: 4 })
|
||||
.onClick(() => {
|
||||
this.secondaryIdx = index;
|
||||
this.updateCardData(this.primaryIdx, index);
|
||||
})
|
||||
}, (period: PeriodRange) => period.id)
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.End)
|
||||
.margin({ top: 8 })
|
||||
}
|
||||
|
||||
if (this.cardData.tableList.length === 0) {
|
||||
Column() {
|
||||
Text($r('app.string.market_ranking_empty'))
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_grey'))
|
||||
}
|
||||
.width('100%')
|
||||
.height(this.emptyHeight())
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
} else {
|
||||
Column() {
|
||||
// 表头
|
||||
Row({ space: 8 }) {
|
||||
Text($r('app.string.market_ranking_contract_name'))
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_tertiary'))
|
||||
.width('35%')
|
||||
Text($r('app.string.market_ranking_latest_price'))
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_tertiary'))
|
||||
.layoutWeight(1)
|
||||
.textAlign(TextAlign.End)
|
||||
Text(this.thirdColumnTitle())
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_tertiary'))
|
||||
.layoutWeight(1)
|
||||
.textAlign(TextAlign.End)
|
||||
}
|
||||
.width('100%')
|
||||
.height(40)
|
||||
|
||||
// 表格列表
|
||||
ForEach(
|
||||
this.cardData.tableList.slice(0, this.displaySize()),
|
||||
(item: RankItem, _index: number) => {
|
||||
Row({ space: 8 }) {
|
||||
Text(item.name)
|
||||
.fontSize(16)
|
||||
.maxFontSize(16)
|
||||
.minFontSize(9)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('35%')
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Text(item.price === undefined ? '--' : `${item.price}`)
|
||||
.fontSize(16)
|
||||
.fontWeight(500)
|
||||
.maxFontSize(16)
|
||||
.minFontSize(9)
|
||||
.fontColor(this.priceColor(item))
|
||||
.layoutWeight(1)
|
||||
.textAlign(TextAlign.End)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Text(this.thirdColumnValue(item))
|
||||
.fontSize(16)
|
||||
.fontWeight(500)
|
||||
.maxFontSize(16)
|
||||
.minFontSize(9)
|
||||
.fontColor(this.thirdColumnColor(item))
|
||||
.layoutWeight(1)
|
||||
.textAlign(TextAlign.End)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.width('100%')
|
||||
.height(45)
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.border({ width: { top: 1 }, color: $r('app.color.divider_color') })
|
||||
.onClick(() => {
|
||||
if (item.market !== undefined) {
|
||||
this.jumpToDetail(item);
|
||||
}
|
||||
})
|
||||
},
|
||||
// 行情更新时字段参与 key,确保整体替换 CardData 后列表行同步刷新。
|
||||
(item: RankItem) =>
|
||||
`${item.code}_${item.market}_${item.price ?? ''}_${item.price_chg ?? ''}_` +
|
||||
`${item.chg_speed ?? ''}_${item.turnover ?? ''}_${item.incre_posit ?? ''}`,
|
||||
)
|
||||
}
|
||||
.width('100%')
|
||||
.height(this.emptyHeight())
|
||||
.animation({ duration: 180, curve: Curve.EaseInOut })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
.borderRadius(4)
|
||||
.padding({ left: 10, right: 10, bottom: 10 })
|
||||
.bindSheet(this.showExplainSheet, this.ExplainSheet, {
|
||||
height: SheetSize.FIT_CONTENT,
|
||||
dragBar: true,
|
||||
showClose: false,
|
||||
maskColor: '#99000000',
|
||||
onDisappear: () => {
|
||||
this.showExplainSheet = false;
|
||||
},
|
||||
radius: {
|
||||
topLeft: 10,
|
||||
topRight: 10,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 先请求合约榜单填充列表,再根据当前 CardData 订阅行情。
|
||||
private async updateCardData(primaryIdx: number, secondaryIdx: number): Promise<void> {
|
||||
const requestVersion: number = this.dataRequestVersion + 1;
|
||||
this.dataRequestVersion = requestVersion;
|
||||
const cacheKey: string = this.getTabCacheKey(primaryIdx, secondaryIdx);
|
||||
const cachedData: CardData | undefined = this.tabDataCache.get(cacheKey);
|
||||
this.stopSubscribeHq();
|
||||
if (cachedData !== undefined) {
|
||||
this.cardData = cachedData;
|
||||
this.contracts = cardDataToContracts(cachedData);
|
||||
if (this.firstPageVisible) {
|
||||
this.subscribeHq(primaryIdx, secondaryIdx);
|
||||
}
|
||||
} else {
|
||||
this.contracts = [];
|
||||
this.cardData = { tableList: [] };
|
||||
}
|
||||
try {
|
||||
const contracts = await MarketRankingDataFetcher
|
||||
.getInstance()
|
||||
.fetchRecommendFutures(primaryIdx, secondaryIdx);
|
||||
if (!this.isCurrentDataRequest(requestVersion, primaryIdx, secondaryIdx)) {
|
||||
return;
|
||||
}
|
||||
this.contracts = contracts;
|
||||
if (cachedData === undefined) {
|
||||
this.cardData = contractsToPlaceholderCardData(contracts);
|
||||
}
|
||||
this.tabDataCache.set(cacheKey, this.cardData);
|
||||
if (this.firstPageVisible) {
|
||||
this.subscribeHq(primaryIdx, secondaryIdx);
|
||||
}
|
||||
} catch {
|
||||
if (!this.isCurrentDataRequest(requestVersion, primaryIdx, secondaryIdx)) {
|
||||
return;
|
||||
}
|
||||
if (cachedData === undefined) {
|
||||
this.contracts = [];
|
||||
this.cardData = { tableList: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 HTTP 保存的合约列表订阅行情,推送后整体更新当前 Tab 缓存。
|
||||
private subscribeHq(primaryIdx: number, secondaryIdx: number): void {
|
||||
this.stopSubscribeHq();
|
||||
if (this.contracts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hqUnsubscribe = MarketRankingHqRequestClient
|
||||
.getInstance()
|
||||
.subscribeHq(this.contracts, primaryIdx, secondaryIdx, (cardData: CardData): void => {
|
||||
if (primaryIdx === this.primaryIdx && secondaryIdx === this.secondaryIdx) {
|
||||
this.cardData = cardData;
|
||||
this.tabDataCache.set(this.getTabCacheKey(primaryIdx, secondaryIdx), cardData);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private stopSubscribeHq(): void {
|
||||
if (this.hqUnsubscribe !== undefined) {
|
||||
this.hqUnsubscribe();
|
||||
this.hqUnsubscribe = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private isCurrentDataRequest(
|
||||
requestVersion: number,
|
||||
primaryIdx: number,
|
||||
secondaryIdx: number,
|
||||
): boolean {
|
||||
return requestVersion === this.dataRequestVersion &&
|
||||
primaryIdx === this.primaryIdx &&
|
||||
secondaryIdx === this.secondaryIdx;
|
||||
}
|
||||
|
||||
private getTabCacheKey(primaryIdx: number, secondaryIdx: number): string {
|
||||
const metric: TabMetric = TAB_METRICS[primaryIdx];
|
||||
if (metric.hasChildren) {
|
||||
return `${metric.id}_${PERIOD_RANGES[secondaryIdx].id}`;
|
||||
}
|
||||
return metric.id;
|
||||
}
|
||||
|
||||
// 跳转到分时详情页
|
||||
// todo: 后续跳转为真实的页面
|
||||
private jumpToDetail(item: RankItem): void {
|
||||
const clientUrl =
|
||||
`client://client.html?action=ymtz^webid=2205^stockcode=${item.code}^marketid=${item.market}`;
|
||||
console.info(`[MarketRanking] detail url: ${clientUrl}`);
|
||||
// 当前仍跳转本地 Mock 页面;接入客户端能力后改为使用 clientUrl。
|
||||
router.pushUrl({
|
||||
url: 'pages/Detail',
|
||||
params: {
|
||||
code: item.code,
|
||||
market: item.market,
|
||||
clientUrl: clientUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 跳转到卡片配置中的标题地址。
|
||||
// todo: 后续跳转为真实的客户端页面。
|
||||
private jumpToCardTitle(): void {
|
||||
const androidUrl = this.cardConfig?.card_url.android ?? '';
|
||||
const clientUrl = androidUrl !== '' ? androidUrl : this.cardConfig?.card_url.ios ?? '';
|
||||
if (clientUrl === '') {
|
||||
return;
|
||||
}
|
||||
console.info(`[MarketRanking] card title url: ${clientUrl}`);
|
||||
router.pushUrl({
|
||||
url: 'pages/StrategyDetail',
|
||||
params: {
|
||||
type: 'home',
|
||||
clientUrl: clientUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 以下是UI相关
|
||||
private toggleUnfolded(): void {
|
||||
this.unfolded = !this.unfolded;
|
||||
try {
|
||||
const store: preferences.Preferences | undefined = this.getPreferenceStore();
|
||||
if (store === undefined) {
|
||||
return;
|
||||
}
|
||||
store.putSync(SP_KEY_FIRST_PAGE_MARKET_RANKING_EXPAND, this.unfolded);
|
||||
store.flush().catch((error: Error) => {
|
||||
console.error(`[MarketRanking] persist unfolded state failed: ${error.message}`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[MarketRanking] persist unfolded state failed: ${JSON.stringify(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private initUnfoldedState(): void {
|
||||
try {
|
||||
const store: preferences.Preferences | undefined = this.getPreferenceStore();
|
||||
if (store === undefined) {
|
||||
this.unfolded = false;
|
||||
return;
|
||||
}
|
||||
this.unfolded =
|
||||
store.getSync(SP_KEY_FIRST_PAGE_MARKET_RANKING_EXPAND, false) as boolean;
|
||||
} catch (error) {
|
||||
console.error(`[MarketRanking] read unfolded state failed: ${JSON.stringify(error)}`);
|
||||
this.unfolded = false;
|
||||
}
|
||||
}
|
||||
|
||||
private getPreferenceStore(): preferences.Preferences | undefined {
|
||||
try {
|
||||
const context: common.UIAbilityContext =
|
||||
this.getUIContext().getHostContext() as common.UIAbilityContext;
|
||||
return preferences.getPreferencesSync(context, {
|
||||
name: SP_FILE_NAME_FIRST_PAGE_MARKET_RANKING,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[MarketRanking] get preference store failed: ${JSON.stringify(error)}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
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 metric = TAB_METRICS[this.primaryIdx];
|
||||
if (metric.id === 'rise' || metric.id === 'fall') {
|
||||
return $r('app.string.market_ranking_change_rate');
|
||||
}
|
||||
if (metric.id === 'riseSpeed') {
|
||||
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 (metric.id === 'fallSpeed') {
|
||||
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 metric.label;
|
||||
}
|
||||
|
||||
// 成交额/日增仓列用中性色,其余按涨跌上色
|
||||
private isNeutralColumn(): boolean {
|
||||
const id = TAB_METRICS[this.primaryIdx].id;
|
||||
return id === 'turnOver' || id === 'increPosit';
|
||||
}
|
||||
|
||||
// 第三列展示值:按当前一级 Tab 从对应原始字段取值并格式化
|
||||
private thirdColumnValue(item: RankItem): string {
|
||||
const id = TAB_METRICS[this.primaryIdx].id;
|
||||
if (id === 'turnOver') {
|
||||
return formatGreatNumber(item.turnover);
|
||||
}
|
||||
if (id === 'increPosit') {
|
||||
return formatGreatNumber(item.incre_posit);
|
||||
}
|
||||
if (id === 'riseSpeed' || id === 'fallSpeed') {
|
||||
return formatPercent(item.chg_speed);
|
||||
}
|
||||
return formatPercent(item.price_chg);
|
||||
}
|
||||
|
||||
// 第三列取色:成交额/日增仓走中性色,其余按对应原始字段的正负取色
|
||||
private thirdColumnColor(item: RankItem): Resource {
|
||||
if (this.isNeutralColumn()) {
|
||||
return $r('app.color.text_primary');
|
||||
}
|
||||
const id = TAB_METRICS[this.primaryIdx].id;
|
||||
const raw = id === 'riseSpeed' || id === 'fallSpeed' ? item.chg_speed : item.price_chg;
|
||||
if (raw === undefined) {
|
||||
return $r('app.color.text_primary');
|
||||
}
|
||||
return riseFallColor(raw);
|
||||
}
|
||||
|
||||
private priceColor(item: RankItem): Resource {
|
||||
if (item.price_chg === undefined) {
|
||||
return $r('app.color.text_primary');
|
||||
}
|
||||
if (item.price_chg > 0) {
|
||||
return $r('app.color.color_rise');
|
||||
} else if (item.price_chg < 0) {
|
||||
return $r('app.color.color_fall');
|
||||
}
|
||||
return $r('app.color.text_primary');
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,33 @@
|
||||
import { Card } from '../common/Card';
|
||||
import { AIView } from '../ai-view/AIView';
|
||||
import { MarketRanking } from '../market-ranking/MarketRanking';
|
||||
import { AiPick } from '../ai-pick/AiPick';
|
||||
import { MarketRankingNodeComponent } from '../market-ranking/MarketRankingNodeComponent';
|
||||
import { AiPickNodeComponent } from '../ai-pick/AiPickNodeComponent';
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct Index {
|
||||
@State refreshTrigger: number = 0;
|
||||
@Provide('firstPageVisible') firstPageVisible: boolean = true;
|
||||
|
||||
build() {
|
||||
Scroll() {
|
||||
Column({ space: 16 }) {
|
||||
Column({ space: 8 }) {
|
||||
Card() {
|
||||
AIView()
|
||||
}
|
||||
Card() {
|
||||
MarketRanking()
|
||||
MarketRankingNodeComponent({
|
||||
refreshTrigger: $refreshTrigger,
|
||||
})
|
||||
}
|
||||
Card() {
|
||||
AiPick()
|
||||
AiPickNodeComponent({
|
||||
refreshTrigger: $refreshTrigger,
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.padding({ left: 6, right: 6 })
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.justifyContent(FlexAlign.Start)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
{ "name": "color_rise", "value": "#FF2436" },
|
||||
{ "name": "color_fall", "value": "#07AB4B" },
|
||||
{ "name": "color_orange", "value": "#FF661A" },
|
||||
{ "name": "bg_orange", "value": "#1AFF661A" }
|
||||
{ "name": "bg_orange", "value": "#1AFF661A" },
|
||||
{ "name": "tab_gradient_start", "value": "#00FFFFFF" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,6 +11,130 @@
|
||||
{
|
||||
"name": "EntryAbility_label",
|
||||
"value": "label"
|
||||
},
|
||||
{
|
||||
"name": "market_ranking_title",
|
||||
"value": "市场排名"
|
||||
},
|
||||
{
|
||||
"name": "market_ranking_expand",
|
||||
"value": "展开"
|
||||
},
|
||||
{
|
||||
"name": "market_ranking_collapse",
|
||||
"value": "收起"
|
||||
},
|
||||
{
|
||||
"name": "market_ranking_empty",
|
||||
"value": "暂无数据"
|
||||
},
|
||||
{
|
||||
"name": "market_ranking_contract_name",
|
||||
"value": "合约名称"
|
||||
},
|
||||
{
|
||||
"name": "market_ranking_latest_price",
|
||||
"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分钟跌速"
|
||||
},
|
||||
{
|
||||
"name": "ai_pick_title",
|
||||
"value": "AI选期"
|
||||
},
|
||||
{
|
||||
"name": "ai_pick_loading",
|
||||
"value": "数据加载中"
|
||||
},
|
||||
{
|
||||
"name": "ai_pick_no_contract",
|
||||
"value": "没有符合的合约"
|
||||
},
|
||||
{
|
||||
"name": "ai_pick_main_contract",
|
||||
"value": "主"
|
||||
},
|
||||
{
|
||||
"name": "ai_pick_custom_strategy",
|
||||
"value": "一句话定制策略"
|
||||
},
|
||||
{
|
||||
"name": "card_explain_confirm",
|
||||
"value": "我知道了"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"string": [
|
||||
{
|
||||
"name": "ai_pick_empty_image_light_url",
|
||||
"value": "https://u.thsi.cn/imgsrc/bbs/8eee9cd4fa7e359032de11a3f7c2a70b_300_230.png"
|
||||
},
|
||||
{
|
||||
"name": "ai_pick_empty_image_dark_url",
|
||||
"value": "https://u.thsi.cn/imgsrc/bbs/1c4e679244ed87231befad2b04cddfe2_300_230.png"
|
||||
},
|
||||
{
|
||||
"name": "ai_pick_home_url",
|
||||
"value": "https://fupage.10jqka.com.cn/ai-web/ai-diagnosis-home.html?sync=1"
|
||||
},
|
||||
{
|
||||
"name": "ai_pick_strategy_detail_url",
|
||||
"value": "https://fupage.10jqka.com.cn/ai-web/ai-diagnosis-detail.html"
|
||||
},
|
||||
{
|
||||
"name": "ai_pick_strategy_label_url",
|
||||
"value": "https://fupage.10jqka.com.cn/ai-web/ai-diagnosis-label.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
{ "name": "pill_active_bg", "value": "#1A527DFF" },
|
||||
{ "name": "pill_inactive_bg", "value": "#0FFFFFFF" },
|
||||
{ "name": "color_rise", "value": "#FF2436" },
|
||||
{ "name": "color_fall", "value": "#07AB4B" }
|
||||
{ "name": "color_fall", "value": "#07AB4B" },
|
||||
{ "name": "tab_gradient_start", "value": "#002B2B2B" }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user