Compare commits

...

10 Commits

16 changed files with 1377 additions and 398 deletions
+104
View File
@@ -0,0 +1,104 @@
# 市场排名接口文档
本文记录 `MarketRankingNodeComponent` 卡片使用的合约榜单 HTTP 接口及 4106 行情订阅协议。当前实现位于
`entry/src/main/ets/market-ranking/MarketRankingDataFetcher.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 分钟涨跌速 |
原始推送为“字段 ID → 数组”的分列结构,相同数组下标表示同一合约。Fetcher 将其合并为
`RankItem[]`,再通过完整替换 `CardData` 刷新 UI。
## 当前 Mock 行为
- HTTP:使用 `@ohos.net.http` 构造 GET URL 和请求选项,但不调用 `request()`
- 行情:构造并输出完整 4106 参数,不连接真实行情桥。
- Mock 行情每 `2500ms` 推送一次,与 `pushtime=2.5` 保持一致。
- 每次请求及订阅参数均使用 `[MarketRanking]` 前缀输出到控制台。
+245 -124
View File
@@ -1,46 +1,100 @@
import { PickContractItem, PickTabData } from './AiPickTypes';
import { common, ConfigurationConstant } from '@kit.AbilityKit';
import { CardData, PickContractItem, PickTabData } from './AiPickTypes';
import { AiPickModel } from './AiPickModel';
import { cellColor, cellValue, formatColName } from './AiPickUtils';
import { MAX_ITEMS } from './AiPickConstant';
// @Builder 按引用传递要求单参数 + 对象字面量,用此 interface 包装 tabIdx
const RIGHT_ARROW_LIGHT =
'https://u.thsi.cn/imgsrc/bbs/8c8290d904cd89250d4e828da5f1010c_300_230.png';
const RIGHT_ARROW_DARK =
'https://u.thsi.cn/imgsrc/bbs/f5ff0a4fcd103d7b3da180ad90d9db23_300_230.png';
const EMPTY_IMAGE_LIGHT =
'https://u.thsi.cn/imgsrc/bbs/8eee9cd4fa7e359032de11a3f7c2a70b_300_230.png';
const EMPTY_IMAGE_DARK =
'https://u.thsi.cn/imgsrc/bbs/1c4e679244ed87231befad2b04cddfe2_300_230.png';
interface StrategyDescParams {
tabIdx: number;
}
@Component
export struct AiPick {
@State vm: AiPickModel = new AiPickModel();
// cardData prop:从第一层 floorList 透传下来的单卡片配置
@Watch('onCardDataChanged') @Prop cardData: CardData | undefined;
// 初值用 placeholder cardData 占位(仅类型占位,实际值由 aboutToAppear 用真实 cardData 创建)
@State vm: AiPickModel = new AiPickModel(this.cardData);
@State tabIdx: number = 0;
@State isDarkMode: boolean = false;
@State tabViewportWidth: number = 0;
@State tabContentWidth: number = 0;
@State tabScrollOffset: number = 0;
private truncateText(text: string): string {
const maxLength = 20;
let currentLength = 0;
let truncatedText = '';
for (const char of text) {
currentLength += /[\u4e00-\u9fa5]/.test(char) ? 2 : 1;
if (currentLength > maxLength) {
return `${truncatedText}...`;
}
truncatedText += char;
}
return truncatedText;
}
private tabTitle(tab: PickTabData): string {
return `${this.truncateText(tab.strategy.name)}(${tab.strategy.count})`;
}
private showTabGradient(): boolean {
const maxOffset = this.tabContentWidth - this.tabViewportWidth;
return maxOffset > 0 && this.tabScrollOffset < maxOffset;
}
private syncColorMode(): void {
const context = getContext(this) as common.UIAbilityContext;
this.isDarkMode = context.config.colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK;
}
aboutToAppear(): void {
this.syncColorMode();
this.vm = new AiPickModel(this.cardData);
this.vm.loadData();
}
// 父组件传入不同卡片配置时,重新构造 Model 并重新加载数据
onCardDataChanged(): void {
this.vm = new AiPickModel(this.cardData);
this.tabIdx = 0;
this.vm.loadData();
}
// 按引用传递:单参数对象字面量,$$.tabIdx 变化时内部 UI 自动刷新
@Builder
StrategyDesc($$: StrategyDescParams) {
// 图片:imgs[0] 白天,imgs[1] 夜间;对应 Vue 端 desc-text.vue 的 imgSrc computed
// 外层整体点击跳 jumpToDetail2tag 点击阻止冒泡后跳 jumpToLabel(对应 @click.stop
// 图片:imgs[0] 白天,imgs[1] 夜间
Row() {
// 左侧:问句 + 标签
Column({ space: 8 }) {
Column({ space: 4 }) {
if (this.vm.tabs[$$.tabIdx].strategy.detail) {
Text(this.vm.tabs[$$.tabIdx].strategy.detail)
.fontSize(14)
.lineHeight(21)
.fontColor($r('app.color.text_primary'))
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
if (this.vm.tabs[$$.tabIdx].strategy.tagList.length > 0) {
Row({ space: 8 }) {
Row({ space: 4 }) {
ForEach(this.vm.tabs[$$.tabIdx].strategy.tagList, (tag: string) => {
Text(tag)
.fontSize(11)
.lineHeight(18)
.fontColor($r('app.color.text_blue'))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(4)
.height(18)
.padding({ left: 3, right: 3 })
.borderRadius(2)
.border({ width: 0.5, color: $r('app.color.text_blue') })
.onClick((event: ClickEvent) => {
@@ -57,9 +111,9 @@ export struct AiPick {
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 10 })
// 右侧:策略图片(仅系统策略有图,对应 Vue 端 desc-text.vue 右侧 image
// 对应 Vue 端 v-if="isTrueArray(tabInfo.imgs) && imgSrc"
// 右侧:策略图片(仅系统策略有图,右侧 image)
// 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])
@@ -70,7 +124,7 @@ export struct AiPick {
}
}
.width('100%')
.margin({ top: 16 })
.margin({ top: 10 })
.alignItems(VerticalAlign.Top)
.onClick(() => {
this.vm.jumpToDetail2(
@@ -94,12 +148,19 @@ export struct AiPick {
.width('100%')
.padding({ bottom: 16 })
Text(this.vm.dataError ? '数据加载失败' : '加载中...')
.fontSize(14)
.fontColor($r('app.color.text_tertiary'))
if (this.vm.dataError) {
this.EmptyState('暂无满足条件的期货合约')
} else {
Column() {
Text('数据加载中')
.fontSize(16)
.fontColor($r('app.color.text_grey'))
}
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 24, bottom: 24 })
.height(186)
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
}
} else {
this.MainContent()
}
@@ -111,9 +172,6 @@ export struct AiPick {
MainContent() {
Column() {
// ── 标题栏 ──────────────────────────────────────────────
// 对应 Vue 端 card-title 组件:点击跳转 cardData.card_url(卡片配置的通用链接),
// 与"一句话定制策略"jumpToNewHome)无关;mockFloorData 中 ai-pick 的 card_url 为空,
// 故标题栏本身不绑定跳转行为,也不显示右箭头
Row() {
Text('AI选期')
.fontSize(18)
@@ -124,125 +182,171 @@ export struct AiPick {
.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)
Stack({ alignContent: Alignment.End }) {
Scroll() {
Row({ space: 8 }) {
ForEach(this.vm.tabs, (tab: PickTabData, index: number) => {
Text(this.tabTitle(tab))
.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: 63 })
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.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)
}
.onAreaChange((oldValue: Area, newValue: Area) => {
this.tabContentWidth = Number(newValue.width);
})
}
.width('100%')
.height(30)
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.align(Alignment.Start)
.onScroll((xOffset: number, _yOffset: number) => {
this.tabScrollOffset = xOffset;
})
if (this.showTabGradient()) {
Row()
.width(24)
.height(30)
.linearGradient({
angle: 90,
colors: [['#00FFFFFF', 0], [$r('app.color.card_bg'), 1]],
})
.hitTestBehavior(HitTestMode.None)
}
}
.width('100%')
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.align(Alignment.Start)
.height(30)
.onAreaChange((oldValue: Area, newValue: Area) => {
this.tabViewportWidth = Number(newValue.width);
})
// ── 策略问句(按引用传递,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') })
if (this.vm.tabs[this.tabIdx].items.length === 0) {
this.EmptyState('没有符合的合约')
} else {
// ── 表头 ──────────────────────────────────────────────
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)
.margin({ left: 8 })
.textAlign(TextAlign.End)
Text(formatColName(this.vm.tabs[this.tabIdx].keysList[2]))
.fontSize(12)
.fontColor($r('app.color.text_tertiary'))
.layoutWeight(1)
.margin({ left: 8 })
.textAlign(TextAlign.End)
}
.width('100%')
.height(24)
.margin({ top: 8 })
.padding({ bottom: 2 })
.alignItems(VerticalAlign.Center)
.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)
// ── 合约列表 ──────────────────────────────────────────
ForEach(
this.vm.tabs[this.tabIdx].items.slice(0, MAX_ITEMS),
(item: PickContractItem, index: number) => {
Row() {
Row({ space: 5 }) {
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(2)
.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($r('app.color.text_primary'))
.fontFamily('monospace')
.fontColor(cellColor(this.vm.tabs[this.tabIdx].keysList[1], item))
.layoutWeight(1)
.margin({ left: 8 })
.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)
.fontFamily('monospace')
.fontColor(cellColor(this.vm.tabs[this.tabIdx].keysList[2], item))
.layoutWeight(1)
.margin({ left: 8 })
.textAlign(TextAlign.End)
.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%')
.width('100%')
.height(44)
.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,
)
.border({ 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)
Row({ space: 2 }) {
Text('一句话定制策略')
.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%')
.justifyContent(FlexAlign.Center)
.alignItems(VerticalAlign.Center)
.margin({ top: 12 })
.onClick(() => {
this.vm.jumpToNewHome(
@@ -253,4 +357,21 @@ export struct AiPick {
}
.width('100%')
}
@Builder
EmptyState(message: string) {
Column() {
Image(this.isDarkMode ? EMPTY_IMAGE_DARK : EMPTY_IMAGE_LIGHT)
.width(120)
.height(120)
.margin({ bottom: 8 })
Text(message)
.fontSize(16)
.fontColor($r('app.color.text_grey'))
}
.width('100%')
.height(186)
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
}
}
+26 -14
View File
@@ -1,7 +1,7 @@
// AI选期接口层:负责从网络获取原始数据并解析为视图数据
// 对应 Vue 端 index.vue 里的 getData()http() 封装 + 解析流程)
// 目前网络请求未接入,直接返回 Mock 数据;接入时替换 fetchAiPickTabs 内部实现即可。
import {
CardData,
PickContractItem,
PickStrategy,
PickTabData,
@@ -13,16 +13,16 @@ 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
/**
* 新版接口地址(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 {
export function parseRawTab(item: RawPickTabItem): PickTabData {
const isSystemPeriod = item.type === 'system_period';
const strategyItem: RawStrategyItem = (
isSystemPeriod ? item.system_strategy : item.custom_strategy
@@ -52,12 +52,9 @@ function parseRawTab(item: RawPickTabItem): PickTabData {
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,
@@ -73,13 +70,28 @@ function parseRawTab(item: RawPickTabItem): PickTabData {
return tabData;
}
// 获取 AI 选期视图数据(原始网络数据 + 解析 PickTabData[]
// TODO: 接入真实接口后,改为调用 @ohos.net.http 请求上述 URL
// 解析 JSON 响应体为 RawPickTabItem[] 后传入 parseRawTab
export function fetchAiPickTabs(): Promise<PickTabData[]> {
// 把 RawPickTabItem[] 解析 PickTabData[](上限 MAX_TABS
export function parseRawTabs(rawTabs: RawPickTabItem[]): PickTabData[] {
return rawTabs.slice(0, MAX_TABS).map(parseRawTab);
}
// 获取 AI 选期视图数据(对应 Vue 端 getData() + useCardHandler.showData 决策)
// data_completed=true:使用 mod_data[0].data 内嵌数据,不发请求
// data_completed=false:发起 HTTP 请求(当前走 Mock),并通过 AiPickStorage 写入卡片级缓存
// TODO: 接入真实接口后,改为调用 @ohos.net.http 请求 cardData.mod_data[0].url,解析 JSON 响应体为 RawPickTabItem[] 后传入 parseRawTabs
export function fetchAiPickTabs(cardData: CardData): Promise<PickTabData[]> {
return new Promise<PickTabData[]>((resolve) => {
setTimeout(() => {
resolve(MOCK_RAW_TABS.slice(0, MAX_TABS).map(parseRawTab));
// 第一档:data_completed 内嵌数据
if (cardData.data_completed) {
const embedded = cardData.mod_data?.[0]?.data;
if (embedded && embedded.length > 0) {
resolve(parseRawTabs(embedded));
return;
}
}
// 第二档:HTTP 请求结果(当前用 mock 模拟)
resolve(parseRawTabs(MOCK_RAW_TABS));
}, MOCK_DELAY_MS);
});
}
+15 -8
View File
@@ -1,22 +1,29 @@
// AI选期卡片常量配置,供 AiPickModel / AiPickUtils / AiPick 共用
// 最多展示的策略 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
// 字段黑名单:过滤 field_list 中不应展示的字段
export const BLACK_LIST: string[] = [
'合约代码', '合约简称', '收盘价', '最新价', '涨跌幅', 'code',
'market_id', 'market_code', '最新涨跌幅',
'合约代码',
'合约简称',
'收盘价',
'最新价',
'涨跌幅',
'code',
'market_id',
'market_code',
'最新涨跌幅',
];
+87 -5
View File
@@ -1,28 +1,110 @@
import router from '@ohos.router';
import { PickTabData } from './AiPickTypes';
import { fetchAiPickTabs } from './AiPickApi';
import { CardData, CardsDataCache, PickTabData } from './AiPickTypes';
import { fetchAiPickTabs, parseRawTabs } from './AiPickApi';
// 顶层缓存 key:对应 Vue 端 __GLOBAL__.cardsDataCache(跨组件/跨页面共享)
const APP_STORAGE_CACHE_KEY = 'cardsDataCache';
// 空 CardData 占位(仅用于锺型调用场景,实际使用需设 cardData 后重新构造 Model, 后续接入后会删除
const EMPTY_CARD_DATA: CardData = {
card_id: 0,
card_key: '',
card_title: { value: '' },
bury_point: '',
};
@Observed
export class AiPickModel {
cardData: CardData;
cardKey: string;
tabs: PickTabData[] = [];
// 数据是否已就绪(对应 Vue 端 dataReady),View 层可据此展示 loading/骨架屏
dataReady: boolean = false;
// 请求是否失败(对应 Vue 端 dataError
dataError: boolean = false;
// 拉取并解析视图数据
// 构造时即同步读出 showData,对应 Vue 端 useCardHandler.showData 三态:
// 1. data_completed=true 且 mod_data[0].data 内嵌 → 直接解析内嵌数据(无需请求)
// 2. 否则从 AppStorage 卡片级缓存读出(对应 __GLOBAL__.cardsDataCache
// 3. 都没有 → 空数组,View 层展示 loading;后续 loadData() 异步拉取
// cardData 可选:不传则使用空占位,Model 处于“未绑定”状态,
// 需在 View 层 aboutToAppear 后赋值真实的 cardData(参见 AiPick.ets 的 @Watch 逻辑)
constructor(cardData?: CardData) {
this.cardData = cardData ?? EMPTY_CARD_DATA;
this.cardKey = this.cardData.card_key;
this.tabs = this.computeShowData();
this.dataReady = this.cardData.data_completed === true && this.tabs.length > 0;
}
// 同步决策:内嵌 > 缓存 > 空
private computeShowData(): PickTabData[] {
if (this.cardData.data_completed === true) {
const embedded = this.cardData.mod_data?.[0]?.data;
if (embedded && embedded.length > 0) {
return parseRawTabs(embedded);
}
}
const cache = AppStorage.get<CardsDataCache>(APP_STORAGE_CACHE_KEY);
return cache?.[this.cardKey] ?? [];
}
// 拉取数据:
// - data_completed=true:直接完成,不再发请求
// - 未绑定 cardData:不动作
// - 其他:调用 fetchAiPickTabs,请求成功后写入 AppStorage
async loadData(): Promise<void> {
if (this.cardKey === '') {
// 未绑定 cardData,不动作
return;
}
if (this.cardData.data_completed === true) {
this.dataReady = true;
this.dataError = false;
return;
}
try {
this.tabs = await fetchAiPickTabs();
const data = await fetchAiPickTabs(this.cardData);
this.tabs = data;
// 写入 AppStorage:合并式更新(AppStorage 不支持原地改 Map
const current: CardsDataCache = AppStorage.get<CardsDataCache>(APP_STORAGE_CACHE_KEY) ?? {};
const next: CardsDataCache = {};
Object.keys(current).forEach((k: string) => {
next[k] = current[k];
});
next[this.cardKey] = data;
AppStorage.setOrCreate<CardsDataCache>(APP_STORAGE_CACHE_KEY, next);
this.dataError = false;
} catch (e) {
this.tabs = [];
// 失败时保留原本的 showData(如有缓存命中)
this.dataError = true;
} finally {
this.dataReady = true;
}
}
/**
* Vue 版 AI 选期跳转协议备忘:
*
* domainrelease 使用 fupagedev/test 使用 futures-test。
* Web 页面统一包装为:
* client.html?action=ymtz^ishiddenbar=1^mode=new^webid=2804^url={qUrl}^title=AI选期
*
* 1. 点击合约:
* client://client.html?action=ymtz^webid=2205^stockcode={contract}^marketid={market}
* 2. 点击策略描述/图片:
* https://{domain}.10jqka.com.cn/ai-web/ai-diagnosis-detail.html
* ?id={strategyId}&type={periodType}&name={encodeURIComponent(name)}
* 3. 点击策略标签:
* https://{domain}.10jqka.com.cn/ai-web/ai-diagnosis-label.html
* ?id={strategyId}&type={periodType}&label={encodeURIComponent(label)}
* 4. 点击“一句话定制策略”:
* https://{domain}.10jqka.com.cn/ai-web/ai-diagnosis-home.html?sync=1
* 5. 旧版“查看详情”:
* https://{domain}.10jqka.com.cn/ai-web/futures-ai-diagnosis.html
* ?id={strategyId}&name={encodeURIComponent(strategyDetail)}
* 6. 点击卡片标题:使用 cardData.card_url.ios/android;当前 AI 选期 Mock 均为空。
*/
jumpToDetail(contract: string, market: string): void {
router.pushUrl({
url: 'pages/Detail',
@@ -107,3 +107,32 @@ export interface RawPickTabItem {
system_strategy?: RawStrategyItem;
custom_strategy?: RawStrategyItem;
}
// ============ 第二层:卡片级配置 ============
// 对应 Vue 端 floorList.card_list 中 ai-pick 那一项,从第一层透传到卡片
export interface CardTitle {
value: string;
}
export interface ModDataItem {
url: string;
param_list?: string[];
req_desc?: string;
// data_completed=true 时,直接把数据内嵌在这里(不再发请求)
data?: RawPickTabItem[];
}
export interface CardData {
card_id: number;
card_key: string;
card_title: CardTitle;
// 对应 Vue 端 mod_data[0]
mod_data?: ModDataItem[];
// 对应 Vue 端 mod_data[0].data:当后端已经在第一层完成数据下发时为 true
data_completed?: boolean;
bury_point: string;
}
// 对应 Vue 端 __GLOBAL__.cardsDataCache[cardKey]
// second 层缓存,记录上一次请求成功的视图数据
export type CardsDataCache = Record<string, PickTabData[]>;
+3 -3
View File
@@ -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)
}
}
@@ -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,19 @@
import { PeriodRange, TabMetric } from './MarketRankingTypes';
import { PeriodRange, TabMetric } from './MarketRankingModels';
// 一级 Tab 配置:涨幅/跌幅/涨速/跌速/成交额/日增仓,hqId/hqName 用于后续接入行情推送
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'), stat: 'rise', api: 'rise_percent', hqId: '34818', hqName: 'price_chg', index: 0, hasChildren: false },
{ id: 'fall', label: $r('app.string.market_ranking_tab_fall'), stat: 'fall', api: 'fall_percent', hqId: '34818', hqName: 'price_chg', index: 1, hasChildren: false },
{ id: 'riseSpeed', label: $r('app.string.market_ranking_tab_rise_speed'), stat: 'riseSpeed', api: 'rise_percent', hqId: '34821', hqName: 'chg_speed', index: 2, hasChildren: true },
{ id: 'fallSpeed', label: $r('app.string.market_ranking_tab_fall_speed'), stat: 'fallSpeed', api: 'fall_percent', hqId: '34821', hqName: 'chg_speed', index: 3, hasChildren: true },
{ id: 'turnOver', label: $r('app.string.market_ranking_tab_turnover'), stat: 'turnOver', api: 'turnover', hqId: '19', hqName: 'turnover', index: 4, hasChildren: false },
{ id: 'increPosit', label: $r('app.string.market_ranking_tab_increase_position'), stat: 'increPosit', api: 'increase_position', hqId: '34355', hqName: 'incre_posit', index: 5, 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'), stat: 'min1', api: 'one_minute', hqId: '34874', index: 0 },
{ id: '5min', label: $r('app.string.market_ranking_period_5min'), stat: 'min5', api: 'five_minute', hqId: '34325', index: 1 },
{ id: '10min', label: $r('app.string.market_ranking_period_10min'), stat: 'min10', api: 'ten_minute', hqId: '34875', index: 2 },
{ id: '15min', label: $r('app.string.market_ranking_period_15min'), stat: 'min15', api: 'fifteen_minute', hqId: '34876', index: 3 },
];
@@ -0,0 +1,273 @@
import http from '@ohos.net.http';
import BuildProfile from 'BuildProfile';
import {
CardData,
MarketRankingQuoteData,
MarketRankingQuoteRequestParams,
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 MOCK_QUOTE_INTERVAL_MS = 2500;
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 type MarketRankingCardDataListener = (cardData: CardData) => void;
export class MarketRankingDataFetcher {
private static readonly instance: MarketRankingDataFetcher = new MarketRankingDataFetcher();
private constructor() {
}
static getInstance(): MarketRankingDataFetcher {
return MarketRankingDataFetcher.instance;
}
/**
* 市场排名合约列表接口(当前暂用 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 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}`;
}
// 模拟行情订阅:先生成原始推送数据并完成合并,再返回完整 CardData。
subscribeQuotes(
contracts: RecommendFuturesItem[],
primaryIdx: number,
secondaryIdx: number,
listener: MarketRankingCardDataListener,
): () => void {
const requestParams = this.buildQuoteRequestParams(contracts, primaryIdx, secondaryIdx);
console.info(`[MarketRanking] subscribeQuotes params: ${JSON.stringify(requestParams)}`);
// todo: 接入真实的行情订阅
const quoteType = this.getQuoteType(primaryIdx, secondaryIdx);
let tick = 0;
const emit = (): void => {
tick += 1;
const quoteData = this.createMockQuoteData(contracts, quoteType, tick);
listener(this.mergeQuoteData(quoteData));
};
emit();
const timer = setInterval(emit, MOCK_QUOTE_INTERVAL_MS);
return (): void => {
clearInterval(timer);
};
}
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;
}
// 对齐 Vue generateHqParam,生成 4106 行情订阅所需的完整请求参数。
buildQuoteRequestParams(
contracts: RecommendFuturesItem[],
primaryIdx: number,
secondaryIdx: number,
): MarketRankingQuoteRequestParams {
const metric = TAB_METRICS[primaryIdx];
const sortId = metric.hasChildren ? PERIOD_RANGES[secondaryIdx].hqId : metric.hqId;
const sortOrder = metric.id === 'fall' || metric.id === 'fallSpeed' ? '1' : '0';
const dataItems: string[] = ['55', '10', '4', '36103', '34818', '6', '7', '66'];
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: string): 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,
requestDic,
};
}
// 按市场拼成 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('');
}
// 模拟 4106 行情接口原始分列数据;非当前 Tab 请求的扩展字段返回空数组。
private createMockQuoteData(
contracts: RecommendFuturesItem[],
quoteType: string,
tick: number,
): MarketRankingQuoteData {
const data: MarketRankingQuoteData = {
code: [],
market: [],
name: [],
price: [],
price_chg: [],
pre_price: [],
open_price: [],
pre_settle_price: [],
turnover: [],
incre_posit: [],
chg_speed_1min: [],
chg_speed_5min: [],
chg_speed_10min: [],
chg_speed_15min: [],
};
contracts.forEach((contract: RecommendFuturesItem, index: number): void => {
const price = 100 + index * 10 + tick * 0.1;
const priceChg = ((tick + index) % 5 - 2) * 0.35;
data.code.push(contract.contract_code);
data.market.push(contract.market);
data.name.push(contract.contract_name);
data.price.push(price);
data.price_chg.push(priceChg);
data.pre_price.push(price - 0.5);
data.open_price.push(price - 0.2);
data.pre_settle_price.push(price - 0.4);
if (quoteType === 'turnover') {
data.turnover.push(100000 + index * 5000 + tick * 100);
}
if (quoteType === 'increase_position') {
data.incre_posit.push(1000 + index * 100 + tick * 10);
}
const speed = priceChg / 2;
if (quoteType === 'one_minute_rise_percent' || quoteType === 'one_minute_fall_percent') {
data.chg_speed_1min.push(speed);
} else if (quoteType === 'five_minute_rise_percent' || quoteType === 'five_minute_fall_percent') {
data.chg_speed_5min.push(speed);
} else if (quoteType === 'ten_minute_rise_percent' || quoteType === 'ten_minute_fall_percent') {
data.chg_speed_10min.push(speed);
} else if (quoteType === 'fifteen_minute_rise_percent' || quoteType === 'fifteen_minute_fall_percent') {
data.chg_speed_15min.push(speed);
}
});
return data;
}
// 对齐 Vue handleSpeedHqRes/handleNormalHqRes:按数组下标合并为 View 使用的行数据。
private mergeQuoteData(data: MarketRankingQuoteData): CardData {
let speedList: number[] = data.chg_speed_1min;
if (speedList.length === 0) {
speedList = data.chg_speed_5min;
}
if (speedList.length === 0) {
speedList = data.chg_speed_10min;
}
if (speedList.length === 0) {
speedList = data.chg_speed_15min;
}
const tableList = data.code.map((code: string, index: number): RankItem => ({
code,
market: data.market[index],
name: data.name[index],
price: data.price[index],
price_chg: data.price_chg[index],
chg_speed: speedList[index],
turnover: data.turnover[index],
incre_posit: data.incre_posit[index],
}));
return { tableList };
}
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 [];
}
}
}
@@ -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> {
}
}
@@ -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,9 +16,44 @@ export interface RankItem {
incre_posit?: number;
}
// 当前市场排名卡片的完整展示数据;网络/行情更新后整体替换以触发 UI 刷新。
export interface CardData {
tableList: RankItem[];
}
// 4106 行情推送的原始分列数据。Vue 中实际为“字段 ID -> 数组”的字典,
// ArkTS 使用显式属性描述同一结构,数组中相同下标代表同一份合约行情。
// 字段 IDcode=4、market=36103、name=55、price=10、price_chg=34818、
// pre_price=6、open_price=7、pre_settle_price=66、turnover=19、incre_posit=34355、
// chg_speed_1min=34874、chg_speed_5min=34325、chg_speed_10min=34875、chg_speed_15min=34876。
export interface MarketRankingQuoteData {
code: string[];
market: string[];
name: string[];
price: number[];
price_chg: number[];
pre_price: number[];
open_price: number[];
pre_settle_price: number[];
turnover: number[];
incre_posit: number[];
chg_speed_1min: number[];
chg_speed_5min: number[];
chg_speed_10min: number[];
chg_speed_15min: number[];
}
export interface MarketRankingQuoteRequestParams {
protocolId: string;
pageId: string;
onlineId: string;
columnOrder: string;
requestDic: string;
}
export interface TabMetric {
id: string;
label: string;
label: ResourceStr;
stat: string; // 埋点用
api: string; // HTTP 接口 quote_type 参数用
hqId: string; // 行情订阅 sortid 用
@@ -29,7 +64,7 @@ export interface TabMetric {
export interface PeriodRange {
id: string;
label: string;
label: ResourceStr;
stat: string;
api: string;
hqId: string;
@@ -42,6 +77,7 @@ export interface PeriodRange {
export interface RecommendFuturesItem {
contract_code: string;
contract_name: string;
market: string;
}
export interface RecommendFuturesResponse {
@@ -0,0 +1,401 @@
import router from '@ohos.router';
import {
CardData,
PeriodRange,
RankItem, RecommendFuturesItem, TabMetric,
} from './MarketRankingModels';
import { MarketRankingDataFetcher } from './MarketRankingDataFetcher';
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 MarketRankingNodeComponent {
@State cardData: CardData = { tableList: [] };
// 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;
private quoteUnsubscribe: (() => void) | undefined;
aboutToAppear(): void {
// Mock 网络延迟:模拟真实接口返回前的等待过程,接入真实请求后移除该 setTimeout。
setTimeout(() => {
this.updateCardData(this.primaryIdx, this.secondaryIdx);
}, 600)
}
aboutToDisappear(): void {
this.stopSubscribeQuotes();
}
build() {
Column() {
// 标题栏:标题 + 展开/收起胶囊紧挨着,都在左侧
Row() {
Text($r('app.string.market_ranking_title'))
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(this.unfolded
? $r('app.string.market_ranking_collapse')
: $r('app.string.market_ranking_expand'))
.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: 8 })
.onClick(() => {
this.unfolded = !this.unfolded;
})
}
.width('100%')
.height(48)
.alignItems(VerticalAlign.Center)
// 一级 Tab(胶囊按钮,可横向滚动)
Stack({ alignContent: Alignment.End }) {
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;
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: [['#00FFFFFF', 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.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;
this.updateCardData(this.primaryIdx, index);
})
}, (period: PeriodRange) => period.id)
}
.width('100%')
.justifyContent(FlexAlign.End)
.padding({ left: 10, right: 10 })
.margin({ top: 8, left: -10, right: -10 })
}
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())
.margin({ top: 10 })
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
} else {
// 表头
Row() {
Text($r('app.string.market_ranking_contract_name'))
.fontSize(12)
.lineHeight(21)
.fontColor($r('app.color.text_tertiary'))
.width('35%')
Text($r('app.string.market_ranking_latest_price'))
.fontSize(12)
.lineHeight(21)
.fontColor($r('app.color.text_tertiary'))
.layoutWeight(1)
.margin({ left: 8 })
.textAlign(TextAlign.End)
Text(this.thirdColumnTitle())
.fontSize(12)
.lineHeight(21)
.fontColor($r('app.color.text_tertiary'))
.layoutWeight(1)
.margin({ left: 8 })
.textAlign(TextAlign.End)
}
.width('100%')
.height(23)
.margin({ top: 10 })
.padding({ bottom: 2 })
.alignItems(VerticalAlign.Center)
.border({ width: { bottom: 0.5 }, color: $r('app.color.divider_color') })
// 表格列表
ForEach(
this.cardData.tableList.slice(0, this.displaySize()),
(item: RankItem, index: number) => {
Row() {
Text(item.name)
.fontSize(16)
.minFontSize(10)
.lineHeight(21)
.fontColor($r('app.color.text_primary'))
.width('35%')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(item.price === undefined ? '--' : `${item.price}`)
.fontSize(16)
.minFontSize(10)
.lineHeight(21)
.fontFamily('monospace')
.fontColor(this.priceColor(item))
.layoutWeight(1)
.margin({ left: 8 })
.textAlign(TextAlign.End)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.thirdColumnValue(item))
.fontSize(16)
.minFontSize(10)
.lineHeight(21)
.fontFamily('monospace')
.fontColor(this.thirdColumnColor(item))
.layoutWeight(1)
.margin({ left: 8 })
.textAlign(TextAlign.End)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.height(45)
.alignItems(VerticalAlign.Center)
.border(index === Math.min(
this.displaySize(),
this.cardData.tableList.length,
) - 1
? undefined
: { width: { bottom: 0.5 }, 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%')
}
// 先请求合约榜单填充列表,再根据当前 CardData 订阅行情。
private async updateCardData(primaryIdx: number, secondaryIdx: number): Promise<void> {
// 切换 Tab 后立即清理旧列表,避免新请求返回前闪现上一 Tab 的数据。
this.stopSubscribeQuotes();
this.cardData = { tableList: [] };
try {
const contracts = await MarketRankingDataFetcher
.getInstance()
.fetchRecommendFutures(primaryIdx, secondaryIdx);
this.cardData = {
tableList: contracts.map((contract: RecommendFuturesItem): RankItem => ({
code: contract.contract_code,
market: contract.market,
name: contract.contract_name,
})),
};
this.subscribeQuotes(primaryIdx, secondaryIdx);
} catch {
this.cardData = { tableList: [] };
}
}
// 使用当前 CardData 中的合约列表订阅行情,推送后整体更新 CardData。
private subscribeQuotes(primaryIdx: number, secondaryIdx: number): void {
const contracts = this.cardData.tableList.map((item: RankItem): RecommendFuturesItem => ({
contract_code: item.code,
contract_name: item.name,
market: item.market,
}));
this.quoteUnsubscribe = MarketRankingDataFetcher
.getInstance()
.subscribeQuotes(contracts, primaryIdx, secondaryIdx, (cardData: CardData): void => {
this.cardData = cardData;
});
}
private stopSubscribeQuotes(): void {
if (this.quoteUnsubscribe !== undefined) {
this.quoteUnsubscribe();
this.quoteUnsubscribe = undefined;
}
}
// 跳转到分时详情页
// 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,
},
});
}
// 以下是UI相关
private showTabGradient(): boolean {
const maxOffset = this.tabContentWidth - this.tabViewportWidth;
return maxOffset > 0 && this.tabScrollOffset < maxOffset;
}
private emptyHeight(): number {
const rowHeight = 45;
const headerHeight = 24;
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 === undefined || item.price_chg === undefined) {
return $r('app.color.text_primary');
}
return riseFallColor(item.price_chg);
}
}
+21 -5
View File
@@ -1,26 +1,42 @@
import { Card } from '../common/Card';
import { AIView } from '../ai-view/AIView';
import { MarketRanking } from '../market-ranking/MarketRanking';
import { MarketRankingNodeComponent } from '../market-ranking/MarketRankingNodeComponent';
import { AiPick } from '../ai-pick/AiPick';
import { CardData } from '../ai-pick/AiPickTypes';
// 模拟 floorList 中 ai-pick 那一项 card_list 配置
// 对应 src/src/pages/card-preview/mockFloorData.ts 第 950-975 行
const aiPickCardData: CardData = {
card_id: 5106,
card_key: 'futuresHomepageAIPickCard',
card_title: { value: 'AI选期' },
bury_point: 'aiPick',
data_completed: false,
mod_data: [{
url: 'https://ftapi.10jqka.com.cn/futgwapi/api/ai_diagnosis/home_strategy/v1/user_data',
param_list: [''],
req_desc: 'http_get',
}],
};
@Entry
@Component
struct Index {
build() {
Scroll() {
Column({ space: 16 }) {
Column({ space: 8 }) {
Card() {
AIView()
}
Card() {
MarketRanking()
MarketRankingNodeComponent()
}
Card() {
AiPick()
AiPick({ cardData: aiPickCardData })
}
}
.width('100%')
.padding(16)
.padding({ left: 6, right: 6 })
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Start)
}
@@ -11,6 +11,106 @@
{
"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分钟跌速"
}
]
}
}