diff --git a/BetterGenshinImpact/View/Pages/GatheringAndFarmingPage.xaml b/BetterGenshinImpact/View/Pages/GatheringAndFarmingPage.xaml
new file mode 100644
index 00000000..ee88f877
--- /dev/null
+++ b/BetterGenshinImpact/View/Pages/GatheringAndFarmingPage.xaml
@@ -0,0 +1,528 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 统一收纳角色培养素材、食材与特产、掉落物和矿石的快捷入口。角色页会展示 3 个常用升级素材,点击素材图标后会先向用户确认,再执行自动匹配到的地图追踪任务。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 素材名称会优先按任务文件名精确匹配,其次按相对路径模糊匹配。若未找到路线,请先在脚本仓库或地图追踪页准备对应任务。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/BetterGenshinImpact/View/Pages/GatheringAndFarmingPage.xaml.cs b/BetterGenshinImpact/View/Pages/GatheringAndFarmingPage.xaml.cs
new file mode 100644
index 00000000..d768cedd
--- /dev/null
+++ b/BetterGenshinImpact/View/Pages/GatheringAndFarmingPage.xaml.cs
@@ -0,0 +1,15 @@
+using BetterGenshinImpact.ViewModel.Pages;
+using System.Windows.Controls;
+
+namespace BetterGenshinImpact.View.Pages;
+
+public partial class GatheringAndFarmingPage : UserControl
+{
+ private GatheringAndFarmingPageViewModel ViewModel { get; }
+
+ public GatheringAndFarmingPage(GatheringAndFarmingPageViewModel viewModel)
+ {
+ DataContext = ViewModel = viewModel;
+ InitializeComponent();
+ }
+}
diff --git a/BetterGenshinImpact/ViewModel/Pages/GatheringAndFarmingPageViewModel.cs b/BetterGenshinImpact/ViewModel/Pages/GatheringAndFarmingPageViewModel.cs
new file mode 100644
index 00000000..652894ca
--- /dev/null
+++ b/BetterGenshinImpact/ViewModel/Pages/GatheringAndFarmingPageViewModel.cs
@@ -0,0 +1,448 @@
+using BetterGenshinImpact.Core.Script;
+using BetterGenshinImpact.Core.Script.Group;
+using BetterGenshinImpact.Service.Interface;
+using BetterGenshinImpact.View.Pages;
+using BetterGenshinImpact.View.Windows;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.Extensions.Logging;
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using System.Windows.Media;
+using Wpf.Ui;
+using Wpf.Ui.Violeta.Controls;
+
+namespace BetterGenshinImpact.ViewModel.Pages;
+
+public partial class GatheringAndFarmingPageViewModel : ViewModel
+{
+ private readonly ILogger _logger = App.GetLogger();
+ private readonly IScriptService _scriptService;
+ private readonly INavigationService _navigationService;
+ private readonly List _pathingTaskIndex = [];
+ private bool _isInitialized;
+
+ [ObservableProperty] private ObservableCollection _characters = [];
+ [ObservableProperty] private ObservableCollection _foodAndSpecialtySections = [];
+ [ObservableProperty] private ObservableCollection _dropItems = [];
+ [ObservableProperty] private ObservableCollection _oreItems = [];
+ [ObservableProperty] private int _availablePathingTaskCount;
+ [ObservableProperty] private string _taskIndexSummary = "尚未扫描路线";
+
+ public int TrackEntryCount =>
+ Characters.Sum(x => x.Materials.Count)
+ + FoodAndSpecialtySections.Sum(x => x.Items.Count)
+ + DropItems.Count
+ + OreItems.Count;
+
+ public GatheringAndFarmingPageViewModel(IScriptService scriptService, INavigationService navigationService)
+ {
+ _scriptService = scriptService;
+ _navigationService = navigationService;
+ }
+
+ public override void OnNavigatedTo()
+ {
+ if (_isInitialized)
+ {
+ return;
+ }
+
+ BuildDesignData();
+ _ = RefreshPathingIndexAsync();
+ _isInitialized = true;
+ }
+
+ [RelayCommand]
+ private void OpenMapPathing()
+ {
+ _navigationService.Navigate(typeof(MapPathingPage));
+ }
+
+ [RelayCommand]
+ private void OpenScriptRepo()
+ {
+ ScriptRepoUpdater.Instance.OpenScriptRepoWindow();
+ }
+
+ [RelayCommand]
+ private async Task RefreshPathingIndexAsync()
+ {
+ try
+ {
+ var pathingRoot = MapPathingViewModel.PathJsonPath;
+ if (!Directory.Exists(pathingRoot))
+ {
+ _pathingTaskIndex.Clear();
+ AvailablePathingTaskCount = 0;
+ TaskIndexSummary = "未找到本地地图追踪目录,编译后首次运行会自动生成。";
+ return;
+ }
+
+ var files = await Task.Run(() => Directory
+ .EnumerateFiles(pathingRoot, "*.json", SearchOption.AllDirectories)
+ .Select(path =>
+ {
+ var relativePath = Path.GetRelativePath(pathingRoot, path);
+ return new PathingTaskIndexEntry(
+ path,
+ relativePath.Replace('/', '\\'),
+ Path.GetFileNameWithoutExtension(path));
+ })
+ .OrderBy(x => x.RelativePath, StringComparer.OrdinalIgnoreCase)
+ .ToList());
+
+ _pathingTaskIndex.Clear();
+ _pathingTaskIndex.AddRange(files);
+ AvailablePathingTaskCount = files.Count;
+ TaskIndexSummary = files.Count == 0
+ ? "已扫描完成,但本地还没有可执行的地图追踪任务。"
+ : $"已完成扫描,可直接匹配 {files.Count} 条地图追踪任务。";
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "刷新采集与锄地路线索引失败");
+ TaskIndexSummary = $"路线索引刷新失败:{ex.Message}";
+ Toast.Error($"路线索引刷新失败:{ex.Message}");
+ }
+ }
+
+ [RelayCommand]
+ private async Task TrackMaterialAsync(GatherTrackItem? item)
+ {
+ if (item == null)
+ {
+ return;
+ }
+
+ if (_pathingTaskIndex.Count == 0)
+ {
+ await RefreshPathingIndexAsync();
+ }
+
+ var matchedTask = FindBestPathingTask(item.SearchKeywords);
+ if (matchedTask == null)
+ {
+ await ThemedMessageBox.WarningAsync(
+ $"未找到与“{item.Name}”匹配的地图追踪任务。\n\n你可以先在“脚本仓库”或“地图追踪”中准备对应路线,然后再从这里一键执行。",
+ "未找到路线");
+ return;
+ }
+
+ var result = await ThemedMessageBox.QuestionAsync(
+ $"是否执行“{item.Name}”对应的地图追踪任务?\n\n匹配到的路线:{matchedTask.RelativePath}",
+ "执行地图追踪",
+ System.Windows.MessageBoxButton.YesNo,
+ System.Windows.MessageBoxResult.No);
+
+ if (result != System.Windows.MessageBoxResult.Yes)
+ {
+ return;
+ }
+
+ try
+ {
+ var project = BuildPathingProject(matchedTask.FullPath);
+ await _scriptService.RunMulti([project]);
+ Toast.Success($"已开始执行路线:{Path.GetFileNameWithoutExtension(matchedTask.FullPath)}");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "执行素材路线失败: {MaterialName}", item.Name);
+ await ThemedMessageBox.ErrorAsync($"执行“{item.Name}”失败:{ex.Message}", "执行失败");
+ }
+ }
+
+ private PathingTaskIndexEntry? FindBestPathingTask(IEnumerable keywords)
+ {
+ var keywordList = keywords
+ .Where(x => !string.IsNullOrWhiteSpace(x))
+ .Select(x => x.Trim())
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ if (keywordList.Count == 0)
+ {
+ return null;
+ }
+
+ return _pathingTaskIndex
+ .Select(entry => new
+ {
+ Entry = entry,
+ Score = keywordList.Max(keyword => ScorePathingTask(entry, keyword))
+ })
+ .Where(x => x.Score > 0)
+ .OrderByDescending(x => x.Score)
+ .ThenBy(x => x.Entry.RelativePath.Length)
+ .ThenBy(x => x.Entry.RelativePath, StringComparer.OrdinalIgnoreCase)
+ .Select(x => x.Entry)
+ .FirstOrDefault();
+ }
+
+ private static int ScorePathingTask(PathingTaskIndexEntry entry, string keyword)
+ {
+ if (entry.FileName.Equals(keyword, StringComparison.OrdinalIgnoreCase))
+ {
+ return 100;
+ }
+
+ if (entry.RelativePath.EndsWith(keyword + ".json", StringComparison.OrdinalIgnoreCase))
+ {
+ return 90;
+ }
+
+ if (entry.FileName.Contains(keyword, StringComparison.OrdinalIgnoreCase))
+ {
+ return 75;
+ }
+
+ if (entry.RelativePath.Contains(keyword, StringComparison.OrdinalIgnoreCase))
+ {
+ return 60;
+ }
+
+ return 0;
+ }
+
+ private static ScriptGroupProject BuildPathingProject(string fullPath)
+ {
+ var folder = Path.GetDirectoryName(fullPath) ?? MapPathingViewModel.PathJsonPath;
+ var relativeFolder = Path.GetRelativePath(MapPathingViewModel.PathJsonPath, folder);
+ if (relativeFolder == ".")
+ {
+ relativeFolder = string.Empty;
+ }
+
+ return ScriptGroupProject.BuildPathingProject(Path.GetFileName(fullPath), relativeFolder);
+ }
+
+ private void BuildDesignData()
+ {
+ Characters =
+ [
+ new GatherCharacterCard(
+ "纳西妲",
+ "须弥 · 草系主C/辅助",
+ "纳",
+ CreatePalette("#84C26E", "#1E3523"),
+ [
+ CreateTrack("月莲", "水边与林地特产路线", "月", "#84C26E", "#1E3523"),
+ CreateTrack("蕈兽", "浮游菌群常规掉落路线", "蕈", "#6BB9A0", "#1B342F", "蕈兽", "浮游菌"),
+ CreateTrack("树莓", "顺路补货的常用食材路线", "莓", "#D36B7F", "#3A1D25")
+ ]),
+ new GatherCharacterCard(
+ "芙宁娜",
+ "枫丹 · 水系辅助",
+ "芙",
+ CreatePalette("#7EB7F3", "#1D2E45"),
+ [
+ CreateTrack("湖光铃兰", "枫丹湖区特产路线", "铃", "#7EB7F3", "#1D2E45"),
+ CreateTrack("原海异种", "海边通刷掉落路线", "海", "#5AA4D6", "#183247", "原海异种", "异海凝珠"),
+ CreateTrack("萃凝晶", "枫丹矿石补货路线", "晶", "#82C9FF", "#20354A")
+ ]),
+ new GatherCharacterCard(
+ "雷电将军",
+ "稻妻 · 充能爆发核心",
+ "雷",
+ CreatePalette("#A887F7", "#2B2144"),
+ [
+ CreateTrack("天云草实", "清籁岛高密度特产路线", "云", "#A887F7", "#2B2144"),
+ CreateTrack("野伏众", "稻妻常刷掉落路线", "伏", "#E09A5E", "#442817", "野伏众", "刀镡"),
+ CreateTrack("紫晶块", "稻妻锻造矿石路线", "紫", "#B498FF", "#322650")
+ ]),
+ new GatherCharacterCard(
+ "钟离",
+ "璃月 · 护盾辅助",
+ "钟",
+ CreatePalette("#D3A65C", "#43301A"),
+ [
+ CreateTrack("石珀", "璃月山体特产路线", "珀", "#D3A65C", "#43301A"),
+ CreateTrack("史莱姆", "通用掉落补货路线", "史", "#73C7C0", "#1C3737", "史莱姆", "史莱姆凝液"),
+ CreateTrack("白铁块", "璃月常用矿石路线", "铁", "#C7D0DA", "#2B3440")
+ ]),
+ new GatherCharacterCard(
+ "那维莱特",
+ "枫丹 · 水系站场",
+ "那",
+ CreatePalette("#68C1E0", "#183743"),
+ [
+ CreateTrack("湖光铃兰", "角色专属特产路线", "铃", "#68C1E0", "#183743"),
+ CreateTrack("原海异种", "海边通刷掉落路线", "海", "#68C1E0", "#183743", "原海异种", "异海凝珠"),
+ CreateTrack("水晶块", "精锻魔矿主力矿线", "晶", "#8FD2F2", "#213A49")
+ ]),
+ new GatherCharacterCard(
+ "娜维娅",
+ "枫丹 · 岩系输出",
+ "娜",
+ CreatePalette("#E0B765", "#45371A"),
+ [
+ CreateTrack("苍晶螺", "枫丹海岸特产路线", "螺", "#73C0D4", "#203743"),
+ CreateTrack("发条机关", "枫丹机械系掉落路线", "机", "#D6A86A", "#45301E"),
+ CreateTrack("白铁块", "锻造补货矿石路线", "铁", "#C7D0DA", "#2B3440")
+ ])
+ ];
+
+ FoodAndSpecialtySections =
+ [
+ new GatherTrackSection(
+ "地方特产",
+ "更适合按地区分批补货,适合角色突破前集中准备。",
+ [
+ CreateTrack("月莲", "须弥水边高密度路线", "月", "#84C26E", "#1E3523"),
+ CreateTrack("清心", "璃月高山特产路线", "清", "#7CC6E6", "#183543"),
+ CreateTrack("苍晶螺", "枫丹海岸特产路线", "螺", "#73C0D4", "#203743"),
+ CreateTrack("湖光铃兰", "枫丹湖区采集路线", "铃", "#7EB7F3", "#1D2E45"),
+ CreateTrack("石珀", "璃月矿壁采集路线", "珀", "#D3A65C", "#43301A"),
+ CreateTrack("天云草实", "清籁岛采集路线", "云", "#A887F7", "#2B2144")
+ ]),
+ new GatherTrackSection(
+ "食材",
+ "适合烹饪、周本前补货或顺路囤积。",
+ [
+ CreateTrack("甜甜花", "通用食材顺路收集", "甜", "#E38B98", "#452128"),
+ CreateTrack("松茸", "林地食材补货路线", "茸", "#D6A86A", "#433118"),
+ CreateTrack("树莓", "前期高频食材路线", "莓", "#D36B7F", "#3A1D25"),
+ CreateTrack("莲蓬", "璃月水域食材路线", "莲", "#7DC0B7", "#1C3533"),
+ CreateTrack("日落果", "蒙德野外食材路线", "果", "#E49B67", "#472818"),
+ CreateTrack("鱼肉", "沿河补货路线", "鱼", "#6AB6E6", "#1C3042")
+ ])
+ ];
+
+ DropItems =
+ [
+ CreateTrack("丘丘人射手", "弓手掉落集中清线", "丘", "#C98A5B", "#3A2518", "丘丘人射手", "丘丘人"),
+ CreateTrack("史莱姆", "元素凝液快速补货", "史", "#73C7C0", "#1C3737", "史莱姆", "史莱姆凝液"),
+ CreateTrack("蕈兽", "真菌孢子常用路线", "蕈", "#6BB9A0", "#1B342F", "蕈兽", "浮游菌"),
+ CreateTrack("发条机关", "枫丹机械掉落路线", "机", "#D6A86A", "#45301E"),
+ CreateTrack("遗迹守卫", "机关核心补货路线", "遗", "#8E9EB5", "#26313F"),
+ CreateTrack("原海异种", "海边材料通刷路线", "海", "#5AA4D6", "#183247", "原海异种", "异海凝珠")
+ ];
+
+ OreItems =
+ [
+ CreateTrack("白铁块", "日常锻造基础矿线", "铁", "#C7D0DA", "#2B3440"),
+ CreateTrack("水晶块", "精锻魔矿主力矿线", "晶", "#8FD2F2", "#213A49"),
+ CreateTrack("紫晶块", "稻妻矿石补货路线", "紫", "#B498FF", "#322650"),
+ CreateTrack("星银矿石", "雪山专属矿石路线", "银", "#9FC6DC", "#213545"),
+ CreateTrack("萃凝晶", "枫丹矿石补货路线", "凝", "#82C9FF", "#20354A"),
+ CreateTrack("铁块", "早期锻造补货路线", "块", "#B4BDC8", "#29323D")
+ ];
+
+ OnPropertyChanged(nameof(TrackEntryCount));
+ }
+
+ private static GatherTrackItem CreateTrack(
+ string name,
+ string description,
+ string shortLabel,
+ string accentHex,
+ string surfaceHex,
+ params string[] keywords)
+ {
+ var palette = CreatePalette(accentHex, surfaceHex);
+ return new GatherTrackItem(
+ name,
+ description,
+ shortLabel,
+ palette.AccentBrush,
+ palette.SurfaceBrush,
+ keywords.Length == 0 ? [name] : keywords);
+ }
+
+ private static GatherPalette CreatePalette(string accentHex, string surfaceHex)
+ {
+ return new GatherPalette(CreateBrush(accentHex), CreateBrush(surfaceHex));
+ }
+
+ private static Brush CreateBrush(string hex)
+ {
+ var color = (Color)ColorConverter.ConvertFromString(hex)!;
+ var brush = new SolidColorBrush(color);
+ brush.Freeze();
+ return brush;
+ }
+
+ private sealed record PathingTaskIndexEntry(string FullPath, string RelativePath, string FileName);
+}
+
+public sealed class GatherPalette
+{
+ public Brush AccentBrush { get; }
+ public Brush SurfaceBrush { get; }
+
+ public GatherPalette(Brush accentBrush, Brush surfaceBrush)
+ {
+ AccentBrush = accentBrush;
+ SurfaceBrush = surfaceBrush;
+ }
+}
+
+public sealed class GatherCharacterCard
+{
+ public string Name { get; }
+ public string Subtitle { get; }
+ public string AvatarText { get; }
+ public Brush AccentBrush { get; }
+ public Brush AccentSurfaceBrush { get; }
+ public ObservableCollection Materials { get; }
+
+ public GatherCharacterCard(
+ string name,
+ string subtitle,
+ string avatarText,
+ GatherPalette palette,
+ IEnumerable materials)
+ {
+ Name = name;
+ Subtitle = subtitle;
+ AvatarText = avatarText;
+ AccentBrush = palette.AccentBrush;
+ AccentSurfaceBrush = palette.SurfaceBrush;
+ Materials = new ObservableCollection(materials);
+ }
+}
+
+public sealed class GatherTrackSection
+{
+ public string Title { get; }
+ public string Description { get; }
+ public ObservableCollection Items { get; }
+
+ public GatherTrackSection(string title, string description, IEnumerable items)
+ {
+ Title = title;
+ Description = description;
+ Items = new ObservableCollection(items);
+ }
+}
+
+public sealed class GatherTrackItem
+{
+ public string Name { get; }
+ public string Description { get; }
+ public string ShortLabel { get; }
+ public Brush AccentBrush { get; }
+ public Brush AccentSurfaceBrush { get; }
+ public IReadOnlyList SearchKeywords { get; }
+
+ public GatherTrackItem(
+ string name,
+ string description,
+ string shortLabel,
+ Brush accentBrush,
+ Brush accentSurfaceBrush,
+ IReadOnlyList searchKeywords)
+ {
+ Name = name;
+ Description = description;
+ ShortLabel = shortLabel;
+ AccentBrush = accentBrush;
+ AccentSurfaceBrush = accentSurfaceBrush;
+ SearchKeywords = searchKeywords;
+ }
+}