基于HarmonyOS API 24 ArkTS 股票投资应用全解析:onChange 回调把输入值同步到组件状态this.formCode=v与this.formName=v,“输入即同步“的模式
一、背景与意义
随着移动互联网与智能手机的深度普及,个人投资者对移动端股票行情与交易工具的依赖程度持续攀升。一款优秀的股票投资 App,不仅需要承载实时行情、自选管理、持仓跟踪、交易记录等核心业务能力,还要在视觉层面传递出专业、稳重、可信赖的金融气质。这对前端 UI 框架的声明能力、状态管理机制以及组件化拆分能力都提出了相当高的要求。

鸿蒙(HarmonyOS)的 ArkTS 声明式开发范式,正是为这类"数据密集 + 交互密集 + 视觉精细"的场景而生。它通过 @Component、@Builder、@State、@Observed 等装饰器,将界面结构、状态驱动、可观察数据模型有机串联起来,让开发者可以用接近自然描述的方式,把一个复杂金融应用拆解为若干职责清晰的组件,再通过组合与层级嵌套拼装出完整的页面。
本文要剖析的,正是一个使用 ArkTS 编写的完整股票投资应用。它覆盖了行情首页、自选股管理、持仓总览、交易记录、个人中心共五大核心模块,内部还嵌套了股票详情、编辑、删除、新增等多种弹窗交互。整份代码约一千九百多行,几乎涵盖了金融类移动应用最常见的 UI 模式:指数卡片、市场概览、K 线走势、盈亏柱状图、持仓分布条、月度收益趋势、表单输入、分组筛选、模态弹窗等等。
通过对这份代码的逐段拆解,你不仅能掌握 ArkTS 的核心语法与最佳实践,更能学到如何把一个业务复杂的应用,按照"类型层 - 数据层 - 设计令牌层 - 组件层"的清晰层次组织起来。这对于任何想要在鸿蒙生态中构建高质量原生应用的开发者,都是一份极具参考价值的实战样本。
接下来,我们将按照代码自上而下的组织顺序,逐层、逐段、逐行地进行剖析。
二、类型定义层:用接口刻画业务元数据
在 ArkTS 中,interface 用于声明对象的形状。这份代码在文件最开头集中定义了七个接口,它们共同构成了整个应用的"元数据字典"。把所有类型定义放在文件顶部,是一种非常值得推崇的工程习惯——它让后续的数据模型、配置常量、组件参数都能在统一的类型约束下流转,编译期就能拦截大量潜在错误。
2.1 颜色方案接口
interface StockColorScheme {
redUp: string
redUpLight: string
redUpDark: string
greenDown: string
greenDownLight: string
black: string
darkBg: string
lightBg: string
cardBg: string
textPrimary: string
textSecondary: string
textHint: string
border: string
white: string
gold: string
}

这一段定义了整个应用的颜色体系。值得特别注意的是,它遵循的是 A 股市场的"红涨绿跌"约定:redUp 表示上涨用的红色,greenDown 表示下跌用的绿色,这与欧美市场的习惯恰好相反。接口中还为每种主色都配备了浅色版本(redUpLight、greenDownLight),用于背景填充;同时区分了三档文字颜色——textPrimary 用于主要内容、textSecondary 用于辅助说明、textHint 用于最弱的提示信息。这种"主色 + 浅色 + 三档文字色"的结构,是金融类应用设计令牌的典型骨架。
2.2 指数、行业、分组、交易类型元数据接口
interface IndexMeta {
name: string
code: string
color: string
bg: string
}
interface StockIndustryMeta {
label: string
color: string
bg: string
}
interface WatchGroupMeta {
label: string
count: number
color: string
}
interface TradeTypeMeta {
label: string
icon: string
color: string
bg: string
}

这四个接口分别描述了四类业务对象的展示属性。IndexMeta 用于上证、深证、创业板等大盘指数,包含名称、代码、主色、背景色四个字段。StockIndustryMeta 用于行业标签,如银行、科技、医药等。WatchGroupMeta 描述自选股分组,额外多了一个 count 字段用于显示分组内的股票数量。TradeTypeMeta 描述交易类型(买入、卖出、分红、申购、赎回),多了一个 icon 字段用于 emoji 图标。
可以看到,它们都有一个共同的模式:label + color + bg。这是"标签胶囊"组件的标准数据结构——文字配上主色文字与浅色背景,就能渲染出一枚精致的彩色标签。把这些公共字段抽象成接口后,标签的渲染逻辑就可以统一复用,而不需要为每种业务对象单独写一套样式。
2.3 图表数据接口
interface BarDataMeta {
label: string
value: number
isPositive: boolean
}
interface CandleBarMeta {
open: number
close: number
high: number
low: number
isUp: boolean
}

最后两个接口服务于图表渲染。BarDataMeta 是柱状图的单条数据,label 是横轴标签(如"1月"),value 是数值,isPositive 决定柱子颜色——正值用红色、负值用绿色。CandleBarMeta 是 K 线图的单根蜡烛,包含开盘、收盘、最高、最低四个价格,以及 isUp 标识当日是上涨还是下跌。这两个接口的存在说明,应用中内置了纯声明式绘制的简易图表,而不依赖任何第三方图表库,这也是 ArkTS 声明能力的一个亮点。
三、数据模型层:可观察的业务实体
紧接着类型定义的,是三个带 @Observed 装饰器的类。@Observed 是 ArkTS 提供的可观察数据装饰器,被它修饰的类实例,其属性变化能够被 UI 框架自动追踪并触发界面刷新。这是声明式 UI 实现"数据驱动视图"的关键机制。
3.1 股票数据模型
@Observed
export class StockItem {
id: number = 0
code: string = ''
name: string = ''
industry: string = ''
price: number = 0
change: number = 0
changeRate: number = 0
volume: number = 0
marketCap: number = 0
peRatio: number = 0
high: number = 0
low: number = 0
open: number = 0
preClose: number = 0
constructor(id: number, code: string, name: string, industry: string,
price: number, changeRate: number, volume: number, marketCap: number) {
this.id = id; this.code = code; this.name = name; this.industry = industry
this.price = price; this.changeRate = changeRate; this.volume = volume
this.marketCap = marketCap
this.change = price * changeRate / 100
this.preClose = price / (1 + changeRate / 100)
this.open = this.preClose * 0.998
this.high = price * 1.015
this.low = this.preClose * 0.985
this.peRatio = 8 + (id * 3.7) % 30
}
}

StockItem 是整个应用最核心的实体。它声明了十四个字段,覆盖了一只股票在行情界面需要展示的全部维度:身份信息(id、code、name、industry)、价格信息(price、change、changeRate、high、low、open、preClose)、市值信息(volume、marketCap、peRatio)。
构造函数只接收八个参数,其余字段都是根据已有字段推算出来的——这是一种非常聪明的"派生字段"设计。例如 change(涨跌额)通过 price * changeRate / 100 计算得出;preClose(昨收价)通过 price / (1 + changeRate / 100) 反推得出,这是基于"涨跌幅 = (现价 - 昨收) / 昨收"这一公式的逆向求解。open(开盘价)取昨收的 99.8%,high(最高价)取现价的 1.015 倍,low(最低价)取昨收的 98.5%,这些比例是为了在 mock 数据中生成看起来合理的波动区间。peRatio(市盈率)则用 8 + (id * 3.7) % 30 生成一个 8 到 38 之间的伪随机值,让每只股票的市盈率各不相同。
这种"少输入、多派生"的构造模式,既减少了 mock 数据的书写量,又保证了字段间的内在一致性——只要给定现价和涨跌幅,其余所有字段都能自动协调。
3.2 持仓数据模型
@Observed
export class HoldingItem {
id: number = 0
code: string = ''
name: string = ''
industry: string = ''
holdCount: number = 0
costPrice: number = 0
currentPrice: number = 0
constructor(id: number, code: string, name: string, industry: string,
holdCount: number, costPrice: number, currentPrice: number) {
this.id = id; this.code = code; this.name = name; this.industry = industry
this.holdCount = holdCount; this.costPrice = costPrice; this.currentPrice = currentPrice
}
}

HoldingItem 描述持仓。它的字段更精简,核心是 holdCount(持仓数量)、costPrice(成本价)、currentPrice(现价)三个。值得注意的是,它没有在构造函数里派生"盈亏额"“盈亏率”"市值"等字段,而是把这些计算留到了 UI 渲染时动态完成。这是一种有意的设计选择——盈亏是高度动态的值(现价会变),把它放在视图层计算可以保证每次渲染都拿到最新结果,而不需要在数据变更时手动同步派生字段。
3.3 交易记录数据模型
@Observed
export class TradeRecord {
id: number = 0
type: string = ''
code: string = ''
name: string = ''
price: number = 0
count: number = 0
amount: number = 0
date: string = ''
time: string = ''
status: string = ''
fee: number = 0
constructor(id: number, type: string, code: string, name: string,
price: number, count: number, date: string, time: string, status: string) {
this.id = id; this.type = type; this.code = code; this.name = name
this.price = price; this.count = count; this.date = date; this.time = time
this.status = status
this.amount = price * count
this.fee = this.amount * 0.0003
}
}

TradeRecord 描述一笔交易记录。构造函数接收九个参数,其中 amount(成交金额)通过 price * count 派生,fee(手续费)通过 amount * 0.0003 派生——这里采用了 A 股常见的万三佣金费率。这种把业务规则(费率)固化在模型里的做法,在 mock 应用中是合理的;在生产环境中,费率通常应该作为配置项或从后端获取,以便支持不同用户的差异化费率。
三个模型都用 export 导出,意味着它们可以被其他文件引用,体现了模块化的组织思想。
四、设计令牌层:集中化的视觉配置
在类型与模型之后,代码进入了一段密集的常量定义。这些常量被称作"设计令牌"(Design Tokens),它们是整个应用视觉风格的单一真相来源。任何组件需要用到颜色、标签、图标时,都从这里取值,而不是把魔法字符串散落在各处。
4.1 颜色方案实例
const STOCK_COLORS: StockColorScheme = {
redUp: '#B71C1C',
redUpLight: '#FFEBEE',
redUpDark: '#7F0000',
greenDown: '#1B5E20',
greenDownLight: '#E8F5E9',
black: '#1A1A1A',
darkBg: '#0D0D0D',
lightBg: '#FFF5F5',
cardBg: '#FFFFFF',
textPrimary: '#1A1A1A',
textSecondary: '#666666',
textHint: '#AAAAAA',
border: '#FFE0E0',
white: '#FFFFFF',
gold: '#FFD700'
}

这里把前面定义的 StockColorScheme 接口实例化。选用的色值经过精心调配:#B71C1C 是一种偏深的正红,比纯红 #FF0000 更稳重,符合金融场景的严肃气质;#1B5E20 是一种深森林绿,避免了刺眼的亮绿。浅色背景 #FFEBEE 与 #E8F5E9 分别是红绿的极淡变体,用于标签胶囊的底色。gold: '#FFD700' 用于 VIP 等级标识,传递尊贵感。整体配色克制而有层次,体现了专业金融产品的审美。
4.2 指数配置
const INDEX_CONFIG: Record<string, IndexMeta> = {
'上证': { name: '上证指数', code: '000001.SH', color: '#B71C1C', bg: '#FFEBEE' },
'深证': { name: '深证成指', code: '399001.SZ', color: '#1A1A1A', bg: '#F5F5F5' },
'创业板': { name: '创业板指', code: '399006.SZ', color: '#FF6F00', bg: '#FFF8E1' }
}

INDEX_CONFIG 是一个 Record<string, IndexMeta> 类型的字典,用股票市场简称作为键。每个指数都配有完整的名称、标准代码、主色与背景色。例如上证指数用红色(呼应其作为主板的市场地位),创业板用橙色(代表成长与活力)。用 Record 类型而非普通对象,能获得更好的类型推导与键名提示。
4.3 行业配置
const INDUSTRY_CONFIG: Record<string, StockIndustryMeta> = {
'银行': { label: '银行', color: '#1565C0', bg: '#E3F2FD' },
'科技': { label: '科技', color: '#6A1B9A', bg: '#F3E5F5' },
'医药': { label: '医药', color: '#2E7D32', bg: '#E8F5E9' },
'新能源': { label: '新能源', color: '#F57F17', bg: '#FFF8E1' },
'消费': { label: '消费', color: '#C62828', bg: '#FFEBEE' },
'地产': { label: '地产', color: '#5D4037', bg: '#EFEBE9' },
'军工': { label: '军工', color: '#37474F', bg: '#ECEFF1' },
'半导体': { label: '半导体', color: '#00695C', bg: '#E0F2F1' }
}

行业配置涵盖了八大行业板块,每个行业都有独特的色彩身份。银行用蓝色(稳健)、科技用紫色(创新)、医药用绿色(健康)、新能源用橙黄(能源)、消费用红色(火热)、地产用棕色(沉稳)、军工用蓝灰(冷峻)、半导体用青绿(精密)。这些色彩选择并非随意,而是参考了大众对各行各业的色彩联想,让用户一眼就能从颜色识别出行业归属。
4.4 自选分组与交易类型配置
const WATCH_GROUPS: WatchGroupMeta[] = [
{ label: '全部', count: 15, color: '#B71C1C' },
{ label: '科技股', count: 5, color: '#6A1B9A' },
{ label: '金融股', count: 4, color: '#1565C0' },
{ label: '消费股', count: 3, color: '#C62828' },
{ label: '新能源', count: 3, color: '#F57F17' }
]
const TRADE_TYPE_CONFIG: Record<string, TradeTypeMeta> = {
'买入': { label: '买入', icon: '🔴', color: '#B71C1C', bg: '#FFEBEE' },
'卖出': { label: '卖出', icon: '🟢', color: '#1B5E20', bg: '#E8F5E9' },
'分红': { label: '分红', icon: '💰', color: '#FF6F00', bg: '#FFF8E1' },
'申购': { label: '申购', icon: '📋', color: '#1565C0', bg: '#E3F2FD' },
'赎回': { label: '赎回', icon: '📤', color: '#5D4037', bg: '#EFEBE9' }
}

