refactor(cards): 统一 AI 选期与市场排名卡片架构

- 将 AI Pick 重构为 NodeComponent、DataFetcher、Models 分层结构
- 新增公共 AllCardsCard 模型与卡片配置 Mock 数据
- 分离 cardConfig 与 cardData 的状态及更新职责
- 对齐两个卡片的标题、配置跳转、深色模式和说明 Sheet 设计
- 完善 AI Pick HTTP 请求、行情订阅、跳转参数及接口文档
- 保留 MarketRanking 榜单请求、行情合并和展开收起交互
This commit is contained in:
clz
2026-07-23 16:56:05 +08:00
parent e3dc1edb37
commit 63fcb9cbaa
18 changed files with 2383 additions and 699 deletions
@@ -0,0 +1,561 @@
import router from '@ohos.router';
import {
CardData,
PickContractItem,
PickStrategy,
PickTabData,
} from './AiPickModels';
import { AllCardsCard } from '../common/AllCardsModels';
import { AiPickDataFetcher } from './AiPickDataFetcher';
import { cellColor, cellValue, formatColName } from './AiPickUtils';
import {
EMPTY_IMAGE_DARK,
EMPTY_IMAGE_LIGHT,
INFO_IMAGE_DARK,
INFO_IMAGE_LIGHT,
RIGHT_ARROW_DARK,
RIGHT_ARROW_LIGHT,
TITLE_ARROW_DARK,
TITLE_ARROW_LIGHT,
} from './AiPickConstant';
interface StrategyDescParams {
tabIdx: number;
}
@Component
export struct AiPickNodeComponent {
// 当前卡片配置。
@State cardConfig: AllCardsCard | undefined = undefined;
@State cardData: CardData = { tabs: [] };
@State tabIdx: number = 0;
@StorageLink('enableDarkMode') isDarkMode: boolean = false;
@State tabViewportWidth: number = 0;
@State tabContentWidth: number = 0;
@State tabScrollOffset: number = 0;
@State showExplainSheet: boolean = false;
private quoteUnsubscribe: (() => void) | undefined;
private showTabGradient(): boolean {
const maxOffset = this.tabContentWidth - this.tabViewportWidth;
return maxOffset > 0 && this.tabScrollOffset < maxOffset;
}
aboutToAppear(): void {
this.updateCardConfig();
setTimeout(() => {
this.updateCardData();
}, 600);
}
aboutToDisappear(): void {
this.stopSubscribeQuotes();
}
// 只负责从公共 Mock 获取并更新卡片配置。
private updateCardConfig(): void {
this.cardConfig = AiPickDataFetcher.getInstance().fetchCardConfig();
}
// 只负责请求并更新卡片展示数据。
private async updateCardData(): Promise<void> {
this.stopSubscribeQuotes();
this.cardData = { tabs: [] };
const fetcher = AiPickDataFetcher.getInstance();
try {
const tabs = await fetcher.fetchCardData();
this.cardData = { tabs };
if (tabs.length > 0) {
this.subscribeQuotes();
}
} catch {
this.stopSubscribeQuotes();
this.cardData = { tabs: [] };
}
}
// 使用当前 CardData 的全部 Tab 合约订阅行情,推送后整体更新 CardData。
private subscribeQuotes(): void {
this.stopSubscribeQuotes();
this.quoteUnsubscribe = AiPickDataFetcher.getInstance().subscribeQuotes(
this.cardData,
(cardData: CardData): void => {
this.cardData = cardData;
},
);
}
private stopSubscribeQuotes(): void {
if (this.quoteUnsubscribe !== undefined) {
this.quoteUnsubscribe();
this.quoteUnsubscribe = undefined;
}
}
@Builder
CardTitle() {
Row({ space: 4 }) {
Text(this.cardConfig?.card_title.value || $r('app.string.ai_pick_title'))
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.maxLines(1)
if ((this.cardConfig?.card_url.android ?? '') !== '' ||
(this.cardConfig?.card_url.ios ?? '') !== '') {
Image(this.isDarkMode ? TITLE_ARROW_DARK : TITLE_ARROW_LIGHT)
.width(12)
.height(12)
}
if ((this.cardConfig?.explain_message ?? '') !== '') {
Image(this.isDarkMode ? INFO_IMAGE_DARK : INFO_IMAGE_LIGHT)
.width(16)
.height(16)
.onClick((event: ClickEvent) => {
this.showExplainSheet = true;
})
}
}
.width('100%')
.height(48)
.alignItems(VerticalAlign.Center)
.onClick(() => {
this.jumpToCardTitle();
})
}
@Builder
ExplainSheet() {
Column() {
Text(this.cardConfig?.explain_title ||
this.cardConfig?.card_title.value ||
$r('app.string.ai_pick_title'))
.width('100%')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.textAlign(TextAlign.Center)
.margin({ bottom: 16 })
Scroll() {
Text(this.cardConfig?.explain_message ?? '')
.width('100%')
.fontSize(16)
.lineHeight(24)
.fontColor($r('app.color.text_primary'))
}
.width('100%')
.scrollBar(BarState.Off)
Text($r('app.string.card_explain_confirm'))
.width('100%')
.height(44)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(Color.White)
.textAlign(TextAlign.Center)
.backgroundColor($r('app.color.text_blue'))
.borderRadius(4)
.margin({ top: 16 })
.onClick(() => {
this.showExplainSheet = false;
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 16, bottom: 18 })
}
@Builder
StrategyDesc($$: StrategyDescParams) {
// 图片:imgs[0] 白天,imgs[1] 夜间
Row() {
// 左侧:问句 + 标签
Column({ space: 4 }) {
if (this.cardData.tabs[$$.tabIdx].strategy.detail) {
Text(this.cardData.tabs[$$.tabIdx].strategy.detail)
.fontSize(14)
.lineHeight(21)
.fontColor($r('app.color.text_primary'))
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
if (this.cardData.tabs[$$.tabIdx].strategy.tagList.length > 0) {
Row({ space: 4 }) {
ForEach(this.cardData.tabs[$$.tabIdx].strategy.tagList, (tag: string) => {
Text(tag)
.fontSize(11)
.lineHeight(18)
.fontColor($r('app.color.text_blue'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.height(18)
.padding({ left: 3, right: 3 })
.borderRadius(2)
.border({ width: 0.5, color: $r('app.color.text_blue') })
.onClick(() => {
this.jumpToLabel(
this.cardData.tabs[$$.tabIdx].strategy,
tag,
);
})
}, (tag: string) => tag)
}
.width('100%')
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 10 })
// 右侧:策略图片(仅系统策略有图,右侧 image)
// imgs 长度>0 且当前选中位(imgs[0],暂无主题切换)非空才渲染
if ((this.cardData.tabs[$$.tabIdx].strategy.imgs[this.isDarkMode ? 1 : 0] ?? '') !== '') {
Image(this.cardData.tabs[$$.tabIdx].strategy.imgs[this.isDarkMode ? 1 : 0] ?? '')
.width(104)
.height(66)
.objectFit(ImageFit.Contain)
.margin({ left: 10 })
}
}
.width('100%')
.margin({ top: 10 })
.alignItems(VerticalAlign.Top)
.onClick(() => {
this.jumpToStrategyDetail(
this.cardData.tabs[$$.tabIdx].strategy,
);
})
}
build() {
Column() {
// 数据未就绪时不渲染依赖 tabs[tabIdx] 的内容,避免越界。
if (this.cardData.tabs.length === 0) {
this.CardTitle()
Column() {
Text($r('app.string.ai_pick_loading'))
.fontSize(16)
.fontColor($r('app.color.text_grey'))
}
.width('100%')
.height(240)
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
} else {
this.MainContent()
}
}
.width('100%')
.bindSheet(this.showExplainSheet, this.ExplainSheet, {
height: SheetSize.FIT_CONTENT,
dragBar: true,
showClose: false,
maskColor: '#99000000', // todo: 后续替换
onDisappear: () => {
this.showExplainSheet = false;
},
radius: {
topLeft: 10,
topRight: 10,
}
})
}
@Builder
MainContent() {
Column() {
// ── 标题栏 ──────────────────────────────────────────────
this.CardTitle()
// ── Tab 栏 ──────────────────────────────────────────────
Stack({ alignContent: Alignment.End }) {
Scroll() {
Row({ space: 8 }) {
ForEach(this.cardData.tabs, (tab: PickTabData, index: number) => {
Text(`${tab.strategy.name}(${tab.strategy.count})`)
.fontSize(14)
.fontWeight(this.tabIdx === index ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.tabIdx === index
? $r('app.color.text_blue')
: $r('app.color.text_secondary'))
.constraintSize({ minWidth: 63 })
.maxLines(1)
.textAlign(TextAlign.Center)
.padding({ top: 6, bottom: 6, left: 12, right: 12 })
.borderRadius(4)
.backgroundColor(this.tabIdx === index
? $r('app.color.pill_active_bg')
: $r('app.color.pill_inactive_bg'))
.onClick(() => {
this.tabIdx = index;
})
}, (tab: PickTabData) => tab.strategy.id)
}
.onAreaChange((oldValue: Area, newValue: Area) => {
this.tabContentWidth = Number(newValue.width);
})
}
.width('100%')
.height(30)
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.align(Alignment.Start)
.onScroll((xOffset: number, _yOffset: number) => {
this.tabScrollOffset = xOffset;
})
if (this.showTabGradient()) {
Row()
.width(24)
.height(30)
.linearGradient({
angle: 90,
colors: [['#00FFFFFF', 0], [$r('app.color.card_bg'), 1]],
})
.hitTestBehavior(HitTestMode.None)
}
}
.width('100%')
.height(30)
.onAreaChange((oldValue: Area, newValue: Area) => {
this.tabViewportWidth = Number(newValue.width);
})
// ── 策略问句(按引用传递,tabIdx 变化时自动刷新)────────
if (this.cardData.tabs[this.tabIdx].items.length > 0) {
this.StrategyDesc({ tabIdx: this.tabIdx })
}
if (this.cardData.tabs[this.tabIdx].items.length === 0) {
this.EmptyState($r('app.string.ai_pick_no_contract'))
} else {
// ── 表头 ──────────────────────────────────────────────
Row() {
Text(formatColName(this.cardData.tabs[this.tabIdx].keysList[0]))
.fontSize(12)
.fontColor($r('app.color.text_tertiary'))
.width('35%')
Text(formatColName(this.cardData.tabs[this.tabIdx].keysList[1]))
.fontSize(12)
.fontColor($r('app.color.text_tertiary'))
.layoutWeight(1)
.margin({ left: 8 })
.textAlign(TextAlign.End)
Text(formatColName(this.cardData.tabs[this.tabIdx].keysList[2]))
.fontSize(12)
.fontColor($r('app.color.text_tertiary'))
.layoutWeight(1)
.margin({ left: 8 })
.textAlign(TextAlign.End)
}
.width('100%')
.height(24)
.margin({ top: 8 })
.padding({ bottom: 2 })
.alignItems(VerticalAlign.Center)
.border({ width: { bottom: 0.5 }, color: $r('app.color.divider_color') })
// ── 合约列表 ──────────────────────────────────────────
ForEach(
this.cardData.tabs[this.tabIdx].items,
(item: PickContractItem) => {
Row() {
Row({ space: 5 }) {
Text(item.name)
.fontSize(16)
.fontColor($r('app.color.text_primary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (item.isMain) {
Text($r('app.string.ai_pick_main_contract'))
.fontSize(11)
.fontColor($r('app.color.color_orange'))
.width(16)
.height(16)
.textAlign(TextAlign.Center)
.borderRadius(2)
.backgroundColor($r('app.color.bg_orange'))
}
}
.width('35%')
.alignItems(VerticalAlign.Center)
Text(cellValue(
this.cardData.tabs[this.tabIdx].keysList[1],
item,
this.cardData.tabs[this.tabIdx].fieldList,
))
.fontSize(16)
.fontFamily('monospace')
.fontColor(cellColor(this.cardData.tabs[this.tabIdx].keysList[1], item))
.layoutWeight(1)
.margin({ left: 8 })
.textAlign(TextAlign.End)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(cellValue(
this.cardData.tabs[this.tabIdx].keysList[2],
item,
this.cardData.tabs[this.tabIdx].fieldList,
))
.fontSize(16)
.fontFamily('monospace')
.fontColor(cellColor(this.cardData.tabs[this.tabIdx].keysList[2], item))
.layoutWeight(1)
.margin({ left: 8 })
.textAlign(TextAlign.End)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.height(44)
.alignItems(VerticalAlign.Center)
.border({ width: { bottom: 0.5 }, color: $r('app.color.divider_color') })
.onClick(() => {
this.jumpToDetail(item);
})
},
// 行情字段参与 key,确保 price/priceChg 变化后对应列表行重新渲染。
(item: PickContractItem) =>
`${item.contract}_${item.market}_${item.price ?? ''}_${item.priceChg ?? ''}`,
)
}
// ── 一句话定制策略 ──────────────────────────────────────
Row({ space: 2 }) {
Text($r('app.string.ai_pick_custom_strategy'))
.fontSize(14)
.lineHeight(20)
.fontColor($r('app.color.text_tertiary'))
Image(this.isDarkMode ? RIGHT_ARROW_DARK : RIGHT_ARROW_LIGHT)
.width(14)
.height(14)
.margin({ top: 2 })
}
.width('100%')
.justifyContent(FlexAlign.Center)
.alignItems(VerticalAlign.Center)
.margin({ top: 6 })
.onClick(() => {
this.jumpToNewHome();
})
}
.width('100%')
}
@Builder
EmptyState(message: ResourceStr) {
Column() {
Image(this.isDarkMode ? EMPTY_IMAGE_DARK : EMPTY_IMAGE_LIGHT)
.width(120)
.height(120)
.margin({ bottom: 8 })
Text(message)
.fontSize(16)
.fontColor($r('app.color.text_grey'))
}
.width('100%')
.height(240)
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
}
// 跳转到分时详情页。
// todo: 后续跳转为真实的客户端页面。
private jumpToDetail(item: PickContractItem): void {
const clientUrl =
`client://client.html?action=ymtz^webid=2205^stockcode=${item.contract}^marketid=${item.market}`;
console.info(`[AiPick] detail url: ${clientUrl}`);
// 当前仍跳转本地 Mock 页面;接入客户端能力后改为使用 clientUrl。
router.pushUrl({
url: 'pages/Detail',
params: {
code: item.contract,
market: item.market,
clientUrl: clientUrl,
},
});
}
// 跳转到卡片配置中的标题地址。
// todo: 后续跳转为真实的客户端页面。
private jumpToCardTitle(): void {
const androidUrl = this.cardConfig?.card_url.android ?? '';
const clientUrl = androidUrl !== '' ? androidUrl : this.cardConfig?.card_url.ios ?? '';
if (clientUrl === '') {
return;
}
console.info(`[AiPick] card title url: ${clientUrl}`);
// 当前仍跳转本地 Mock 页面;接入客户端能力后改为使用 clientUrl。
router.pushUrl({
url: 'pages/StrategyDetail',
params: {
type: 'home',
clientUrl: clientUrl,
},
});
}
// 跳转到一句话定制策略主页。
// todo: 后续跳转为真实的客户端页面。
private jumpToNewHome(): void {
const pageUrl = 'https://fupage.10jqka.com.cn/ai-web/ai-diagnosis-home.html?sync=1';
const clientUrl =
`client://client.html?action=ymtz^ishiddenbar=1^mode=new^webid=2804^url=${pageUrl}^title=AI选期`;
console.info(`[AiPick] home url: ${clientUrl}`);
// 当前仍跳转本地 Mock 页面;接入客户端能力后改为使用 clientUrl。
router.pushUrl({
url: 'pages/StrategyDetail',
params: {
type: 'home',
clientUrl: clientUrl,
},
});
}
// 跳转到策略详情页。
// todo: 后续跳转为真实的客户端页面。
private jumpToStrategyDetail(strategy: PickStrategy): void {
const pageUrl = 'https://fupage.10jqka.com.cn/ai-web/ai-diagnosis-detail.html' +
`?id=${strategy.id}&type=${strategy.periodType}&name=${encodeURIComponent(strategy.name)}`;
const clientUrl =
`client://client.html?action=ymtz^ishiddenbar=1^mode=new^webid=2804^url=${pageUrl}^title=AI选期`;
console.info(`[AiPick] strategy detail url: ${clientUrl}`);
// 当前仍跳转本地 Mock 页面;接入客户端能力后改为使用 clientUrl。
router.pushUrl({
url: 'pages/StrategyDetail',
params: {
type: 'detail',
id: strategy.id,
periodType: strategy.periodType,
name: strategy.name,
clientUrl: clientUrl,
},
});
}
// 跳转到策略标签页。
// todo: 后续跳转为真实的客户端页面。
private jumpToLabel(strategy: PickStrategy, label: string): void {
const pageUrl = 'https://fupage.10jqka.com.cn/ai-web/ai-diagnosis-label.html' +
`?id=${strategy.id}&type=${strategy.periodType}&label=${encodeURIComponent(label)}`;
const clientUrl =
`client://client.html?action=ymtz^ishiddenbar=1^mode=new^webid=2804^url=${pageUrl}^title=AI选期`;
console.info(`[AiPick] label url: ${clientUrl}`);
// 当前仍跳转本地 Mock 页面;接入客户端能力后改为使用 clientUrl。
router.pushUrl({
url: 'pages/StrategyDetail',
params: {
type: 'label',
id: strategy.id,
periodType: strategy.periodType,
label: label,
clientUrl: clientUrl,
},
});
}
}