# 桌面宠物实现计划 > **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。 **目标:** 构建一款 Windows 桌面宠物应用,展现用户 CFC 系统的五维能量状态、任务完成情况,并支持与用户互动完成打卡任务(如喝水提醒、任务完成确认)。应用使用 WPF + WebView2 架构,支持用户自定义形象和自定义状态。 **架构:** WPF (.NET 6) 主进程 + WebView2 渲染层。主进程负责 API 调用(CFC 后端 localhost:9082)、状态管理、托盘图标与 JWT 缓存;WebView2 负责精灵渲染、CSS 动画、用户交互。通过 `window.cfcPetAPI` Bridge 实现 C# ↔ JavaScript 双向通信。 **技术栈:** - UI 框架:WPF (.NET 6, Windows 专用) - 渲染引擎:WebView2(Edge 内核,Win10+ 预装) - API 调用:HttpClient + HttpClientFactory,JWT 缓存 - 动画:CSS Animation + JavaScript - 配置存储:JSON 文件 + 用户配置目录 - 打包:MSIX / ClickOnce **构建环境:** WPF 仅支持 Windows,需在 Windows 机器(.NET 6 SDK)上 `dotnet build` / `dotnet test`。本仓库其余部分为 Java/前端,桌面宠物是独立新目录 `cfc-desktop-pet/`,不影响现有模块。 **规格文档:** `docs/superpowers/specs/2026-10-10-desktop-pet-design.md` --- ## 任务 1:解决方案骨架 + WPF 主项目 **文件:** - 创建:`cfc-desktop-pet/XafPet.Desktop.sln` - 创建:`cfc-desktop-pet/src/XafPet.Desktop/XafPet.Desktop.csproj` - 创建:`cfc-desktop-pet/src/XafPet.Desktop/App.xaml` / `App.xaml.cs` - 创建:`cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml` / `MainWindow.xaml.cs` - 创建:`cfc-desktop-pet/tests/XafPet.Desktop.Tests/XafPet.Desktop.Tests.csproj` - [ ] **步骤 1:创建目录结构** ```bash mkdir -p cfc-desktop-pet/src/XafPet.Desktop/PetEngine \ cfc-desktop-pet/src/XafPet.Desktop/Assets \ cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/xibao \ cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/fubao \ cfc-desktop-pet/tests/XafPet.Desktop.Tests ``` - [ ] **步骤 2:创建 WPF 项目文件** `cfc-desktop-pet/src/XafPet.Desktop/XafPet.Desktop.csproj`: ```xml WinExe net6.0-windows enable true enable XafPet.Desktop XafPet.Desktop ``` - [ ] **步骤 3:创建 App 入口** `cfc-desktop-pet/src/XafPet.Desktop/App.xaml`: ```xml ``` `cfc-desktop-pet/src/XafPet.Desktop/App.xaml.cs`: ```csharp using System.Windows; namespace XafPet.Desktop; public partial class App : Application { } ``` - [ ] **步骤 4:创建主窗口(透明置顶,占位内容)** `cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml`: ```xml ``` `cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml.cs`: ```csharp using System.Windows; using System.Input.Mouse; namespace XafPet.Desktop; public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); RootGrid.MouseLeftButtonDown += (s, e) => { if (e.ButtonState == MouseButtonState.Pressed) DragMove(); }; } } ``` - [ ] **步骤 5:创建测试项目** `cfc-desktop-pet/tests/XafPet.Desktop.Tests/XafPet.Desktop.Tests.csproj`: ```xml net6.0-windows enable enable false ``` - [ ] **步骤 6:创建解决方案文件并添加项目** ```bash cd cfc-desktop-pet dotnet new sln -n XafPet.Desktop dotnet sln add src/XafPet.Desktop/XafPet.Desktop.csproj dotnet sln add tests/XafPet.Desktop.Tests/XafPet.Desktop.Tests.csproj ``` - [ ] **步骤 7:编译验证** ```bash cd cfc-desktop-pet dotnet build XafPet.Desktop.sln ``` 预期:`Build succeeded`,0 错误。 - [ ] **步骤 8:Commit** ```bash git add cfc-desktop-pet/ git commit -m "feat: 桌面宠物解决方案骨架 (WPF + WebView2 + xUnit)" ``` --- ## 任务 2:宠物状态枚举 + 形象配置模型 **文件:** - 创建:`cfc-desktop-pet/src/XafPet.Desktop/PetEngine/PetState.cs` - 创建:`cfc-desktop-pet/src/XafPet.Desktop/PetEngine/PetConfig.cs` - 测试:`cfc-desktop-pet/tests/XafPet.Desktop.Tests/PetConfigTests.cs` - [ ] **步骤 1:编写失败的测试** `cfc-desktop-pet/tests/XafPet.Desktop.Tests/PetConfigTests.cs`: ```csharp using System.Text.Json; using XafPet.Desktop.PetEngine; using Xunit; namespace XafPet.Desktop.Tests; public class PetConfigTests { private const string ValidJson = """ { "version": "1.0", "type": "animal", "species": "cockatiel", "gender": "female", "image_base_url": "./DefaultPets/xibao/", "default_image": "standing.png", "actions": { "idle": "wings_idle", "happy": "wings_spread", "custom_state_1": "user_animation_1" }, "parameters": { "move_distance": 15, "animation_speed": 1.0, "interaction_timeout": 300 } } """; [Fact] public void Parse_ValidJson_LoadsAllFields() { var config = PetConfig.Parse(ValidJson); Assert.Equal("1.0", config.Version); Assert.Equal(PetType.Animal, config.Type); Assert.Equal("cockatiel", config.Species); Assert.Equal("female", config.Gender); Assert.Equal("./DefaultPets/xibao/", config.ImageBaseUrl); Assert.Equal("standing.png", config.DefaultImage); Assert.Equal("wings_idle", config.Actions["idle"]); Assert.Equal(15, config.Parameters.MoveDistance); } [Fact] public void Parse_MissingActionsKey_UsesDefaults() { var json = """{ "version": "1.0", "type": "human", "image_base_url": "./pets/", "default_image": "a.png" }"""; var config = PetConfig.Parse(json); Assert.Equal("idle", config.Actions["idle"]); } [Fact] public void Parse_InvalidJson_ThrowsJsonException() { Assert.Throws(() => PetConfig.Parse("{ not valid json")); } [Fact] public void Parse_CustomStates_CountedCorrectly() { var config = PetConfig.Parse(ValidJson); Assert.Equal(1, config.CustomStateCount); Assert.Equal("user_animation_1", config.GetActionForState("custom_state_1")); } } ``` - [ ] **步骤 2:运行测试确认失败** ```bash cd cfc-desktop-pet dotnet test tests/XafPet.Desktop.Tests --filter "FullyQualifiedName~PetConfigTests" -v ``` 预期:FAIL — `PetConfig` 类型不存在。 - [ ] **步骤 3:实现 PetState.cs** `cfc-desktop-pet/src/XafPet.Desktop/PetEngine/PetState.cs`: ```csharp namespace XafPet.Desktop.PetEngine; /// 宠物核心状态枚举(C# 侧定义,JS 侧同名接收) public enum PetState { Idle, // 默认 Happy, // 任务全完成 Busy, // 有任务进行中 Worried, // 任务超期未完成 Excited, // 健康打卡完成 Remind, // 提醒用户(喝水/任务) Energetic, // 身维度能量高 Tired, // 身维度能量低 Curious, // 智维度能量高 Sad, // 全维度低 Sleeping, // 用户无操作 >15min Watching, // 用户活跃,注视光标 WaitingForInteraction, // 等待用户互动(打卡确认) MiniGame // 小游戏模式 } /// 推送 WebView 的状态负载 public sealed record PetStatePayload( string State, // 枚举名 camelCase:如 "waitingForInteraction" string? Reason, // 可选:如 "drink_water"、"task_complete" object? Data // 可选:任务列表/能量值等 ); ``` - [ ] **步骤 4:实现 PetConfig.cs** `cfc-desktop-pet/src/XafPet.Desktop/PetEngine/PetConfig.cs`: ```csharp using System.Text.Json; using System.Text.Json.Serialization; namespace XafPet.Desktop.PetEngine; public enum PetType { Animal, Human, Custom } /// 宠物形象配置(pet-config.json) public sealed class PetConfig { [JsonPropertyName("version")] public string Version { get; set; } = "1.0"; [JsonPropertyName("type")] public PetType Type { get; set; } = PetType.Custom; [JsonPropertyName("species")] public string Species { get; set; } = "other"; [JsonPropertyName("gender")] public string Gender { get; set; } = "neutral"; [JsonPropertyName("image_base_url")] public string ImageBaseUrl { get; set; } = ""; [JsonPropertyName("default_image")] public string DefaultImage { get; set; } = "standing.png"; [JsonPropertyName("actions")] public Dictionary Actions { get; set; } = new(); [JsonPropertyName("sounds")] public Dictionary Sounds { get; set; } = new(); [JsonPropertyName("parameters")] public PetParameters Parameters { get; set; } = new(); [JsonIgnore] public int CustomStateCount => Actions.Keys.Count(k => k.StartsWith("custom_state_")); /// 解析 JSON 字符串;缺省 actions 时补默认映射(key=value) public static PetConfig Parse(string json) { var config = JsonSerializer.Deserialize(json) ?? throw new JsonException("pet-config.json 反序列化结果为 null"); if (config.Actions.Count == 0) { config.Actions = new Dictionary { ["idle"] = "idle", ["happy"] = "happy", ["busy"] = "busy", ["sleeping"] = "sleeping" }; } return config; } public string GetActionForState(string stateName) => Actions.TryGetValue(stateName, out var v) ? v : stateName; } public sealed class PetParameters { [JsonPropertyName("move_distance")] public int MoveDistance { get; set; } = 15; [JsonPropertyName("animation_speed")] public double AnimationSpeed { get; set; } = 1.0; [JsonPropertyName("interaction_timeout")] public int InteractionTimeout { get; set; } = 300; // 秒 } ``` - [ ] **步骤 5:运行测试验证通过** ```bash cd cfc-desktop-pet dotnet test tests/XafPet.Desktop.Tests --filter "FullyQualifiedName~PetConfigTests" -v ``` 预期:PASS — 4/4 通过。 - [ ] **步骤 6:Commit** ```bash git add cfc-desktop-pet/ git commit -m "feat: 宠物状态枚举 + 形象配置模型 (PetState/PetConfig)" ``` ## 任务 3:CFC API 客户端 **文件:** - 创建:`cfc-desktop-pet/src/XafPet.Desktop/PetEngine/CfcApiModels.cs` - 创建:`cfc-desktop-pet/src/XafPet.Desktop/PetEngine/ApiClient.cs` - 测试:`cfc-desktop-pet/tests/XafPet.Desktop.Tests/ApiClientTests.cs` - [ ] **步骤 1:编写失败的测试** `cfc-desktop-pet/tests/XafPet.Desktop.Tests/ApiClientTests.cs`: ```csharp using System.Net; using System.Text.Json; using Moq; using Moq.Protected; using XafPet.Desktop.PetEngine; using Xunit; namespace XafPet.Desktop.Tests; public class ApiClientTests { private static ApiClient CreateClientWithResponse(HttpStatusCode code, string json, string authToken = "test-token") { var handler = new Mock(); handler.Protected() .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) .ReturnsAsync(new HttpResponseMessage(code) { Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") }); var http = new HttpClient(handler.Object); return new ApiClient(http, "http://localhost:9082", authToken); } [Fact] public async Task GetTodayTasks_ReturnsTaskList() { var json = """ { "code": 200, "message": "ok", "data": [ { "id": 1, "title": "喝水", "status": "PENDING", "dueDate": "2026-10-10" } ] } """; var client = CreateClientWithResponse(HttpStatusCode.OK, json); var tasks = await client.GetTodayTasksAsync(); Assert.Single(tasks); Assert.Equal("喝水", tasks[0].Title); Assert.Equal("PENDING", tasks[0].Status); } [Fact] public async Task GetEnergyOverview_ReturnsDimensions() { var json = """ { "code": 200, "message": "ok", "data": { "dimensions": [ { "dimension": "身", "value": 80, "color": "#FF8C42" }, { "dimension": "智", "value": 45, "color": "#6366F1" } ] } } """; var client = CreateClientWithResponse(HttpStatusCode.OK, json); var overview = await client.GetEnergyOverviewAsync(); Assert.Equal(2, overview.Dimensions.Count); Assert.Equal(80, overview.Dimensions[0].Value); } [Fact] public async Task GetDailyCheckin_ReturnsStatus() { var json = """ { "code": 200, "message": "ok", "data": { "checked": true, "items": ["water", "exercise"] } } """; var client = CreateClientWithResponse(HttpStatusCode.OK, json); var status = await client.GetDailyCheckinAsync(); Assert.True(status.Checked); Assert.Contains("water", status.Items); } [Fact] public async Task CompleteDrinkWater_PostsCorrectEndpoint() { var json = """{ "code": 200, "message": "打卡成功", "data": null }"""; var client = CreateClientWithResponse(HttpStatusCode.OK, json); var result = await client.CompleteDrinkWaterAsync(); Assert.Equal("打卡成功", result.Message); } [Fact] public async Task NonSuccessCode_ThrowsApiException() { var json = """{ "code": 500, "message": "服务器错误", "data": null }"""; var client = CreateClientWithResponse(HttpStatusCode.OK, json); await Assert.ThrowsAsync(() => client.GetTodayTasksAsync()); } } ``` - [ ] **步骤 2:运行测试确认失败** ```bash cd cfc-desktop-pet dotnet test tests/XafPet.Desktop.Tests --filter "FullyQualifiedName~ApiClientTests" -v ``` 预期:FAIL — `ApiClient`/`ApiException` 不存在。 - [ ] **步骤 3:实现 CfcApiModels.cs(API 响应 DTO)** `cfc-desktop-pet/src/XafPet.Desktop/PetEngine/CfcApiModels.cs`: ```csharp using System.Text.Json.Serialization; namespace XafPet.Desktop.PetEngine; /// CFC 统一响应包装 Result public sealed class ApiResult { [JsonPropertyName("code")] public int Code { get; set; } [JsonPropertyName("message")] public string? Message { get; set; } [JsonPropertyName("data")] public T? Data { get; set; } public bool IsSuccess => Code == 200; } public sealed class CfcTask { [JsonPropertyName("id")] public long Id { get; set; } [JsonPropertyName("title")] public string? Title { get; set; } [JsonPropertyName("status")] public string? Status { get; set; } // PENDING / IN_PROGRESS / COMPLETED / REJECTED [JsonPropertyName("dueDate")] public string? DueDate { get; set; } } public sealed class DimensionValue { [JsonPropertyName("dimension")] public string? Dimension { get; set; } [JsonPropertyName("value")] public int Value { get; set; } [JsonPropertyName("color")] public string? Color { get; set; } } public sealed class EnergyOverview { [JsonPropertyName("dimensions")] public List Dimensions { get; set; } = new(); } public sealed class DailyCheckin { [JsonPropertyName("checked")] public bool Checked { get; set; } [JsonPropertyName("items")] public List Items { get; set; } = new(); } /// API 返回非 200 code 时抛出 public sealed class ApiException : Exception { public int ApiCode { get; } public ApiException(int apiCode, string message) : base(message) { ApiCode = apiCode; } } ``` - [ ] **步骤 4:实现 ApiClient.cs** `cfc-desktop-pet/src/XafPet.Desktop/PetEngine/ApiClient.cs`: ```csharp using System.Net.Http; using System.Text; using System.Text.Json; namespace XafPet.Desktop.PetEngine; /// CFC 后端 API 客户端。所有接口统一 POST + JWT Bearer。 public sealed class ApiClient { private readonly HttpClient _http; private readonly string _baseUrl; private string _token; private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); public ApiClient(HttpClient http, string baseUrl, string token) { _http = http; _baseUrl = baseUrl.TrimEnd('/'); _token = token; } public void UpdateToken(string token) => _token = token; private async Task PostAsync(string path, object? body = null) { using var req = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}{path}"); req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); if (body != null) { req.Content = new StringContent(JsonSerializer.Serialize(body, JsonOpts), Encoding.UTF8, "application/json"); } var resp = await _http.SendAsync(req); var json = await resp.Content.ReadAsStringAsync(); var result = JsonSerializer.Deserialize>(json, JsonOpts) ?? throw new ApiException(-1, "响应解析失败"); if (!result.IsSuccess) throw new ApiException(result.Code, result.Message ?? "未知错误"); return result.Data; } /// POST /api/tasks/today — 今日任务 public Task?> GetTodayTasksAsync() => PostAsync>("/api/tasks/today", new Dictionary()); /// POST /api/energy/overview — 五维能量概览 public Task GetEnergyOverviewAsync() => PostAsync("/api/energy/overview"); /// POST /api/daily/checkin — 今日打卡状态 public Task GetDailyCheckinAsync() => PostAsync("/api/daily/checkin"); /// POST /api/daily/checkin — 完成喝水打卡(body: {"type":"water"}) public async Task?> CompleteDrinkWaterAsync() { using var req = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}/api/daily/checkin"); req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); req.Content = new StringContent("""{"type":"water"}""", Encoding.UTF8, "application/json"); var resp = await _http.SendAsync(req); var json = await resp.Content.ReadAsStringAsync(); var result = JsonSerializer.Deserialize>(json, JsonOpts) ?? throw new ApiException(-1, "响应解析失败"); if (!result.IsSuccess) throw new ApiException(result.Code, result.Message ?? "未知错误"); return result; } } ``` - [ ] **步骤 5:运行测试验证通过** ```bash cd cfc-desktop-pet dotnet test tests/XafPet.Desktop.Tests --filter "FullyQualifiedName~ApiClientTests" -v ``` 预期:PASS — 5/5 通过。 - [ ] **步骤 6:Commit** ```bash git add cfc-desktop-pet/ git commit -m "feat: CFC API 客户端 (今日任务/能量概览/健康打卡)" ``` ## 任务 4:状态管理器(API 数据 → 宠物状态) **文件:** - 创建:`cfc-desktop-pet/src/XafPet.Desktop/PetEngine/StateManager.cs` - 测试:`cfc-desktop-pet/tests/XafPet.Desktop.Tests/StateManagerTests.cs` - [ ] **步骤 1:编写失败的测试** `cfc-desktop-pet/tests/XafPet.Desktop.Tests/StateManagerTests.cs`: ```csharp using XafPet.Desktop.PetEngine; using Xunit; namespace XafPet.Desktop.Tests; public class StateManagerTests { [Fact] public void AllTasksCompleted_ReturnsHappy() { var tasks = new List { new() { Id = 1, Title = "喝水", Status = "COMPLETED" }, new() { Id = 2, Title = "运动", Status = "COMPLETED" } }; var state = StateManager.MapState(tasks, null, null, false); Assert.Equal(PetState.Happy, state); } [Fact] public void SomeTasksPending_ReturnsBusy() { var tasks = new List { new() { Id = 1, Title = "喝水", Status = "COMPLETED" }, new() { Id = 2, Title = "运动", Status = "PENDING" } }; var state = StateManager.MapState(tasks, null, null, false); Assert.Equal(PetState.Busy, state); } [Fact] public void NoTasks_ReturnsIdle() { var state = StateManager.MapState(new List(), null, null, false); Assert.Equal(PetState.Idle, state); } [Fact] public void CheckinDone_OverridesToExcited() { var state = StateManager.MapState(new List(), null, new DailyCheckin { Checked = true, Items = new List { "water" } }, false); Assert.Equal(PetState.Excited, state); } [Fact] public void LowBodyEnergy_ReturnsTired() { var energy = new EnergyOverview { Dimensions = new List { new() { Dimension = "身", Value = 20, Color = "#FF8C42" }, new() { Dimension = "智", Value = 80, Color = "#6366F1" } } }; var state = StateManager.MapState(new List(), energy, null, false); Assert.Equal(PetState.Tired, state); } [Fact] public void AllDimensionsLow_ReturnsSad() { var energy = new EnergyOverview { Dimensions = new List { new() { Dimension = "身", Value = 15, Color = "#FF8C42" }, new() { Dimension = "智", Value = 20, Color = "#6366F1" }, new() { Dimension = "富", Value = 25, Color = "#F59E0B" } } }; var state = StateManager.MapState(new List(), energy, null, false); Assert.Equal(PetState.Sad, state); } [Fact] public void HighBodyEnergy_ReturnsEnergetic() { var energy = new EnergyOverview { Dimensions = new List { new() { Dimension = "身", Value = 90, Color = "#FF8C42" } } }; var state = StateManager.MapState(new List(), energy, null, false); Assert.Equal(PetState.Energetic, state); } [Fact] public void UserInactive_ReturnsSleeping() { var state = StateManager.MapState(new List(), null, null, true); Assert.Equal(PetState.Sleeping, state); } [Fact] public void StateToCamelCase_ConvertsEnumName() { Assert.Equal("waitingForInteraction", StateManager.StateToCamelCase(PetState.WaitingForInteraction)); Assert.Equal("miniGame", StateManager.StateToCamelCase(PetState.MiniGame)); Assert.Equal("idle", StateManager.StateToCamelCase(PetState.Idle)); } } ``` - [ ] **步骤 2:运行测试确认失败** ```bash cd cfc-desktop-pet dotnet test tests/XafPet.Desktop.Tests --filter "FullyQualifiedName~StateManagerTests" -v ``` 预期:FAIL — `StateManager` 不存在。 - [ ] **步骤 3:实现 StateManager.cs** `cfc-desktop-pet/src/XafPet.Desktop/PetEngine/StateManager.cs`: ```csharp using System.Text.RegularExpressions; namespace XafPet.Desktop.PetEngine; /// 把 CFC API 数据转换为宠物状态(纯逻辑,无 UI 依赖) public static class StateManager { /// 核心状态映射。优先级:Sleeping > Excited > Happy > Worried > Busy > Sad > Tired/Energetic/Curious > Idle public static PetState MapState( List? tasks, EnergyOverview? energy, DailyCheckin? checkin, bool userInactive) { // 1. 用户无操作 → 睡眠(最高优先级,除非有未完成任务需要提醒) var hasPendingTasks = tasks?.Any(t => t.Status is "PENDING" or "IN_PROGRESS") ?? false; if (userInactive && !hasPendingTasks) return PetState.Sleeping; // 2. 健康打卡完成 → 兴奋(次高) if (checkin is { Checked: true }) return PetState.Excited; // 3. 任务状态 if (tasks is { Count: > 0 }) { var allDone = tasks.All(t => t.Status == "COMPLETED"); if (allDone) return PetState.Happy; // 超期未完成(dueDate 早于今天且非完成态)→ 担忧 var today = DateOnly.FromDateTime(DateTime.Today); var overdue = tasks.Any(t => t.Status is "PENDING" or "IN_PROGRESS" && DateOnly.TryParse(t.DueDate, out var due) && due < today); if (overdue) return PetState.Worried; return PetState.Busy; } // 4. 五维能量 if (energy is { Dimensions.Count: > 0 }) { var dims = energy.Dimensions; var allLow = dims.All(d => d.Value < 30); if (allLow) return PetState.Sad; var body = dims.FirstOrDefault(d => d.Dimension == "身"); if (body != null && body.Value < 30) return PetState.Tired; if (body != null && body.Value >= 85) return PetState.Energetic; var mind = dims.FirstOrDefault(d => d.Dimension == "智"); if (mind != null && mind.Value >= 85) return PetState.Curious; } return PetState.Idle; } /// 枚举 → camelCase(JS 侧状态名)。WaitingForInteraction → waitingForInteraction public static string StateToCamelCase(PetState state) { var name = state.ToString(); return Regex.Replace(name, "(^[A-Z])", m => m.Value.ToLowerInvariant()); } } ``` - [ ] **步骤 4:运行测试验证通过** ```bash cd cfc-desktop-pet dotnet test tests/XafPet.Desktop.Tests --filter "FullyQualifiedName~StateManagerTests" -v ``` 预期:PASS — 9/9 通过。 - [ ] **步骤 5:Commit** ```bash git add cfc-desktop-pet/ git commit -m "feat: 状态管理器 (API数据→宠物状态映射)" ``` ## 任务 5:默认宠物资产(浠宝/福宝占位 PNG + 配置) **文件:** - 创建:`cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/xibao/pet-config.json` - 创建:`cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/fubao/pet-config.json` - 创建:`cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/xibao/standing.png`(1x1 透明占位) - 创建:`cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/fubao/standing.png`(1x1 透明占位) - [ ] **步骤 1:编写浠宝配置** `cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/xibao/pet-config.json`: ```json { "version": "1.0", "type": "animal", "species": "cockatiel", "gender": "female", "image_base_url": "./DefaultPets/xibao/", "default_image": "standing.png", "actions": { "idle": "wings_idle", "happy": "wings_spread", "busy": "head_tilt", "waitingForInteraction": "hand_gesture", "sleeping": "breath_loop", "celebrate": "celebrate", "sad": "sad_animation" }, "sounds": { "click": "pip.mp3", "complete": "cheer.mp3", "remind": "water_please.mp3" }, "parameters": { "move_distance": 15, "animation_speed": 1.0, "interaction_timeout": 300 } } ``` - [ ] **步骤 2:编写福宝配置** `cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/fubao/pet-config.json`: ```json { "version": "1.0", "type": "animal", "species": "british_shorthair", "gender": "male", "image_base_url": "./DefaultPets/fubao/", "default_image": "standing.png", "actions": { "idle": "tail_wrap", "happy": "belly_show", "busy": "paw_press", "waitingForInteraction": "tail_flick", "sleeping": "breath_loop", "celebrate": "celebrate", "sad": "sad_animation" }, "sounds": { "click": "tap.mp3", "complete": "bell.mp3", "remind": "water_please.mp3" }, "parameters": { "move_distance": 12, "animation_speed": 1.0, "interaction_timeout": 300 } } ``` - [ ] **步骤 3:生成占位 PNG(1x1 透明)** ```bash python3 -c " import struct, zlib def png_chunk(tag, data): c = struct.pack('>I', len(data)) + tag + data return c + struct.pack('>I', zlib.crc32(tag + data) & 0xffffffff) ihdr = struct.pack('>IIBBBBB', 1, 1, 8, 6, 0, 0, 0) raw = b'\x00' + b'\x00\x00\x00\x00' png = b'\x89PNG\r\n\x1a\n' png += png_chunk(b'IHDR', ihdr) png += png_chunk(b'IDAT', zlib.compress(raw)) png += png_chunk(b'IEND', b'') for d in ['cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/xibao', 'cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/fubao']: open(d + '/standing.png', 'wb').write(png) print('written', d + '/standing.png') " ``` - [ ] **步骤 4:验证配置可被 PetConfig 解析** ```bash cd cfc-desktop-pet dotnet test tests/XafPet.Desktop.Tests --filter "FullyQualifiedName~PetConfigTests" -v ``` 预期:PASS(回归确认)。 - [ ] **步骤 5:Commit** ```bash git add cfc-desktop-pet/ git commit -m "feat: 默认宠物资产 (浠宝/福宝 占位配置)" ``` ## 任务 6:WebView2 宿主 + Bridge 注册 **文件:** - 创建:`cfc-desktop-pet/src/XafPet.Desktop/PetEngine/WebViewHost.xaml` / `WebViewHost.xaml.cs` - 修改:`cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml` / `MainWindow.xaml.cs` - [ ] **步骤 1:创建 WebViewHost 控件(XAML)** `cfc-desktop-pet/src/XafPet.Desktop/PetEngine/WebViewHost.xaml`: ```xml ``` - [ ] **步骤 2:实现 WebViewHost 代码(Bridge 注册 + 初始化)** `cfc-desktop-pet/src/XafPet.Desktop/PetEngine/WebViewHost.xaml.cs`: ```csharp using System; using System.IO; using System.Threading.Tasks; using System.Windows.Controls; using Microsoft.Web.WebView2.Core; namespace XafPet.Desktop.PetEngine; public partial class WebViewHost : UserControl { public event Action? InteractionReceived; // action, dataJson public WebViewHost() { InitializeComponent(); Loaded += async (_, _) => await InitializeWebViewAsync(); } private async Task InitializeWebViewAsync() { var env = await CoreWebView2Environment.CreateAsync( userDataFolder: Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "XafPet", "WebView2")); await PetWebView.EnsureCoreWebView2Async(env); PetWebView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false; PetWebView.CoreWebView2.Settings.AreDevToolsEnabled = true; PetWebView.CoreWebView2.Settings.IsStatusBarEnabled = false; PetWebView.CoreWebView2.AddHostObjectToScript("cfcHost", new CfcHostBridge(this)); var htmlPath = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "Assets", "pet.html")); PetWebView.CoreWebView2.Navigate($"file:///{htmlPath.Replace('\\', '/')}"); } /// C# → JS:推送宠物状态 public void NotifyState(string stateJson) { PetWebView.ExecuteScriptAsync($"window.cfcPetAPI?.notifyState({stateJson})"); } /// C# → JS:推送自定义状态 public void SetCustomState(string stateName, string dataJson) { PetWebView.ExecuteScriptAsync( $"window.cfcPetAPI?.setCustomState('{stateName}', {dataJson})"); } /// C# → JS:显示气泡 public void ShowBubble(string text, int durationMs = 5000) { var escaped = text.Replace("\\", "\\\\").Replace("'", "\\'"); PetWebView.ExecuteScriptAsync( $"window.cfcPetAPI?.showBubble('{escaped}', {durationMs})"); } /// C# → JS:加载配置 public void LoadPetConfig(string configJson) { PetWebView.ExecuteScriptAsync($"window.cfcPetAPI?.loadConfig({configJson})"); } internal void RaiseInteraction(string action, string? dataJson) => InteractionReceived?.Invoke(action, dataJson); } internal sealed class CfcHostBridge { private readonly WebViewHost _host; public CfcHostBridge(WebViewHost host) => _host = host; public void onInteract(string action, string dataJson) => _host.RaiseInteraction(action, dataJson); public void onDragStart() { } public void onDragEnd() { } } ``` - [ ] **步骤 3:创建 pet.html** `cfc-desktop-pet/src/XafPet.Desktop/Assets/pet.html`: ```html Xaf Pet
pet
``` - [ ] **步骤 4:修改 MainWindow 使用 WebViewHost** `cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml`: ```xml ``` `cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml.cs`: ```csharp using System.Windows; using System.Windows.Input; namespace XafPet.Desktop; public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); RootGrid.MouseLeftButtonDown += (s, e) => { if (e.ButtonState == MouseButtonState.Pressed) DragMove(); }; Loaded += async (_, _) => { await Task.Delay(2000); PetHost.NotifyState("{\"state\":\"happy\",\"reason\":\"welcome\"}"); }; } } ``` - [ ] **步骤 5:编译验证** ```bash cd cfc-desktop-pet dotnet build XafPet.Desktop.sln ``` 预期:`Build succeeded`,0 错误。 - [ ] **步骤 6:Commit** ```bash git add cfc-desktop-pet/ git commit -m "feat: WebView2 宿主 + C#/JS Bridge 注册" ``` ## 任务 7:宠物引擎 JS(状态机 + 动画) **文件:** - 创建:`cfc-desktop-pet/src/XafPet.Desktop/Assets/pet-engine.js` - 创建:`cfc-desktop-pet/src/XafPet.Desktop/Assets/animations.css` - 创建:`cfc-desktop-pet/src/XafPet.Desktop/Assets/interactions.js` - [ ] **步骤 1:创建 animations.css** `cfc-desktop-pet/src/XafPet.Desktop/Assets/animations.css`: ```css /* ===== 浠艾福桌面宠物动画定义 ===== */ @keyframes wings_idle { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(-3px) } } @keyframes wings_spread { 0% { transform: scale(1) } 30% { transform: scale(1.15, 0.95) } 70% { transform: scale(0.95, 1.1) } 100% { transform: scale(1) } } @keyframes head_tilt { 0%, 100% { transform: rotate(0deg) } 25% { transform: rotate(-8deg) } 75% { transform: rotate(6deg) } } @keyframes hand_gesture { 0%, 100% { transform: translateY(0) } 20% { transform: translateY(-8px) } 40% { transform: translateY(0) } 60% { transform: translateY(-5px) } 80% { transform: translateY(0) } } @keyframes breath_loop { 0%, 100% { transform: scale(1, 1) } 50% { transform: scale(1.04, 0.96) } } @keyframes celebrate { 0%, 100% { transform: translateY(0) rotate(0) } 25% { transform: translateY(-16px) rotate(-4deg) } 50% { transform: translateY(0) rotate(0) } 75% { transform: translateY(-16px) rotate(4deg) } } @keyframes sad_animation { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(2px) } } @keyframes tail_wrap { 0%, 100% { transform: rotate(0deg) } 50% { transform: rotate(3deg) } } @keyframes belly_show { 0%, 100% { transform: rotate(0deg) scale(1) } 50% { transform: rotate(-4deg) scale(1.05) } } @keyframes paw_press { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(3px) } } @keyframes tail_flick { 0%, 100% { transform: rotate(0deg) } 30% { transform: rotate(10deg) } 60% { transform: rotate(-10deg) } } @keyframes blink_eye { 0%, 90%, 100% { transform: scaleY(1) } 95% { transform: scaleY(0.1) } } @keyframes look_around { 0%, 100% { transform: rotate(0deg) } 25% { transform: rotate(-12deg) } 75% { transform: rotate(12deg) } } @keyframes wave { 0%, 100% { transform: rotate(0deg) } 25% { transform: rotate(-15deg) } 50% { transform: rotate(10deg) } 75% { transform: rotate(-15deg) } } @keyframes stretch { 0%, 100% { transform: scaleY(1) } 50% { transform: scaleY(1.15) } } @keyframes clap { 0%, 100% { transform: scale(1) } 40% { transform: scale(1.1, 0.9) } 70% { transform: scale(0.95, 1.05) } } @keyframes bow { 0%, 100% { transform: rotate(0deg) } 50% { transform: rotate(10deg) } } @keyframes whistle { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(-4px) } } @keyframes ear_scrape { 0%, 100% { transform: rotate(0deg) } 50% { transform: rotate(6deg) } } @keyframes pet_touch { 0% { transform: scale(1) } 50% { transform: scale(1.08) } 100% { transform: scale(1) } } @keyframes puzzle_active { 0%, 100% { transform: rotate(0deg) } 50% { transform: rotate(180deg) } } @keyframes worried_walk { 0%, 100% { transform: translateX(0) } 25% { transform: translateX(6px) } 75% { transform: translateX(-6px) } } .anim-wings_idle { animation: wings_idle 2s ease-in-out infinite; } .anim-wings_spread { animation: wings_spread 1.2s ease-in-out 1; } .anim-head_tilt { animation: head_tilt 1.6s ease-in-out infinite; } .anim-hand_gesture { animation: hand_gesture 1.8s ease-in-out infinite; } .anim-breath_loop { animation: breath_loop 2.4s ease-in-out infinite; } .anim-celebrate { animation: celebrate 0.8s ease-in-out 3; } .anim-sad_animation { animation: sad_animation 3s ease-in-out infinite; } .anim-tail_wrap { animation: tail_wrap 2s ease-in-out infinite; } .anim-belly_show { animation: belly_show 1.4s ease-in-out 2; } .anim-paw_press { animation: paw_press 1s ease-in-out 2; } .anim-tail_flick { animation: tail_flick 1.5s ease-in-out 2; } .anim-blink_eye { animation: blink_eye 3.5s ease-in-out infinite; } .anim-look_around { animation: look_around 2s ease-in-out 1; } .anim-wave { animation: wave 1.2s ease-in-out 2; } .anim-stretch { animation: stretch 1.8s ease-in-out 1; } .anim-clap { animation: clap 0.6s ease-in-out 3; } .anim-bow { animation: bow 1.2s ease-in-out 2; } .anim-whistle { animation: whistle 0.9s ease-in-out 3; } .anim-ear_scrape { animation: ear_scrape 1s ease-in-out 2; } .anim-pet_touch { animation: pet_touch 0.6s ease-in-out 1; } .anim-puzzle_active { animation: puzzle_active 1s ease-in-out infinite; } .anim-worried_walk { animation: worried_walk 1.2s ease-in-out infinite; } ``` - [ ] **步骤 2:创建 pet-engine.js** `cfc-desktop-pet/src/XafPet.Desktop/Assets/pet-engine.js`: ```javascript (function () { 'use strict'; const petImg = document.getElementById('pet-img'); const petEl = document.getElementById('pet'); const bubble = document.getElementById('bubble'); let currentState = 'idle'; let currentAnimation = null; let config = null; let bubbleTimer = null; const DEFAULT_IMAGE = 'data:image/svg+xml;utf8,' + encodeURIComponent('' + '🦜'); const BUBBLE_TEXTS = { happy: '完成了!继续保持~', busy: '有任务在等你呢~', worried: '任务要过期啦,快去看看!', excited: '打卡成功,太棒了!', remind: '该喝水啦~', tired: '有点累了呢…', sad: '要照顾好自己哦…', waitingForInteraction: '来和我互动一下吧!', miniGame: '我们一起玩吧!', sleeping: 'zZ…', custom: '自定义状态' }; window.cfcPetAPI = { notifyState: function (stateJson) { const payload = typeof stateJson === 'string' ? JSON.parse(stateJson) : stateJson; setState(payload.state, payload.reason || null, payload.data || null); }, setCustomState: function (stateName, dataJson) { const data = typeof dataJson === 'string' ? JSON.parse(dataJson) : dataJson; setState(stateName, 'custom', data); }, loadConfig: function (configJson) { config = typeof configJson === 'string' ? JSON.parse(configJson) : configJson; applyImage(config); }, showBubble: function (text, durationMs) { bubble.textContent = text; bubble.style.display = 'block'; clearTimeout(bubbleTimer); if (durationMs) bubbleTimer = setTimeout(hideBubble, durationMs); }, hideBubble: hideBubble, getState: function () { return currentState; } }; function hideBubble() { bubble.style.display = 'none'; } function applyImage(cfg) { if (!cfg || !cfg.default_image) return; const src = cfg.image_base_url + cfg.default_image; petImg.src = src; petImg.onerror = function () { petImg.src = DEFAULT_IMAGE; }; } function setState(stateName, reason, data) { currentState = stateName; if (currentAnimation) { petEl.classList.remove(currentAnimation); currentAnimation = null; } let animClass = null; if (config && config.actions && config.actions[stateName]) { animClass = 'anim-' + config.actions[stateName]; } else { animClass = 'anim-' + stateName; } const styleEl = document.getElementById('pet-animations'); const sheet = styleEl ? styleEl.sheet : null; const hasClass = sheet && Array.from(sheet.cssRules).some(r => r.selectorText === '.' + animClass); if (hasClass) { petEl.classList.add(animClass); currentAnimation = animClass; } if (reason === 'drink_water') { bubble.textContent = '该喝水啦~'; bubble.style.display = 'block'; clearTimeout(bubbleTimer); bubbleTimer = setTimeout(hideBubble, 8000); } else if (reason === 'welcome') { bubble.textContent = '我来了!'; bubble.style.display = 'block'; clearTimeout(bubbleTimer); bubbleTimer = setTimeout(hideBubble, 3000); } else if (BUBBLE_TEXTS[stateName]) { bubble.textContent = BUBBLE_TEXTS[stateName]; bubble.style.display = 'block'; clearTimeout(bubbleTimer); bubbleTimer = setTimeout(hideBubble, 5000); } } petImg.src = DEFAULT_IMAGE; setInterval(function () { if (document.getElementById('pet-img')) { petImg.classList.add('anim-blink_eye'); setTimeout(() => petImg.classList.remove('anim-blink_eye'), 200); } }, 3500); })(); ``` - [ ] **步骤 3:创建 interactions.js** `cfc-desktop-pet/src/XafPet.Desktop/Assets/interactions.js`: ```javascript (function () { 'use strict'; const petEl = document.getElementById('pet'); const petImg = document.getElementById('pet-img'); let dragStartX = 0, dragStartY = 0; let pressTimer = null; let isDragging = false; petEl.addEventListener('mousedown', function (e) { dragStartX = e.clientX; dragStartY = e.clientY; isDragging = false; pressTimer = setTimeout(() => { notifyInteract('pet', {}); }, 1500); }); petEl.addEventListener('mousemove', function (e) { const dx = e.clientX - dragStartX; const dy = e.clientY - dragStartY; if (Math.abs(dx) > 4 || Math.abs(dy) > 4) { isDragging = true; clearTimeout(pressTimer); notifyDragStart(); } }); petEl.addEventListener('mouseup', function (e) { clearTimeout(pressTimer); if (!isDragging) { notifyInteract('click', {}); } else { notifyDragEnd(); } }); function notifyInteract(action, data) { try { chrome.webview.hostObjects.sync.cfcHost.onInteract(action, JSON.stringify(data)); } catch (err) { console.log('[interaction]', action, data); } } function notifyDragStart() { try { chrome.webview.hostObjects.sync.cfcHost.onDragStart(); } catch (e) { /* noop */ } } function notifyDragEnd() { try { chrome.webview.hostObjects.sync.cfcHost.onDragEnd(); } catch (e) { /* noop */ } } })(); ``` - [ ] **步骤 4:语法检查 JS(若 node 不可用则跳过)** ```bash node --check cfc-desktop-pet/src/XafPet.Desktop/Assets/pet-engine.js node --check cfc-desktop-pet/src/XafPet.Desktop/Assets/interactions.js ``` - [ ] **步骤 5:编译验证** ```bash cd cfc-desktop-pet dotnet build XafPet.Desktop.sln ``` 预期:`Build succeeded`。 - [ ] **步骤 6:Commit** ```bash git add cfc-desktop-pet/ git commit -m "feat: 宠物引擎 JS (状态机+动画) + CSS 动画库" ``` ## 任务 8:状态轮询与推送集成 **文件:** - 修改:`cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml.cs` - 修改:`cfc-desktop-pet/src/XafPet.Desktop/XafPet.Desktop.csproj`(添加 WindowsForms 引用) - [ ] **步骤 1:修改 csproj 添加 WindowsForms 引用** 在 `XafPet.Desktop.csproj` 的 `` 追加: ```xml ``` - [ ] **步骤 2:实现完整 MainWindow(含轮询 + 交互回调)** `cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml.cs`(完整替换): ```csharp using System; using System.IO; using System.Text.Json; using System.Timers; using System.Windows; using System.Windows.Forms; using XafPet.Desktop.PetEngine; namespace XafPet.Desktop; public partial class MainWindow : Window { private NotifyIcon? _trayIcon; private ApiClient? _api; private Timer? _taskTimer; private Timer? _energyTimer; private Timer? _waterTimer; private string _baseUrl = "http://localhost:9082"; public MainWindow() { InitializeComponent(); RootGrid.MouseLeftButtonDown += (s, e) => { if (e.ButtonState == MouseButtonState.Pressed) DragMove(); }; PetHost.InteractionReceived += OnInteractionReceived; Loaded += OnLoaded; Closed += OnClosed; } private void OnLoaded(object sender, RoutedEventArgs e) { var token = TokenStore.Load(); _api = new ApiClient(new HttpClient(), _baseUrl, token ?? "dev-token"); StartPolling(); SetupTray(); _ = SyncMascotAsync(); } private void StartPolling() { _taskTimer = new Timer(30_000) { AutoReset = true }; _taskTimer.Elapsed += async (_, _) => await PollTasksAsync(); _taskTimer.Start(); _energyTimer = new Timer(60_000) { AutoReset = true }; _energyTimer.Elapsed += async (_, _) => await PollHealthAsync(); _energyTimer.Start(); StartWaterReminder(); } private async Task PollTasksAsync() { if (_api == null) return; try { var tasks = await _api.GetTodayTasksAsync(); var state = StateManager.MapState(tasks ?? new List(), null, null, false); PushState(state, "tasks"); } catch (ApiException ex) { PushBubble($"服务暂时不可用({ex.ApiCode})"); } catch (Exception) { PushBubble("连接后端失败,请检查服务"); } } private async Task PollHealthAsync() { if (_api == null) return; try { var energy = await _api.GetEnergyOverviewAsync(); var checkin = await _api.GetDailyCheckinAsync(); var state = StateManager.MapState(null, energy, checkin, false); PushState(state, "health"); } catch (Exception) { /* 静默 */ } } private void PushState(PetState state, string reason) { var payload = new PetStatePayload(StateManager.StateToCamelCase(state), reason, null); PetHost.NotifyState(JsonSerializer.Serialize(payload)); } private void PushBubble(string text) { PetHost.ShowBubble(text, 5000); } private void SetupTray() { var menu = new ContextMenuStrip(); menu.Items.Add("设置", null, (_, _) => ShowSettings()); menu.Items.Add("显示/隐藏", null, (_, _) => ToggleVisible()); menu.Items.Add("退出", null, (_, _) => { _trayIcon?.Dispose(); Application.Current.Shutdown(); }); _trayIcon = new NotifyIcon { Text = "浠艾福桌面宠物", Icon = SystemIcons.Application, ContextMenuStrip = menu, Visible = true }; _trayIcon.DoubleClick += (_, _) => ToggleVisible(); } private void ToggleVisible() { Visibility = Visibility == Visibility.Visible ? Visibility.Hidden : Visibility.Visible; } private void ShowSettings() { var win = new SettingsWindow(TokenStore.Load(), _baseUrl) { Owner = this }; if (win.ShowDialog() == true) { _baseUrl = win.BaseUrl; if (!string.IsNullOrEmpty(win.Token)) TokenStore.Save(win.Token); _api = new ApiClient(new HttpClient(), _baseUrl, TokenStore.Load() ?? "dev-token"); StartPolling(); } } private async void OnClosed(object? sender, EventArgs e) { _taskTimer?.Stop(); _energyTimer?.Stop(); _waterTimer?.Stop(); _taskTimer?.Dispose(); _energyTimer?.Dispose(); _waterTimer?.Dispose(); _trayIcon?.Dispose(); } // ── 喝水提醒 ───────────────────────────────────────────── private void StartWaterReminder() { _waterTimer = new Timer(60 * 60 * 1000) { AutoReset = true }; _waterTimer.Elapsed += async (_, _) => await CheckWaterAsync(); _waterTimer.Start(); _ = CheckWaterAsync(); } private async Task CheckWaterAsync() { if (_api == null) return; var hour = DateTime.Now.Hour; if (hour < 10 || hour > 20) return; try { var checkin = await _api.GetDailyCheckinAsync(); if (checkin != null && !checkin.Items.Contains("water")) { PushState(PetState.WaitingForInteraction, "drink_water"); PetHost.ShowBubble("该喝水啦~点我确认完成!", 15000); } } catch (Exception) { } } private void OnInteractionReceived(string action, string? dataJson) { switch (action) { case "click": _ = HandleClickAsync(); break; case "pet": PetHost.ShowBubble("嘻嘻~", 2000); break; } } private async Task HandleClickAsync() { if (_api == null) return; try { var result = await _api.CompleteDrinkWaterAsync(); if (result?.IsSuccess == true) { PushState(PetState.Happy, "drink_complete"); PetHost.ShowBubble("喝水打卡成功!太棒了~", 4000); } } catch (Exception) { PushBubble("打卡失败,请稍后再试"); } } // ── 吉祥物同步 ─────────────────────────────────────────── private async Task SyncMascotAsync() { if (_api == null) return; try { var mascot = await _api.GetMascotAsync(); var petName = mascot?.MascotId ?? "xibao"; var cfgPath = Path.Combine(AppContext.BaseDirectory, "DefaultPets", petName, "pet-config.json"); if (File.Exists(cfgPath)) { var json = File.ReadAllText(cfgPath); var parsed = PetConfig.Parse(json); PetHost.LoadPetConfig(JsonSerializer.Serialize(parsed)); } } catch (Exception) { /* 默认浠宝 */ } } } ``` - [ ] **步骤 3:更新 App.xaml.cs 声明命名空间(WPF 所需)** `cfc-desktop-pet/src/XafPet.Desktop/App.xaml.cs`: ```csharp using System.Windows; using System.Windows.Forms; // NotifyIcon namespace XafPet.Desktop; public partial class App : Application { protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); // 确保单实例(避免重复启动) if (!SingleInstanceHelper.TryEnterFirstInstance()) { Current.Shutdown(); return; } } } ``` - [ ] **步骤 4:单实例辅助类** `cfc-desktop-pet/src/XafPet.Desktop/SingleInstanceHelper.cs`: ```csharp using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Runtime.InteropServices; namespace XafPet.Desktop; /// 简单的单实例检测(按窗口标题匹配进程) public static class SingleInstanceHelper { [DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hWnd); public static bool TryEnterFirstInstance() { var name = Process.GetCurrentProcess().ProcessName; var instances = Process.GetProcessesByName(name); if (instances.Length > 1) { SetForegroundWindow(instances[0].MainWindowHandle); return false; } return true; } } ``` - [ ] **步骤 5:编译验证** ```bash cd cfc-desktop-pet dotnet build XafPet.Desktop.sln ``` 预期:`Build succeeded`。 - [ ] **步骤 6:Commit** ```bash git add cfc-desktop-pet/ git commit -m "feat: 状态轮询 + 托盘 + 喝水提醒闭环 + 单实例" ``` ## 任务 9:Token 存储 + 设置窗口 **文件:** - 创建:`cfc-desktop-pet/src/XafPet.Desktop/TokenStore.cs` - 创建:`cfc-desktop-pet/src/XafPet.Desktop/SettingsWindow.xaml` / `SettingsWindow.xaml.cs` - 测试:`cfc-desktop-pet/tests/XafPet.Desktop.Tests/TokenStoreTests.cs` - [ ] **步骤 1:编写 TokenStore 测试** `cfc-desktop-pet/tests/XafPet.Desktop.Tests/TokenStoreTests.cs`: ```csharp using System.IO; using Xunit; using XafPet.Desktop; namespace XafPet.Desktop.Tests; public class TokenStoreTests { private string TestDir => Path.Combine(Path.GetTempPath(), "xptest_" + Guid.NewGuid().ToString("N")); [Fact] public void SaveAndLoad_RoundsTrip() { var path = Path.Combine(TestDir, "token.bin"); TokenStore.SetPath(path); TokenStore.Save("my-jwt-token"); var loaded = TokenStore.Load(); Assert.Equal("my-jwt-token", loaded); TokenStore.Clear(); } [Fact] public void Load_MissingFile_ReturnsNull() { TokenStore.SetPath(Path.Combine(TestDir, "nonexistent.bin")); Assert.Null(TokenStore.Load()); } [Fact] public void Clear_RemovesFile() { var path = Path.Combine(TestDir, "token.bin"); TokenStore.SetPath(path); TokenStore.Save("token"); TokenStore.Clear(); Assert.False(File.Exists(path)); } } ``` - [ ] **步骤 2:实现 TokenStore(注入路径,便于测试)** `cfc-desktop-pet/src/XafPet.Desktop/TokenStore.cs`: ```csharp using System.IO; using System.Security.Cryptography; using System.Text; namespace XafPet.Desktop; public static class TokenStore { private static string _path = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "XafPet", "token.bin"); public static void SetPath(string path) => _path = path; public static void Save(string token) { Directory.CreateDirectory(Path.GetDirectoryName(_path)!); var encrypted = ProtectedData.Protect( Encoding.UTF8.GetBytes(token), null, DataProtectionScope.CurrentUser); File.WriteAllBytes(_path, encrypted); } public static string? Load() { if (!File.Exists(_path)) return null; try { var encrypted = File.ReadAllBytes(_path); var decrypted = ProtectedData.Unprotect( encrypted, null, DataProtectionScope.CurrentUser); return Encoding.UTF8.GetString(decrypted); } catch (CryptographicException) { return null; } } public static void Clear() { if (File.Exists(_path)) File.Delete(_path); } } ``` - [ ] **步骤 3:运行测试验证通过** ```bash cd cfc-desktop-pet dotnet test tests/XafPet.Desktop.Tests --filter "FullyQualifiedName~TokenStoreTests" -v ``` 预期:PASS — 3/3 通过。 - [ ] **步骤 4:创建 SettingsWindow** `cfc-desktop-pet/src/XafPet.Desktop/SettingsWindow.xaml`: ```xml