feat: AI选期商业卡片
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
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%')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// AI选期接口层:负责从网络获取原始数据
|
||||
// 对应 Vue 端 index.vue 里的 getData()(http() 封装 + __GLOBAL__.api.API_HOST 拼接)
|
||||
// 目前网络请求未接入,直接返回 Mock 数据;接入时替换 fetchAiPickTabs 内部实现即可,
|
||||
// 上层 AiPickModel 的调用方式不需要改动。
|
||||
import { RawPickTabItem } from './AiPickTypes';
|
||||
import { MOCK_RAW_TABS } from './AiPickMock';
|
||||
|
||||
// 新版接口地址(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;
|
||||
|
||||
// 获取 AI 选期原始数据
|
||||
// TODO: 接入真实接口后,改为调用 @ohos.net.http 请求上述 URL,
|
||||
// 解析 JSON 响应体为 RawPickTabItem[] 返回
|
||||
export function fetchAiPickTabs(): Promise<RawPickTabItem[]> {
|
||||
return new Promise<RawPickTabItem[]>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(MOCK_RAW_TABS);
|
||||
}, MOCK_DELAY_MS);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// AI选期卡片常量配置,供 AiPickModel / AiPickUtils / AiPick 共用
|
||||
|
||||
// 最多展示的策略 Tab 数(对应 Vue 端 getData 里 .slice(0, 5))
|
||||
export const MAX_TABS = 5;
|
||||
|
||||
// 每个 Tab 最多展示的合约条数(对应 Vue 端 tabDataList 里 .slice(0, 3))
|
||||
export const MAX_ITEMS = 3;
|
||||
|
||||
// 策略标签最多展示条数(对应 Vue 端 tagList.slice(0, 3))
|
||||
export const MAX_TAGS = 3;
|
||||
|
||||
// 表格列数:合约名称 + 最多 2 个数据列(对应 Vue 端 getShowKeyList 的 slice(0, 3))
|
||||
export const MAX_SHOW_KEYS = 3;
|
||||
|
||||
// 自定义 DOUBLE 字段最多取前 2 个用于表头(对应 Vue 端 showKey = doubleListKey.slice(0, 2))
|
||||
export const MAX_DOUBLE_KEYS = 2;
|
||||
|
||||
// 字段黑名单:过滤 field_list 中不应展示的字段(对应 Vue 端 utils.ts 的 blackList)
|
||||
export const BLACK_LIST: string[] = [
|
||||
'合约代码', '合约简称', '收盘价', '最新价', '涨跌幅', 'code',
|
||||
'market_id', 'market_code', '最新涨跌幅',
|
||||
];
|
||||
@@ -0,0 +1,98 @@
|
||||
import { RawPickTabItem } from './AiPickTypes';
|
||||
|
||||
// 完全模拟 user_data 接口响应(新版,isCustomVersion=true)
|
||||
// 数组每项对应一个策略 Tab,最多 5 项,这里给 3 项
|
||||
// 注意:detail_count 与 detail_list/field_list/origin_result_list 同级
|
||||
// (对应 Vue 端 strategyItem['detail_count'],不在嵌套的策略详情对象里)
|
||||
export const MOCK_RAW_TABS: RawPickTabItem[] = [
|
||||
// ── Tab 1:自定义策略(type = 'custom_strategy')──────────────────
|
||||
{
|
||||
type: 'custom_strategy',
|
||||
custom_strategy: {
|
||||
custom_strategy: {
|
||||
id: 'strat_001',
|
||||
strategy_name: '铜类多头趋势',
|
||||
user_question: '近期有色金属板块持续走强,铜主力合约突破前高,量价齐升',
|
||||
strategy_type: 'custom_strategy',
|
||||
tag_list: ['多头', '有色金属'],
|
||||
},
|
||||
detail_count: 5,
|
||||
detail_list: [
|
||||
{ contract: 'CU2506', market: '70', contract_name: '沪铜2506', main_contract_type: '1' },
|
||||
{ contract: 'CU2507', market: '70', contract_name: '沪铜2507', main_contract_type: '0' },
|
||||
{ contract: 'NI2506', market: '70', contract_name: '沪镍2506', main_contract_type: '1' },
|
||||
{ contract: 'AL2506', market: '70', contract_name: '沪铝2506', main_contract_type: '1' },
|
||||
{ contract: 'ZN2506', market: '70', contract_name: '沪锌2506', main_contract_type: '1' },
|
||||
],
|
||||
field_list: [
|
||||
{ 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 },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// ── Tab 2:系统周期策略(type = 'system_period')──────────────────
|
||||
{
|
||||
type: 'system_period',
|
||||
system_strategy: {
|
||||
system_strategy: {
|
||||
id: 'strat_002',
|
||||
strategy_name: '黑色系超跌反弹',
|
||||
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_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')──────────────────
|
||||
{
|
||||
type: 'custom_strategy',
|
||||
custom_strategy: {
|
||||
custom_strategy: {
|
||||
id: 'strat_003',
|
||||
strategy_name: '农产品季节性',
|
||||
user_question: '豆粕、菜粕受季节性需求驱动,历史同期胜率超70%',
|
||||
strategy_type: 'custom_strategy',
|
||||
tag_list: ['季节性', '农产品'],
|
||||
},
|
||||
detail_count: 4,
|
||||
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' },
|
||||
],
|
||||
field_list: [
|
||||
{ key: '成交量', type: 'DOUBLE', unit: '手' },
|
||||
{ key: '持仓量', type: 'DOUBLE', unit: '手' },
|
||||
],
|
||||
origin_result_list: [
|
||||
{ '成交量': 986000, '持仓量': 3102000 },
|
||||
{ '成交量': 324000, '持仓量': 1258000 },
|
||||
{ '成交量': 189000, '持仓量': 683000 },
|
||||
{ '成交量': 142000, '持仓量': 521000 },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,117 @@
|
||||
import router from '@ohos.router';
|
||||
import {
|
||||
PickContractItem,
|
||||
PickStrategy,
|
||||
PickTabData,
|
||||
RawPickTabItem,
|
||||
RawStrategyDetail,
|
||||
RawStrategyItem,
|
||||
} from './AiPickTypes';
|
||||
import { getResData, getTableList, ResData } from './AiPickUtils';
|
||||
import { fetchAiPickTabs } from './AiPickApi';
|
||||
import { MAX_ITEMS, MAX_TABS, MAX_TAGS } from './AiPickConstant';
|
||||
|
||||
// 对应 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;
|
||||
}
|
||||
|
||||
// ── Model ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Observed
|
||||
export class AiPickModel {
|
||||
tabs: PickTabData[] = [];
|
||||
// 数据是否已就绪(对应 Vue 端 dataReady),View 层可据此展示 loading/骨架屏
|
||||
dataReady: boolean = false;
|
||||
// 请求是否失败(对应 Vue 端 dataError)
|
||||
dataError: boolean = false;
|
||||
|
||||
// 拉取数据并解析为视图数据(对应 Vue 端 getData() → formatTabData())
|
||||
async loadData(): Promise<void> {
|
||||
try {
|
||||
const rawTabs = await fetchAiPickTabs();
|
||||
this.tabs = rawTabs.slice(0, MAX_TABS).map(parseRawTab);
|
||||
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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// ============ 视图数据类型(供 View 消费) ============
|
||||
|
||||
// 单条合约行
|
||||
export interface PickContractItem {
|
||||
contract: string;
|
||||
market: string;
|
||||
name: string;
|
||||
// 最新价(接入行情后实时更新)
|
||||
price?: number;
|
||||
// 涨跌幅——结算价(接入行情后实时更新)
|
||||
priceChg?: number;
|
||||
// 是否主力合约
|
||||
isMain: boolean;
|
||||
// 自定义策略额外展示的字段(key → 已格式化的字符串值,对应 Vue 端 item[name])
|
||||
extraFields: Record<string, string>;
|
||||
}
|
||||
|
||||
// 字段元信息(对应 Vue 端 fieldList,用于取单位)
|
||||
export interface PickFieldItem {
|
||||
key: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
// 单个 Tab 的完整数据
|
||||
export interface PickTabData {
|
||||
// Tab 标题(策略名,含"(N个)"后缀)
|
||||
title: string;
|
||||
// 问句信息(用于 desc-text 区域)
|
||||
strategy: PickStrategy;
|
||||
// 合约行列表(最多 3 条)
|
||||
items: PickContractItem[];
|
||||
// 列名,共 3 项:keysList[0] 固定'合约名称',[1][2] 为数据列
|
||||
// 对应 Vue 端 getShowKeyList 的输出
|
||||
keysList: string[];
|
||||
// 字段元信息列表,用于查找单位(对应 Vue 端 fieldList)
|
||||
fieldList: PickFieldItem[];
|
||||
}
|
||||
|
||||
// 策略摘要(问句 + 标签 + 图片,对应 desc-text.vue)
|
||||
export interface PickStrategy {
|
||||
id: string;
|
||||
name: string;
|
||||
// 问句正文(user_question 优先,否则 content)
|
||||
detail: string;
|
||||
// 策略类型
|
||||
type: string;
|
||||
// period_type(用于跳转详情页)
|
||||
periodType: string;
|
||||
// 最多 3 个标签
|
||||
tagList: string[];
|
||||
// 图片:[白天图, 夜间图];系统策略有值,自定义策略为空数组
|
||||
imgs: string[];
|
||||
// 合约总数(detail_count)
|
||||
count: number;
|
||||
}
|
||||
|
||||
// ============ 后端接口原始响应类型 ============
|
||||
// 对应接口:futgwapi/api/ai_diagnosis/home_strategy/v1/user_data(新版自定义)
|
||||
// futgwapi/api/ai_diagnosis/system_strategy/v1/home_data(旧版系统)
|
||||
|
||||
export interface RawStrategyDetail {
|
||||
id: string;
|
||||
strategy_name: string;
|
||||
// 新版自定义策略
|
||||
user_question?: string;
|
||||
// 旧版系统策略
|
||||
content?: string;
|
||||
strategy_type: string;
|
||||
tag_list?: string[];
|
||||
// 系统策略图片
|
||||
white_img?: string;
|
||||
black_img?: string;
|
||||
}
|
||||
|
||||
export interface RawFieldItem {
|
||||
key: string;
|
||||
// 'DOUBLE' | 'STRING' | ...
|
||||
type: string;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface RawDetailItem {
|
||||
contract: string;
|
||||
market: string;
|
||||
contract_name: string;
|
||||
// '1' = 主力合约
|
||||
main_contract_type: string;
|
||||
}
|
||||
|
||||
export type RawResultItem = Record<string, number | string>;
|
||||
|
||||
// 每个 Tab 对应的策略数据块(custom_strategy 或 system_strategy 键下的内容)
|
||||
export interface RawStrategyItem {
|
||||
// 嵌套的同名策略详情(custom_strategy.custom_strategy 或 system_strategy.system_strategy)
|
||||
custom_strategy?: RawStrategyDetail;
|
||||
system_strategy?: RawStrategyDetail;
|
||||
detail_list?: RawDetailItem[];
|
||||
field_list?: RawFieldItem[];
|
||||
origin_result_list?: RawResultItem[];
|
||||
detail_count?: number;
|
||||
}
|
||||
|
||||
// 接口响应数组的每一项(一个 Tab)
|
||||
export interface RawPickTabItem {
|
||||
// 'system_period' | 'custom_strategy'
|
||||
type: string;
|
||||
system_strategy?: RawStrategyItem;
|
||||
custom_strategy?: RawStrategyItem;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// 对应 Vue 端 utils.ts + pick-stock-list.vue:纯函数工具集,不持有状态
|
||||
import {
|
||||
PickContractItem,
|
||||
PickFieldItem,
|
||||
RawDetailItem,
|
||||
RawFieldItem,
|
||||
RawResultItem,
|
||||
RawStrategyItem,
|
||||
} from './AiPickTypes';
|
||||
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 inBlack = BLACK_LIST.includes(key) || extBlackList.includes(key);
|
||||
const matchRegExp = key.startsWith('涨跌幅[') || key.startsWith('期货@涨跌幅[');
|
||||
return !inBlack && !matchRegExp;
|
||||
}
|
||||
|
||||
// 对应 formatBigData:大数换算(万/亿/万亿),含 Vue 端进位修正逻辑
|
||||
// (如 9999.995 亿四舍五入到 2 位后达到 10000,需进一步换算成 1.00 万亿)
|
||||
export function formatBigData(num: number | string): string {
|
||||
const asNum = Number(num);
|
||||
if (isNaN(asNum)) {
|
||||
return `${num}`;
|
||||
}
|
||||
const tenThousand = 1e4;
|
||||
const billion = 1e8;
|
||||
const trillion = 1e12;
|
||||
// 先精确到 3 位小数,与 Vue 端 Number(Number(num).toFixed(3)) 对齐
|
||||
const formatNum = Number(asNum.toFixed(3));
|
||||
const absNum = Math.abs(formatNum);
|
||||
|
||||
if (absNum >= trillion) {
|
||||
return `${(formatNum / trillion).toFixed(2)}万亿`;
|
||||
}
|
||||
if (absNum >= billion) {
|
||||
if (Number((absNum / billion).toFixed(2)) >= tenThousand) {
|
||||
return `${(formatNum / trillion).toFixed(2)}万亿`;
|
||||
}
|
||||
return `${(formatNum / billion).toFixed(2)}亿`;
|
||||
}
|
||||
if (absNum >= tenThousand) {
|
||||
if (Number((absNum / tenThousand).toFixed(2)) >= tenThousand) {
|
||||
return `${(formatNum / billion).toFixed(2)}亿`;
|
||||
}
|
||||
return `${(formatNum / tenThousand).toFixed(2)}万`;
|
||||
}
|
||||
// 小于 1 万:Vue 端返回原始数字(模板里自动转字符串),这里等价转换
|
||||
return `${formatNum}`;
|
||||
}
|
||||
|
||||
// 对应 getShowKeyList:生成 3 列列名
|
||||
export function getShowKeyList(doubleKeys: string[]): string[] {
|
||||
if (doubleKeys.length === 0) {
|
||||
return ['合约名称', '最新价', '涨跌幅(结)'];
|
||||
}
|
||||
// ['合约名称', doubleKeys[0], '涨跌幅(结)', '最新价'].slice(0, 3)
|
||||
return ['合约名称'].concat(doubleKeys.concat(['涨跌幅(结)', '最新价'])).slice(0, MAX_SHOW_KEYS);
|
||||
}
|
||||
|
||||
// 对应 getResultValueList:从 origin_result_list 中取 DOUBLE 字段并格式化
|
||||
export function getResultValueList(
|
||||
resultList: RawResultItem[],
|
||||
doubleListKey: string[],
|
||||
): Record<string, string>[] {
|
||||
return resultList.map(v => {
|
||||
const res: Record<string, string> = {};
|
||||
for (const key of doubleListKey) {
|
||||
if (v[key] !== undefined) {
|
||||
res[key] = formatBigData(v[key] as number | string);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
export interface ResData {
|
||||
showKeyList: string[];
|
||||
fieldList: PickFieldItem[];
|
||||
resultValueList: Record<string, string>[];
|
||||
list: RawDetailItem[];
|
||||
}
|
||||
|
||||
// 对应 getResData:从 RawStrategyItem 里提取展示所需的全部数据
|
||||
export function getResData(strategyItem: RawStrategyItem): ResData {
|
||||
const rawFieldList: RawFieldItem[] = strategyItem.field_list ?? [];
|
||||
const resultList: RawResultItem[] = strategyItem.origin_result_list ?? [];
|
||||
const list: RawDetailItem[] = strategyItem.detail_list ?? [];
|
||||
|
||||
// 过滤出 DOUBLE 类型且不在黑名单的字段
|
||||
const doubleList: RawFieldItem[] = rawFieldList.filter(
|
||||
(v: RawFieldItem) => v.type === 'DOUBLE' && filterFieldKey(v.key),
|
||||
);
|
||||
// 全部 DOUBLE key 用于格式化 resultValueList(对应 Vue 端逻辑)
|
||||
const doubleListKey: string[] = doubleList.map((v: RawFieldItem) => 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 fi: PickFieldItem = { key: v.key, unit: v.unit ?? '' };
|
||||
return fi;
|
||||
});
|
||||
|
||||
const resultValueList: Record<string, string>[] = getResultValueList(resultList, doubleListKey);
|
||||
|
||||
const result: ResData = { showKeyList, fieldList, resultValueList, list };
|
||||
return result;
|
||||
}
|
||||
|
||||
// 对应 getTableList:将 detail_list 和 origin_result_list 合并成 PickContractItem
|
||||
export function getTableList(
|
||||
list: RawDetailItem[],
|
||||
resultValueList: Record<string, string>[],
|
||||
): PickContractItem[] {
|
||||
return list.map((item: RawDetailItem, index: number) => {
|
||||
const extraFields: Record<string, string> = resultValueList[index] ?? {};
|
||||
const ci: PickContractItem = {
|
||||
contract: item.contract,
|
||||
market: item.market,
|
||||
name: item.contract_name,
|
||||
isMain: item.main_contract_type === '1',
|
||||
extraFields,
|
||||
};
|
||||
return ci;
|
||||
});
|
||||
}
|
||||
|
||||
// ── View 层专用纯函数(对应 Vue 端 pick-stock-list.vue)──────────────
|
||||
|
||||
// 对应 formatName:去除"期货@"前缀和"[...]"括号内容
|
||||
export function formatColName(name: string): string {
|
||||
return name.replace(/期货@/g, '').replace(/\[.*?\]/g, '');
|
||||
}
|
||||
|
||||
// 对应 getUnit:从 fieldList 里按 key 查单位
|
||||
export function getUnit(key: string, fieldList: PickFieldItem[]): string {
|
||||
const item = fieldList.find(f => f.key === key);
|
||||
return item ? item.unit : '';
|
||||
}
|
||||
|
||||
// 对应表格单元格取值逻辑:
|
||||
// '最新价' → item.price
|
||||
// '涨跌幅(结)' → formatPercent(item.priceChg)
|
||||
// 其他 → item.extraFields[name] + unit
|
||||
export function cellValue(colName: string, item: PickContractItem, fieldList: PickFieldItem[]): string {
|
||||
if (colName === '最新价') {
|
||||
return item.price !== undefined ? `${item.price}` : '--';
|
||||
}
|
||||
if (colName === '涨跌幅(结)') {
|
||||
return formatPercent(item.priceChg);
|
||||
}
|
||||
const raw = item.extraFields[colName] ?? '--';
|
||||
const unit = getUnit(colName, fieldList);
|
||||
return unit ? `${raw}${unit}` : raw;
|
||||
}
|
||||
|
||||
// 对应表格单元格取色逻辑:
|
||||
// '最新价' / '涨跌幅(结)' → 按 priceChg 涨跌取色
|
||||
// 其他 → text-primary(中性色)
|
||||
export function cellColor(colName: string, item: PickContractItem): Resource {
|
||||
if (colName === '最新价' || colName === '涨跌幅(结)') {
|
||||
return riseFallColor(item.priceChg);
|
||||
}
|
||||
return $r('app.color.text_primary');
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Card } from '../common/Card';
|
||||
import { AIView } from '../ai-view/AIView';
|
||||
import { MarketRanking } from '../market-ranking/MarketRanking';
|
||||
import { AiPick } from '../ai-pick/AiPick';
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
@@ -14,6 +15,9 @@ struct Index {
|
||||
Card() {
|
||||
MarketRanking()
|
||||
}
|
||||
Card() {
|
||||
AiPick()
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import router from '@ohos.router';
|
||||
|
||||
// 对应 Vue 端三种跳转目标:detail / label / home
|
||||
type StrategyDetailType = 'detail' | 'label' | 'home';
|
||||
|
||||
interface StrategyDetailParams {
|
||||
type: StrategyDetailType;
|
||||
id?: string;
|
||||
name?: string;
|
||||
periodType?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct StrategyDetail {
|
||||
@State pageType: StrategyDetailType = 'detail';
|
||||
@State strategyId: string = '';
|
||||
@State strategyName: string = '';
|
||||
@State periodType: string = '';
|
||||
@State label: string = '';
|
||||
|
||||
aboutToAppear(): void {
|
||||
const params = router.getParams() as StrategyDetailParams;
|
||||
if (params) {
|
||||
this.pageType = params.type ?? 'detail';
|
||||
this.strategyId = params.id ?? '';
|
||||
this.strategyName = params.name ?? '';
|
||||
this.periodType = params.periodType ?? '';
|
||||
this.label = params.label ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
private pageTitle(): string {
|
||||
if (this.pageType === 'home') {
|
||||
return '一句话定制策略';
|
||||
}
|
||||
if (this.pageType === 'label') {
|
||||
return `标签:${this.label}`;
|
||||
}
|
||||
return 'AI选期详情';
|
||||
}
|
||||
|
||||
private pageDesc(): string {
|
||||
if (this.pageType === 'home') {
|
||||
return 'mock 一句话定制策略主页,对应 Vue 端 ai-diagnosis-home.html';
|
||||
}
|
||||
if (this.pageType === 'label') {
|
||||
return `mock 标签筛选页,对应 Vue 端 ai-diagnosis-label.html\nlabel=${this.label}`;
|
||||
}
|
||||
return `mock 策略详情页,对应 Vue 端 ai-diagnosis-detail.html`;
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
// 导航栏
|
||||
Row() {
|
||||
Text('返回')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_blue'))
|
||||
.onClick(() => {
|
||||
router.back();
|
||||
})
|
||||
Text(this.pageTitle())
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.margin({ left: 16 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
|
||||
// 参数展示
|
||||
Column({ space: 12 }) {
|
||||
if (this.strategyId) {
|
||||
Row() {
|
||||
Text('策略 ID')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_tertiary'))
|
||||
.layoutWeight(1)
|
||||
Text(this.strategyId)
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(2)
|
||||
.textAlign(TextAlign.End)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 12, bottom: 12 })
|
||||
.border({ width: { bottom: 0.5 }, color: $r('app.color.divider_color') })
|
||||
}
|
||||
|
||||
if (this.strategyName) {
|
||||
Row() {
|
||||
Text('策略名称')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_tertiary'))
|
||||
.layoutWeight(1)
|
||||
Text(this.strategyName)
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(2)
|
||||
.textAlign(TextAlign.End)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 12, bottom: 12 })
|
||||
.border({ width: { bottom: 0.5 }, color: $r('app.color.divider_color') })
|
||||
}
|
||||
|
||||
if (this.periodType) {
|
||||
Row() {
|
||||
Text('Period Type')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_tertiary'))
|
||||
.layoutWeight(1)
|
||||
Text(this.periodType)
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(2)
|
||||
.textAlign(TextAlign.End)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 12, bottom: 12 })
|
||||
.border({ width: { bottom: 0.5 }, color: $r('app.color.divider_color') })
|
||||
}
|
||||
|
||||
if (this.label) {
|
||||
Row() {
|
||||
Text('标签')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_tertiary'))
|
||||
.layoutWeight(1)
|
||||
Text(this.label)
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(2)
|
||||
.textAlign(TextAlign.End)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 12, bottom: 12 })
|
||||
.border({ width: { bottom: 0.5 }, color: $r('app.color.divider_color') })
|
||||
}
|
||||
|
||||
Text(this.pageDesc())
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_tertiary'))
|
||||
.margin({ top: 16 })
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.borderRadius(12)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
.margin({ left: 16, right: 16 })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor($r('app.color.page_bg'))
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.justifyContent(FlexAlign.Start)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,11 @@
|
||||
"module": {
|
||||
"name": "entry",
|
||||
"type": "entry",
|
||||
"requestPermissions": [
|
||||
{
|
||||
"name": "ohos.permission.INTERNET"
|
||||
}
|
||||
],
|
||||
"description": "$string:module_desc",
|
||||
"mainElement": "EntryAbility",
|
||||
"deviceTypes": [
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
{ "name": "pill_active_bg", "value": "#1A2E58FF" },
|
||||
{ "name": "pill_inactive_bg", "value": "#0A000000" },
|
||||
{ "name": "color_rise", "value": "#FF2436" },
|
||||
{ "name": "color_fall", "value": "#07AB4B" }
|
||||
{ "name": "color_fall", "value": "#07AB4B" },
|
||||
{ "name": "color_orange", "value": "#FF661A" },
|
||||
{ "name": "bg_orange", "value": "#1AFF661A" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"src": [
|
||||
"pages/Index",
|
||||
"pages/Detail"
|
||||
"pages/Detail",
|
||||
"pages/StrategyDetail"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user