feat: AI选期商业卡片

This commit is contained in:
clz
2026-07-21 21:19:20 +08:00
parent 9f6a0c642b
commit ac024eab05
12 changed files with 971 additions and 2 deletions
+256
View File
@@ -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
// 外层整体点击跳 jumpToDetail2tag 点击阻止冒泡后跳 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%')
}
}
+25
View File
@@ -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', '最新涨跌幅',
];
+98
View File
@@ -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 },
],
},
},
];
+117
View File
@@ -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 },
});
}
}
+109
View File
@@ -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;
}
+169
View File
@@ -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');
}
+4
View File
@@ -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)
+161
View File
@@ -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)
}
}
+5
View File
@@ -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"
]
}