diff --git a/.gitignore b/.gitignore index 9420128..2ff28cc 100644 --- a/.gitignore +++ b/.gitignore @@ -63,7 +63,8 @@ BenchmarkDotNet.Artifacts/ # .NET Core project.lock.json project.fragment.lock.json -artifacts/ +# 仅忽略仓库根目录的构建产物目录,避免误伤源码子目录(如 Agents/MarketAnalysis/Artifacts/) +/artifacts/ # ASP.NET Scaffolding ScaffoldingReadMe.txt diff --git a/docs/design-system.md b/docs/design-system.md index e83484f..46ca742 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -1,9 +1,17 @@ # MarketAssistant UI 设计系统 -> **版本**: 2.0 +> **版本**: 2.1 > **平台**: Avalonia 11.x 跨平台桌面应用 > **产品**: 金融市场数据终端 (A股 + 虚拟币) > **设计方向**: 清晰专业风 — Bloomberg Terminal 的严谨 × 现代 SaaS 的优雅 +> +> **v2.1 变更(与 design-prototype.html v5 的裁决)**: +> 1. 深色背景以本文档色值为准(`bg-root #0A0E17` / `bg-surface #111722` / `bg-elevated #161C2A`),原型中更深的 `#080C14/#0E1320/#141B2A` 弃用; +> 2. 导航活跃指示条采用**左侧 3px 竖条**,不采用原型的底部横条;选中项背景使用 `bg-selected` 浅色填充而非整块品牌蓝; +> 3. 首页不放 K 线区,采用 Bento Grid(热门标的置左最宽 + 快讯 + 最近查看);K 线保留在资产详情页。原型中的"AI 市场概览横幅"待数据源就绪后再引入; +> 4. 涨跌标签一律带约 12% 透明度背景(`BullishTagBackgroundBrush` 等),靠色块而非纯文字色区分语义; +> 5. 响应式简化为两档:≥1100px 完整布局,<1100px 单列隐藏详情面板; +> 6. TopBar 常驻:标题/返回 + A股/虚拟币分段切换器(复用 Ctrl+M 同一逻辑);行情 ticker 需真实指数数据源,无数据时整段隐藏。 --- diff --git a/src/MarketAssistant.Agents/Analysts/AIAgentFailureIsolation.cs b/src/MarketAssistant.Agents/Analysts/AIAgentFailureIsolation.cs index 3ffb158..4ffb84a 100644 --- a/src/MarketAssistant.Agents/Analysts/AIAgentFailureIsolation.cs +++ b/src/MarketAssistant.Agents/Analysts/AIAgentFailureIsolation.cs @@ -1,7 +1,7 @@ -using System.Runtime.CompilerServices; -using System.Text; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; +using System.Runtime.CompilerServices; +using System.Text; namespace MarketAssistant.Agents.Analysts; diff --git a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Artifacts/AnalystArtifactStore.cs b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Artifacts/AnalystArtifactStore.cs new file mode 100644 index 0000000..19573be --- /dev/null +++ b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Artifacts/AnalystArtifactStore.cs @@ -0,0 +1,56 @@ +using System.Text; + +namespace MarketAssistant.Services.Agents.MarketAnalysis.Artifacts; + +/// +/// 分析师产物存储(P1-07):按 Run 隔离保存每位分析师的完整产物, +/// 协调阶段只传摘要,全文通过工具按需读取。 +/// +public interface IAnalystArtifactStore +{ + /// 写入/覆盖指定 Run 下某分析师的产物全文。 + Task SaveAsync(Guid runId, string analystName, string content, CancellationToken cancellationToken = default); + + /// 读取指定 Run 下某分析师的产物全文;不存在时返回 null。 + Task GetAsync(Guid runId, string analystName, CancellationToken cancellationToken = default); +} + +/// +/// 基于文件系统的产物存储实现:%APPDATA%/MarketAssistant/analyst-artifacts/{runId}/{analystName}.md +/// +public sealed class FileAnalystArtifactStore : IAnalystArtifactStore +{ + private readonly string _rootDirectory; + + public FileAnalystArtifactStore(string rootDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(rootDirectory); + _rootDirectory = rootDirectory; + } + + public async Task SaveAsync(Guid runId, string analystName, string content, CancellationToken cancellationToken = default) + { + var path = GetPath(runId, analystName); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllTextAsync(path, content ?? string.Empty, Encoding.UTF8, cancellationToken); + } + + public async Task GetAsync(Guid runId, string analystName, CancellationToken cancellationToken = default) + { + var path = GetPath(runId, analystName); + if (!File.Exists(path)) + return null; + + return await File.ReadAllTextAsync(path, Encoding.UTF8, cancellationToken); + } + + private string GetPath(Guid runId, string analystName) + { + // analystName 为 MAF Agent ASCII Name,仅允许安全字符 + var safeName = string.Concat(analystName.Where(char.IsLetterOrDigit)); + if (string.IsNullOrEmpty(safeName)) + throw new ArgumentException("分析师名称不能为空", nameof(analystName)); + + return Path.Combine(_rootDirectory, runId.ToString("N"), $"{safeName}.md"); + } +} \ No newline at end of file diff --git a/src/MarketAssistant.App.Services/Applications/Assets/AShareAssetInfoService.cs b/src/MarketAssistant.App.Services/Applications/Assets/AShareAssetInfoService.cs index a360777..a0b2bef 100644 --- a/src/MarketAssistant.App.Services/Applications/Assets/AShareAssetInfoService.cs +++ b/src/MarketAssistant.App.Services/Applications/Assets/AShareAssetInfoService.cs @@ -2,7 +2,6 @@ using MarketAssistant.DataProviders.AShare; using MarketAssistant.Infrastructure.Core; using Microsoft.Extensions.Logging; -using System.Globalization; namespace MarketAssistant.Applications.Assets; @@ -84,7 +83,7 @@ public async Task GetAssetInfoAsync(string code, string market = "", } // 当前价格 - assetInfo.CurrentPrice = data.LastPrice.ToString(CultureInfo.InvariantCulture); + assetInfo.CurrentPrice = PriceFormatter.Format(data.LastPrice); // 涨跌幅(CLS 的 change 为小数比率,如 -0.0082 表示 -0.82%) var changeRatio = data.Change; @@ -103,7 +102,7 @@ public async Task> GetHotAssetsAsync() // HTTP 访问、GBK 解码与解析由 SinaFundFlowClient 负责;此处仅做业务映射。 try { - var items = await _sinaFundFlowClient.GetTopNetInflowAsync(8); + var items = await _sinaFundFlowClient.GetTopNetInflowAsync(12); return items.Select(item => { diff --git a/src/MarketAssistant.App.Services/Applications/Assets/CryptoAssetInfoService.cs b/src/MarketAssistant.App.Services/Applications/Assets/CryptoAssetInfoService.cs index f5a5dd7..3efbe0f 100644 --- a/src/MarketAssistant.App.Services/Applications/Assets/CryptoAssetInfoService.cs +++ b/src/MarketAssistant.App.Services/Applications/Assets/CryptoAssetInfoService.cs @@ -82,7 +82,7 @@ public async Task GetAssetInfoAsync(string code, string market = "", Name = ExtractBaseCurrency(ticker.Symbol), // 提取基础币种 MarketType = MarketType.Crypto, Market = "Binance", - CurrentPrice = FormatPrice(ticker.LastPrice), + CurrentPrice = PriceFormatter.Format(ticker.LastPrice), ChangePercentage = FormatPercentage(ticker.PriceChangePercent), Volume24h = FormatVolume(ticker.Volume), MarketCap = await FetchMarketCapAsync(ExtractBaseCurrency(ticker.Symbol), cancellationToken) @@ -109,7 +109,7 @@ public async Task> GetHotAssetsAsync() // 稳定币列表(用于过滤稳定币互换交易对) var stablecoins = new[] { "USDT", "USDC", "BUSD", "FDUSD", "DAI", "TUSD", "USDP" }; - // 筛选USDT交易对,排除稳定币互换(需同时满足:基础币种是稳定币 且 价格接近1.0),按24小时交易量排序,取前8个 + // 筛选USDT交易对,排除稳定币互换(需同时满足:基础币种是稳定币 且 价格接近1.0),按24小时交易量排序,取前12个 var hotAssets = tickers .Where(t => t.Symbol.EndsWith("USDT") && t.Symbol != "USDT") // 过滤稳定币互换交易对(基础币种是稳定币 且 价格接近1.0,同时满足才过滤) @@ -123,13 +123,13 @@ public async Task> GetHotAssetsAsync() return !(isStablecoin && isPriceNearOne); }) .OrderByDescending(t => t.QuoteVolume) - .Take(8) + .Take(12) .Select(t => new HotAsset { Name = ExtractBaseCurrency(t.Symbol), Code = t.Symbol, Market = "Binance", - CurrentPrice = FormatPrice(t.LastPrice), + CurrentPrice = PriceFormatter.Format(t.LastPrice), ChangePercentage = FormatOpenClosePercentage(t.OpenPrice, t.LastPrice), MarketType = MarketType.Crypto, MetricLabel = "交易量", @@ -201,26 +201,6 @@ private async Task> GetSymbolsAsync(CancellationToken ca return tradingSymbols; } - /// - /// 格式化价格显示 - /// - private string FormatPrice(decimal price) - { - // 根据价格大小选择精度 - if (price >= 1000) - { - return price.ToString("N2"); // 1000+ 显示2位小数 - } - else if (price >= 1) - { - return price.ToString("N4"); // 1-1000 显示4位小数 - } - else - { - return price.ToString("N6"); // <1 显示6位小数 - } - } - /// /// 格式化百分比显示 /// diff --git a/src/MarketAssistant.App.Services/Applications/Assets/Models/AssetItem.cs b/src/MarketAssistant.App.Services/Applications/Assets/Models/AssetItem.cs index ad3a4bd..8f3f412 100644 --- a/src/MarketAssistant.App.Services/Applications/Assets/Models/AssetItem.cs +++ b/src/MarketAssistant.App.Services/Applications/Assets/Models/AssetItem.cs @@ -15,6 +15,16 @@ public class AssetItem /// public string Code { get; set; } = string.Empty; + /// + /// 当前价格(仅用于首页最近查看等场景的展示,历史记录不依赖) + /// + public string CurrentPrice { get; set; } = string.Empty; + + /// + /// 涨跌幅百分比(如 "+1.26%",仅用于展示) + /// + public string ChangePercentage { get; set; } = string.Empty; + public override string ToString() => string.IsNullOrEmpty(Name) ? Code : $"{Name} ({Code})"; } diff --git a/src/MarketAssistant.App.Services/Applications/Assets/Models/HotAsset.cs b/src/MarketAssistant.App.Services/Applications/Assets/Models/HotAsset.cs index b457070..a02a212 100644 --- a/src/MarketAssistant.App.Services/Applications/Assets/Models/HotAsset.cs +++ b/src/MarketAssistant.App.Services/Applications/Assets/Models/HotAsset.cs @@ -62,6 +62,13 @@ public class HotAsset /// public string FormattedMetric => FormatMetric(MetricValue); + /// + /// 行尾标签文本:A 股显示所属板块,加密货币显示核心指标(如"24h量 2.41B") + /// + public string TagText => MarketType == MarketType.Crypto + ? $"{MetricLabel} {FormattedMetric}" + : SectorName ?? string.Empty; + private static string FormatMetric(string? value) { if (string.IsNullOrWhiteSpace(value)) diff --git a/src/MarketAssistant.App.Services/Applications/Assets/PriceFormatter.cs b/src/MarketAssistant.App.Services/Applications/Assets/PriceFormatter.cs new file mode 100644 index 0000000..3cd13f4 --- /dev/null +++ b/src/MarketAssistant.App.Services/Applications/Assets/PriceFormatter.cs @@ -0,0 +1,25 @@ +namespace MarketAssistant.Applications.Assets; + +/// +/// 价格展示格式化工具(按价格量级选择小数位),供资产信息服务与实时行情刷新共用。 +/// +public static class PriceFormatter +{ + /// + /// 按量级格式化价格:千及以上 2 位小数,1 及以上 4 位,小于 1 取 6 位(适配低价币)。 + /// + public static string Format(decimal price) + { + if (price >= 1000) + { + return price.ToString("N2"); + } + + if (price >= 1) + { + return price.ToString("N4"); + } + + return price.ToString("N6"); + } +} \ No newline at end of file diff --git a/src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs b/src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs index c3aa3a6..ffac5b5 100644 --- a/src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs +++ b/src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs @@ -45,17 +45,6 @@ public bool LoadKnowledge set => SetProperty(ref _loadKnowledge, value); } - private bool _enableExperimentalTrading; - /// - /// 是否启用实验性自主交易。默认关闭;仅虚拟币等支持交易的市场生效。 - /// 开启即表示用户理解实盘风险、密钥与不可撤销操作。 - /// - public bool EnableExperimentalTrading - { - get => _enableExperimentalTrading; - set => SetProperty(ref _enableExperimentalTrading, value); - } - [JsonIgnore] public const string VectorCollectionName = "knowledge"; diff --git a/src/MarketAssistant.App/Assets/Images/wallet.svg b/src/MarketAssistant.App/Assets/Images/wallet.svg new file mode 100644 index 0000000..13214bc --- /dev/null +++ b/src/MarketAssistant.App/Assets/Images/wallet.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/MarketAssistant.App/Assets/Raw/kline_chart.html b/src/MarketAssistant.App/Assets/Raw/kline_chart.html index c548fcc..67672f6 100644 --- a/src/MarketAssistant.App/Assets/Raw/kline_chart.html +++ b/src/MarketAssistant.App/Assets/Raw/kline_chart.html @@ -5,6 +5,13 @@ 股票K线图 - + + + + + + diff --git a/src/MarketAssistant.App/Resources/Styles/CardStyles.axaml b/src/MarketAssistant.App/Resources/Styles/CardStyles.axaml index 5d120c9..ebf13e1 100644 --- a/src/MarketAssistant.App/Resources/Styles/CardStyles.axaml +++ b/src/MarketAssistant.App/Resources/Styles/CardStyles.axaml @@ -1,6 +1,34 @@ + + + + + + + + + + + + @@ -40,8 +68,8 @@ - - + + @@ -52,22 +80,22 @@ diff --git a/src/MarketAssistant.App/Resources/Styles/Colors.axaml b/src/MarketAssistant.App/Resources/Styles/Colors.axaml index a2c9407..f50d515 100644 --- a/src/MarketAssistant.App/Resources/Styles/Colors.axaml +++ b/src/MarketAssistant.App/Resources/Styles/Colors.axaml @@ -33,12 +33,12 @@ - - #FAFAFA + + #F5F6F8 #FFFFFF #FFFFFF - #EEEEEE + #F0F1F3 #212121 @@ -46,48 +46,79 @@ #BDBDBD - #E0E0E0 - #EEEEEE + #E0E2E8 + #EEF0F4 + #EEF0F4 #F5F5F5 #EEEEEE #E3F2FD + #1565C0 #40000000 #FEF2F2 + + + #ECFDF5 + #10B981 + #065F46 + #FFFBEB + #F59E0B + #92400E + #F0F9FF + #3B82F6 + #1E40AF + #FEE2E2 + #EF4444 + #991B1B - - #121212 - #1E1E1E + + #0A0E17 + #111722 - #1E1E1E - #383838 + #111722 + #1B2233 - - #FFFFFF - #B3B3B3 - #666666 + + #E4EAF5 + #8894A8 + #5A6680 - #3A3A3A - #2C2C2C + #1E2636 + #182032 - #2C2C2C - #383838 - #1E3A5F + #161C2A + #1B2233 + #262D3D + #42A5F5 #80000000 #2D1B1B + + + #1E10B981 + #10B981 + #34D399 + #1EF59E0B + #F59E0B + #FBBF24 + #1E3B82F6 + #3B82F6 + #93C5FD + #1EEF4444 + #EF4444 + #FCA5A5 @@ -121,9 +152,8 @@ - + - diff --git a/src/MarketAssistant.App/Resources/Styles/Spacing.axaml b/src/MarketAssistant.App/Resources/Styles/Spacing.axaml index 11bcb74..48f3563 100644 --- a/src/MarketAssistant.App/Resources/Styles/Spacing.axaml +++ b/src/MarketAssistant.App/Resources/Styles/Spacing.axaml @@ -54,6 +54,9 @@ 12 11 10 + + + JetBrains Mono, SF Mono, Consolas, Cascadia Mono diff --git a/src/MarketAssistant.App/Resources/Styles/TextStyles.axaml b/src/MarketAssistant.App/Resources/Styles/TextStyles.axaml index bdd3624..c7de234 100644 --- a/src/MarketAssistant.App/Resources/Styles/TextStyles.axaml +++ b/src/MarketAssistant.App/Resources/Styles/TextStyles.axaml @@ -109,9 +109,11 @@ + diff --git a/src/MarketAssistant.App/ViewModels/AboutPageViewModel.cs b/src/MarketAssistant.App/ViewModels/AboutPageViewModel.cs index 72bcc32..4bcd558 100644 --- a/src/MarketAssistant.App/ViewModels/AboutPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/AboutPageViewModel.cs @@ -299,6 +299,7 @@ private void InitializeFeatureItems() { IconSource = "/Assets/Images/refresh.svg", Title = "更新日志", + Description = "了解每个版本的功能更新与问题修复", ButtonText = "查看", Command = new RelayCommand(() => OpenUrl(AppInfo.ChangelogUrl)) }); @@ -307,6 +308,7 @@ private void InitializeFeatureItems() { IconSource = "/Assets/Images/globe.svg", Title = "官方网站", + Description = "访问项目主页,获取最新动态与文档", ButtonText = "查看", Command = new RelayCommand(() => OpenUrl(AppInfo.OfficialWebsite)) }); @@ -315,6 +317,7 @@ private void InitializeFeatureItems() { IconSource = "/Assets/Images/feedback.svg", Title = "意见反馈", + Description = "提交遇到的问题或功能建议", ButtonText = "反馈", Command = new RelayCommand(() => OpenUrl(AppInfo.FeedbackUrl)) }); @@ -323,6 +326,7 @@ private void InitializeFeatureItems() { IconSource = "/Assets/Images/license.svg", Title = "许可证", + Description = "查看本应用的开源许可条款", ButtonText = "查看", Command = new RelayCommand(() => OpenUrl(AppInfo.LicenseUrl)) }); @@ -331,6 +335,7 @@ private void InitializeFeatureItems() { IconSource = "/Assets/Images/qq.svg", Title = $"官方QQ群: {AppInfo.QQGroupNumber}", + Description = "加入社区,与其他用户交流使用心得", ButtonText = "加入", Command = new RelayCommand(() => OpenUrl(AppInfo.QQGroupUrl)) }); @@ -349,6 +354,11 @@ public class FeatureItem /// public string Title { get; set; } = ""; + /// + /// 功能项描述 + /// + public string Description { get; set; } = ""; + /// /// 按钮文本 /// diff --git a/src/MarketAssistant.App/ViewModels/AssetPageViewModel.cs b/src/MarketAssistant.App/ViewModels/AssetPageViewModel.cs index 7358254..455b91e 100644 --- a/src/MarketAssistant.App/ViewModels/AssetPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/AssetPageViewModel.cs @@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Messaging; using MarketAssistant.Applications.Charts; using MarketAssistant.Applications.Charts.Models; +using MarketAssistant.Applications.Assets; using MarketAssistant.Infrastructure; using MarketAssistant.Infrastructure.Core; using MarketAssistant.DataProviders; @@ -55,6 +56,16 @@ public partial class AssetPageViewModel : ViewModelBase, INavigationAware当前价展示文本(按量级格式化,适配低价币) + public string CurrentPriceText => PriceFormatter.Format(CurrentPrice); + + /// 涨跌额展示文本(按量级格式化,适配低价币) + public string PriceChangeText => PriceFormatter.Format(PriceChange); + + partial void OnCurrentPriceChanged(decimal value) => OnPropertyChanged(nameof(CurrentPriceText)); + + partial void OnPriceChangeChanged(decimal value) => OnPropertyChanged(nameof(PriceChangeText)); + /// /// 计算属性用于UI绑定 /// diff --git a/src/MarketAssistant.App/ViewModels/AssetSelectionPageViewModel.cs b/src/MarketAssistant.App/ViewModels/AssetSelectionPageViewModel.cs index 560a7d5..bc88671 100644 --- a/src/MarketAssistant.App/ViewModels/AssetSelectionPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/AssetSelectionPageViewModel.cs @@ -140,14 +140,21 @@ public string CurrentInputContent }; /// - /// 当前按钮文本 + /// 当前按钮文本(随模式与市场类型切换,如"开始选股"/"开始选币") /// - public string CurrentButtonText => SelectedMode?.ModeType switch + public string CurrentButtonText { - SelectionModeType.UserRequirement => "开始选股", - SelectionModeType.NewsAnalysis => "基于新闻选股", - _ => "开始分析" - }; + get + { + var noun = _marketContext.CurrentMarket == MarketType.Crypto ? "选币" : "选股"; + return SelectedMode?.ModeType switch + { + SelectionModeType.UserRequirement => $"开始{noun}", + SelectionModeType.NewsAnalysis => $"基于新闻{noun}", + _ => "开始分析" + }; + } + } /// /// 推荐投资标的列表(用于UI绑定) @@ -240,6 +247,7 @@ partial void OnSelectedModeChanged(SelectionModeItem? value) OnPropertyChanged(nameof(CurrentInputContent)); OnPropertyChanged(nameof(CurrentPlaceholder)); OnPropertyChanged(nameof(CurrentButtonText)); + OnPropertyChanged(nameof(CurrentButtonText)); OnPropertyChanged(nameof(IsInputAreaVisible)); OnPropertyChanged(nameof(IsQuickStrategyAreaVisible)); } diff --git a/src/MarketAssistant.App/ViewModels/FavoritesPageViewModel.cs b/src/MarketAssistant.App/ViewModels/FavoritesPageViewModel.cs index 48d2a48..ada77f5 100644 --- a/src/MarketAssistant.App/ViewModels/FavoritesPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/FavoritesPageViewModel.cs @@ -299,7 +299,7 @@ private void FlushPendingPriceUpdates() if (_assetIndex.TryGetValue(symbol, out var asset)) { - asset.CurrentPrice = update.Price.ToString("G"); + asset.CurrentPrice = PriceFormatter.Format(update.Price); asset.ChangePercentage = $"{update.Change:F2}%"; } } diff --git a/src/MarketAssistant.App/ViewModels/Home/HomeSearchViewModel.cs b/src/MarketAssistant.App/ViewModels/Home/HomeSearchViewModel.cs index f87b864..4171963 100644 --- a/src/MarketAssistant.App/ViewModels/Home/HomeSearchViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/Home/HomeSearchViewModel.cs @@ -31,6 +31,12 @@ public partial class HomeSearchViewModel : ViewModelBase, IDisposable [ObservableProperty] private bool _isSearching; + /// + /// 当前键盘/鼠标高亮的结果项(Up/Down 仅移动高亮,导航由 Enter 或点击显式触发) + /// + [ObservableProperty] + private AssetItem? _selectedResult; + /// /// 搜索结果集合 /// @@ -73,43 +79,42 @@ partial void OnSearchQueryChanged(string value) return; } - // 触发防抖搜索 - _ = Task.Run(async () => - { - try - { - // 等待200毫秒 - await Task.Delay(DebounceDelayMs, cancellationToken); + // 触发防抖搜索:延迟与请求共用同一令牌,避免慢响应的旧查询覆盖新查询结果 + _ = DebouncedSearchAsync(value, cancellationToken); + } - // 如果没有被取消,执行搜索 - if (!cancellationToken.IsCancellationRequested) - { - await SearchAsync(value); - } - } - catch (TaskCanceledException) - { - // 防抖被取消,正常情况,不记录日志 - Logger?.LogDebug("搜索防抖被取消,查询:{Query}", value); - } - catch (Exception ex) - { - Logger?.LogError(ex, "搜索资产时发生错误,查询:{Query}", value); - } - }, cancellationToken); + /// + /// 防抖后执行搜索 + /// + private async Task DebouncedSearchAsync(string value, CancellationToken cancellationToken) + { + try + { + await Task.Delay(DebounceDelayMs, cancellationToken); + await SearchAsync(value, cancellationToken); + } + catch (OperationCanceledException) + { + // 防抖或请求被取消,正常情况,不记录日志 + Logger?.LogDebug("搜索被取消,查询:{Query}", value); + } + catch (Exception ex) + { + Logger?.LogError(ex, "搜索资产时发生错误,查询:{Query}", value); + } } /// - /// 执行搜索 + /// 执行搜索并刷新结果列表 /// - [RelayCommand] - private async Task SearchAsync(string? query) + private async Task SearchAsync(string query, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(query)) { IsSearching = false; IsSearchResultVisible = false; SearchResults.Clear(); + SelectedResult = null; Logger?.LogDebug("搜索查询为空,清空结果"); return; } @@ -119,13 +124,17 @@ private async Task SearchAsync(string? query) await SafeExecuteAsync(async () => { - var results = await HomeAssetService.SearchAssetAsync(query, CancellationToken.None); + var results = await HomeAssetService.SearchAssetAsync(query, cancellationToken); + + // 请求期间查询已变化则丢弃本次结果 + cancellationToken.ThrowIfCancellationRequested(); Logger?.LogInformation("搜索完成,找到 {Count} 个结果", results.Count); // 确保在 UI 线程上更新集合 await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => { + SelectedResult = null; SearchResults.Clear(); foreach (var asset in results) { @@ -160,6 +169,30 @@ private void NavigateToAsset(AssetItem? asset) AssetSelected?.Invoke(this, asset); } + /// + /// 在结果列表中移动键盘高亮项,返回是否发生了移动。 + /// 仅移动选中项不触发导航,导航由 Enter 或点击显式触发。 + /// + public bool MoveSelection(int offset) + { + if (SearchResults.Count == 0) + { + return false; + } + + var currentIndex = SelectedResult is null ? -1 : SearchResults.IndexOf(SelectedResult); + var newIndex = Math.Clamp(currentIndex + offset, 0, SearchResults.Count - 1); + var target = SearchResults[newIndex]; + + if (target == SelectedResult) + { + return false; + } + + SelectedResult = target; + return true; + } + /// /// 清空搜索(包括文本和结果) /// @@ -172,6 +205,7 @@ public void ClearSearch() SearchQuery = string.Empty; SearchResults.Clear(); + SelectedResult = null; IsSearchResultVisible = false; IsSearching = false; } diff --git a/src/MarketAssistant.App/ViewModels/Home/RecentAssetsViewModel.cs b/src/MarketAssistant.App/ViewModels/Home/RecentAssetsViewModel.cs index 8a601cd..80d3779 100644 --- a/src/MarketAssistant.App/ViewModels/Home/RecentAssetsViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/Home/RecentAssetsViewModel.cs @@ -1,4 +1,5 @@ using CommunityToolkit.Mvvm.Input; +using MarketAssistant.Applications.Assets; using MarketAssistant.Applications.Assets.Models; using MarketAssistant.Applications.History; using MarketAssistant.Applications.Home; @@ -24,6 +25,9 @@ public partial class RecentAssetsViewModel : ViewModelBase, IDisposable private IHomeAssetService HomeAssetService => _serviceProvider.GetRequiredKeyedService(_marketContext.CurrentMarket); + private IAssetInfoService AssetInfoService => + _serviceProvider.GetRequiredKeyedService(_marketContext.CurrentMarket); + /// /// 最近查看资产集合 /// @@ -68,6 +72,9 @@ await SafeExecuteAsync(async () => { var recentAssets = await HistoryService.GetHistoryAsync(); + // 并行补全实时价格/涨跌幅(单个失败仅留空,不影响整体加载) + await Task.WhenAll(recentAssets.Select(EnrichWithQuoteAsync)); + RecentAssets.Clear(); foreach (var asset in recentAssets) { @@ -76,6 +83,23 @@ await SafeExecuteAsync(async () => }, "加载最近查看资产"); } + /// + /// 补全单个资产的实时价格与涨跌幅 + /// + private async Task EnrichWithQuoteAsync(AssetItem asset) + { + try + { + var info = await AssetInfoService.GetAssetInfoAsync(asset.Code); + asset.CurrentPrice = info.CurrentPrice; + asset.ChangePercentage = info.ChangePercentage; + } + catch (Exception ex) + { + Logger?.LogWarning(ex, "补全最近查看资产行情失败: {Code}", asset.Code); + } + } + /// /// 添加资产到最近查看 /// diff --git a/src/MarketAssistant.App/ViewModels/IndexTickerItemViewModel.cs b/src/MarketAssistant.App/ViewModels/IndexTickerItemViewModel.cs new file mode 100644 index 0000000..9ce251b --- /dev/null +++ b/src/MarketAssistant.App/ViewModels/IndexTickerItemViewModel.cs @@ -0,0 +1,41 @@ +namespace MarketAssistant.ViewModels; + +/// +/// 顶栏行情条单项(当前为模拟数据,待接入真实指数服务后替换) +/// +public class IndexTickerItemViewModel +{ + /// + /// 指数名称(如"上证"、"BTC") + /// + public string Name { get; } + + /// + /// 最新点位/价格 + /// + public string Price { get; } + + /// + /// 涨跌幅文本(含正负号) + /// + public string ChangeText { get; } + + /// + /// 是否上涨(决定涨跌配色) + /// + public bool IsUp { get; } + + /// + /// 是否下跌(决定涨跌配色) + /// + public bool IsDown { get; } + + public IndexTickerItemViewModel(string name, string price, string changeText, bool isUp) + { + Name = name; + Price = price; + ChangeText = changeText; + IsUp = isUp; + IsDown = !isUp; + } +} \ No newline at end of file diff --git a/src/MarketAssistant.App/ViewModels/MCPConfigPageViewModel.cs b/src/MarketAssistant.App/ViewModels/MCPConfigPageViewModel.cs index ab9048b..4f0c04f 100644 --- a/src/MarketAssistant.App/ViewModels/MCPConfigPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/MCPConfigPageViewModel.cs @@ -49,6 +49,28 @@ public partial class MCPConfigPageViewModel : ViewModelBase, INavigationAware [ObservableProperty] private string _transportType = "stdio"; + /// + /// 是否为 stdio 传输类型(控制参数与环境变量输入区的可见性) + /// + public bool IsStdio => TransportType == "stdio"; + + /// + /// 命令/URL 输入框占位提示,随传输类型切换 + /// + public string CommandPlaceholder => TransportType switch + { + "stdio" => "请输入命令,如 npx", + "sse" => "请输入 URL,如 http://localhost:3000/sse", + _ => "请输入 URL,如 http://localhost:3000/mcp" + }; + + /// + /// 命令/URL 输入框下方说明文字,随传输类型切换 + /// + public string CommandHint => TransportType == "stdio" + ? "提示:命令参数与环境变量请在下方对应输入框填写" + : $"提示:URL 示例 http://localhost:3000/{(TransportType == "sse" ? "sse" : "mcp")}"; + [ObservableProperty] private string _command = string.Empty; @@ -67,8 +89,21 @@ public partial class MCPConfigPageViewModel : ViewModelBase, INavigationAware [ObservableProperty] private ObservableCollection _toolItems = new(); + // 表单校验错误信息(保存/测试时触发,修正后即时清除) + [ObservableProperty] + private string? _nameError; + + [ObservableProperty] + private string? _commandError; + private MCPServerConfig? _editingConfig; + // 正在编辑的列表源配置(用于切换确认被拒时回退选中项) + private MCPServerConfig? _editingSource; + + // 回退选中项期间抑制选择变更处理,避免递归触发确认 + private bool _suppressSelectionChanged; + public MCPConfigPageViewModel( MCPServerConfigService configService, INotificationService notificationService, @@ -115,6 +150,7 @@ private void AddServer() { // 清空选中项,避免与编辑状态冲突 SelectedConfig = null; + _editingSource = null; _editingConfig = new MCPServerConfig { @@ -135,6 +171,8 @@ private void EditServer() { if (SelectedConfig == null) return; + _editingSource = SelectedConfig; + // 手动复制配置 _editingConfig = new MCPServerConfig { @@ -147,7 +185,8 @@ private void EditServer() IsEnabled = SelectedConfig.IsEnabled, EnvironmentVariables = new Dictionary(SelectedConfig.EnvironmentVariables), Category = SelectedConfig.Category, - AllowedTools = [.. SelectedConfig.AllowedTools] + AllowedTools = [.. SelectedConfig.AllowedTools], + AllowAllTools = SelectedConfig.AllowAllTools }; LoadConfigToUI(_editingConfig); IsEditing = true; @@ -161,16 +200,9 @@ private void SaveServer() { if (_editingConfig == null) return; - // 验证必填字段 - if (string.IsNullOrWhiteSpace(Name)) - { - _notificationService?.ShowWarning("请输入服务器名称"); - return; - } - - if (string.IsNullOrWhiteSpace(Command)) + // 校验必填字段 + if (!ValidateForm()) { - _notificationService?.ShowWarning("请输入命令或URL"); return; } @@ -187,6 +219,7 @@ private void SaveServer() _mcpToolProvider.Invalidate(); IsEditing = false; _editingConfig = null; + _editingSource = null; _notificationService?.ShowSuccess("保存成功"); Logger?.LogInformation("MCP服务器配置已保存: {Name}", Name); @@ -199,14 +232,36 @@ private void SaveServer() } /// - /// 取消编辑 + /// 取消编辑(存在未保存修改时需用户确认) /// [RelayCommand] - private void CancelEdit() + private async Task CancelEdit() + { + if (HasUnsavedChanges()) + { + var confirmed = await _dialogService.ShowConfirmationAsync( + "未保存的修改", + "当前有未保存的修改,确定放弃并退出编辑吗?", + "放弃修改", + "继续编辑"); + + if (!confirmed) return; + } + + ForceCancelEdit(); + } + + /// + /// 直接退出编辑状态,不做脏检查(用于页面导航离开等自动取消场景) + /// + private void ForceCancelEdit() { SelectedConfig = null; IsEditing = false; _editingConfig = null; + _editingSource = null; + NameError = null; + CommandError = null; } /// @@ -217,16 +272,9 @@ private async Task TestConnection() { if (_editingConfig == null) return; - // 验证必填字段 - if (string.IsNullOrWhiteSpace(Name)) + // 校验必填字段 + if (!ValidateForm()) { - _notificationService?.ShowWarning("请输入服务器名称"); - return; - } - - if (string.IsNullOrWhiteSpace(Command)) - { - _notificationService?.ShowWarning("请输入命令或URL"); return; } @@ -313,6 +361,8 @@ private async Task DeleteServer() LoadServerConfigs(); _mcpToolProvider.Invalidate(); IsEditing = false; + _editingConfig = null; + _editingSource = null; _notificationService?.ShowSuccess("删除成功"); } } @@ -396,6 +446,10 @@ private void LoadConfigToUI(MCPServerConfig config) { EnvironmentVariablesText = string.Empty; } + + // 重新载入表单时清除历史校验错误 + NameError = null; + CommandError = null; } /// @@ -439,20 +493,177 @@ private void SaveUIToConfig(MCPServerConfig config) return result; } + /// + /// 校验名称必填 + /// + private void ValidateName() + { + NameError = string.IsNullOrWhiteSpace(Name) ? "请输入服务器名称" : null; + } + + /// + /// 校验命令/URL必填及URL格式(sse/streamableHttp 时 Command 字段存储 URL) + /// + private void ValidateCommand() + { + if (string.IsNullOrWhiteSpace(Command)) + { + CommandError = TransportType == "stdio" ? "请输入命令" : "请输入 URL"; + } + else if (TransportType != "stdio" && !Uri.TryCreate(Command, UriKind.Absolute, out _)) + { + CommandError = "URL 格式不正确,例如 http://localhost:3000/mcp"; + } + else + { + CommandError = null; + } + } + + /// + /// 校验表单必填项,返回是否全部通过 + /// + private bool ValidateForm() + { + ValidateName(); + ValidateCommand(); + return NameError is null && CommandError is null; + } + + partial void OnNameChanged(string value) + { + // 已显示错误时即时重新校验,便于用户修正后错误提示消失 + if (NameError is not null) + { + ValidateName(); + } + } + + partial void OnCommandChanged(string value) + { + if (CommandError is not null) + { + ValidateCommand(); + } + } + + partial void OnTransportTypeChanged(string value) + { + OnPropertyChanged(nameof(IsStdio)); + OnPropertyChanged(nameof(CommandPlaceholder)); + OnPropertyChanged(nameof(CommandHint)); + if (CommandError is not null) + { + ValidateCommand(); + } + } + partial void OnSelectedConfigChanged(MCPServerConfig? value) { - if (value != null) + if (value is null || _suppressSelectionChanged) + { + return; + } + + // 存在未保存修改时先确认,用户拒绝则回退选中项 + if (IsEditing && HasUnsavedChanges() && !ReferenceEquals(value, _editingSource)) + { + _ = HandleSelectionChangeAsync(value); + return; + } + + EditServer(); + } + + /// + /// 处理带未保存修改的选中切换:确认丢弃后加载目标配置,否则回退选中项 + /// + private async Task HandleSelectionChangeAsync(MCPServerConfig target) + { + try + { + var confirmed = await _dialogService.ShowConfirmationAsync( + "未保存的修改", + $"服务器「{(target.Name is { Length: > 0 } n ? n : target.Id)}」有未保存的修改,切换后将丢弃这些修改。是否继续?", + "丢弃并切换", + "继续编辑"); + + if (confirmed) + { + EditServer(); + } + else + { + _suppressSelectionChanged = true; + try + { + SelectedConfig = _editingSource; + } + finally + { + _suppressSelectionChanged = false; + } + } + } + catch (Exception ex) { + Logger?.LogError(ex, "处理MCP服务器切换确认失败"); EditServer(); } } + /// + /// 判断当前表单是否存在未保存的修改 + /// + private bool HasUnsavedChanges() + { + if (_editingConfig is null || !IsEditing) + { + return false; + } + + return !string.Equals(Name, _editingConfig.Name, StringComparison.Ordinal) + || !string.Equals(Description, _editingConfig.Description, StringComparison.Ordinal) + || !string.Equals(TransportType, _editingConfig.TransportType, StringComparison.Ordinal) + || !string.Equals(Command, _editingConfig.Command, StringComparison.Ordinal) + || !string.Equals(Arguments, _editingConfig.Arguments, StringComparison.Ordinal) + || IsEnabled != _editingConfig.IsEnabled + || !AreDictionariesEqual(ParseEnvironmentVariables(), _editingConfig.EnvironmentVariables) + || !AreAllowedToolsEqual(); + } + + /// + /// 比较两个环境变量字典是否一致(忽略顺序) + /// + private static bool AreDictionariesEqual(Dictionary left, Dictionary right) + => left.Count == right.Count && left.All(kv => right.TryGetValue(kv.Key, out var value) && value == kv.Value); + + /// + /// 比较当前勾选的工具白名单(含允许全部工具开关)与编辑配置是否一致 + /// + private bool AreAllowedToolsEqual() + { + if (AllowAllTools != _editingConfig!.AllowAllTools) + { + return false; + } + + if (AllowAllTools) + { + return true; + } + + var selected = ToolItems.Where(item => item.IsSelected).Select(item => item.Name).ToList(); + return selected.Count == _editingConfig.AllowedTools.Count + && selected.All(_editingConfig.AllowedTools.Contains); + } + public void OnNavigatedFrom() { // 离开页面时如果正在编辑但未保存,自动取消编辑状态 if (IsEditing) { - CancelEdit(); + ForceCancelEdit(); } } diff --git a/src/MarketAssistant.App/ViewModels/MainWindowViewModel.cs b/src/MarketAssistant.App/ViewModels/MainWindowViewModel.cs index dca50b9..b18f83b 100644 --- a/src/MarketAssistant.App/ViewModels/MainWindowViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/MainWindowViewModel.cs @@ -1,10 +1,8 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using MarketAssistant.Applications.Settings; using MarketAssistant.Services.Market; using MarketAssistant.Services.Navigation; using MarketAssistant.Services.Notification; -using MarketAssistant.Services.Settings; using MarketAssistant.ViewModels.Trading; using Microsoft.Extensions.Logging; using System.Collections.ObjectModel; @@ -18,7 +16,6 @@ public partial class MainWindowViewModel : ViewModelBase private readonly NavigationService _navigationService; private readonly MarketContext _marketContext; private readonly INotificationService _notificationService; - private readonly IUserSettingService _userSettingService; private bool _isSynchronizingNavigationSelection; [ObservableProperty] @@ -28,19 +25,40 @@ public partial class MainWindowViewModel : ViewModelBase public bool CanGoBack => _navigationService.CanGoBack; public string CurrentPageTitle => _navigationService.CurrentPage?.Title ?? string.Empty; - public ObservableCollection NavigationItems { get; } + public ObservableCollection MainNavigationItems { get; } + + public ObservableCollection BottomNavigationItems { get; } + + /// + /// 顶栏行情条(当前为模拟数据,待接入真实指数服务) + /// + public ObservableCollection IndexTickers { get; } + + /// + /// 行情条是否可见(无数据时整段隐藏,对齐设计系统裁决 #6) + /// + public bool HasIndexTickers => IndexTickers.Count > 0; /// /// 当前市场类型显示文本 /// public string CurrentMarketText => _marketContext.CurrentMarket == MarketType.AShare ? "A股市场" : "虚拟币市场"; + /// + /// 当前是否为 A 股市场(用于顶栏分段切换器视觉状态) + /// + public bool IsAShareMarket => _marketContext.CurrentMarket == MarketType.AShare; + + /// + /// 当前是否为虚拟币市场(用于顶栏分段切换器视觉状态) + /// + public bool IsCryptoMarket => _marketContext.CurrentMarket == MarketType.Crypto; + public MainWindowViewModel( IServiceProvider serviceProvider, NavigationService navigationService, MarketContext marketContext, INotificationService notificationService, - IUserSettingService userSettingService, ILogger? logger = null) : base(logger) { @@ -48,10 +66,12 @@ public MainWindowViewModel( _navigationService = navigationService; _marketContext = marketContext; _notificationService = notificationService; - _userSettingService = userSettingService; - NavigationItems = new ObservableCollection(); + MainNavigationItems = new ObservableCollection(); + BottomNavigationItems = new ObservableCollection(); + IndexTickers = new ObservableCollection(); RebuildNavigationItems(); + RebuildIndexTickers(); // 监听导航服务属性变更 _navigationService.PropertyChanged += OnNavigationServicePropertyChanged; @@ -60,38 +80,70 @@ public MainWindowViewModel( SubscribeToMarketChanges(_marketContext); // 默认导航到首页。SelectedNavigationItem 的变更回调负责实际导航,避免重复入栈。 - SelectedNavigationItem = NavigationItems[0]; + SelectedNavigationItem = MainNavigationItems[0]; } protected override void OnMarketChanged(MarketType newMarket) { OnPropertyChanged(nameof(CurrentMarketText)); + OnPropertyChanged(nameof(IsAShareMarket)); + OnPropertyChanged(nameof(IsCryptoMarket)); RebuildNavigationItems(); + RebuildIndexTickers(); } private void RebuildNavigationItems() { - NavigationItems.Clear(); + MainNavigationItems.Clear(); + BottomNavigationItems.Clear(); - NavigationItems.Add(new NavigationItemViewModel("首页", "avares://MarketAssistant/Assets/Images/tab_home.svg", "avares://MarketAssistant/Assets/Images/tab_home_on.svg", () => _serviceProvider.GetRequiredService())); - NavigationItems.Add(new NavigationItemViewModel("收藏", "avares://MarketAssistant/Assets/Images/tab_favorites.svg", "avares://MarketAssistant/Assets/Images/tab_favorites_on.svg", () => _serviceProvider.GetRequiredService())); - NavigationItems.Add(new NavigationItemViewModel("告警", "avares://MarketAssistant/Assets/Images/tab_alert.svg", "avares://MarketAssistant/Assets/Images/tab_alert_on.svg", () => _serviceProvider.GetRequiredService())); - NavigationItems.Add(new NavigationItemViewModel("AI选股", "avares://MarketAssistant/Assets/Images/tab_analysis.svg", "avares://MarketAssistant/Assets/Images/tab_analysis_on.svg", () => _serviceProvider.GetRequiredService())); - // 交易为实验功能:仅当前市场支持交易且用户在设置中显式开启时可见(默认关闭) + MainNavigationItems.Add(new NavigationItemViewModel("首页", "avares://MarketAssistant/Assets/Images/tab_home.svg", "avares://MarketAssistant/Assets/Images/tab_home_on.svg", () => _serviceProvider.GetRequiredService())); + MainNavigationItems.Add(new NavigationItemViewModel("收藏", "avares://MarketAssistant/Assets/Images/tab_favorites.svg", "avares://MarketAssistant/Assets/Images/tab_favorites_on.svg", () => _serviceProvider.GetRequiredService())); + MainNavigationItems.Add(new NavigationItemViewModel("告警", "avares://MarketAssistant/Assets/Images/tab_alert.svg", "avares://MarketAssistant/Assets/Images/tab_alert_on.svg", () => _serviceProvider.GetRequiredService())); + MainNavigationItems.Add(new NavigationItemViewModel("AI选股", "avares://MarketAssistant/Assets/Images/tab_analysis.svg", "avares://MarketAssistant/Assets/Images/tab_analysis_on.svg", () => _serviceProvider.GetRequiredService())); + // 交易入口跟随市场能力:虚拟币等支持交易的市场可见,A 股不可见 if (IsTradingVisible()) { - NavigationItems.Add(new NavigationItemViewModel("交易", "avares://MarketAssistant/Assets/Images/tab_trading.svg", "avares://MarketAssistant/Assets/Images/tab_trading_on.svg", () => _serviceProvider.GetRequiredService())); + MainNavigationItems.Add(new NavigationItemViewModel("交易", "avares://MarketAssistant/Assets/Images/tab_trading.svg", "avares://MarketAssistant/Assets/Images/tab_trading_on.svg", () => _serviceProvider.GetRequiredService())); + } + + // 底部固定:设置 / 关于(对齐原型 sidebar nav-bot) + BottomNavigationItems.Add(new NavigationItemViewModel("设置", "avares://MarketAssistant/Assets/Images/tab_settings.svg", "avares://MarketAssistant/Assets/Images/tab_settings_on.svg", () => _serviceProvider.GetRequiredService())); + BottomNavigationItems.Add(new NavigationItemViewModel("关于", "avares://MarketAssistant/Assets/Images/tab_about.svg", "avares://MarketAssistant/Assets/Images/tab_about_on.svg", () => _serviceProvider.GetRequiredService())); + } + + /// + /// 按当前市场填充顶栏行情条。 + /// 当前为示意数据。接入真实指数服务时仅需替换本方法: + /// 在 App.Services 对应市场模块(AShareMarketModule / CryptoMarketModule)注册 + /// IIndexQuoteService(Keyed by MarketType),此处改为调用其接口即可,XAML 无需改动。 + /// + private void RebuildIndexTickers() + { + IndexTickers.Clear(); + + if (_marketContext.CurrentMarket == MarketType.AShare) + { + IndexTickers.Add(new IndexTickerItemViewModel("上证", "3,387.52", "+1.24%", isUp: true)); + IndexTickers.Add(new IndexTickerItemViewModel("深证", "10,892.41", "-0.38%", isUp: false)); + IndexTickers.Add(new IndexTickerItemViewModel("创业", "2,156.30", "+0.82%", isUp: true)); + } + else + { + // 加密市场展示指数类行情(与热门标的中的 BTC/ETH 等个币区分),避免信息重复 + IndexTickers.Add(new IndexTickerItemViewModel("CoinDesk 20", "2,184.62", "+1.92%", isUp: true)); + IndexTickers.Add(new IndexTickerItemViewModel("Bitwise 10", "4,356.18", "+2.35%", isUp: true)); + IndexTickers.Add(new IndexTickerItemViewModel("DPI", "112.47", "-0.86%", isUp: false)); } - NavigationItems.Add(new NavigationItemViewModel("设置", "avares://MarketAssistant/Assets/Images/tab_settings.svg", "avares://MarketAssistant/Assets/Images/tab_settings_on.svg", () => _serviceProvider.GetRequiredService())); - NavigationItems.Add(new NavigationItemViewModel("关于", "avares://MarketAssistant/Assets/Images/tab_about.svg", "avares://MarketAssistant/Assets/Images/tab_about_on.svg", () => _serviceProvider.GetRequiredService())); + + OnPropertyChanged(nameof(HasIndexTickers)); } /// - /// 交易导航可见性:市场支持交易(如虚拟币)且用户显式开启实验开关;A 股始终不可见。 + /// 交易导航可见性:仅当前市场支持交易时可见(如虚拟币);A 股始终不可见。 /// private bool IsTradingVisible() - => _marketContext.CurrentCapability.SupportsTrading - && _userSettingService.CurrentSetting.EnableExperimentalTrading; + => _marketContext.CurrentCapability.SupportsTrading; private void OnNavigationServicePropertyChanged(object? sender, PropertyChangedEventArgs e) { @@ -111,7 +163,9 @@ private void OnNavigationServicePropertyChanged(object? sender, PropertyChangedE _isSynchronizingNavigationSelection = true; try { - SelectedNavigationItem = NavigationItems.FirstOrDefault( + SelectedNavigationItem = MainNavigationItems.FirstOrDefault( + item => item.Title == _navigationService.CurrentRootNavigationItemTitle) + ?? BottomNavigationItems.FirstOrDefault( item => item.Title == _navigationService.CurrentRootNavigationItemTitle); } finally @@ -142,6 +196,28 @@ private void ToggleMarket() ? MarketType.Crypto : MarketType.AShare; + SwitchToMarket(newMarket); + } + + /// + /// 按指定市场切换(顶栏分段切换器使用) + /// + /// 目标市场类型 + [RelayCommand] + private void SwitchMarket(MarketType market) + { + // 已是目标市场则不重复切换刷新页面 + if (_marketContext.CurrentMarket == market) + return; + + SwitchToMarket(market); + } + + /// + /// 执行市场切换、提示并刷新当前页面 + /// + private void SwitchToMarket(MarketType newMarket) + { _marketContext.SwitchMarket(newMarket); // 显示切换提示 diff --git a/src/MarketAssistant.App/Views/Components/AnalysisReportView.axaml b/src/MarketAssistant.App/Views/Components/AnalysisReportView.axaml index 8f68c9a..7d5692b 100644 --- a/src/MarketAssistant.App/Views/Components/AnalysisReportView.axaml +++ b/src/MarketAssistant.App/Views/Components/AnalysisReportView.axaml @@ -240,15 +240,15 @@ - + Foreground="{DynamicResource SuccessPanelTextBrush}"/> @@ -266,15 +266,15 @@ - + Foreground="{DynamicResource DangerPanelTextBrush}"/> @@ -318,7 +318,7 @@ - + - + Foreground="{DynamicResource WarningPanelTextBrush}"/> diff --git a/src/MarketAssistant.App/Views/Components/ProgressDisplayView.axaml b/src/MarketAssistant.App/Views/Components/ProgressDisplayView.axaml index 9b318f7..0366f87 100644 --- a/src/MarketAssistant.App/Views/Components/ProgressDisplayView.axaml +++ b/src/MarketAssistant.App/Views/Components/ProgressDisplayView.axaml @@ -107,14 +107,14 @@ - diff --git a/src/MarketAssistant.App/Views/Pages/AboutPageView.axaml b/src/MarketAssistant.App/Views/Pages/AboutPageView.axaml index 97b42f8..bb53068 100644 --- a/src/MarketAssistant.App/Views/Pages/AboutPageView.axaml +++ b/src/MarketAssistant.App/Views/Pages/AboutPageView.axaml @@ -33,6 +33,7 @@ Padding="0" Width="{StaticResource SmallIconSize}" Height="{StaticResource SmallIconSize}" + ToolTip.Tip="在 GitHub 上查看源码" Command="{Binding OpenGitHubCommand}"> - + + + @@ -200,7 +204,7 @@ Margin="{StaticResource HorizontalMargin}"> - diff --git a/src/MarketAssistant.App/Views/Pages/AssetPageView.axaml b/src/MarketAssistant.App/Views/Pages/AssetPageView.axaml index 91903d7..5e73f72 100644 --- a/src/MarketAssistant.App/Views/Pages/AssetPageView.axaml +++ b/src/MarketAssistant.App/Views/Pages/AssetPageView.axaml @@ -40,6 +40,7 @@ Padding="{StaticResource TinyPadding}" VerticalAlignment="Center"> @@ -48,7 +49,8 @@ - @@ -132,7 +135,7 @@ VerticalAlignment="Center" /> - diff --git a/src/MarketAssistant.App/Views/Pages/AssetSelectionPageView.axaml b/src/MarketAssistant.App/Views/Pages/AssetSelectionPageView.axaml index 85c174b..af8758c 100644 --- a/src/MarketAssistant.App/Views/Pages/AssetSelectionPageView.axaml +++ b/src/MarketAssistant.App/Views/Pages/AssetSelectionPageView.axaml @@ -20,10 +20,49 @@ + + + + + + + + + - + - - - - @@ -58,108 +94,90 @@ - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + TextWrapping="Wrap"/> + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - + + - - - - - - - - - + VerticalAlignment="Center"/> + + + - - - - - - - + + + + + + + - \ No newline at end of file + diff --git a/src/MarketAssistant.App/Views/Pages/HomePageView.axaml b/src/MarketAssistant.App/Views/Pages/HomePageView.axaml index 974694a..7e04391 100644 --- a/src/MarketAssistant.App/Views/Pages/HomePageView.axaml +++ b/src/MarketAssistant.App/Views/Pages/HomePageView.axaml @@ -16,41 +16,80 @@ + + + + + + + - + + - + - - - - - - - - - - + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ColumnDefinitions="3*,2*"> - - + + - + FontWeight="SemiBold"/> - - + + - + - + + - + - - - - - + + + + - - - - - - - - - + + + + + + + + + + - - - - - - - - - - - - - - - - - + + \ No newline at end of file diff --git a/src/MarketAssistant.App/Views/Pages/HomePageView.axaml.cs b/src/MarketAssistant.App/Views/Pages/HomePageView.axaml.cs index a652af3..a40bb88 100644 --- a/src/MarketAssistant.App/Views/Pages/HomePageView.axaml.cs +++ b/src/MarketAssistant.App/Views/Pages/HomePageView.axaml.cs @@ -15,22 +15,66 @@ public HomePageView() } /// - /// 搜索结果项点击处理 + /// 搜索结果项点击处理:激活所点资产并导航 /// - private void SearchResultItem_PointerPressed(object? sender, PointerPressedEventArgs e) + private void SearchResultItem_Tapped(object? sender, TappedEventArgs e) { - if (sender is Control control && - control.Tag is AssetItem selectedAsset && + if (sender is Control { DataContext: AssetItem asset } && DataContext is HomePageViewModel viewModel) { - // 标记事件已处理,防止AutoCompleteBox处理 e.Handled = true; - // 关闭下拉框 - viewModel.Search.IsSearchResultVisible = false; + // 执行导航(内部会关闭下拉框) + viewModel.Search.NavigateToAssetCommand.Execute(asset); + } + } + + /// + /// 搜索框键盘处理:Esc 关闭结果、上下键移动高亮、回车跳转高亮结果。 + /// 输入法组合期间回车由输入法消费,不会进入此处理,避免重复提交。 + /// + private void StockSearchBox_KeyDown(object? sender, KeyEventArgs e) + { + if (DataContext is not HomePageViewModel viewModel) + { + return; + } - // 执行导航 - viewModel.Search.NavigateToAssetCommand.Execute(selectedAsset); + var search = viewModel.Search; + + switch (e.Key) + { + case Key.Escape: + if (search.IsSearchResultVisible) + { + search.IsSearchResultVisible = false; + e.Handled = true; + } + break; + + case Key.Down: + if (search.IsSearchResultVisible && search.MoveSelection(1)) + { + e.Handled = true; + } + break; + + case Key.Up: + if (search.IsSearchResultVisible && search.MoveSelection(-1)) + { + e.Handled = true; + } + break; + + case Key.Enter: + if (search.IsSearchResultVisible && + search.SelectedResult is AssetItem selectedAsset) + { + search.IsSearchResultVisible = false; + search.NavigateToAssetCommand.Execute(selectedAsset); + e.Handled = true; + } + break; } } diff --git a/src/MarketAssistant.App/Views/Pages/MCPConfigPageView.axaml b/src/MarketAssistant.App/Views/Pages/MCPConfigPageView.axaml index 5821523..f9bd178 100644 --- a/src/MarketAssistant.App/Views/Pages/MCPConfigPageView.axaml +++ b/src/MarketAssistant.App/Views/Pages/MCPConfigPageView.axaml @@ -12,7 +12,7 @@ - 280 + 280 4,0 @@ -39,40 +39,46 @@ Background="{DynamicResource CardBackgroundBrush}"> - + + + - - - - - + - + @@ -151,61 +159,8 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -276,8 +231,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -335,7 +339,6 @@ - @@ -343,4 +346,4 @@ - + \ No newline at end of file diff --git a/src/MarketAssistant.App/Views/Pages/Trading/TradingPageView.axaml b/src/MarketAssistant.App/Views/Pages/Trading/TradingPageView.axaml index e5fd839..efe919d 100644 --- a/src/MarketAssistant.App/Views/Pages/Trading/TradingPageView.axaml +++ b/src/MarketAssistant.App/Views/Pages/Trading/TradingPageView.axaml @@ -3,77 +3,175 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="using:MarketAssistant.ViewModels.Trading" - xmlns:controls="using:MarketAssistant.Views.Controls" + xmlns:converts="using:MarketAssistant.Converters" xmlns:trading="using:MarketAssistant.Views.Pages.Trading" mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="800" x:Class="MarketAssistant.Views.Pages.Trading.TradingPageView" x:DataType="vm:TradingPageViewModel"> - - - - - - - - + + + 20,16,20,20 + - - - - - + + + + + + + - - - - + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MarketAssistant.DataProviders/AShare/EastMoneyNewsClient.cs b/src/MarketAssistant.DataProviders/AShare/EastMoneyNewsClient.cs index b6f374b..49426e1 100644 --- a/src/MarketAssistant.DataProviders/AShare/EastMoneyNewsClient.cs +++ b/src/MarketAssistant.DataProviders/AShare/EastMoneyNewsClient.cs @@ -64,7 +64,7 @@ public async Task> SearchNewsAsync( }); var param = Uri.EscapeDataString(payloadJson); var url = $"search/jsonp?cb=jQuery¶m={param}"; -using var httpClient = _httpClientFactory.CreateClient("EastMoneySearch"); + using var httpClient = _httpClientFactory.CreateClient("EastMoneySearch"); httpClient.Timeout = TimeSpan.FromSeconds(15); using var request = new HttpRequestMessage(HttpMethod.Get, url); diff --git a/src/MarketAssistant.Infrastructure/Factories/ModelDiscoveryService.cs b/src/MarketAssistant.Infrastructure/Factories/ModelDiscoveryService.cs index 9038829..a176ceb 100644 --- a/src/MarketAssistant.Infrastructure/Factories/ModelDiscoveryService.cs +++ b/src/MarketAssistant.Infrastructure/Factories/ModelDiscoveryService.cs @@ -116,4 +116,4 @@ private static bool TryGetArray(JsonElement root, string propertyName, out JsonE return false; } - } +} diff --git a/tests/Application/HomeSearchViewModelTest.cs b/tests/Application/HomeSearchViewModelTest.cs new file mode 100644 index 0000000..5eb0278 --- /dev/null +++ b/tests/Application/HomeSearchViewModelTest.cs @@ -0,0 +1,129 @@ +using MarketAssistant.Applications.Assets.Models; +using MarketAssistant.Applications.Settings; +using MarketAssistant.Services.Market; +using MarketAssistant.Services.Settings; +using MarketAssistant.ViewModels.Home; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace TestMarketAssistant.Application; + +/// +/// HomeSearchViewModel 单元测试:覆盖键盘高亮导航(MoveSelection)与清空逻辑 +/// +[TestClass] +public class HomeSearchViewModelTest +{ + private static HomeSearchViewModel CreateViewModel() + { + var services = new ServiceCollection(); + var userSettingService = new Mock(); + userSettingService + .Setup(s => s.CurrentSetting) + .Returns(new UserSetting()); + services.AddSingleton(userSettingService.Object); + var serviceProvider = services.BuildServiceProvider(); + var marketContext = new MarketContext(userSettingService.Object, serviceProvider); + + return new HomeSearchViewModel( + serviceProvider, + marketContext, + NullLogger.Instance); + } + + private static List CreateResults(int count) + => Enumerable.Range(1, count) + .Select(i => new AssetItem { Name = $"资产{i}", Code = $"CODE{i:000}" }) + .ToList(); + + [TestMethod] + public void MoveSelection_WithNoResults_ShouldReturnFalse() + { + var vm = CreateViewModel(); + + Assert.IsFalse(vm.MoveSelection(1)); + Assert.IsNull(vm.SelectedResult); + } + + [TestMethod] + public void MoveSelection_WithNoHighlight_DownShouldSelectFirstItem() + { + var vm = CreateViewModel(); + foreach (var item in CreateResults(3)) + { + vm.SearchResults.Add(item); + } + + Assert.IsTrue(vm.MoveSelection(1)); + Assert.AreSame(vm.SearchResults[0], vm.SelectedResult); + } + + [TestMethod] + public void MoveSelection_AtLastItem_DownShouldStayAndReturnFalse() + { + var vm = CreateViewModel(); + foreach (var item in CreateResults(2)) + { + vm.SearchResults.Add(item); + } + vm.SelectedResult = vm.SearchResults[1]; + + Assert.IsFalse(vm.MoveSelection(1)); + Assert.AreSame(vm.SearchResults[1], vm.SelectedResult); + } + + [TestMethod] + public void MoveSelection_AtFirstItem_UpShouldStayAndReturnFalse() + { + var vm = CreateViewModel(); + foreach (var item in CreateResults(2)) + { + vm.SearchResults.Add(item); + } + vm.SelectedResult = vm.SearchResults[0]; + + Assert.IsFalse(vm.MoveSelection(-1)); + Assert.AreSame(vm.SearchResults[0], vm.SelectedResult); + } + + [TestMethod] + public void MoveSelection_SequentialDowns_ShouldMoveThroughList() + { + var vm = CreateViewModel(); + foreach (var item in CreateResults(3)) + { + vm.SearchResults.Add(item); + } + + vm.MoveSelection(1); + Assert.AreSame(vm.SearchResults[0], vm.SelectedResult); + + vm.MoveSelection(1); + Assert.AreSame(vm.SearchResults[1], vm.SelectedResult); + + vm.MoveSelection(-1); + Assert.AreSame(vm.SearchResults[0], vm.SelectedResult); + } + + [TestMethod] + public void ClearSearch_ShouldClearQueryResultsAndSelection() + { + var vm = CreateViewModel(); + foreach (var item in CreateResults(2)) + { + vm.SearchResults.Add(item); + } + vm.SelectedResult = vm.SearchResults[0]; + vm.SearchQuery = "茅台"; + vm.IsSearchResultVisible = true; + + vm.ClearSearch(); + + Assert.AreEqual(string.Empty, vm.SearchQuery); + Assert.AreEqual(0, vm.SearchResults.Count); + Assert.IsNull(vm.SelectedResult); + Assert.IsFalse(vm.IsSearchResultVisible); + Assert.IsFalse(vm.IsSearching); + } +}