diff --git a/entry/src/main/ets/ai-pick/AiPick.ets b/entry/src/main/ets/ai-pick/AiPick.ets new file mode 100644 index 0000000..dd1d9a5 --- /dev/null +++ b/entry/src/main/ets/ai-pick/AiPick.ets @@ -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%') + } +} diff --git a/entry/src/main/ets/ai-pick/AiPickApi.ets b/entry/src/main/ets/ai-pick/AiPickApi.ets new file mode 100644 index 0000000..cbac8d8 --- /dev/null +++ b/entry/src/main/ets/ai-pick/AiPickApi.ets @@ -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 { + return new Promise((resolve) => { + setTimeout(() => { + resolve(MOCK_RAW_TABS); + }, MOCK_DELAY_MS); + }); +} diff --git a/entry/src/main/ets/ai-pick/AiPickConstant.ets b/entry/src/main/ets/ai-pick/AiPickConstant.ets new file mode 100644 index 0000000..ecaf852 --- /dev/null +++ b/entry/src/main/ets/ai-pick/AiPickConstant.ets @@ -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', '最新涨跌幅', +]; diff --git a/entry/src/main/ets/ai-pick/AiPickMock.ets b/entry/src/main/ets/ai-pick/AiPickMock.ets new file mode 100644 index 0000000..4cc8882 --- /dev/null +++ b/entry/src/main/ets/ai-pick/AiPickMock.ets @@ -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 }, + ], + }, + }, +]; diff --git a/entry/src/main/ets/ai-pick/AiPickModel.ets b/entry/src/main/ets/ai-pick/AiPickModel.ets new file mode 100644 index 0000000..d90fe52 --- /dev/null +++ b/entry/src/main/ets/ai-pick/AiPickModel.ets @@ -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 { + 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 }, + }); + } +} diff --git a/entry/src/main/ets/ai-pick/AiPickTypes.ets b/entry/src/main/ets/ai-pick/AiPickTypes.ets new file mode 100644 index 0000000..902f7a1 --- /dev/null +++ b/entry/src/main/ets/ai-pick/AiPickTypes.ets @@ -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; +} + +// 字段元信息(对应 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; + +// 每个 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; +} diff --git a/entry/src/main/ets/ai-pick/AiPickUtils.ets b/entry/src/main/ets/ai-pick/AiPickUtils.ets new file mode 100644 index 0000000..3c151c0 --- /dev/null +++ b/entry/src/main/ets/ai-pick/AiPickUtils.ets @@ -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[] { + return resultList.map(v => { + const res: Record = {}; + 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[]; + 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[] = 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[], +): PickContractItem[] { + return list.map((item: RawDetailItem, index: number) => { + const extraFields: Record = 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'); +} diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 6528c0b..4f9a0d8 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -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) diff --git a/entry/src/main/ets/pages/StrategyDetail.ets b/entry/src/main/ets/pages/StrategyDetail.ets new file mode 100644 index 0000000..df48530 --- /dev/null +++ b/entry/src/main/ets/pages/StrategyDetail.ets @@ -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) + } +} diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5 index 53024e8..3783bb3 100644 --- a/entry/src/main/module.json5 +++ b/entry/src/main/module.json5 @@ -2,6 +2,11 @@ "module": { "name": "entry", "type": "entry", + "requestPermissions": [ + { + "name": "ohos.permission.INTERNET" + } + ], "description": "$string:module_desc", "mainElement": "EntryAbility", "deviceTypes": [ diff --git a/entry/src/main/resources/base/element/color.json b/entry/src/main/resources/base/element/color.json index affaef5..70bf1a3 100644 --- a/entry/src/main/resources/base/element/color.json +++ b/entry/src/main/resources/base/element/color.json @@ -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" } ] } diff --git a/entry/src/main/resources/base/profile/main_pages.json b/entry/src/main/resources/base/profile/main_pages.json index 0a21ce4..5f9d570 100644 --- a/entry/src/main/resources/base/profile/main_pages.json +++ b/entry/src/main/resources/base/profile/main_pages.json @@ -1,6 +1,7 @@ { "src": [ "pages/Index", - "pages/Detail" + "pages/Detail", + "pages/StrategyDetail" ] }