WATCH_GROUPS 是一个数组而非字典,因为分组是有顺序的,"全部"永远排在最前。每个分组携带数量与主色,用于渲染带计数的选择标签。TRADE_TYPE_CONFIG 则为五种交易类型都配了 emoji 图标——买入用红圆、卖出用绿圆、分红用钱袋、申购用剪贴板、赎回用发件箱。emoji 的运用让交易类型一目了然,也增添了界面的亲和力。
4.5 图表数据
const MONTHLY_PROFIT_DATA: BarDataMeta[] = [
{ label: '1月', value: 3200, isPositive: true },
{ label: '2月', value: -1800, isPositive: false },
{ label: '3月', value: 5600, isPositive: true },
{ label: '4月', value: 2400, isPositive: true },
{ label: '5月', value: -3200, isPositive: false },
{ label: '6月', value: 6800, isPositive: true },
{ label: '7月', value: 4200, isPositive: true },
{ label: '8月', value: -1500, isPositive: false }
]
const CANDLE_DATA: CandleBarMeta[] = [
{ open: 18.5, close: 19.2, high: 19.5, low: 18.3, isUp: true },
{ open: 19.2, close: 18.8, high: 19.4, low: 18.6, isUp: false },
{ open: 18.8, close: 19.5, high: 19.8, low: 18.7, isUp: true },
{ open: 19.5, close: 20.3, high: 20.5, low: 19.3, isUp: true },
{ open: 20.3, close: 19.9, high: 20.4, low: 19.7, isUp: false },
{ open: 19.9, close: 20.8, high: 21.0, low: 19.8, isUp: true },
{ open: 20.8, close: 21.5, high: 21.8, low: 20.6, isUp: true },
{ open: 21.5, close: 21.0, high: 21.6, low: 20.8, isUp: false },
{ open: 21.0, close: 21.8, high: 22.0, low: 20.9, isUp: true },
{ open: 21.8, close: 22.5, high: 22.8, low: 21.7, isUp: true }
]
这两组数据分别服务于"月度收益趋势柱状图"和"个股 K 线走势"。月度数据有八个月,正负交替,模拟了一个波动中向上的投资曲线——三个月亏损、五个月盈利,累计为正。K 线数据有十根蜡烛,价格从 18.5 一路震荡上行到 22.5,整体呈上涨趋势,中间夹杂三根阴线制造波动感。这些数据虽然是写死的 mock,但编排得很有"真实感",让图表看起来不像测试数据。
五、Mock 数据层:模拟真实投资组合
设计令牌之后,是四组规模可观的 mock 数据数组。它们用前面定义的数据模型类实例化,模拟出一个相当完整的投资场景。
5.1 热门股票列表
const mockStocks: StockItem[] = [
new StockItem(1, '600519', '贵州茅台', '消费', 1685.50, 2.35, 28500, 21180),
new StockItem(2, '601398', '工商银行', '银行', 5.82, 0.52, 895000, 20700),
new StockItem(3, '000858', '五粮液', '消费', 156.30, -1.28, 125000, 6070),
new StockItem(4, '300750', '宁德时代', '新能源', 218.70, 3.65, 95000, 9610),
new StockItem(5, '601318', '中国平安', '银行', 48.56, 1.23, 320000, 8870),
new StockItem(6, '000725', '京东方A', '半导体', 4.32, -0.69, 685000, 1620),
new StockItem(7, '002594', '比亚迪', '新能源', 268.40, 4.12, 78000, 7820),
new StockItem(8, '600036', '招商银行', '银行', 35.78, 0.85, 165000, 9020),
new StockItem(9, '601012', '隆基绿能', '新能源', 22.15, -2.15, 210000, 1680),
new StockItem(10, '000333', '美的集团', '消费', 68.92, 1.56, 95000, 4830),
new StockItem(11, '600276', '恒瑞医药', '医药', 45.30, 2.88, 86000, 2890),
new StockItem(12, '002475', '立讯精密', '半导体', 38.65, -1.05, 125000, 2750),
new StockItem(13, '601628', '中国人寿', '银行', 32.48, 0.95, 145000, 9160),
new StockItem(14, '300059', '东方财富', '科技', 15.87, 3.28, 320000, 2480),
new StockItem(15, '600585', '海螺水泥', '地产', 25.60, -0.78, 88000, 1370),
new StockItem(16, '002230', '科大讯飞', '科技', 52.30, 5.62, 115000, 1210),
new StockItem(17, '600009', '上海机场', '消费', 42.15, 1.88, 68000, 1050),
new StockItem(18, '601888', '中国中免', '消费', 85.60, -1.45, 58000, 1770)
]
这里列出了十八只 A 股市场真实的知名股票,覆盖消费、银行、新能源、半导体、医药、地产、科技等多个行业。每只股票的代码、名称、行业、现价、涨跌幅、成交量、市值都接近真实水平——贵州茅台 1685 元、工商银行 5.82 元、宁德时代 218 元,这些价格区间符合各只股票在真实市场中的定位。涨跌幅有正有负,从 -2.15% 到 +5.62%,分布合理。这样规模的 mock 数据,足以让行情列表呈现出真实的市场质感。
5.2 自选股、持仓与交易记录
const mockWatchStocks: StockItem[] = [ /* 15只自选股 */ ]
const mockHoldings: HoldingItem[] = [
new HoldingItem(1, '600519', '贵州茅台', '消费', 100, 1580.00, 1685.50),
new HoldingItem(2, '300750', '宁德时代', '新能源', 300, 185.50, 218.70),
new HoldingItem(3, '002594', '比亚迪', '新能源', 200, 240.00, 268.40),
new HoldingItem(4, '601398', '工商银行', '银行', 5000, 5.50, 5.82),
new HoldingItem(5, '300059', '东方财富', '科技', 2000, 13.20, 15.87),
new HoldingItem(6, '000725', '京东方A', '半导体', 8000, 4.65, 4.32),
new HoldingItem(7, '600276', '恒瑞医药', '医药', 500, 42.10, 45.30),
new HoldingItem(8, '002230', '科大讯飞', '科技', 800, 48.50, 52.30)
]
mockWatchStocks 包含十五只自选股,是 mockStocks 的子集。mockHoldings 是八只持仓,每只都给出了持仓量、成本价与现价。可以注意到,大部分持仓的现价高于成本价(盈利),只有京东方A 是浮亏(成本 4.65,现价 4.32)。这种"盈多亏少"的分布,模拟了一个表现良好的投资组合,让持仓页面的盈亏展示更具正向观感。
交易记录 mockTrades 包含二十条记录,涵盖买入、卖出、分红、申购、赎回五种类型,时间跨度从 7 月 22 日到 8 月 8 日。每条记录都有完整的成交价、数量、日期、时间、状态。基金类记录(申购易方达蓝筹、赎回沪深300ETF)的 code 字段为空,这与真实场景一致——基金没有股票代码。
六、工具函数层:业务逻辑的封装
mock 数据之后,是一组工具函数。它们把"取统计数据"“格式化数字”"根据盈亏取颜色"等可复用逻辑封装起来,避免在组件里重复书写。
function getStockTotalCount(): number { return 18 }
function getWatchCount(): number { return 15 }
function getHoldingCount(): number { return 8 }
function getTradeCount(): number { return 20 }
function getTotalAssets(): number { return 528360 }
function getTotalProfit(): number { return 48650 }
function getTotalProfitRate(): number { return 10.14 }
function getAvailableCash(): number { return 85600 }
function getTodayProfit(): number { return 3260 }
前四个函数返回各类数据的条数,后五个返回账户的汇总指标——总资产 52.8 万、总盈亏 4.87 万、收益率 10.14%、可用资金 8.56 万、今日盈亏 3260 元。把这些数值封装成函数而非直接写在 UI 里,有两个好处:一是语义清晰,getTotalAssets() 比一个裸数字 528360 可读性高得多;二是未来接入真实数据接口时,只需要改这几个函数的实现,UI 层完全不用动。
function getProfitColor(isPositive: boolean): string {
return isPositive ? STOCK_COLORS.redUp : STOCK_COLORS.greenDown
}
function getProfitBg(isPositive: boolean): string {
return isPositive ? STOCK_COLORS.redUpLight : STOCK_COLORS.greenDownLight
}
这两个函数是整个应用使用频率最高的工具——几乎所有涉及盈亏展示的地方都会调用它们。传入一个布尔值(是否为正),返回对应的主色或浅色。这种封装让"红涨绿跌"的色彩约定集中在一处,假如将来要切换成"绿涨红跌"(适配海外市场),只需要改这两个函数即可。
function formatVolume(v: number): string {
if (v >= 10000) {
return (v / 10000).toFixed(1) + '万'
}
return v.toString()
}
function formatMarketCap(v: number): string {
if (v >= 10000) {
return (v / 10000).toFixed(1) + '万亿'
}
return v.toString() + '亿'
}
这两个是数字格式化函数。formatVolume 把成交量按"万"为单位显示,例如 28500 显示为"2.9万"。formatMarketCap 把市值按"亿"或"万亿"显示,例如 21180 显示为"2.1万亿"。金融数据的特点是数字往往很大,不做单位换算的话,界面会出现一长串数字,既不美观也不易读。这两个函数用简单的除法和 toFixed,就把数字压缩成了符合中文阅读习惯的简洁形式。
七、底部导航枚举与入口组件
7.1 Tab 枚举
enum StockTab {
MARKET = 0,
WATCHLIST = 1,
HOLDING = 2,
TRADE = 3,
PROFILE = 4
}
这里用枚举定义了五个底部 Tab 的索引。用枚举而非裸数字,是为了在组件里写 StockTab.MARKET 而不是 0,语义更清晰,也避免了魔法数字带来的维护风险。枚举值从 0 开始递增,正好对应底部 Tab 从左到右的顺序。
7.2 入口组件
@Entry
@Component
struct StockInvestApp {
@State activeTab: StockTab = StockTab.MARKET
@Builder contentArea() {
Column() {
if (this.activeTab === StockTab.MARKET) {
MarketHomeContent()
} else if (this.activeTab === StockTab.WATCHLIST) {
WatchlistContent()
} else if (this.activeTab === StockTab.HOLDING) {
HoldingContent()
} else if (this.activeTab === StockTab.TRADE) {
TradeRecordContent()
} else {
ProfileContent()
}
}
.layoutWeight(1)
}
StockInvestApp 是整个应用的入口组件,由 @Entry 和 @Component 两个装饰器标记。它持有一个 @State 状态变量 activeTab,初始值为 StockTab.MARKET,即应用启动后默认显示行情首页。
@Builder contentArea() 定义了一个可复用的构建器,它根据 activeTab 的当前值,条件渲染对应的页面组件。if-else if-else 链条清晰地把五个 Tab 与五个组件一一对应。外层的 Column 加了 .layoutWeight(1),让内容区占据除底部导航栏以外的全部剩余空间。这种"构建器 + 条件渲染"的模式,让入口组件的 build 方法保持简洁,把页面切换逻辑收敛在一处。
@Builder bottomTabItem(icon: string, label: string, tab: StockTab) {
Column() {
Text(icon).fontSize(20).opacity(this.activeTab === tab ? 1.0 : 0.4)
Text(label).fontSize(10)
.fontColor(this.activeTab === tab ? STOCK_COLORS.redUp : STOCK_COLORS.textHint)
.fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
if (this.activeTab === tab) {
Column().width(20).height(3)
.backgroundColor(STOCK_COLORS.redUp).borderRadius(2).margin({ top: 3 })
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 6, bottom: 6 })
.onClick(() => { this.activeTab = tab })
}
bottomTabItem 是单个底部 Tab 项的构建器,接收图标、文字、枚举值三个参数。它的精妙之处在于用三元运算符实现了"选中态"与"未选中态"的视觉差异:选中时图标不透明度为 1.0、文字为红色加粗、下方多一根红色短指示条;未选中时图标半透明、文字为灰色常规字重、没有指示条。if (this.activeTab === tab) 这段条件渲染,让指示条只在选中时才出现,实现了一个流畅的选中反馈。点击事件里 this.activeTab = tab 触发状态变更,UI 自动重渲染——这就是声明式 UI 的核心魅力。
build() {
Column() {
this.contentArea()
Row() {
this.bottomTabItem('📈', '行情', StockTab.MARKET)
this.bottomTabItem('⭐', '自选', StockTab.WATCHLIST)
this.bottomTabItem('💼', '持仓', StockTab.HOLDING)
this.bottomTabItem('📋', '交易', StockTab.TRADE)
this.bottomTabItem('👤', '我的', StockTab.PROFILE)
}
.width('100%')
.backgroundColor(STOCK_COLORS.white)
.padding({ top: 4, bottom: 8 })
.shadow({ radius: 10, color: '#1AB71C1C', offsetY: -3 })
}
.width('100%').height('100%')
.backgroundColor(STOCK_COLORS.lightBg)
}
}
入口的 build 方法把内容区与底部导航栏组合在一起。底部导航栏是一个 Row,横向排列五个 bottomTabItem,每个项因为带了 .layoutWeight(1) 所以会均分宽度。导航栏整体加了白色背景与一个向上的阴影(offsetY: -3 表示阴影偏上),制造出一种"浮在内容之上"的层次感。阴影颜色 #1AB71C1C 是带透明度的红色,与整体色调呼应。最外层 Column 撑满全屏,背景色是极淡的粉红 #FFF5F5,让白色卡片在其上显得更突出。
八、行情首页组件:信息密度与交互深度的平衡
行情首页是用户打开应用后看到的第一屏,承担着"市场全景"的职责。它需要在有限的屏幕空间内,同时展示大盘指数、市场概览、热门股票列表,并且支持点击查看个股详情。
8.1 状态与模态遮罩
@Component
struct MarketHomeContent {
@State showStockDetail: boolean = false
@State selectedStock: StockItem | null = null
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(26,26,26,0.6)')
.onClick(onClose)
}
组件持有两个状态:showStockDetail 控制详情弹窗的显隐,selectedStock 记录当前选中的股票。selectedStock 的类型是 StockItem | null,用联合类型表示"可能没有选中任何股票"。
modalOverlay 是一个通用的模态遮罩构建器,接收一个 onClose 回调。它渲染一个铺满全屏的半透明黑色 Column(透明度 0.6),点击时触发关闭回调。这种"遮罩 + 回调"的抽象,让所有弹窗都能复用同一套关闭逻辑。
8.2 股票详情弹窗
股票详情弹窗是整个应用最复杂的弹窗之一,它集成了头部信息、K 线图、行情数据网格、操作按钮四个区块。
@Builder stockDetailModal() {
Column() {
this.modalOverlay(() => { this.showStockDetail = false })
Column() {
// 头部
Column() {
Row() {
Column() {
Text(this.selectedStock?.name ?? '').fontSize(18).fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text(this.selectedStock?.code ?? '').fontSize(11)
.fontColor('#AAAAAA').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column() {
Text(INDUSTRY_CONFIG[this.selectedStock?.industry ?? '']?.label ?? '')
.fontSize(10).fontColor('#FFFFFF')
.backgroundColor(INDUSTRY_CONFIG[this.selectedStock?.industry ?? '']?.color ?? '#888888')
.padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(8)
}
Text('✕').fontSize(18).fontColor('#AAAAAA')
.margin({ left: 10 })
.onClick(() => { this.showStockDetail = false })
}
弹窗整体是一个 Column,先渲染遮罩,再渲染内容卡片。内容卡片的头部是黑底白字的区域,营造一种"专业终端"的氛围。头部用 Row 横向排列三部分:左侧是股票名称与代码(左对齐,占据剩余宽度),中间是行业标签(用 INDUSTRY_CONFIG 取色),右侧是关闭按钮(✕)。这里大量使用了可选链 ?. 与空值合并 ??,因为 selectedStock 可能为 null,必须做防御性处理。INDUSTRY_CONFIG[this.selectedStock?.industry ?? '']?.label ?? '' 这种连续的可选链,保证即使行业配置里没有对应项,也不会崩溃。
Row() {
Text(this.selectedStock?.price.toFixed(2) ?? '0.00').fontSize(36)
.fontWeight(FontWeight.Bold)
.fontColor(getProfitColor((this.selectedStock?.changeRate ?? 0) >= 0))
Column() {
Text(((this.selectedStock?.changeRate ?? 0) >= 0 ? '+' : '') + (this.selectedStock?.change ?? 0).toFixed(2))
.fontSize(14).fontColor(getProfitColor((this.selectedStock?.changeRate ?? 0) >= 0))
Text(((this.selectedStock?.changeRate ?? 0) >= 0 ? '+' : '') + (this.selectedStock?.changeRate ?? 0).toFixed(2) + '%')
.fontSize(14).fontColor(getProfitColor((this.selectedStock?.changeRate ?? 0) >= 0))
}
.margin({ left: 12 })
}
价格展示区用 36 号超大字体显示现价,右侧用两行 14 号字显示涨跌额与涨跌幅。getProfitColor 根据涨跌幅正负返回红或绿。注意 (this.selectedStock?.changeRate ?? 0) >= 0 ? '+' : '' 这个三元表达式——盈利时在数字前加"+"号,亏损时不加(负号由 toFixed 自动产生),这是一个常见的金融数字格式化技巧。
Scroll() {
Column() {
// K线样式卡片
Column() {
Text('📉 近10日K线走势').fontSize(13).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary)
.width('100%').padding({ left: 16, top: 14, bottom: 10 })
Row() {
ForEach(CANDLE_DATA, (c: CandleBarMeta) => {
Column() {
// 上影线
Column().width(1).height(6)
.backgroundColor(c.isUp ? STOCK_COLORS.redUp : STOCK_COLORS.greenDown)
// 实体
Column()
.width(10)
.height(Math.abs(c.close - c.open) * 12 < 8 ? '8vp' : (Math.abs(c.close - c.open) * 12).toFixed(0) + 'vp')
.backgroundColor(c.isUp ? STOCK_COLORS.redUp : STOCK_COLORS.greenDown)
.borderRadius(2)
// 下影线
Column().width(1).height(6)
.backgroundColor(c.isUp ? STOCK_COLORS.redUp : STOCK_COLORS.greenDown)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
})
}
K 线图的实现是整个应用最值得称道的部分之一——它完全用 ArkTS 的基础组件手绘而成,没有任何图表库依赖。每根蜡烛由三个 Column 垂直堆叠:上影线(宽 1、高 6)、实体(宽 10、高按开盘收盘差值的 12 倍计算)、下影线(宽 1、高 6)。实体高度有一个保底逻辑:Math.abs(c.close - c.open) * 12 < 8 ? '8vp' : ...,当差值太小导致高度不足 8vp 时,强制设为 8vp,避免蜡烛实体消失。颜色根据 isUp 取红或绿。十根蜡烛通过 ForEach 横向排列,每个 Column 带 layoutWeight(1) 均分宽度,形成完整的 K 线序列。
Row() {
Text('开盘 ' + (this.selectedStock?.open.toFixed(2) ?? '0.00')).fontSize(10)
.fontColor(STOCK_COLORS.textSecondary)
Text('最高 ' + (this.selectedStock?.high.toFixed(2) ?? '0.00')).fontSize(10)
.fontColor(STOCK_COLORS.redUp).margin({ left: 12 })
Text('最低 ' + (this.selectedStock?.low.toFixed(2) ?? '0.00')).fontSize(10)
.fontColor(STOCK_COLORS.greenDown).margin({ left: 12 })
}
K 线下方是一行开盘、最高、最低的简要数据。"最高"用红色、"最低"用绿色,延续了红涨绿跌的视觉语言。
// 行情数据网格
Row() {
Column() {
Text('成交量').fontSize(10).fontColor(STOCK_COLORS.textHint)
Text(formatVolume(this.selectedStock?.volume ?? 0)).fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary).margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 12, bottom: 12 })
// ... 市值、市盈率、昨收 三个同样的结构
}
行情数据网格用 Row 横向排列四个 Column,每个 Column 包含一个小标题(成交量、市值、市盈率、昨收)与一个数值。每个 Column 都带 layoutWeight(1) 实现四等分。数值用 formatVolume、formatMarketCap 等工具函数格式化,确保显示简洁。
// 操作按钮
Row() {
Text('🔴 买入').fontSize(14).fontColor('#FFFFFF')
.backgroundColor(STOCK_COLORS.redUp).borderRadius(22)
.padding({ left: 28, right: 28, top: 11, bottom: 11 })
Text('🟢 卖出').fontSize(14).fontColor('#FFFFFF')
.backgroundColor(STOCK_COLORS.greenDown).borderRadius(22)
.padding({ left: 28, right: 28, top: 11, bottom: 11 })
.margin({ left: 10 })
Text('⭐ 加自选').fontSize(14).fontColor(STOCK_COLORS.redUp)
.backgroundColor(STOCK_COLORS.redUpLight).borderRadius(22)
.padding({ left: 22, right: 22, top: 11, bottom: 11 })
.margin({ left: 10 })
}
.width('100%').justifyContent(FlexAlign.Center)
弹窗底部是三个操作按钮:买入(红底白字)、卖出(绿底白字)、加自选(浅红底红字)。三个按钮用 justifyContent(FlexAlign.Center) 居中排列,圆角 22 营造胶囊按钮的形态。买入卖出用实色填充强调主要操作,加自选用浅色弱化次要操作,这种"主次分明"的按钮层级是良好的交互设计实践。
8.3 股票列表项构建器
@Builder stockItemBuilder(s: StockItem) {
Row() {
Column() {
Text(s.name).fontSize(14).fontWeight(FontWeight.Medium)
.fontColor(STOCK_COLORS.textPrimary)
Text(s.code).fontSize(10).fontColor(STOCK_COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column() {
Text(INDUSTRY_CONFIG[s.industry]?.label ?? s.industry).fontSize(9)
.fontColor(INDUSTRY_CONFIG[s.industry]?.color ?? '#888888')
.backgroundColor(INDUSTRY_CONFIG[s.industry]?.bg ?? '#F5F5F5')
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6)
}
.alignItems(HorizontalAlign.Center)
Column() {
Text(s.price.toFixed(2)).fontSize(15).fontWeight(FontWeight.Bold)
.fontColor(getProfitColor(s.changeRate >= 0))
Row() {
Text((s.changeRate >= 0 ? '+' : '') + s.changeRate.toFixed(2) + '%')
.fontSize(11).fontColor(getProfitColor(s.changeRate >= 0))
}
.backgroundColor(getProfitBg(s.changeRate >= 0))
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(4)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.End)
Column() {
Text(formatVolume(s.volume)).fontSize(12)
.fontColor(STOCK_COLORS.textSecondary)
Text('量').fontSize(9).fontColor(STOCK_COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End).margin({ left: 10 })
}
.width('100%').padding({ top: 12, bottom: 12, left: 14, right: 14 })
.backgroundColor(STOCK_COLORS.cardBg)
.borderRadius(0)
.onClick(() => { this.selectedStock = s; this.showStockDetail = true })
}
stockItemBuilder 是行情列表中单个股票项的渲染逻辑。整行用 Row 横向排列四块内容:左侧是名称+代码(占主宽)、中间是行业标签、右侧是价格+涨跌幅、最右是成交量。价格区是视觉重点——15 号粗体显示现价,下方是带浅色背景的涨跌幅胶囊。点击整行触发 onClick,把选中的股票赋值给 selectedStock 并打开详情弹窗。这个构建器会被列表区重复调用十八次,每次传入不同的 StockItem。
8.4 行情首页主体
build() {
Stack() {
Column() {
// 顶部标题
Row() {
Text('行情中心').fontSize(18).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary)
Column().layoutWeight(1)
Text('🔍').fontSize(20)
}
.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 10 })
行情首页的 build 用 Stack 作为根容器——这是因为详情弹窗需要叠加在主内容之上。顶部标题栏用 Row 排列标题"行情中心"与搜索图标,中间用 Column().layoutWeight(1) 占位把搜索图标推到最右。
Scroll() {
Column() {
// 大盘指数卡片
Row() {
Column() {
Text(INDEX_CONFIG['上证']?.name ?? '').fontSize(11)
.fontColor('#AAAAAA')
Text('3185.62').fontSize(18).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.redUp).margin({ top: 4 })
Row() {
Text('+28.56').fontSize(10).fontColor(STOCK_COLORS.redUp)
Text('+0.91%').fontSize(10).fontColor(STOCK_COLORS.redUp)
.margin({ left: 4 })
}
.margin({ top: 3 })
Text('成交 3856亿').fontSize(9).fontColor('#AAAAAA')
.margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.padding({ top: 14, bottom: 14, left: 8, right: 8 })
.backgroundColor(STOCK_COLORS.cardBg)
.borderRadius(12)
// 深证、创业板 结构相同
}
.width('100%').padding({ left: 14, right: 14 })
大盘指数区用 Row 横向排列三张卡片(上证、深证、创业板)。每张卡片显示指数名称、点位、涨跌额、涨跌幅、成交额五个信息。上证与创业板涨(红色),深证跌(绿色),呈现出一个有涨有跌的真实市场状态。卡片之间用 margin({ left: 6 }) 留出间距。整个滚动区域用 Scroll 包裹,因为内容会超出屏幕高度。
// 市场概览
Row() {
Column() {
Text('涨家数').fontSize(10).fontColor(STOCK_COLORS.textHint)
Text('2856').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.redUp).margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
// 跌家数、平家数、涨停、跌停 结构相同
}
市场概览用五等分的 Row 展示涨家数、跌家数、平家数、涨停数、跌停数。这五个数字一扫而过就能让用户把握市场整体情绪——涨家数 2856 远多于跌家数 1856,说明当天市场偏强。
// 热门股票列表
Column() {
this.stockItemBuilder(mockStocks[0])
Divider().color(STOCK_COLORS.border)
this.stockItemBuilder(mockStocks[1])
Divider().color(STOCK_COLORS.border)
// ... 共18只
}
.width('100%').backgroundColor(STOCK_COLORS.cardBg)
.margin({ left: 14, right: 14, bottom: 20 })
.borderRadius(12)
}
}
.layoutWeight(1).scrollBar(BarState.Off)
}
.width('100%').height('100%')
if (this.showStockDetail) { this.stockDetailModal() }
}
.width('100%').height('100%')
}
热门股票列表用 Column 纵向排列十八个 stockItemBuilder 调用,每两项之间用 Divider 分隔。这里没有用 ForEach 而是手动展开十八次调用,虽然代码更长,但调试时更容易定位到具体某一项。最后用 if (this.showStockDetail) { this.stockDetailModal() } 条件渲染详情弹窗——只有 showStockDetail 为 true 时弹窗才挂载,这是控制弹窗显隐的标准做法。
九、自选股管理组件:增删改的完整闭环
自选股页面比行情首页多了"管理"属性——用户可以添加、编辑、删除自选股。这意味着它需要管理更多的状态与更多的弹窗。
9.1 状态声明
@Component
struct WatchlistContent {
@State selectedGroup: string = '全部'
@State showEditModal: boolean = false
@State showDeleteModal: boolean = false
@State showAddModal: boolean = false
@State selectedStock: StockItem | null = null
@State formCode: string = ''
@State formName: string = ''
@State formGroup: string = '科技股'
这个组件持有七个状态变量,是所有组件中最多的。selectedGroup 记录当前选中的分组,三个 show*Modal 分别控制三个弹窗的显隐,selectedStock 记录当前操作的股票,formCode、formName、formGroup 是新增表单的三个字段。把表单状态直接放在组件级别,是 ArkTS 中处理表单的常见方式——表单字段变化会自动触发相关输入框的重渲染。
9.2 编辑弹窗
编辑弹窗允许用户修改自选股的分组归属与提醒设置。
@Builder editWatchModal() {
Column() {
this.modalOverlay(() => { this.showEditModal = false })
Column() {
Row() {
Text('编辑自选股').fontSize(17).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary)
Column().layoutWeight(1)
Text('✕').fontSize(18).fontColor(STOCK_COLORS.textHint)
.onClick(() => { this.showEditModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color(STOCK_COLORS.border)
弹窗头部是标题"编辑自选股"与关闭按钮,中间用 Divider 分隔头部与内容区。
Column() {
Text('股票信息').fontSize(12).fontColor(STOCK_COLORS.textSecondary)
.margin({ top: 16, left: 20 })
Row() {
Column() {
Text(this.selectedStock?.name ?? '').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary)
Text(this.selectedStock?.code ?? '').fontSize(11)
.fontColor(STOCK_COLORS.textHint).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column() {
Text(this.selectedStock?.price.toFixed(2) ?? '0.00').fontSize(18).fontWeight(FontWeight.Bold)
.fontColor(getProfitColor((this.selectedStock?.changeRate ?? 0) >= 0))
}
}
股票信息区只读展示名称、代码、现价,不可编辑——因为这些是行情数据,用户不应手动修改。
Text('所属分组').fontSize(12).fontColor(STOCK_COLORS.textSecondary)
.margin({ top: 18, left: 20 })
Row() {
ForEach(['科技股', '金融股', '消费股', '新能源'], (g: string) => {
if (this.formGroup === g) {
Text(g).fontSize(11).fontColor('#FFFFFF')
.backgroundColor(STOCK_COLORS.redUp)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(12).margin({ left: 3, right: 3 })
} else {
Text(g).fontSize(11).fontColor(STOCK_COLORS.redUp)
.backgroundColor(STOCK_COLORS.redUpLight)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(12).margin({ left: 3, right: 3 })
.onClick(() => { this.formGroup = g })
}
})
}
分组选择用四个胶囊标签实现,选中的标签是红底白字,未选中是浅红底红字。点击未选中标签时更新 formGroup 状态。这种"标签选择器"是移动端单选交互的优秀模式,比下拉菜单更直观。
Text('提醒设置').fontSize(12).fontColor(STOCK_COLORS.textSecondary)
.margin({ top: 18, left: 20 })
Row() {
Text('价格提醒').fontSize(13).fontColor(STOCK_COLORS.textPrimary).layoutWeight(1)
Text('未开启 ›').fontSize(12).fontColor(STOCK_COLORS.textHint)
}
.width('100%').padding({ left: 20, right: 20, top: 8 })
Row() {
Text('涨跌提醒').fontSize(13).fontColor(STOCK_COLORS.textPrimary).layoutWeight(1)
Text('已开启 ›').fontSize(12).fontColor(STOCK_COLORS.redUp)
}
提醒设置区用两行列表项展示价格提醒与涨跌提醒的开关状态。"未开启"用灰色,“已开启"用红色,状态一目了然。每行右侧的”›"箭头暗示可点击进入详细设置。
Row() {
Text('取消').fontSize(14).fontColor(STOCK_COLORS.textSecondary)
.backgroundColor('#F5F5F5').borderRadius(22)
.padding({ left: 28, right: 28, top: 11, bottom: 11 })
.onClick(() => { this.showEditModal = false })
Text('保存').fontSize(14).fontColor('#FFFFFF')
.backgroundColor(STOCK_COLORS.redUp).borderRadius(22)
.padding({ left: 28, right: 28, top: 11, bottom: 11 })
.margin({ left: 12 })
.onClick(() => { this.showEditModal = false })
}
.width('100%').justifyContent(FlexAlign.Center)
弹窗底部是"取消"与"保存"两个按钮。取消用灰底,保存用红底白字,主次分明。两个按钮的点击事件都是关闭弹窗(mock 应用没有真正持久化逻辑)。
9.3 删除确认弹窗
@Builder deleteWatchModal() {
Column() {
this.modalOverlay(() => { this.showDeleteModal = false })
Column() {
Column() {
Text('⚠️').fontSize(40)
}
.width(68).height(68).borderRadius(34)
.backgroundColor(STOCK_COLORS.redUpLight)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Text('确认删除此自选股?').fontSize(17).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary).margin({ top: 16 })
Text('删除后将不再显示该股票行情').fontSize(12).fontColor(STOCK_COLORS.textSecondary)
.margin({ top: 4 })
删除确认弹窗的视觉风格与编辑弹窗不同——它更紧凑、更聚焦。顶部是一个 68x68 的圆形图标区,浅红底配警告 emoji。下方是加粗的确认问句与灰色说明文字,明确告知用户删除的后果。这种"图标 + 问句 + 说明"的三段式结构,是危险操作确认弹窗的标准范式。
Row() {
Text(this.selectedStock?.name ?? '').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary)
Text(this.selectedStock?.code ?? '').fontSize(11)
.fontColor(STOCK_COLORS.textHint).margin({ left: 8 })
Text(this.selectedStock?.price.toFixed(2) ?? '0.00').fontSize(14)
.fontColor(getProfitColor((this.selectedStock?.changeRate ?? 0) >= 0))
.margin({ left: 12 })
}
.backgroundColor(STOCK_COLORS.lightBg).borderRadius(10)
.padding({ left: 16, right: 16, top: 10, bottom: 10 }).margin({ top: 16, left: 20, right: 20 })
弹窗中部用浅色背景卡片展示即将删除的股票信息,让用户在确认前再次核对。这是一个体贴的细节——避免用户误删。
9.4 新增弹窗与表单输入
@Builder addWatchModal() {
// ... 头部省略
Column() {
Text('股票代码').fontSize(12).fontColor(STOCK_COLORS.textSecondary)
.margin({ top: 16, left: 20 })
TextInput({ placeholder: '如:600519' })
.placeholderColor(STOCK_COLORS.textHint).fontSize(14)
.width('100%').backgroundColor(STOCK_COLORS.lightBg).borderRadius(8)
.margin({ left: 20, right: 20, top: 6 })
.onChange((v: string) => { this.formCode = v })
Text('股票名称').fontSize(12).fontColor(STOCK_COLORS.textSecondary)
.margin({ top: 16, left: 20 })
TextInput({ placeholder: '如:贵州茅台' })
.placeholderColor(STOCK_COLORS.textHint).fontSize(14)
.width('100%').backgroundColor(STOCK_COLORS.lightBg).borderRadius(8)
.margin({ left: 20, right: 20, top: 6 })
.onChange((v: string) => { this.formName = v })
新增弹窗用 TextInput 组件接收用户输入。每个输入框都有 placeholder(占位提示文字)、placeholderColor(占位文字颜色)、浅色背景与圆角。onChange 回调把输入值同步到组件状态——this.formCode = v 与 this.formName = v。这种"输入即同步"的模式,让表单状态始终与界面保持一致。
分组选择部分与编辑弹窗完全一致,复用了同样的标签选择器逻辑。
9.5 自选股列表项与主体
@Builder watchStockBuilder(s: StockItem) {
Row() {
Column() {
Row() {
Text(s.name).fontSize(14).fontWeight(FontWeight.Medium)
.fontColor(STOCK_COLORS.textPrimary)
Text(INDUSTRY_CONFIG[s.industry]?.label ?? '').fontSize(8)
.fontColor(INDUSTRY_CONFIG[s.industry]?.color ?? '#888888')
.backgroundColor(INDUSTRY_CONFIG[s.industry]?.bg ?? '#F5F5F5')
.padding({ left: 4, right: 4, top: 1, bottom: 1 }).borderRadius(4)
.margin({ left: 6 })
}
Text(s.code).fontSize(10).fontColor(STOCK_COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column() {
Text(s.price.toFixed(2)).fontSize(15).fontWeight(FontWeight.Bold)
.fontColor(getProfitColor(s.changeRate >= 0))
Text((s.changeRate >= 0 ? '+' : '') + s.changeRate.toFixed(2) + '%')
.fontSize(11).fontColor(getProfitColor(s.changeRate >= 0))
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
Column() {
Text('编辑').fontSize(10).fontColor(STOCK_COLORS.textSecondary)
.backgroundColor('#F5F5F5').borderRadius(8)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.onClick(() => { this.selectedStock = s; this.showEditModal = true })
Text('删除').fontSize(10).fontColor(STOCK_COLORS.redUp)
.backgroundColor(STOCK_COLORS.redUpLight).borderRadius(8)
.padding({ left: 8, right: 8, top: 3, bottom: 3 }).margin({ top: 4 })
.onClick(() => { this.selectedStock = s; this.showDeleteModal = true })
}
.alignItems(HorizontalAlign.End).margin({ left: 10 })
}
.width('100%').padding({ top: 12, bottom: 12, left: 14, right: 14 })
.backgroundColor(STOCK_COLORS.cardBg)
}
自选股列表项与行情列表项类似,但右侧多了"编辑"与"删除"两个小按钮。这两个按钮是内联的胶囊形态,编辑用灰色、删除用浅红色,点击分别打开编辑弹窗与删除确认弹窗。把操作按钮直接放在列表项里,省去了用户长按或滑动才能触发操作的步骤,交互更直接。
主体 build 方法中,分组标签用横向 Scroll 包裹(支持左右滑动),列表用纵向 Scroll 包裹。三个弹窗通过 if 条件挂载:
if (this.showEditModal) { this.editWatchModal() }
if (this.showDeleteModal) { this.deleteWatchModal() }
if (this.showAddModal) { this.addWatchModal() }
这种"多弹窗共存于一个 Stack"的结构,让自选股页面成为一个完整的增删改闭环。
十、持仓管理组件:数据可视化的大舞台
持仓页面是整个应用信息量最大、可视化元素最丰富的页面。它把账户汇总、持仓分布、盈亏柱状图、持仓明细四块内容垂直堆叠在一个滚动区域里。
10.1 持仓汇总卡片
@Component
struct HoldingContent {
maxProfit: number = 10000
maxLoss: number = -5000
组件持有两个非状态属性 maxProfit 与 maxLoss,用作后续柱状图高度计算的基准值。把它们定义为类属性而非魔法数字,便于统一调整。
Column() {
Row() {
Text('持仓总览').fontSize(15).fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Column().layoutWeight(1)
Text('更新于 15:00').fontSize(10).fontColor('#AAAAAA')
}
.width('100%')
Row() {
Column() {
Text('总资产').fontSize(11).fontColor('#AAAAAA')
Text('¥' + getTotalAssets().toLocaleString()).fontSize(28).fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF').margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column() {
Text('总盈亏').fontSize(11).fontColor('#AAAAAA')
Text('+' + getTotalProfit().toLocaleString()).fontSize(20).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.redUp).margin({ top: 4 })
}
.alignItems(HorizontalAlign.End)
}
汇总卡片采用深色背景——通过 linearGradient 实现从 #1A1A1A 到 #0D0D0D 的 135 度线性渐变,营造高级的"黑金"质感。这种深色卡片在金融应用中非常常见,它能让关键的资产数字(28 号白色超大字体)在视觉上绝对突出。toLocaleString() 方法自动给大数字加千分位逗号,让 528360 显示为 528,360,可读性大幅提升。
Row() {
Column() {
Text('收益率').fontSize(10).fontColor('#AAAAAA')
Text('+' + getTotalProfitRate().toFixed(2) + '%').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.redUp).margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column() {
Text('今日盈亏').fontSize(10).fontColor('#AAAAAA')
Text('+' + getTodayProfit().toString()).fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.redUp).margin({ top: 3 })
}
.alignItems(HorizontalAlign.Center).layoutWeight(1)
Column() {
Text('可用资金').fontSize(10).fontColor('#AAAAAA')
Text('¥' + getAvailableCash().toLocaleString()).fontSize(16).fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF').margin({ top: 3 })
}
.alignItems(HorizontalAlign.End).layoutWeight(1)
}
第二行用三等分展示收益率、今日盈亏、可用资金。收益率与今日盈亏用红色(盈利),可用资金用白色(中性)。三列分别左对齐、居中、右对齐,形成视觉上的对称感。
10.2 持仓分布条
Column() {
Text('📊 持仓分布').fontSize(13).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary)
.width('100%').padding({ left: 16, top: 14, bottom: 10 })
Row() {
Column().width('30%').height(24)
.backgroundColor('#B71C1C').borderRadius({ topLeft: 6, bottomLeft: 6 })
Column().width('22%').height(24)
.backgroundColor('#FF6F00')
Column().width('18%').height(24)
.backgroundColor('#6A1B9A')
Column().width('12%').height(24)
.backgroundColor('#1565C0')
Column().width('8%').height(24)
.backgroundColor('#2E7D32')
Column().width('6%').height(24)
.backgroundColor('#5D4037')
Column().width('4%').height(24)
.backgroundColor('#37474F')
Column().width('0%').height(24)
.backgroundColor('#00695C').borderRadius({ topRight: 6, bottomRight: 6 })
}
.width('100%').padding({ left: 16, right: 16 })
持仓分布用一条横向的"堆叠条"来可视化——八段不同颜色、不同宽度的 Column 拼成一条完整的横条,每段的宽度百分比对应该股票在持仓中的占比。贵州茅台占 30%(最宽,红色)、宁德时代占 22%(橙色)、比亚迪占 18%(紫色)……这种堆叠条是饼图的变体,在横向空间有限时比饼图更省地方,同时保留了"占比对比"的信息。最左与最右两段加了对应的圆角,让整条看起来更精致。
下方配了三列图例,每列两行,把每段颜色对应的股票名称与占比列出,方便用户对照。
10.3 个股盈亏柱状图
Row() {
ForEach(mockHoldings, (h: HoldingItem) => {
Column() {
Text(((h.currentPrice - h.costPrice) * h.holdCount >= 0 ? '+' : '') + ((h.currentPrice - h.costPrice) * h.holdCount).toFixed(0))
.fontSize(8)
.fontColor(getProfitColor(h.currentPrice >= h.costPrice))
.margin({ bottom: 3 })
if (h.currentPrice >= h.costPrice) {
Column()
.width(18)
.height((Math.abs((h.currentPrice - h.costPrice) * h.holdCount) / this.maxProfit * 60).toFixed(0) + 'vp')
.backgroundColor(STOCK_COLORS.redUp)
.borderRadius({ topLeft: 3, topRight: 3 })
} else {
Column().height(2).backgroundColor(STOCK_COLORS.border)
Column()
.width(18)
.height((Math.abs((h.currentPrice - h.costPrice) * h.holdCount) / Math.abs(this.maxLoss) * 40).toFixed(0) + 'vp')
.backgroundColor(STOCK_COLORS.greenDown)
.borderRadius({ bottomLeft: 3, bottomRight: 3 })
}
Text(h.name.substring(0, 2)).fontSize(7).fontColor(STOCK_COLORS.textHint)
.margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
})
}
这是又一个纯声明式绘制的图表——个股盈亏柱状图。八只持仓各占一等分宽度,每根柱子的高度按盈亏额等比缩放:盈利柱用红色、向上生长,亏损柱用绿色、向下生长(中间有一条 2vp 的灰色基线分隔正负)。柱子顶部或底部还标注了盈亏金额(8 号字),最下方是股票名称前两个字。Math.abs(...) / this.maxProfit * 60 这种计算把绝对盈亏额映射到 0-60vp 的视觉高度范围,保证最大盈利柱不超过 60vp,避免溢出。这种"数据驱动的尺寸计算"是声明式 UI 绘制图表的核心技巧。
10.4 持仓明细列表项
@Builder holdingItemBuilder(h: HoldingItem) {
Column() {
Row() {
Column() {
Text(h.name).fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary)
Row() {
Text(h.code).fontSize(10).fontColor(STOCK_COLORS.textHint)
Text(INDUSTRY_CONFIG[h.industry]?.label ?? h.industry).fontSize(8)
.fontColor(INDUSTRY_CONFIG[h.industry]?.color ?? '#888888')
.backgroundColor(INDUSTRY_CONFIG[h.industry]?.bg ?? '#F5F5F5')
.padding({ left: 4, right: 4, top: 1, bottom: 1 }).borderRadius(4)
.margin({ left: 6 })
}
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column() {
Text('持仓量').fontSize(9).fontColor(STOCK_COLORS.textHint)
Text(h.holdCount.toString()).fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Center)
Column() {
Text('成本价').fontSize(9).fontColor(STOCK_COLORS.textHint)
Text(h.costPrice.toFixed(2)).fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Center).margin({ left: 10 })
Column() {
Text('现价').fontSize(9).fontColor(STOCK_COLORS.textHint)
Text(h.currentPrice.toFixed(2)).fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(getProfitColor(h.currentPrice >= h.costPrice)).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Center).margin({ left: 10 })
}
持仓明细每项分两行。第一行是名称+行业标签、持仓量、成本价、现价四列。现价的字体颜色用 getProfitColor(h.currentPrice >= h.costPrice) 动态决定——现价高于成本价(盈利)显红色,低于成本价(亏损)显绿色,让用户一眼就能看出每只持仓的盈亏方向。
Row() {
Column() {
Text('盈亏').fontSize(9).fontColor(STOCK_COLORS.textHint)
Text(((h.currentPrice - h.costPrice) * h.holdCount >= 0 ? '+' : '') + ((h.currentPrice - h.costPrice) * h.holdCount).toFixed(2))
.fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(getProfitColor(h.currentPrice >= h.costPrice)).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
Column() {
Text('盈亏率').fontSize(9).fontColor(STOCK_COLORS.textHint)
Text(((h.currentPrice - h.costPrice) / h.costPrice * 100 >= 0 ? '+' : '') + ((h.currentPrice - h.costPrice) / h.costPrice * 100).toFixed(2) + '%')
.fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(getProfitColor(h.currentPrice >= h.costPrice)).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).margin({ left: 16 })
Column() {
Text('市值').fontSize(9).fontColor(STOCK_COLORS.textHint)
Text((h.currentPrice * h.holdCount).toFixed(2)).fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).margin({ left: 16 })
Column().layoutWeight(1)
Column() {
Text('占比').fontSize(9).fontColor(STOCK_COLORS.textHint)
Column().width(40).height(4)
.backgroundColor(getProfitColor(h.currentPrice >= h.costPrice))
.borderRadius(2).margin({ top: 4 })
}
.alignItems(HorizontalAlign.End)
}
第二行展示盈亏额、盈亏率、市值、占比四列。盈亏额通过 (h.currentPrice - h.costPrice) * h.holdCount 实时计算,盈亏率通过 (h.currentPrice - h.costPrice) / h.costPrice * 100 实时计算——这就是前面在数据模型层提到的"把盈亏计算留在视图层"的设计,确保每次渲染都用最新的现价。最右的"占比"用一根 40x4 的小色条示意,颜色跟随盈亏方向,是一个轻量的视觉辅助。
十一、交易记录组件:时间序列的优雅呈现
交易记录页面展示用户的历史交易流水,支持按类型筛选,并支持查看详情与删除。
11.1 状态与详情弹窗
@Component
struct TradeRecordContent {
@State selectedType: string = '全部'
@State showDetailModal: boolean = false
@State showDeleteModal: boolean = false
@State selectedTrade: TradeRecord | null = null
组件持有四个状态:selectedType 是当前筛选的交易类型,两个 show*Modal 控制弹窗,selectedTrade 记录当前操作的交易记录。
详情弹窗的结构比自选股弹窗更丰富——它要展示交易类型标签、股票名称、成交金额、成交价、数量、手续费、日期时间、状态等多维信息。
Column() {
Row() {
Text(TRADE_TYPE_CONFIG[this.selectedTrade?.type ?? '买入']?.icon + ' ' + (TRADE_TYPE_CONFIG[this.selectedTrade?.type ?? '买入']?.label ?? '买入'))
.fontSize(12).fontColor(TRADE_TYPE_CONFIG[this.selectedTrade?.type ?? '买入']?.color ?? '#888888')
.backgroundColor(TRADE_TYPE_CONFIG[this.selectedTrade?.type ?? '买入']?.bg ?? '#F5F5F5')
.padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(10)
Text('#T' + (this.selectedTrade?.id ?? 0)).fontSize(11)
.fontColor(STOCK_COLORS.textHint).margin({ left: 8 })
}
.width('100%').margin({ top: 16 })
Text(this.selectedTrade?.name ?? '').fontSize(22).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary).margin({ top: 12 })
Text(this.selectedTrade?.code ?? '').fontSize(12)
.fontColor(STOCK_COLORS.textHint).margin({ top: 2 })
Text('¥' + (this.selectedTrade?.amount.toFixed(2) ?? '0.00')).fontSize(32)
.fontWeight(FontWeight.Bold)
.fontColor(TRADE_TYPE_CONFIG[this.selectedTrade?.type ?? '买入']?.color ?? STOCK_COLORS.textPrimary)
.margin({ top: 12 })
弹窗顶部的交易类型标签从 TRADE_TYPE_CONFIG 取色,包含 emoji 图标与文字标签。旁边是交易编号(#T1 格式)。下方是 22 号粗体的股票名称、12 号的代码、32 号超大字体的成交金额——金额的颜色跟随交易类型(买入红、卖出绿、分红橙……),让用户从颜色就能判断资金流向。
Divider().color(STOCK_COLORS.border).margin({ top: 16 })
Row() {
Text('成交价格').fontSize(12).fontColor(STOCK_COLORS.textSecondary)
Column().layoutWeight(1)
Text('¥' + (this.selectedTrade?.price.toFixed(2) ?? '0.00')).fontSize(13)
.fontColor(STOCK_COLORS.textPrimary)
}
.width('100%').padding({ top: 12 })
// 成交数量、手续费、交易日期、状态 结构相同
金额下方用 Divider 分隔,然后是五行的"键值对"明细——每行用 Row 实现,左侧是标签(如"成交价格"),中间用 Column().layoutWeight(1) 占位,右侧是数值。这种"左标签右数值"的布局是详情页的经典模式,信息密度高且对齐整齐。
11.2 交易记录列表项
@Builder tradeItemBuilder(t: TradeRecord) {
Row() {
Column() {
Text(TRADE_TYPE_CONFIG[t.type]?.icon ?? '📋').fontSize(20)
}
.width(36).height(36).borderRadius(10)
.backgroundColor(TRADE_TYPE_CONFIG[t.type]?.bg ?? '#F5F5F5')
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column() {
Row() {
Text(t.name).fontSize(13).fontWeight(FontWeight.Medium)
.fontColor(STOCK_COLORS.textPrimary)
if (t.code !== '') {
Text(t.code).fontSize(9).fontColor(STOCK_COLORS.textHint)
.margin({ left: 6 })
}
}
Row() {
Text(TRADE_TYPE_CONFIG[t.type]?.label ?? t.type).fontSize(10)
.fontColor(TRADE_TYPE_CONFIG[t.type]?.color ?? '#888888')
Text('·' + t.date + ' ' + t.time).fontSize(9)
.fontColor(STOCK_COLORS.textHint).margin({ left: 6 })
}
.margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
Column() {
Text('¥' + t.amount.toFixed(2)).fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(TRADE_TYPE_CONFIG[t.type]?.color ?? STOCK_COLORS.textPrimary)
Text(t.count.toString() + '股 × ' + t.price.toFixed(2)).fontSize(10)
.fontColor(STOCK_COLORS.textHint).margin({ top: 2 })
Text(t.status).fontSize(9)
.fontColor(t.status === '已成' || t.status === '已到' ? STOCK_COLORS.redUp : STOCK_COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%').padding({ top: 10, bottom: 10, left: 14, right: 14 })
.backgroundColor(STOCK_COLORS.cardBg)
.onClick(() => { this.selectedTrade = t; this.showDetailModal = true })
}
交易记录列表项的布局很有讲究:最左边是一个 36x36 的圆角方块图标(背景色取自交易类型配置),中间是名称+代码、类型+日期时间两行,右侧是金额、数量×价格、状态三行。注意 if (t.code !== '') 这个条件——基金类交易没有代码,所以代码标签只在非空时才渲染,避免出现空的间距。状态文字的颜色也做了条件处理:"已成"或"已到"显示红色(强调完成),其他状态显示灰色。
11.3 统计卡片与类型筛选
Row() {
Column() {
Text('本月交易').fontSize(10).fontColor(STOCK_COLORS.textHint)
Text('14').fontSize(18).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary).margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('买入金额').fontSize(10).fontColor(STOCK_COLORS.textHint)
Text('¥48.5万').fontSize(18).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.redUp).margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('卖出金额').fontSize(10).fontColor(STOCK_COLORS.textHint)
Text('¥18.2万').fontSize(18).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.greenDown).margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('手续费').fontSize(10).fontColor(STOCK_COLORS.textHint)
Text('¥186').fontSize(18).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textSecondary).margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
统计卡片用四等分展示本月交易笔数、买入金额、卖出金额、手续费。买入用红色、卖出用绿色,与交易类型的色彩约定一致。手续费用灰色,表示它是中性的成本项。
类型筛选条与自选股页面的分组标签结构一致,用横向 Scroll 包裹六个类型标签(全部、买入、卖出、分红、申购、赎回),选中的标签红底白字,未选中的浅红底红字。
十二、个人中心组件:账户与设置的汇聚地
个人中心页面汇聚了用户信息、资产概览、投资统计、月度收益趋势、账户管理等内容,是整个应用信息维度的"终点站"。
12.1 用户信息卡片
@Component
struct ProfileContent {
maxProfitAbs: number = 6800
@Builder statCard(icon: string, label: string, value: string, color: string) {
Column() {
Text(icon).fontSize(20).margin({ bottom: 4 })
Text(value).fontSize(16).fontWeight(FontWeight.Bold).fontColor(color)
Text(label).fontSize(10).fontColor(STOCK_COLORS.textSecondary).margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.backgroundColor(STOCK_COLORS.cardBg)
.borderRadius(12).padding({ top: 14, bottom: 14 })
}
组件定义了一个 statCard 构建器,接收图标、标签、数值、颜色四个参数,渲染一个统计小卡片。这个构建器会被复用四次,展示持仓数、自选数、交易笔数、胜率四个指标。把可复用的 UI 片段抽象成带参数的构建器,是减少代码重复的有效手段。
Column() {
Row() {
Column() {
Text('👤').fontSize(36)
}
.width(60).height(60).borderRadius(30)
.backgroundColor('#3D0000')
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column() {
Text('张投资').fontSize(18).fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('证券账户 A88562100').fontSize(12)
.fontColor('#AAAAAA').margin({ top: 3 })
Row() {
Text('A股').fontSize(9).fontColor('#FFFFFF')
.backgroundColor(STOCK_COLORS.redUp).borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
Text('VIP3').fontSize(9).fontColor('#1A1A1A')
.backgroundColor(STOCK_COLORS.gold).borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).margin({ left: 6 })
Text('信用账户').fontSize(9).fontColor('#FFFFFF')
.backgroundColor('#5D4037').borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).margin({ left: 6 })
}
.margin({ top: 6 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 14 })
}
.width('100%').padding(16)
}
.width('100%')
.linearGradient({ angle: 135, colors: [['#1A1A1A', 0.0], ['#0D0D0D', 1.0]] })
.borderRadius(16)
.margin({ left: 14, right: 14, top: 14 })
.shadow({ radius: 10, color: '#331A1A1A', offsetY: 4 })
用户信息卡片同样采用深色渐变背景。左侧是 60x60 的圆形头像区(深红底配 emoji),右侧是用户名、账户号、三个身份标签(A股、VIP3、信用账户)。VIP3 用金色背景,传递尊贵感;信用账户用棕色,区别于普通账户。三个标签紧凑排列,让用户的"身份画像"一目了然。
12.2 资产概览与投资统计
Column() {
Text('💰 资产概览').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary)
.width('100%').padding({ left: 16, top: 14, bottom: 10 })
Row() {
Column() {
Text('总资产').fontSize(10).fontColor(STOCK_COLORS.textHint)
Text('¥' + getTotalAssets().toLocaleString()).fontSize(22).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary).margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column() {
Text('总盈亏').fontSize(10).fontColor(STOCK_COLORS.textHint)
Text('+' + getTotalProfit().toLocaleString()).fontSize(18).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.redUp).margin({ top: 4 })
Text('+' + getTotalProfitRate().toFixed(2) + '%').fontSize(11)
.fontColor(STOCK_COLORS.redUp).margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%').padding({ left: 16, right: 16 })
Divider().color(STOCK_COLORS.border).margin({ top: 12, left: 16, right: 16 })
Row() {
Column() {
Text('可用资金').fontSize(10).fontColor(STOCK_COLORS.textHint)
Text('¥' + getAvailableCash().toLocaleString()).fontSize(15).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary).margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column() {
Text('持仓市值').fontSize(10).fontColor(STOCK_COLORS.textHint)
Text('¥442,760').fontSize(15).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary).margin({ top: 3 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 14 })
}
资产概览卡片用白色背景,分两行展示:第一行是总资产(22 号大字)与总盈亏+收益率,中间用 Divider 分隔;第二行是可用资金与持仓市值。这里的数值都通过工具函数获取,保证与持仓页面的数据一致。
投资统计区用 2x2 的网格排列四个 statCard,展示持仓数、自选数、交易笔数、胜率。胜率 68% 用红色高亮,是一个让投资者有成就感的数字。
12.3 月度收益趋势柱状图
Row() {
ForEach(MONTHLY_PROFIT_DATA, (d: BarDataMeta) => {
Column() {
Text((d.isPositive ? '+' : '') + d.value.toString())
.fontSize(8)
.fontColor(getProfitColor(d.isPositive))
.margin({ bottom: 3 })
if (d.isPositive) {
Column()
.width(16)
.height((Math.abs(d.value) / this.maxProfitAbs * 65).toFixed(0) + 'vp')
.backgroundColor(STOCK_COLORS.redUp)
.borderRadius({ topLeft: 3, topRight: 3 })
} else {
Column().height(2).backgroundColor(STOCK_COLORS.border)
Column()
.width(16)
.height((Math.abs(d.value) / this.maxProfitAbs * 40).toFixed(0) + 'vp')
.backgroundColor(STOCK_COLORS.greenDown)
.borderRadius({ bottomLeft: 3, bottomRight: 3 })
}
Text(d.label).fontSize(8).fontColor(STOCK_COLORS.textHint)
.margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
})
}
月度收益柱状图与持仓页面的盈亏柱状图原理相同,只是数据源换成了 MONTHLY_PROFIT_DATA。八个月的收益数据各占一等分宽度,柱子高度按 Math.abs(d.value) / this.maxProfitAbs * 65 计算,盈利柱红色向上、亏损柱绿色向下。柱子顶部标注收益金额,底部标注月份。最下方还有一行"累计收益 +¥15,700"的汇总,用红色粗体强调正向收益。
12.4 账户管理列表
Column() {
Row() {
Text('🏦').fontSize(18)
Text('银行卡管理').fontSize(13).fontColor(STOCK_COLORS.textPrimary)
.layoutWeight(1).margin({ left: 10 })
Text('已绑2张 ›').fontSize(11).fontColor(STOCK_COLORS.textHint)
}
.width('100%').padding({ top: 12, bottom: 12, left: 4 })
Divider().color(STOCK_COLORS.border)
Row() {
Text('🔐').fontSize(18)
Text('安全中心').fontSize(13).fontColor(STOCK_COLORS.textPrimary)
.layoutWeight(1).margin({ left: 10 })
Text('›').fontSize(18).fontColor(STOCK_COLORS.textHint)
}
// 风险测评、交易规则、联系客服 结构相同
}
账户管理区是一个垂直的设置列表,每行用 Row 排列:左侧 emoji 图标、中间项目名称、右侧状态或箭头。银行卡管理显示"已绑2张"(带状态值),安全中心只显示箭头,风险测评显示"积极型"(带评估结果),交易规则与联系客服只显示箭头。行与行之间用 Divider 分隔。这种"图标 + 名称 + 状态 + 箭头"的列表项,是移动端设置页的标准范式。
页面最底部是版本号"v2.1 · 智能投资管家 · 2026",用居中的 10 号灰色字呈现,为整个应用画上一个低调的句号。
十三、关键特性对比总结
为了更直观地呈现这个股票投资应用各模块的特点,下面用一张表格对五大核心模块进行横向对比。
| 模块 | 核心职责 | 主要状态变量 | 弹窗数量 | 可视化元素 | 交互特色 |
|---|---|---|---|---|---|
| 行情首页 | 展示大盘指数、市场概览、热门股票 | 2 个(弹窗显隐 + 选中股票) | 1 个(股票详情) | K 线图、指数卡片、市场概览 | 点击列表项打开详情弹窗 |
| 自选股管理 | 管理自选股的分组与提醒 | 7 个(分组、三个弹窗、选中股票、三个表单字段) | 3 个(编辑、删除、新增) | 分组标签、胶囊选择器 | 增删改完整闭环、表单输入 |
| 持仓管理 | 展示账户汇总与持仓明细 | 2 个(基准值) | 0 个 | 深色汇总卡、持仓分布条、盈亏柱状图 | 数据可视化最为丰富 |
| 交易记录 | 展示与筛选历史交易流水 | 4 个(类型筛选、两个弹窗、选中记录) | 2 个(详情、删除) | 统计卡片、类型筛选条、流水列表 | 按类型筛选、查看交易详情 |
| 个人中心 | 展示用户信息与账户设置 | 1 个(基准值) | 0 个 | 用户卡片、资产概览、月度收益图、设置列表 | 信息汇聚、统计卡片复用 |
从表格可以看出,五个模块各有侧重:行情首页与持仓管理侧重"信息展示",自选股管理侧重"增删改操作",交易记录侧重"筛选与查询",个人中心侧重"汇总与设置"。这种职责分离让每个模块都保持了适度的复杂度,避免了单个组件承担过多职责。
安装DevEco Studio程序

选择目标安装目录:

设置环境变量,但是需要重启一下:

新建一个空白模板:

设置API为24的模板项目:
初始化项目,自动下载相关依赖:

完整代码:
// ============ 类型定义 ============
interface StockColorScheme {
redUp: string
redUpLight: string
redUpDark: string
greenDown: string
greenDownLight: string
black: string
darkBg: string
lightBg: string
cardBg: string
textPrimary: string
textSecondary: string
textHint: string
border: string
white: string
gold: string
}
interface IndexMeta {
name: string
code: string
color: string
bg: string
}
interface StockIndustryMeta {
label: string
color: string
bg: string
}
interface WatchGroupMeta {
label: string
count: number
color: string
}
interface TradeTypeMeta {
label: string
icon: string
color: string
bg: string
}
interface BarDataMeta {
label: string
value: number
isPositive: boolean
}
interface CandleBarMeta {
open: number
close: number
high: number
low: number
isUp: boolean
}
// ============ 股票数据模型 ============
@Observed
export class StockItem {
id: number = 0
code: string = ''
name: string = ''
industry: string = ''
price: number = 0
change: number = 0
changeRate: number = 0
volume: number = 0
marketCap: number = 0
peRatio: number = 0
high: number = 0
low: number = 0
open: number = 0
preClose: number = 0
constructor(id: number, code: string, name: string, industry: string, price: number, changeRate: number, volume: number, marketCap: number) {
this.id = id; this.code = code; this.name = name; this.industry = industry
this.price = price; this.changeRate = changeRate; this.volume = volume
this.marketCap = marketCap
this.change = price * changeRate / 100
this.preClose = price / (1 + changeRate / 100)
this.open = this.preClose * 0.998
this.high = price * 1.015
this.low = this.preClose * 0.985
this.peRatio = 8 + (id * 3.7) % 30
}
}
// ============ 持仓数据模型 ============
@Observed
export class HoldingItem {
id: number = 0
code: string = ''
name: string = ''
industry: string = ''
holdCount: number = 0
costPrice: number = 0
currentPrice: number = 0
constructor(id: number, code: string, name: string, industry: string, holdCount: number, costPrice: number, currentPrice: number) {
this.id = id; this.code = code; this.name = name; this.industry = industry
this.holdCount = holdCount; this.costPrice = costPrice; this.currentPrice = currentPrice
}
}
// ============ 交易记录数据模型 ============
@Observed
export class TradeRecord {
id: number = 0
type: string = ''
code: string = ''
name: string = ''
price: number = 0
count: number = 0
amount: number = 0
date: string = ''
time: string = ''
status: string = ''
fee: number = 0
constructor(id: number, type: string, code: string, name: string, price: number, count: number, date: string, time: string, status: string) {
this.id = id; this.type = type; this.code = code; this.name = name
this.price = price; this.count = count; this.date = date; this.time = time
this.status = status
this.amount = price * count
this.fee = this.amount * 0.0003
}
}
// ============ 设计令牌 ============
const STOCK_COLORS: StockColorScheme = {
redUp: '#B71C1C',
redUpLight: '#FFEBEE',
redUpDark: '#7F0000',
greenDown: '#1B5E20',
greenDownLight: '#E8F5E9',
black: '#1A1A1A',
darkBg: '#0D0D0D',
lightBg: '#FFF5F5',
cardBg: '#FFFFFF',
textPrimary: '#1A1A1A',
textSecondary: '#666666',
textHint: '#AAAAAA',
border: '#FFE0E0',
white: '#FFFFFF',
gold: '#FFD700'
}
const INDEX_CONFIG: Record<string, IndexMeta> = {
'上证': { name: '上证指数', code: '000001.SH', color: '#B71C1C', bg: '#FFEBEE' },
'深证': { name: '深证成指', code: '399001.SZ', color: '#1A1A1A', bg: '#F5F5F5' },
'创业板': { name: '创业板指', code: '399006.SZ', color: '#FF6F00', bg: '#FFF8E1' }
}
const INDUSTRY_CONFIG: Record<string, StockIndustryMeta> = {
'银行': { label: '银行', color: '#1565C0', bg: '#E3F2FD' },
'科技': { label: '科技', color: '#6A1B9A', bg: '#F3E5F5' },
'医药': { label: '医药', color: '#2E7D32', bg: '#E8F5E9' },
'新能源': { label: '新能源', color: '#F57F17', bg: '#FFF8E1' },
'消费': { label: '消费', color: '#C62828', bg: '#FFEBEE' },
'地产': { label: '地产', color: '#5D4037', bg: '#EFEBE9' },
'军工': { label: '军工', color: '#37474F', bg: '#ECEFF1' },
'半导体': { label: '半导体', color: '#00695C', bg: '#E0F2F1' }
}
const WATCH_GROUPS: WatchGroupMeta[] = [
{ label: '全部', count: 15, color: '#B71C1C' },
{ label: '科技股', count: 5, color: '#6A1B9A' },
{ label: '金融股', count: 4, color: '#1565C0' },
{ label: '消费股', count: 3, color: '#C62828' },
{ label: '新能源', count: 3, color: '#F57F17' }
]
const TRADE_TYPE_CONFIG: Record<string, TradeTypeMeta> = {
'买入': { label: '买入', icon: '🔴', color: '#B71C1C', bg: '#FFEBEE' },
'卖出': { label: '卖出', icon: '🟢', color: '#1B5E20', bg: '#E8F5E9' },
'分红': { label: '分红', icon: '💰', color: '#FF6F00', bg: '#FFF8E1' },
'申购': { label: '申购', icon: '📋', color: '#1565C0', bg: '#E3F2FD' },
'赎回': { label: '赎回', icon: '📤', color: '#5D4037', bg: '#EFEBE9' }
}
const MONTHLY_PROFIT_DATA: BarDataMeta[] = [
{ label: '1月', value: 3200, isPositive: true },
{ label: '2月', value: -1800, isPositive: false },
{ label: '3月', value: 5600, isPositive: true },
{ label: '4月', value: 2400, isPositive: true },
{ label: '5月', value: -3200, isPositive: false },
{ label: '6月', value: 6800, isPositive: true },
{ label: '7月', value: 4200, isPositive: true },
{ label: '8月', value: -1500, isPositive: false }
]
const CANDLE_DATA: CandleBarMeta[] = [
{ open: 18.5, close: 19.2, high: 19.5, low: 18.3, isUp: true },
{ open: 19.2, close: 18.8, high: 19.4, low: 18.6, isUp: false },
{ open: 18.8, close: 19.5, high: 19.8, low: 18.7, isUp: true },
{ open: 19.5, close: 20.3, high: 20.5, low: 19.3, isUp: true },
{ open: 20.3, close: 19.9, high: 20.4, low: 19.7, isUp: false },
{ open: 19.9, close: 20.8, high: 21.0, low: 19.8, isUp: true },
{ open: 20.8, close: 21.5, high: 21.8, low: 20.6, isUp: true },
{ open: 21.5, close: 21.0, high: 21.6, low: 20.8, isUp: false },
{ open: 21.0, close: 21.8, high: 22.0, low: 20.9, isUp: true },
{ open: 21.8, close: 22.5, high: 22.8, low: 21.7, isUp: true }
]
// ============ 全局写死数据:18只热门股票 ============
const mockStocks: StockItem[] = [
new StockItem(1, '600519', '贵州茅台', '消费', 1685.50, 2.35, 28500, 21180),
new StockItem(2, '601398', '工商银行', '银行', 5.82, 0.52, 895000, 20700),
new StockItem(3, '000858', '五粮液', '消费', 156.30, -1.28, 125000, 6070),
new StockItem(4, '300750', '宁德时代', '新能源', 218.70, 3.65, 95000, 9610),
new StockItem(5, '601318', '中国平安', '银行', 48.56, 1.23, 320000, 8870),
new StockItem(6, '000725', '京东方A', '半导体', 4.32, -0.69, 685000, 1620),
new StockItem(7, '002594', '比亚迪', '新能源', 268.40, 4.12, 78000, 7820),
new StockItem(8, '600036', '招商银行', '银行', 35.78, 0.85, 165000, 9020),
new StockItem(9, '601012', '隆基绿能', '新能源', 22.15, -2.15, 210000, 1680),
new StockItem(10, '000333', '美的集团', '消费', 68.92, 1.56, 95000, 4830),
new StockItem(11, '600276', '恒瑞医药', '医药', 45.30, 2.88, 86000, 2890),
new StockItem(12, '002475', '立讯精密', '半导体', 38.65, -1.05, 125000, 2750),
new StockItem(13, '601628', '中国人寿', '银行', 32.48, 0.95, 145000, 9160),
new StockItem(14, '300059', '东方财富', '科技', 15.87, 3.28, 320000, 2480),
new StockItem(15, '600585', '海螺水泥', '地产', 25.60, -0.78, 88000, 1370),
new StockItem(16, '002230', '科大讯飞', '科技', 52.30, 5.62, 115000, 1210),
new StockItem(17, '600009', '上海机场', '消费', 42.15, 1.88, 68000, 1050),
new StockItem(18, '601888', '中国中免', '消费', 85.60, -1.45, 58000, 1770)
]
// ============ 全局写死数据:15只自选股 ============
const mockWatchStocks: StockItem[] = [
new StockItem(1, '600519', '贵州茅台', '消费', 1685.50, 2.35, 28500, 21180),
new StockItem(2, '300750', '宁德时代', '新能源', 218.70, 3.65, 95000, 9610),
new StockItem(3, '002594', '比亚迪', '新能源', 268.40, 4.12, 78000, 7820),
new StockItem(4, '000725', '京东方A', '半导体', 4.32, -0.69, 685000, 1620),
new StockItem(5, '601398', '工商银行', '银行', 5.82, 0.52, 895000, 20700),
new StockItem(6, '300059', '东方财富', '科技', 15.87, 3.28, 320000, 2480),
new StockItem(7, '002230', '科大讯飞', '科技', 52.30, 5.62, 115000, 1210),
new StockItem(8, '600276', '恒瑞医药', '医药', 45.30, 2.88, 86000, 2890),
new StockItem(9, '000858', '五粮液', '消费', 156.30, -1.28, 125000, 6070),
new StockItem(10, '601318', '中国平安', '银行', 48.56, 1.23, 320000, 8870),
new StockItem(11, '600036', '招商银行', '银行', 35.78, 0.85, 165000, 9020),
new StockItem(12, '000333', '美的集团', '消费', 68.92, 1.56, 95000, 4830),
new StockItem(13, '002475', '立讯精密', '半导体', 38.65, -1.05, 125000, 2750),
new StockItem(14, '601012', '隆基绿能', '新能源', 22.15, -2.15, 210000, 1680),
new StockItem(15, '601628', '中国人寿', '银行', 32.48, 0.95, 145000, 9160)
]
// ============ 全局写死数据:8只持仓 ============
const mockHoldings: HoldingItem[] = [
new HoldingItem(1, '600519', '贵州茅台', '消费', 100, 1580.00, 1685.50),
new HoldingItem(2, '300750', '宁德时代', '新能源', 300, 185.50, 218.70),
new HoldingItem(3, '002594', '比亚迪', '新能源', 200, 240.00, 268.40),
new HoldingItem(4, '601398', '工商银行', '银行', 5000, 5.50, 5.82),
new HoldingItem(5, '300059', '东方财富', '科技', 2000, 13.20, 15.87),
new HoldingItem(6, '000725', '京东方A', '半导体', 8000, 4.65, 4.32),
new HoldingItem(7, '600276', '恒瑞医药', '医药', 500, 42.10, 45.30),
new HoldingItem(8, '002230', '科大讯飞', '科技', 800, 48.50, 52.30)
]
// ============ 全局写死数据:20条交易记录 ============
const mockTrades: TradeRecord[] = [
new TradeRecord(1, '买入', '600519', '贵州茅台', 1680.00, 100, '2026-08-08', '10:32:15', '已成'),
new TradeRecord(2, '买入', '300750', '宁德时代', 215.30, 300, '2026-08-08', '09:45:22', '已成'),
new TradeRecord(3, '卖出', '000858', '五粮液', 158.20, 200, '2026-08-07', '14:20:33', '已成'),
new TradeRecord(4, '买入', '002594', '比亚迪', 265.00, 200, '2026-08-07', '11:15:08', '已成'),
new TradeRecord(5, '买入', '601398', '工商银行', 5.78, 5000, '2026-08-06', '09:35:12', '已成'),
new TradeRecord(6, '卖出', '300059', '东方财富', 15.60, 1000, '2026-08-06', '13:50:45', '已成'),
new TradeRecord(7, '买入', '600276', '恒瑞医药', 44.80, 500, '2026-08-05', '10:18:30', '已成'),
new TradeRecord(8, '买入', '000725', '京东方A', 4.68, 8000, '2026-08-05', '14:22:18', '已成'),
new TradeRecord(9, '卖出', '601318', '中国平安', 48.20, 1000, '2026-08-04', '11:05:55', '已成'),
new TradeRecord(10, '买入', '300059', '东方财富', 14.85, 2000, '2026-08-04', '09:42:10', '已成'),
new TradeRecord(11, '分红', '600519', '贵州茅台', 21.67, 100, '2026-08-03', '00:00:00', '已到'),
new TradeRecord(12, '买入', '002230', '科大讯飞', 49.20, 800, '2026-08-02', '10:35:28', '已成'),
new TradeRecord(13, '卖出', '000333', '美的集团', 67.50, 300, '2026-08-01', '14:15:42', '已成'),
new TradeRecord(14, '买入', '601012', '隆基绿能', 23.20, 2000, '2026-07-31', '11:28:15', '已成'),
new TradeRecord(15, '买入', '600036', '招商银行', 35.20, 1000, '2026-07-30', '09:50:33', '已成'),
new TradeRecord(16, '卖出', '002475', '立讯精密', 39.80, 500, '2026-07-29', '13:40:22', '已成'),
new TradeRecord(17, '申购', '', '易方达蓝筹', 2.568, 10000, '2026-07-28', '15:00:00', '已确'),
new TradeRecord(18, '买入', '601628', '中国人寿', 31.80, 1000, '2026-07-25', '10:12:08', '已成'),
new TradeRecord(19, '卖出', '600585', '海螺水泥', 26.20, 2000, '2026-07-24', '14:30:15', '已成'),
new TradeRecord(20, '赎回', '', '沪深300ETF', 4.125, 5000, '2026-07-22', '15:00:00', '已到')
]
// ============ 统计函数 ============
function getStockTotalCount(): number { return 18 }
function getWatchCount(): number { return 15 }
function getHoldingCount(): number { return 8 }
function getTradeCount(): number { return 20 }
function getTotalAssets(): number { return 528360 }
function getTotalProfit(): number { return 48650 }
function getTotalProfitRate(): number { return 10.14 }
function getAvailableCash(): number { return 85600 }
function getTodayProfit(): number { return 3260 }
function getProfitColor(isPositive: boolean): string {
return isPositive ? STOCK_COLORS.redUp : STOCK_COLORS.greenDown
}
function getProfitBg(isPositive: boolean): string {
return isPositive ? STOCK_COLORS.redUpLight : STOCK_COLORS.greenDownLight
}
function formatVolume(v: number): string {
if (v >= 10000) {
return (v / 10000).toFixed(1) + '万'
}
return v.toString()
}
function formatMarketCap(v: number): string {
if (v >= 10000) {
return (v / 10000).toFixed(1) + '万亿'
}
return v.toString() + '亿'
}
// ============ 底部 Tab 枚举 ============
enum StockTab {
MARKET = 0,
WATCHLIST = 1,
HOLDING = 2,
TRADE = 3,
PROFILE = 4
}
// ============ 入口页面 ============
@Entry
@Component
struct StockInvestApp {
@State activeTab: StockTab = StockTab.MARKET
@Builder contentArea() {
Column() {
if (this.activeTab === StockTab.MARKET) {
MarketHomeContent()
} else if (this.activeTab === StockTab.WATCHLIST) {
WatchlistContent()
} else if (this.activeTab === StockTab.HOLDING) {
HoldingContent()
} else if (this.activeTab === StockTab.TRADE) {
TradeRecordContent()
} else {
ProfileContent()
}
}
.layoutWeight(1)
}
@Builder bottomTabItem(icon: string, label: string, tab: StockTab) {
Column() {
Text(icon).fontSize(20).opacity(this.activeTab === tab ? 1.0 : 0.4)
Text(label).fontSize(10)
.fontColor(this.activeTab === tab ? STOCK_COLORS.redUp : STOCK_COLORS.textHint)
.fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
if (this.activeTab === tab) {
Column().width(20).height(3)
.backgroundColor(STOCK_COLORS.redUp).borderRadius(2).margin({ top: 3 })
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 6, bottom: 6 })
.onClick(() => { this.activeTab = tab })
}
build() {
Column() {
this.contentArea()
Row() {
this.bottomTabItem('📈', '行情', StockTab.MARKET)
this.bottomTabItem('⭐', '自选', StockTab.WATCHLIST)
this.bottomTabItem('💼', '持仓', StockTab.HOLDING)
this.bottomTabItem('📋', '交易', StockTab.TRADE)
this.bottomTabItem('👤', '我的', StockTab.PROFILE)
}
.width('100%')
.backgroundColor(STOCK_COLORS.white)
.padding({ top: 4, bottom: 8 })
.shadow({ radius: 10, color: '#1AB71C1C', offsetY: -3 })
}
.width('100%').height('100%')
.backgroundColor(STOCK_COLORS.lightBg)
}
}
// ============ 行情首页页 ============
@Component
struct MarketHomeContent {
@State showStockDetail: boolean = false
@State selectedStock: StockItem | null = null
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(26,26,26,0.6)')
.onClick(onClose)
}
// ========== 股票详情弹框 ==========
@Builder stockDetailModal() {
Column() {
this.modalOverlay(() => { this.showStockDetail = false })
Column() {
// 头部
Column() {
Row() {
Column() {
Text(this.selectedStock?.name ?? '').fontSize(18).fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text(this.selectedStock?.code ?? '').fontSize(11)
.fontColor('#AAAAAA').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column() {
Text(INDUSTRY_CONFIG[this.selectedStock?.industry ?? '']?.label ?? '')
.fontSize(10).fontColor('#FFFFFF')
.backgroundColor(INDUSTRY_CONFIG[this.selectedStock?.industry ?? '']?.color ?? '#888888')
.padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(8)
}
Text('✕').fontSize(18).fontColor('#AAAAAA')
.margin({ left: 10 })
.onClick(() => { this.showStockDetail = false })
}
.width('100%')
Row() {
Text(this.selectedStock?.price.toFixed(2) ?? '0.00').fontSize(36)
.fontWeight(FontWeight.Bold)
.fontColor(getProfitColor((this.selectedStock?.changeRate ?? 0) >= 0))
Column() {
Text(((this.selectedStock?.changeRate ?? 0) >= 0 ? '+' : '') + (this.selectedStock?.change ?? 0).toFixed(2))
.fontSize(14).fontColor(getProfitColor((this.selectedStock?.changeRate ?? 0) >= 0))
Text(((this.selectedStock?.changeRate ?? 0) >= 0 ? '+' : '') + (this.selectedStock?.changeRate ?? 0).toFixed(2) + '%')
.fontSize(14).fontColor(getProfitColor((this.selectedStock?.changeRate ?? 0) >= 0))
}
.margin({ left: 12 })
}
.width('100%').margin({ top: 8 })
}
.width('100%').padding(16)
.backgroundColor(STOCK_COLORS.black)
Scroll() {
Column() {
// K线样式卡片
Column() {
Text('📉 近10日K线走势').fontSize(13).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary)
.width('100%').padding({ left: 16, top: 14, bottom: 10 })
Row() {
ForEach(CANDLE_DATA, (c: CandleBarMeta) => {
Column() {
// 上影线
Column().width(1).height(6)
.backgroundColor(c.isUp ? STOCK_COLORS.redUp : STOCK_COLORS.greenDown)
// 实体
Column()
.width(10)
.height(Math.abs(c.close - c.open) * 12 < 8 ? '8vp' : (Math.abs(c.close - c.open) * 12).toFixed(0) + 'vp')
.backgroundColor(c.isUp ? STOCK_COLORS.redUp : STOCK_COLORS.greenDown)
.borderRadius(2)
// 下影线
Column().width(1).height(6)
.backgroundColor(c.isUp ? STOCK_COLORS.redUp : STOCK_COLORS.greenDown)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
})
}
.padding({ left: 10, right: 10, bottom: 14 })
Row() {
Text('开盘 ' + (this.selectedStock?.open.toFixed(2) ?? '0.00')).fontSize(10)
.fontColor(STOCK_COLORS.textSecondary)
Text('最高 ' + (this.selectedStock?.high.toFixed(2) ?? '0.00')).fontSize(10)
.fontColor(STOCK_COLORS.redUp).margin({ left: 12 })
Text('最低 ' + (this.selectedStock?.low.toFixed(2) ?? '0.00')).fontSize(10)
.fontColor(STOCK_COLORS.greenDown).margin({ left: 12 })
}
.width('100%').padding({ left: 16, bottom: 14 })
}
.width('100%').backgroundColor(STOCK_COLORS.cardBg)
.borderRadius(0)
// 行情数据网格
Row() {
Column() {
Text('成交量').fontSize(10).fontColor(STOCK_COLORS.textHint)
Text(formatVolume(this.selectedStock?.volume ?? 0)).fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary).margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 12, bottom: 12 })
Column() {
Text('市值').fontSize(10).fontColor(STOCK_COLORS.textHint)
Text(formatMarketCap(this.selectedStock?.marketCap ?? 0)).fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary).margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 12, bottom: 12 })
Column() {
Text('市盈率').fontSize(10).fontColor(STOCK_COLORS.textHint)
Text(this.selectedStock?.peRatio.toFixed(2) ?? '0.00').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary).margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 12, bottom: 12 })
Column() {
Text('昨收').fontSize(10).fontColor(STOCK_COLORS.textHint)
Text(this.selectedStock?.preClose.toFixed(2) ?? '0.00').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary).margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 12, bottom: 12 })
}
.width('100%').backgroundColor(STOCK_COLORS.cardBg)
Divider().color(STOCK_COLORS.border)
// 操作按钮
Row() {
Text('🔴 买入').fontSize(14).fontColor('#FFFFFF')
.backgroundColor(STOCK_COLORS.redUp).borderRadius(22)
.padding({ left: 28, right: 28, top: 11, bottom: 11 })
Text('🟢 卖出').fontSize(14).fontColor('#FFFFFF')
.backgroundColor(STOCK_COLORS.greenDown).borderRadius(22)
.padding({ left: 28, right: 28, top: 11, bottom: 11 })
.margin({ left: 10 })
Text('⭐ 加自选').fontSize(14).fontColor(STOCK_COLORS.redUp)
.backgroundColor(STOCK_COLORS.redUpLight).borderRadius(22)
.padding({ left: 22, right: 22, top: 11, bottom: 11 })
.margin({ left: 10 })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ top: 16, bottom: 18 })
.backgroundColor(STOCK_COLORS.cardBg)
}
}
.layoutWeight(1)
}
.width('92%').constraintSize({ maxHeight: '85%' })
.backgroundColor(STOCK_COLORS.cardBg)
.alignItems(HorizontalAlign.Center)
.position({ x: '4%', y: '7%' })
.borderRadius(0)
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
// ========== 股票列表项 Builder ==========
@Builder stockItemBuilder(s: StockItem) {
Row() {
Column() {
Text(s.name).fontSize(14).fontWeight(FontWeight.Medium)
.fontColor(STOCK_COLORS.textPrimary)
Text(s.code).fontSize(10).fontColor(STOCK_COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column() {
Text(INDUSTRY_CONFIG[s.industry]?.label ?? s.industry).fontSize(9)
.fontColor(INDUSTRY_CONFIG[s.industry]?.color ?? '#888888')
.backgroundColor(INDUSTRY_CONFIG[s.industry]?.bg ?? '#F5F5F5')
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6)
}
.alignItems(HorizontalAlign.Center)
Column() {
Text(s.price.toFixed(2)).fontSize(15).fontWeight(FontWeight.Bold)
.fontColor(getProfitColor(s.changeRate >= 0))
Row() {
Text((s.changeRate >= 0 ? '+' : '') + s.changeRate.toFixed(2) + '%')
.fontSize(11).fontColor(getProfitColor(s.changeRate >= 0))
}
.backgroundColor(getProfitBg(s.changeRate >= 0))
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(4)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.End)
Column() {
Text(formatVolume(s.volume)).fontSize(12)
.fontColor(STOCK_COLORS.textSecondary)
Text('量').fontSize(9).fontColor(STOCK_COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End).margin({ left: 10 })
}
.width('100%').padding({ top: 12, bottom: 12, left: 14, right: 14 })
.backgroundColor(STOCK_COLORS.cardBg)
.borderRadius(0)
.onClick(() => { this.selectedStock = s; this.showStockDetail = true })
}
build() {
Stack() {
Column() {
// 顶部标题
Row() {
Text('行情中心').fontSize(18).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary)
Column().layoutWeight(1)
Text('🔍').fontSize(20)
}
.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 10 })
Scroll() {
Column() {
// 大盘指数卡片
Row() {
// 上证
Column() {
Text(INDEX_CONFIG['上证']?.name ?? '').fontSize(11)
.fontColor('#AAAAAA')
Text('3185.62').fontSize(18).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.redUp).margin({ top: 4 })
Row() {
Text('+28.56').fontSize(10).fontColor(STOCK_COLORS.redUp)
Text('+0.91%').fontSize(10).fontColor(STOCK_COLORS.redUp)
.margin({ left: 4 })
}
.margin({ top: 3 })
Text('成交 3856亿').fontSize(9).fontColor('#AAAAAA')
.margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.padding({ top: 14, bottom: 14, left: 8, right: 8 })
.backgroundColor(STOCK_COLORS.cardBg)
.borderRadius(12)
// 深证
Column() {
Text(INDEX_CONFIG['深证']?.name ?? '').fontSize(11)
.fontColor('#AAAAAA')
Text('10625.38').fontSize(18).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.greenDown).margin({ top: 4 })
Row() {
Text('-45.20').fontSize(10).fontColor(STOCK_COLORS.greenDown)
Text('-0.42%').fontSize(10).fontColor(STOCK_COLORS.greenDown)
.margin({ left: 4 })
}
.margin({ top: 3 })
Text('成交 4520亿').fontSize(9).fontColor('#AAAAAA')
.margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.padding({ top: 14, bottom: 14, left: 8, right: 8 })
.backgroundColor(STOCK_COLORS.cardBg)
.borderRadius(12).margin({ left: 6 })
// 创业板
Column() {
Text(INDEX_CONFIG['创业板']?.name ?? '').fontSize(11)
.fontColor('#AAAAAA')
Text('2085.45').fontSize(18).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.redUp).margin({ top: 4 })
Row() {
Text('+15.32').fontSize(10).fontColor(STOCK_COLORS.redUp)
Text('+0.74%').fontSize(10).fontColor(STOCK_COLORS.redUp)
.margin({ left: 4 })
}
.margin({ top: 3 })
Text('成交 1680亿').fontSize(9).fontColor('#AAAAAA')
.margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.padding({ top: 14, bottom: 14, left: 8, right: 8 })
.backgroundColor(STOCK_COLORS.cardBg)
.borderRadius(12).margin({ left: 6 })
}
.width('100%').padding({ left: 14, right: 14 })
// 市场概览
Row() {
Column() {
Text('涨家数').fontSize(10).fontColor(STOCK_COLORS.textHint)
Text('2856').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.redUp).margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('跌家数').fontSize(10).fontColor(STOCK_COLORS.textHint)
Text('1856').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.greenDown).margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('平家数').fontSize(10).fontColor(STOCK_COLORS.textHint)
Text('168').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textSecondary).margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('涨停').fontSize(10).fontColor(STOCK_COLORS.textHint)
Text('56').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.redUpDark).margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('跌停').fontSize(10).fontColor(STOCK_COLORS.textHint)
Text('8').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.greenDown).margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').padding({ top: 12, bottom: 12 })
.backgroundColor(STOCK_COLORS.cardBg)
.margin({ left: 14, right: 14, top: 8 })
.borderRadius(12)
// 热门股票标题
Row() {
Text('🔥 热门股票').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary)
Column().layoutWeight(1)
Text('更多 ›').fontSize(11).fontColor(STOCK_COLORS.textHint)
}
.width('100%').padding({ left: 16, right: 16, top: 16, bottom: 8 })
// 热门股票列表
Column() {
this.stockItemBuilder(mockStocks[0])
Divider().color(STOCK_COLORS.border)
this.stockItemBuilder(mockStocks[1])
Divider().color(STOCK_COLORS.border)
this.stockItemBuilder(mockStocks[2])
Divider().color(STOCK_COLORS.border)
this.stockItemBuilder(mockStocks[3])
Divider().color(STOCK_COLORS.border)
this.stockItemBuilder(mockStocks[4])
Divider().color(STOCK_COLORS.border)
this.stockItemBuilder(mockStocks[5])
Divider().color(STOCK_COLORS.border)
this.stockItemBuilder(mockStocks[6])
Divider().color(STOCK_COLORS.border)
this.stockItemBuilder(mockStocks[7])
Divider().color(STOCK_COLORS.border)
this.stockItemBuilder(mockStocks[8])
Divider().color(STOCK_COLORS.border)
this.stockItemBuilder(mockStocks[9])
Divider().color(STOCK_COLORS.border)
this.stockItemBuilder(mockStocks[10])
Divider().color(STOCK_COLORS.border)
this.stockItemBuilder(mockStocks[11])
Divider().color(STOCK_COLORS.border)
this.stockItemBuilder(mockStocks[12])
Divider().color(STOCK_COLORS.border)
this.stockItemBuilder(mockStocks[13])
Divider().color(STOCK_COLORS.border)
this.stockItemBuilder(mockStocks[14])
Divider().color(STOCK_COLORS.border)
this.stockItemBuilder(mockStocks[15])
Divider().color(STOCK_COLORS.border)
this.stockItemBuilder(mockStocks[16])
Divider().color(STOCK_COLORS.border)
this.stockItemBuilder(mockStocks[17])
}
.width('100%').backgroundColor(STOCK_COLORS.cardBg)
.margin({ left: 14, right: 14, bottom: 20 })
.borderRadius(12)
}
}
.layoutWeight(1).scrollBar(BarState.Off)
}
.width('100%').height('100%')
if (this.showStockDetail) { this.stockDetailModal() }
}
.width('100%').height('100%')
}
}
// ============ 自选股页 ============
@Component
struct WatchlistContent {
@State selectedGroup: string = '全部'
@State showEditModal: boolean = false
@State showDeleteModal: boolean = false
@State showAddModal: boolean = false
@State selectedStock: StockItem | null = null
@State formCode: string = ''
@State formName: string = ''
@State formGroup: string = '科技股'
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(26,26,26,0.6)')
.onClick(onClose)
}
// ========== 编辑弹框 ==========
@Builder editWatchModal() {
Column() {
this.modalOverlay(() => { this.showEditModal = false })
Column() {
Row() {
Text('编辑自选股').fontSize(17).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary)
Column().layoutWeight(1)
Text('✕').fontSize(18).fontColor(STOCK_COLORS.textHint)
.onClick(() => { this.showEditModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color(STOCK_COLORS.border)
Column() {
Text('股票信息').fontSize(12).fontColor(STOCK_COLORS.textSecondary)
.margin({ top: 16, left: 20 })
Row() {
Column() {
Text(this.selectedStock?.name ?? '').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary)
Text(this.selectedStock?.code ?? '').fontSize(11)
.fontColor(STOCK_COLORS.textHint).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column() {
Text(this.selectedStock?.price.toFixed(2) ?? '0.00').fontSize(18).fontWeight(FontWeight.Bold)
.fontColor(getProfitColor((this.selectedStock?.changeRate ?? 0) >= 0))
}
}
.width('100%').padding({ left: 20, right: 20, top: 8 })
Text('所属分组').fontSize(12).fontColor(STOCK_COLORS.textSecondary)
.margin({ top: 18, left: 20 })
Row() {
ForEach(['科技股', '金融股', '消费股', '新能源'], (g: string) => {
if (this.formGroup === g) {
Text(g).fontSize(11).fontColor('#FFFFFF')
.backgroundColor(STOCK_COLORS.redUp)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(12).margin({ left: 3, right: 3 })
} else {
Text(g).fontSize(11).fontColor(STOCK_COLORS.redUp)
.backgroundColor(STOCK_COLORS.redUpLight)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(12).margin({ left: 3, right: 3 })
.onClick(() => { this.formGroup = g })
}
})
}
.margin({ left: 16, right: 16, top: 6 })
Text('提醒设置').fontSize(12).fontColor(STOCK_COLORS.textSecondary)
.margin({ top: 18, left: 20 })
Row() {
Text('价格提醒').fontSize(13).fontColor(STOCK_COLORS.textPrimary).layoutWeight(1)
Text('未开启 ›').fontSize(12).fontColor(STOCK_COLORS.textHint)
}
.width('100%').padding({ left: 20, right: 20, top: 8 })
Row() {
Text('涨跌提醒').fontSize(13).fontColor(STOCK_COLORS.textPrimary).layoutWeight(1)
Text('已开启 ›').fontSize(12).fontColor(STOCK_COLORS.redUp)
}
.width('100%').padding({ left: 20, right: 20, top: 12, bottom: 16 })
}
.layoutWeight(1)
Row() {
Text('取消').fontSize(14).fontColor(STOCK_COLORS.textSecondary)
.backgroundColor('#F5F5F5').borderRadius(22)
.padding({ left: 28, right: 28, top: 11, bottom: 11 })
.onClick(() => { this.showEditModal = false })
Text('保存').fontSize(14).fontColor('#FFFFFF')
.backgroundColor(STOCK_COLORS.redUp).borderRadius(22)
.padding({ left: 28, right: 28, top: 11, bottom: 11 })
.margin({ left: 12 })
.onClick(() => { this.showEditModal = false })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 20, right: 20, top: 12, bottom: 18 })
}
.width('90%').constraintSize({ maxHeight: '75%' })
.backgroundColor('#FFFFFF').borderRadius(18)
.alignItems(HorizontalAlign.Center)
.position({ x: '5%', y: '12%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
}
.width('100%').padding({ left: 16, right: 16, bottom: 14 })
}
.width('100%').backgroundColor(STOCK_COLORS.cardBg)
.borderRadius(12).margin({ left: 14, right: 14, top: 10 })
// 盈亏分布柱状图
Column() {
Text('📈 个股盈亏分布').fontSize(13).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary)
.width('100%').padding({ left: 16, top: 14, bottom: 10 })
Row() {
ForEach(mockHoldings, (h: HoldingItem) => {
Column() {
Text(((h.currentPrice - h.costPrice) * h.holdCount >= 0 ? '+' : '') + ((h.currentPrice - h.costPrice) * h.holdCount).toFixed(0))
.fontSize(8)
.fontColor(getProfitColor(h.currentPrice >= h.costPrice))
.margin({ bottom: 3 })
if (h.currentPrice >= h.costPrice) {
Column()
.width(18)
.height((Math.abs((h.currentPrice - h.costPrice) * h.holdCount) / this.maxProfit * 60).toFixed(0) + 'vp')
.backgroundColor(STOCK_COLORS.redUp)
.borderRadius({ topLeft: 3, topRight: 3 })
} else {
Column().height(2).backgroundColor(STOCK_COLORS.border)
Column()
.width(18)
.height((Math.abs((h.currentPrice - h.costPrice) * h.holdCount) / Math.abs(this.maxLoss) * 40).toFixed(0) + 'vp')
.backgroundColor(STOCK_COLORS.greenDown)
.borderRadius({ bottomLeft: 3, bottomRight: 3 })
}
Text(h.name.substring(0, 2)).fontSize(7).fontColor(STOCK_COLORS.textHint)
.margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
})
}
.padding({ left: 8, right: 8, bottom: 14 })
}
.width('100%').backgroundColor(STOCK_COLORS.cardBg)
.borderRadius(12).margin({ left: 14, right: 14, top: 10 })
// 持仓列表
Text('💼 持仓明细').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary)
.width('100%').padding({ left: 18, top: 16, bottom: 8 })
Column() {
this.holdingItemBuilder(mockHoldings[0])
Divider().color(STOCK_COLORS.border)
this.holdingItemBuilder(mockHoldings[1])
Divider().color(STOCK_COLORS.border)
this.holdingItemBuilder(mockHoldings[2])
Divider().color(STOCK_COLORS.border)
this.holdingItemBuilder(mockHoldings[3])
Divider().color(STOCK_COLORS.border)
this.holdingItemBuilder(mockHoldings[4])
Divider().color(STOCK_COLORS.border)
this.holdingItemBuilder(mockHoldings[5])
Divider().color(STOCK_COLORS.border)
this.holdingItemBuilder(mockHoldings[6])
Divider().color(STOCK_COLORS.border)
this.holdingItemBuilder(mockHoldings[7])
}
.width('100%').backgroundColor(STOCK_COLORS.cardBg)
.margin({ left: 14, right: 14, bottom: 20 })
.borderRadius(12)
}
.padding({ bottom: 20 })
}
.layoutWeight(1).scrollBar(BarState.Off)
.width('100%')
}
}
// ============ 交易记录页 ============
@Component
struct TradeRecordContent {
@State selectedType: string = '全部'
@State showDetailModal: boolean = false
@State showDeleteModal: boolean = false
@State selectedTrade: TradeRecord | null = null
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(26,26,26,0.6)')
.onClick(onClose)
}
// ========== 交易详情弹框 ==========
@Builder tradeDetailModal() {
Column() {
this.modalOverlay(() => { this.showDetailModal = false })
Column() {
Row() {
Text('交易详情').fontSize(17).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary)
Column().layoutWeight(1)
Text('✕').fontSize(18).fontColor(STOCK_COLORS.textHint)
.onClick(() => { this.showDetailModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color(STOCK_COLORS.border)
Column() {
// 类型标签
Row() {
Text(TRADE_TYPE_CONFIG[this.selectedTrade?.type ?? '买入']?.icon + ' ' + (TRADE_TYPE_CONFIG[this.selectedTrade?.type ?? '买入']?.label ?? '买入'))
.fontSize(12).fontColor(TRADE_TYPE_CONFIG[this.selectedTrade?.type ?? '买入']?.color ?? '#888888')
.backgroundColor(TRADE_TYPE_CONFIG[this.selectedTrade?.type ?? '买入']?.bg ?? '#F5F5F5')
.padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(10)
Text('#T' + (this.selectedTrade?.id ?? 0)).fontSize(11)
.fontColor(STOCK_COLORS.textHint).margin({ left: 8 })
}
.width('100%').margin({ top: 16 })
// 股票名称
Text(this.selectedTrade?.name ?? '').fontSize(22).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary).margin({ top: 12 })
Text(this.selectedTrade?.code ?? '').fontSize(12)
.fontColor(STOCK_COLORS.textHint).margin({ top: 2 })
// 金额
Text('¥' + (this.selectedTrade?.amount.toFixed(2) ?? '0.00')).fontSize(32)
.fontWeight(FontWeight.Bold)
.fontColor(TRADE_TYPE_CONFIG[this.selectedTrade?.type ?? '买入']?.color ?? STOCK_COLORS.textPrimary)
.margin({ top: 12 })
Divider().color(STOCK_COLORS.border).margin({ top: 16 })
// 明细
Row() {
Text('成交价格').fontSize(12).fontColor(STOCK_COLORS.textSecondary)
Column().layoutWeight(1)
Text('¥' + (this.selectedTrade?.price.toFixed(2) ?? '0.00')).fontSize(13)
.fontColor(STOCK_COLORS.textPrimary)
}
.width('100%').padding({ top: 12 })
Row() {
Text('成交数量').fontSize(12).fontColor(STOCK_COLORS.textSecondary)
Column().layoutWeight(1)
Text((this.selectedTrade?.count ?? 0).toString() + '股').fontSize(13)
.fontColor(STOCK_COLORS.textPrimary)
}
.width('100%').padding({ top: 10 })
Row() {
Text('手续费').fontSize(12).fontColor(STOCK_COLORS.textSecondary)
Column().layoutWeight(1)
Text('¥' + (this.selectedTrade?.fee.toFixed(2) ?? '0.00')).fontSize(13)
.fontColor(STOCK_COLORS.textPrimary)
}
.width('100%').padding({ top: 10 })
Row() {
Text('交易日期').fontSize(12).fontColor(STOCK_COLORS.textSecondary)
Column().layoutWeight(1)
Text((this.selectedTrade?.date ?? '') + ' ' + (this.selectedTrade?.time ?? '')).fontSize(13)
.fontColor(STOCK_COLORS.textPrimary)
}
.width('100%').padding({ top: 10 })
Row() {
Text('状态').fontSize(12).fontColor(STOCK_COLORS.textSecondary)
Column().layoutWeight(1)
Text(this.selectedTrade?.status ?? '').fontSize(13)
.fontColor(STOCK_COLORS.redUp).fontWeight(FontWeight.Medium)
}
.width('100%').padding({ top: 10, bottom: 16 })
}
.width('100%').padding({ left: 20, right: 20 })
Row() {
Text('🗑️ 删除记录').fontSize(13).fontColor('#FFFFFF')
.backgroundColor(STOCK_COLORS.redUp).borderRadius(18)
.padding({ left: 22, right: 22, top: 9, bottom: 9 })
.onClick(() => { this.showDeleteModal = true })
Text('📋 导出').fontSize(13).fontColor(STOCK_COLORS.textPrimary)
.backgroundColor('#F5F5F5').borderRadius(18)
.padding({ left: 22, right: 22, top: 9, bottom: 9 })
.margin({ left: 10 })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 20, right: 20, top: 8, bottom: 18 })
}
.width('88%').backgroundColor('#FFFFFF').borderRadius(18)
.alignItems(HorizontalAlign.Center)
.position({ x: '6%', y: '14%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
// ========== 删除确认弹框 ==========
@Builder deleteTradeModal() {
Column() {
this.modalOverlay(() => { this.showDeleteModal = false })
Column() {
Column() {
Text('🗑️').fontSize(40)
}
.width(68).height(68).borderRadius(34)
.backgroundColor(STOCK_COLORS.redUpLight)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Text('确认删除此交易记录?').fontSize(17).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary).margin({ top: 16 })
Text('删除后记录将不可恢复').fontSize(12).fontColor(STOCK_COLORS.textSecondary)
.margin({ top: 4 })
Row() {
Text(TRADE_TYPE_CONFIG[this.selectedTrade?.type ?? '买入']?.icon ?? '📋')
.fontSize(14)
Text(this.selectedTrade?.name ?? '').fontSize(13).fontWeight(FontWeight.Medium)
.fontColor(STOCK_COLORS.textPrimary).margin({ left: 6 })
Text('¥' + (this.selectedTrade?.amount.toFixed(2) ?? '0.00')).fontSize(13)
.fontColor(TRADE_TYPE_CONFIG[this.selectedTrade?.type ?? '买入']?.color ?? '#888888')
.margin({ left: 12 })
}
.backgroundColor(STOCK_COLORS.lightBg).borderRadius(10)
.padding({ left: 16, right: 16, top: 10, bottom: 10 }).margin({ top: 16, left: 20, right: 20 })
Row() {
Text('取消').fontSize(14).fontColor(STOCK_COLORS.textSecondary)
.backgroundColor('#F5F5F5').borderRadius(22)
.padding({ left: 30, right: 30, top: 11, bottom: 11 })
.onClick(() => { this.showDeleteModal = false })
Text('确认删除').fontSize(14).fontColor('#FFFFFF')
.backgroundColor(STOCK_COLORS.redUp).borderRadius(22)
.padding({ left: 30, right: 30, top: 11, bottom: 11 })
.margin({ left: 12 })
.onClick(() => { this.showDeleteModal = false })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 20, right: 20, top: 22, bottom: 22 })
}
.width('82%').backgroundColor('#FFFFFF').borderRadius(18)
.alignItems(HorizontalAlign.Center)
.position({ x: '9%', y: '30%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
// ========== 交易记录列表项 ==========
@Builder tradeItemBuilder(t: TradeRecord) {
Row() {
Column() {
Text(TRADE_TYPE_CONFIG[t.type]?.icon ?? '📋').fontSize(20)
}
.width(36).height(36).borderRadius(10)
.backgroundColor(TRADE_TYPE_CONFIG[t.type]?.bg ?? '#F5F5F5')
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column() {
Row() {
Text(t.name).fontSize(13).fontWeight(FontWeight.Medium)
.fontColor(STOCK_COLORS.textPrimary)
if (t.code !== '') {
Text(t.code).fontSize(9).fontColor(STOCK_COLORS.textHint)
.margin({ left: 6 })
}
}
Row() {
Text(TRADE_TYPE_CONFIG[t.type]?.label ?? t.type).fontSize(10)
.fontColor(TRADE_TYPE_CONFIG[t.type]?.color ?? '#888888')
Text('·' + t.date + ' ' + t.time).fontSize(9)
.fontColor(STOCK_COLORS.textHint).margin({ left: 6 })
}
.margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
Column() {
Text('¥' + t.amount.toFixed(2)).fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(TRADE_TYPE_CONFIG[t.type]?.color ?? STOCK_COLORS.textPrimary)
Text(t.count.toString() + '股 × ' + t.price.toFixed(2)).fontSize(10)
.fontColor(STOCK_COLORS.textHint).margin({ top: 2 })
Text(t.status).fontSize(9)
.fontColor(t.status === '已成' || t.status === '已到' ? STOCK_COLORS.redUp : STOCK_COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%').padding({ top: 10, bottom: 10, left: 14, right: 14 })
.backgroundColor(STOCK_COLORS.cardBg)
.onClick(() => { this.selectedTrade = t; this.showDetailModal = true })
}
build() {
Stack() {
Column() {
// 顶部标题
Row() {
Text('交易记录').fontSize(18).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary)
Column().layoutWeight(1)
Text(getTradeCount().toString() + '笔').fontSize(13)
.fontColor(STOCK_COLORS.textSecondary)
}
.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 10 })
// 统计卡片
Row() {
Column() {
Text('本月交易').fontSize(10).fontColor(STOCK_COLORS.textHint)
Text('14').fontSize(18).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary).margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('买入金额').fontSize(10).fontColor(STOCK_COLORS.textHint)
Text('¥48.5万').fontSize(18).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.redUp).margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('卖出金额').fontSize(10).fontColor(STOCK_COLORS.textHint)
Text('¥18.2万').fontSize(18).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.greenDown).margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('手续费').fontSize(10).fontColor(STOCK_COLORS.textHint)
Text('¥186').fontSize(18).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textSecondary).margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').padding({ top: 14, bottom: 14 })
.backgroundColor(STOCK_COLORS.cardBg)
.margin({ left: 14, right: 14, bottom: 8 })
.borderRadius(12)
// 类型筛选
Scroll() {
Row() {
ForEach(['全部', '买入', '卖出', '分红', '申购', '赎回'], (t: string) => {
if (this.selectedType === t) {
Text(t).fontSize(11).fontColor('#FFFFFF')
.backgroundColor(STOCK_COLORS.redUp)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(14).margin({ left: 4, right: 4 })
} else {
Text(t).fontSize(11).fontColor(STOCK_COLORS.redUp)
.backgroundColor(STOCK_COLORS.cardBg)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(14).margin({ left: 4, right: 4 })
.onClick(() => { this.selectedType = t })
}
})
}
.padding({ left: 10, right: 10 })
}
.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).height(36)
// 交易记录列表
Scroll() {
Column() {
this.tradeItemBuilder(mockTrades[0])
Divider().color(STOCK_COLORS.border)
this.tradeItemBuilder(mockTrades[1])
Divider().color(STOCK_COLORS.border)
this.tradeItemBuilder(mockTrades[2])
Divider().color(STOCK_COLORS.border)
this.tradeItemBuilder(mockTrades[3])
Divider().color(STOCK_COLORS.border)
this.tradeItemBuilder(mockTrades[4])
Divider().color(STOCK_COLORS.border)
this.tradeItemBuilder(mockTrades[5])
Divider().color(STOCK_COLORS.border)
this.tradeItemBuilder(mockTrades[6])
Divider().color(STOCK_COLORS.border)
this.tradeItemBuilder(mockTrades[7])
Divider().color(STOCK_COLORS.border)
this.tradeItemBuilder(mockTrades[8])
Divider().color(STOCK_COLORS.border)
this.tradeItemBuilder(mockTrades[9])
Divider().color(STOCK_COLORS.border)
this.tradeItemBuilder(mockTrades[10])
Divider().color(STOCK_COLORS.border)
this.tradeItemBuilder(mockTrades[11])
Divider().color(STOCK_COLORS.border)
this.tradeItemBuilder(mockTrades[12])
Divider().color(STOCK_COLORS.border)
this.tradeItemBuilder(mockTrades[13])
Divider().color(STOCK_COLORS.border)
this.tradeItemBuilder(mockTrades[14])
Divider().color(STOCK_COLORS.border)
this.tradeItemBuilder(mockTrades[15])
Divider().color(STOCK_COLORS.border)
this.tradeItemBuilder(mockTrades[16])
Divider().color(STOCK_COLORS.border)
this.tradeItemBuilder(mockTrades[17])
Divider().color(STOCK_COLORS.border)
this.tradeItemBuilder(mockTrades[18])
Divider().color(STOCK_COLORS.border)
this.tradeItemBuilder(mockTrades[19])
}
.backgroundColor(STOCK_COLORS.cardBg)
.margin({ left: 14, right: 14, bottom: 20 })
.borderRadius(12)
.padding({ bottom: 20 })
}
.layoutWeight(1).scrollBar(BarState.Off)
}
.width('100%').height('100%')
if (this.showDetailModal) { this.tradeDetailModal() }
if (this.showDeleteModal) { this.deleteTradeModal() }
}
.width('100%').height('100%')
}
}
// ============ 我的页面 ============
@Component
struct ProfileContent {
maxProfitAbs: number = 6800
@Builder statCard(icon: string, label: string, value: string, color: string) {
Column() {
Text(icon).fontSize(20).margin({ bottom: 4 })
Text(value).fontSize(16).fontWeight(FontWeight.Bold).fontColor(color)
Text(label).fontSize(10).fontColor(STOCK_COLORS.textSecondary).margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.backgroundColor(STOCK_COLORS.cardBg)
.borderRadius(12).padding({ top: 14, bottom: 14 })
}
build() {
Scroll() {
Column() {
// 用户信息卡片
Column() {
Row() {
Column() {
Text('👤').fontSize(36)
}
.width(60).height(60).borderRadius(30)
.backgroundColor('#3D0000')
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column() {
Text('张投资').fontSize(18).fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('证券账户 A88562100').fontSize(12)
.fontColor('#AAAAAA').margin({ top: 3 })
Row() {
Text('A股').fontSize(9).fontColor('#FFFFFF')
.backgroundColor(STOCK_COLORS.redUp).borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
Text('VIP3').fontSize(9).fontColor('#1A1A1A')
.backgroundColor(STOCK_COLORS.gold).borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).margin({ left: 6 })
Text('信用账户').fontSize(9).fontColor('#FFFFFF')
.backgroundColor('#5D4037').borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).margin({ left: 6 })
}
.margin({ top: 6 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 14 })
}
.width('100%').padding(16)
}
.width('100%')
.linearGradient({ angle: 135, colors: [['#1A1A1A', 0.0], ['#0D0D0D', 1.0]] })
.borderRadius(16)
.margin({ left: 14, right: 14, top: 14 })
.shadow({ radius: 10, color: '#331A1A1A', offsetY: 4 })
// 资产卡片
Column() {
Text('💰 资产概览').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary)
.width('100%').padding({ left: 16, top: 14, bottom: 10 })
Row() {
Column() {
Text('总资产').fontSize(10).fontColor(STOCK_COLORS.textHint)
Text('¥' + getTotalAssets().toLocaleString()).fontSize(22).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary).margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column() {
Text('总盈亏').fontSize(10).fontColor(STOCK_COLORS.textHint)
Text('+' + getTotalProfit().toLocaleString()).fontSize(18).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.redUp).margin({ top: 4 })
Text('+' + getTotalProfitRate().toFixed(2) + '%').fontSize(11)
.fontColor(STOCK_COLORS.redUp).margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%').padding({ left: 16, right: 16 })
Divider().color(STOCK_COLORS.border).margin({ top: 12, left: 16, right: 16 })
Row() {
Column() {
Text('可用资金').fontSize(10).fontColor(STOCK_COLORS.textHint)
Text('¥' + getAvailableCash().toLocaleString()).fontSize(15).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary).margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column() {
Text('持仓市值').fontSize(10).fontColor(STOCK_COLORS.textHint)
Text('¥442,760').fontSize(15).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary).margin({ top: 3 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 14 })
}
.width('100%').backgroundColor(STOCK_COLORS.cardBg)
.borderRadius(12).margin({ left: 14, right: 14, top: 10 })
// 投资统计
Text('📊 投资统计').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary)
.width('100%').padding({ left: 18, top: 16, bottom: 8 })
Row() {
this.statCard('📈', '持仓数', '8', STOCK_COLORS.redUp)
this.statCard('⭐', '自选数', '15', STOCK_COLORS.textPrimary)
}
.width('100%').padding({ left: 14, right: 14 })
Row() {
this.statCard('📋', '交易笔数', '20', STOCK_COLORS.textPrimary)
this.statCard('🎯', '胜率', '68%', STOCK_COLORS.redUp)
}
.width('100%').padding({ left: 14, right: 14, top: 8 })
// 月度收益柱状图
Column() {
Text('📊 月度收益趋势').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary)
.width('100%').padding({ left: 16, top: 14, bottom: 10 })
Row() {
ForEach(MONTHLY_PROFIT_DATA, (d: BarDataMeta) => {
Column() {
Text((d.isPositive ? '+' : '') + d.value.toString())
.fontSize(8)
.fontColor(getProfitColor(d.isPositive))
.margin({ bottom: 3 })
if (d.isPositive) {
Column()
.width(16)
.height((Math.abs(d.value) / this.maxProfitAbs * 65).toFixed(0) + 'vp')
.backgroundColor(STOCK_COLORS.redUp)
.borderRadius({ topLeft: 3, topRight: 3 })
} else {
Column().height(2).backgroundColor(STOCK_COLORS.border)
Column()
.width(16)
.height((Math.abs(d.value) / this.maxProfitAbs * 40).toFixed(0) + 'vp')
.backgroundColor(STOCK_COLORS.greenDown)
.borderRadius({ bottomLeft: 3, bottomRight: 3 })
}
Text(d.label).fontSize(8).fontColor(STOCK_COLORS.textHint)
.margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
})
}
.padding({ left: 8, right: 8, bottom: 14 })
Row() {
Text('累计收益').fontSize(12).fontColor(STOCK_COLORS.textSecondary)
Column().layoutWeight(1)
Text('+¥15,700').fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.redUp)
}
.width('100%').padding({ left: 16, right: 16, bottom: 14 })
}
.width('100%').backgroundColor(STOCK_COLORS.cardBg)
.borderRadius(12).margin({ left: 14, right: 14, top: 10 })
// 快捷操作
Text('⚙️ 账户管理').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor(STOCK_COLORS.textPrimary)
.width('100%').padding({ left: 18, top: 16, bottom: 8 })
Column() {
Row() {
Text('🏦').fontSize(18)
Text('银行卡管理').fontSize(13).fontColor(STOCK_COLORS.textPrimary)
.layoutWeight(1).margin({ left: 10 })
Text('已绑2张 ›').fontSize(11).fontColor(STOCK_COLORS.textHint)
}
.width('100%').padding({ top: 12, bottom: 12, left: 4 })
Divider().color(STOCK_COLORS.border)
Row() {
Text('🔐').fontSize(18)
Text('安全中心').fontSize(13).fontColor(STOCK_COLORS.textPrimary)
.layoutWeight(1).margin({ left: 10 })
Text('›').fontSize(18).fontColor(STOCK_COLORS.textHint)
}
.width('100%').padding({ top: 12, bottom: 12, left: 4 })
Divider().color(STOCK_COLORS.border)
Row() {
Text('📊').fontSize(18)
Text('风险测评').fontSize(13).fontColor(STOCK_COLORS.textPrimary)
.layoutWeight(1).margin({ left: 10 })
Text('积极型 ›').fontSize(11).fontColor(STOCK_COLORS.redUp)
}
.width('100%').padding({ top: 12, bottom: 12, left: 4 })
Divider().color(STOCK_COLORS.border)
Row() {
Text('📜').fontSize(18)
Text('交易规则').fontSize(13).fontColor(STOCK_COLORS.textPrimary)
.layoutWeight(1).margin({ left: 10 })
Text('›').fontSize(18).fontColor(STOCK_COLORS.textHint)
}
.width('100%').padding({ top: 12, bottom: 12, left: 4 })
Divider().color(STOCK_COLORS.border)
Row() {
Text('📞').fontSize(18)
Text('联系客服').fontSize(13).fontColor(STOCK_COLORS.textPrimary)
.layoutWeight(1).margin({ left: 10 })
Text('›').fontSize(18).fontColor(STOCK_COLORS.textHint)
}
.width('100%').padding({ top: 12, bottom: 12, left: 4 })
}
.width('100%').backgroundColor(STOCK_COLORS.cardBg)
.borderRadius(12).margin({ left: 14, right: 14 })
.padding({ left: 16, right: 16 })
Text('v2.1 · 智能投资管家 · 2026').fontSize(10)
.fontColor(STOCK_COLORS.textHint)
.alignSelf(ItemAlign.Center).margin({ top: 20, bottom: 20 })
}
.padding({ bottom: 20 })
}
.layoutWeight(1).scrollBar(BarState.Off)
.width('100%')
}
}
十四、总结
通过对这份近两千行 ArkTS 代码的逐段剖析,我们可以清晰地看到一个专业级股票投资应用是如何被组织和构建的。

从架构层面看,这份代码遵循了非常清晰的分层思想。最顶层是类型定义层,用七个接口刻画了颜色、指数、行业、分组、交易类型、柱状图、K 线等业务元数据;接下来是数据模型层,用三个 @Observed 类封装了股票、持仓、交易记录三个核心实体,并在构造函数中实现了派生字段的自动计算;再往下是设计令牌层,把所有颜色、配置、mock 数据集中管理,成为整个应用视觉与数据的单一真相来源;最后是组件层,用一个入口组件加五个页面组件,通过 @Builder、@State、条件渲染等机制,拼装出完整的五 Tab 应用。
从技术层面看,这份代码充分展现了 ArkTS 声明式范式的几个核心能力。第一是状态驱动视图——@State 变量的变更自动触发 UI 重渲染,开发者只需要关心"状态是什么",不需要手动操作 DOM。第二是构建器的复用——@Builder 把可复用的 UI 片段封装成带参数的函数,如 bottomTabItem、stockItemBuilder、statCard 等,大幅减少了代码重复。第三是条件渲染与列表渲染——if 控制弹窗挂载、ForEach 驱动列表与图表的渲染,两者结合能应对绝大多数动态界面需求。第四是纯声明式图表——K 线图、柱状图、分布条全部用基础组件(Column、Row)配合数据驱动的尺寸计算手绘而成,不依赖任何第三方图表库,体现了框架本身足够的表达力。
从设计层面看,这份代码在视觉细节上做得相当考究。红涨绿跌的色彩约定贯穿始终,通过 getProfitColor 与 getProfitBg 两个工具函数集中管理;深色渐变卡片用于资产汇总等关键信息,制造高级感与视觉焦点;标签胶囊、统计卡片、设置列表等组件形态,都遵循了移动端金融应用的成熟设计模式;emoji 图标的运用既传达了语义,又增添了亲和力。这些细节累积起来,让整个应用呈现出一种"专业但不冰冷、丰富但不杂乱"的观感。
从工程层面看,这份代码也有一些值得借鉴的实践。把所有类型定义放在文件顶部、把所有 mock 数据与配置集中管理、把工具函数独立出来、把可复用 UI 抽象成构建器——这些都是降低维护成本、提升可读性的有效手段。当然,作为一份 mock 应用,它也有一些可以进一步优化的空间:例如列表渲染可以考虑用 ForEach 配合数据数组替代手动展开;表单可以增加校验逻辑;弹窗可以抽取成独立的子组件以降低单个组件的状态复杂度。但作为一份展示 ArkTS 声明式能力的完整样本,它已经覆盖了足够多的场景与模式,具有很高的学习与参考价值。
更多推荐

所有评论(0)