|
|
@@ -0,0 +1,2112 @@
|
|
|
+# 桌面宠物实现计划
|
|
|
+
|
|
|
+> **面向 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
|
|
|
+<Project Sdk="Microsoft.NET.Sdk">
|
|
|
+ <PropertyGroup>
|
|
|
+ <OutputType>WinExe</OutputType>
|
|
|
+ <TargetFramework>net6.0-windows</TargetFramework>
|
|
|
+ <Nullable>enable</Nullable>
|
|
|
+ <UseWPF>true</UseWPF>
|
|
|
+ <ImplicitUsings>enable</ImplicitUsings>
|
|
|
+ <AssemblyName>XafPet.Desktop</AssemblyName>
|
|
|
+ <RootNamespace>XafPet.Desktop</RootNamespace>
|
|
|
+ </PropertyGroup>
|
|
|
+ <ItemGroup>
|
|
|
+ <PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2210.55" />
|
|
|
+ </ItemGroup>
|
|
|
+ <ItemGroup>
|
|
|
+ <None Include="Assets\**" CopyToOutputDirectory="PreserveNewest" />
|
|
|
+ <None Include="DefaultPets\**" CopyToOutputDirectory="PreserveNewest" />
|
|
|
+ </ItemGroup>
|
|
|
+</Project>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:创建 App 入口**
|
|
|
+
|
|
|
+`cfc-desktop-pet/src/XafPet.Desktop/App.xaml`:
|
|
|
+
|
|
|
+```xml
|
|
|
+<Application x:Class="XafPet.Desktop.App"
|
|
|
+ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
|
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
|
+ StartupUri="MainWindow.xaml">
|
|
|
+ <Application.Resources></Application.Resources>
|
|
|
+</Application>
|
|
|
+```
|
|
|
+
|
|
|
+`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
|
|
|
+<Window x:Class="XafPet.Desktop.MainWindow"
|
|
|
+ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
|
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
|
+ Title="浠艾福桌面宠物"
|
|
|
+ Width="200" Height="200"
|
|
|
+ WindowStyle="None"
|
|
|
+ AllowsTransparency="True"
|
|
|
+ Background="Transparent"
|
|
|
+ Topmost="True"
|
|
|
+ ShowInTaskbar="False"
|
|
|
+ ResizeMode="NoResize">
|
|
|
+ <Grid x:Name="RootGrid">
|
|
|
+ <TextBlock Text="浠艾福桌面宠物"
|
|
|
+ HorizontalAlignment="Center" VerticalAlignment="Center"
|
|
|
+ Foreground="#4A9BD7" FontSize="14"/>
|
|
|
+ </Grid>
|
|
|
+</Window>
|
|
|
+```
|
|
|
+
|
|
|
+`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
|
|
|
+<Project Sdk="Microsoft.NET.Sdk">
|
|
|
+ <PropertyGroup>
|
|
|
+ <TargetFramework>net6.0-windows</TargetFramework>
|
|
|
+ <Nullable>enable</Nullable>
|
|
|
+ <ImplicitUsings>enable</ImplicitUsings>
|
|
|
+ <IsPackable>false</IsPackable>
|
|
|
+ </PropertyGroup>
|
|
|
+ <ItemGroup>
|
|
|
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
|
|
|
+ <PackageReference Include="xunit" Version="2.5.3" />
|
|
|
+ <PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
|
|
+ <PackageReference Include="Moq" Version="4.20.69" />
|
|
|
+ </ItemGroup>
|
|
|
+ <ItemGroup>
|
|
|
+ <ProjectReference Include="..\..\src\XafPet.Desktop\XafPet.Desktop.csproj" />
|
|
|
+ </ItemGroup>
|
|
|
+</Project>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 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<JsonException>(() => 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;
|
|
|
+
|
|
|
+/// <summary>宠物核心状态枚举(C# 侧定义,JS 侧同名接收)</summary>
|
|
|
+public enum PetState
|
|
|
+{
|
|
|
+ Idle, // 默认
|
|
|
+ Happy, // 任务全完成
|
|
|
+ Busy, // 有任务进行中
|
|
|
+ Worried, // 任务超期未完成
|
|
|
+ Excited, // 健康打卡完成
|
|
|
+ Remind, // 提醒用户(喝水/任务)
|
|
|
+ Energetic, // 身维度能量高
|
|
|
+ Tired, // 身维度能量低
|
|
|
+ Curious, // 智维度能量高
|
|
|
+ Sad, // 全维度低
|
|
|
+ Sleeping, // 用户无操作 >15min
|
|
|
+ Watching, // 用户活跃,注视光标
|
|
|
+ WaitingForInteraction, // 等待用户互动(打卡确认)
|
|
|
+ MiniGame // 小游戏模式
|
|
|
+}
|
|
|
+
|
|
|
+/// <summary>推送 WebView 的状态负载</summary>
|
|
|
+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 }
|
|
|
+
|
|
|
+/// <summary>宠物形象配置(pet-config.json)</summary>
|
|
|
+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<string, string> Actions { get; set; } = new();
|
|
|
+
|
|
|
+ [JsonPropertyName("sounds")]
|
|
|
+ public Dictionary<string, string> Sounds { get; set; } = new();
|
|
|
+
|
|
|
+ [JsonPropertyName("parameters")]
|
|
|
+ public PetParameters Parameters { get; set; } = new();
|
|
|
+
|
|
|
+ [JsonIgnore]
|
|
|
+ public int CustomStateCount => Actions.Keys.Count(k => k.StartsWith("custom_state_"));
|
|
|
+
|
|
|
+ /// <summary>解析 JSON 字符串;缺省 actions 时补默认映射(key=value)</summary>
|
|
|
+ public static PetConfig Parse(string json)
|
|
|
+ {
|
|
|
+ var config = JsonSerializer.Deserialize<PetConfig>(json)
|
|
|
+ ?? throw new JsonException("pet-config.json 反序列化结果为 null");
|
|
|
+ if (config.Actions.Count == 0)
|
|
|
+ {
|
|
|
+ config.Actions = new Dictionary<string, string>
|
|
|
+ {
|
|
|
+ ["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<HttpMessageHandler>();
|
|
|
+ handler.Protected()
|
|
|
+ .Setup<Task<HttpResponseMessage>>("SendAsync",
|
|
|
+ ItExpr.IsAny<HttpRequestMessage>(),
|
|
|
+ ItExpr.IsAny<CancellationToken>())
|
|
|
+ .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<ApiException>(() => 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;
|
|
|
+
|
|
|
+/// <summary>CFC 统一响应包装 Result<T></summary>
|
|
|
+public sealed class ApiResult<T>
|
|
|
+{
|
|
|
+ [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<DimensionValue> Dimensions { get; set; } = new();
|
|
|
+}
|
|
|
+
|
|
|
+public sealed class DailyCheckin
|
|
|
+{
|
|
|
+ [JsonPropertyName("checked")]
|
|
|
+ public bool Checked { get; set; }
|
|
|
+
|
|
|
+ [JsonPropertyName("items")]
|
|
|
+ public List<string> Items { get; set; } = new();
|
|
|
+}
|
|
|
+
|
|
|
+/// <summary>API 返回非 200 code 时抛出</summary>
|
|
|
+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;
|
|
|
+
|
|
|
+/// <summary>CFC 后端 API 客户端。所有接口统一 POST + JWT Bearer。</summary>
|
|
|
+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<T?> PostAsync<T>(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<ApiResult<T>>(json, JsonOpts)
|
|
|
+ ?? throw new ApiException(-1, "响应解析失败");
|
|
|
+ if (!result.IsSuccess)
|
|
|
+ throw new ApiException(result.Code, result.Message ?? "未知错误");
|
|
|
+ return result.Data;
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>POST /api/tasks/today — 今日任务</summary>
|
|
|
+ public Task<List<CfcTask>?> GetTodayTasksAsync()
|
|
|
+ => PostAsync<List<CfcTask>>("/api/tasks/today", new Dictionary<string, object>());
|
|
|
+
|
|
|
+ /// <summary>POST /api/energy/overview — 五维能量概览</summary>
|
|
|
+ public Task<EnergyOverview?> GetEnergyOverviewAsync()
|
|
|
+ => PostAsync<EnergyOverview>("/api/energy/overview");
|
|
|
+
|
|
|
+ /// <summary>POST /api/daily/checkin — 今日打卡状态</summary>
|
|
|
+ public Task<DailyCheckin?> GetDailyCheckinAsync()
|
|
|
+ => PostAsync<DailyCheckin>("/api/daily/checkin");
|
|
|
+
|
|
|
+ /// <summary>POST /api/daily/checkin — 完成喝水打卡(body: {"type":"water"})</summary>
|
|
|
+ public async Task<ApiResult<object>?> 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<ApiResult<object>>(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<CfcTask>
|
|
|
+ {
|
|
|
+ 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<CfcTask>
|
|
|
+ {
|
|
|
+ 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<CfcTask>(), null, null, false);
|
|
|
+ Assert.Equal(PetState.Idle, state);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void CheckinDone_OverridesToExcited()
|
|
|
+ {
|
|
|
+ var state = StateManager.MapState(new List<CfcTask>(), null,
|
|
|
+ new DailyCheckin { Checked = true, Items = new List<string> { "water" } }, false);
|
|
|
+ Assert.Equal(PetState.Excited, state);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void LowBodyEnergy_ReturnsTired()
|
|
|
+ {
|
|
|
+ var energy = new EnergyOverview
|
|
|
+ {
|
|
|
+ Dimensions = new List<DimensionValue>
|
|
|
+ {
|
|
|
+ new() { Dimension = "身", Value = 20, Color = "#FF8C42" },
|
|
|
+ new() { Dimension = "智", Value = 80, Color = "#6366F1" }
|
|
|
+ }
|
|
|
+ };
|
|
|
+ var state = StateManager.MapState(new List<CfcTask>(), energy, null, false);
|
|
|
+ Assert.Equal(PetState.Tired, state);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void AllDimensionsLow_ReturnsSad()
|
|
|
+ {
|
|
|
+ var energy = new EnergyOverview
|
|
|
+ {
|
|
|
+ Dimensions = new List<DimensionValue>
|
|
|
+ {
|
|
|
+ new() { Dimension = "身", Value = 15, Color = "#FF8C42" },
|
|
|
+ new() { Dimension = "智", Value = 20, Color = "#6366F1" },
|
|
|
+ new() { Dimension = "富", Value = 25, Color = "#F59E0B" }
|
|
|
+ }
|
|
|
+ };
|
|
|
+ var state = StateManager.MapState(new List<CfcTask>(), energy, null, false);
|
|
|
+ Assert.Equal(PetState.Sad, state);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void HighBodyEnergy_ReturnsEnergetic()
|
|
|
+ {
|
|
|
+ var energy = new EnergyOverview
|
|
|
+ {
|
|
|
+ Dimensions = new List<DimensionValue>
|
|
|
+ {
|
|
|
+ new() { Dimension = "身", Value = 90, Color = "#FF8C42" }
|
|
|
+ }
|
|
|
+ };
|
|
|
+ var state = StateManager.MapState(new List<CfcTask>(), energy, null, false);
|
|
|
+ Assert.Equal(PetState.Energetic, state);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void UserInactive_ReturnsSleeping()
|
|
|
+ {
|
|
|
+ var state = StateManager.MapState(new List<CfcTask>(), 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;
|
|
|
+
|
|
|
+/// <summary>把 CFC API 数据转换为宠物状态(纯逻辑,无 UI 依赖)</summary>
|
|
|
+public static class StateManager
|
|
|
+{
|
|
|
+ /// <summary>核心状态映射。优先级:Sleeping > Excited > Happy > Worried > Busy > Sad > Tired/Energetic/Curious > Idle</summary>
|
|
|
+ public static PetState MapState(
|
|
|
+ List<CfcTask>? 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;
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>枚举 → camelCase(JS 侧状态名)。WaitingForInteraction → waitingForInteraction</summary>
|
|
|
+ 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
|
|
|
+<UserControl x:Class="XafPet.Desktop.PetEngine.WebViewHost"
|
|
|
+ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
|
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
|
+ xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
|
|
|
+ Background="Transparent">
|
|
|
+ <Grid>
|
|
|
+ <wv2:WebView2 x:Name="PetWebView"
|
|
|
+ DefaultBackgroundColor="Transparent"
|
|
|
+ HorizontalAlignment="Stretch" VerticalAlignment="Stretch"/>
|
|
|
+ </Grid>
|
|
|
+</UserControl>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 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<string, string?>? 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('\\', '/')}");
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>C# → JS:推送宠物状态</summary>
|
|
|
+ public void NotifyState(string stateJson)
|
|
|
+ {
|
|
|
+ PetWebView.ExecuteScriptAsync($"window.cfcPetAPI?.notifyState({stateJson})");
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>C# → JS:推送自定义状态</summary>
|
|
|
+ public void SetCustomState(string stateName, string dataJson)
|
|
|
+ {
|
|
|
+ PetWebView.ExecuteScriptAsync(
|
|
|
+ $"window.cfcPetAPI?.setCustomState('{stateName}', {dataJson})");
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>C# → JS:显示气泡</summary>
|
|
|
+ public void ShowBubble(string text, int durationMs = 5000)
|
|
|
+ {
|
|
|
+ var escaped = text.Replace("\\", "\\\\").Replace("'", "\\'");
|
|
|
+ PetWebView.ExecuteScriptAsync(
|
|
|
+ $"window.cfcPetAPI?.showBubble('{escaped}', {durationMs})");
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>C# → JS:加载配置</summary>
|
|
|
+ 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
|
|
|
+<!DOCTYPE html>
|
|
|
+<html lang="zh-CN">
|
|
|
+<head>
|
|
|
+<meta charset="UTF-8" />
|
|
|
+<title>Xaf Pet</title>
|
|
|
+<link rel="stylesheet" href="animations.css" />
|
|
|
+<style>
|
|
|
+ html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: transparent; }
|
|
|
+ #pet-stage { position: fixed; inset: 0; display: flex; align-items: flex-end; justify-content: center; pointer-events: none; }
|
|
|
+ #pet { width: 120px; height: 140px; pointer-events: auto; cursor: grab; user-select: none; }
|
|
|
+ #pet img { width: 100%; height: 100%; object-fit: contain; pointer-events: none; }
|
|
|
+ #bubble { position: absolute; bottom: 150px; left: 50%; transform: translateX(-50%);
|
|
|
+ background: #fff; border: 2px solid #4A9BD7; border-radius: 12px; padding: 6px 12px;
|
|
|
+ font: 13px/1.4 'PingFang SC', sans-serif; color: #333; white-space: nowrap;
|
|
|
+ display: none; box-shadow: 0 2px 8px rgba(0,0,0,.12); }
|
|
|
+ #bubble::after { content: ''; position: absolute; top: 100%; left: 50%; transform: translateX(-50%);
|
|
|
+ border: 6px solid transparent; border-top-color: #4A9BD7; }
|
|
|
+</style>
|
|
|
+</head>
|
|
|
+<body>
|
|
|
+<div id="pet-stage">
|
|
|
+ <div id="pet">
|
|
|
+ <img id="pet-img" src="" alt="pet" draggable="false" />
|
|
|
+ <div id="bubble"></div>
|
|
|
+ </div>
|
|
|
+</div>
|
|
|
+<script src="pet-engine.js"></script>
|
|
|
+<script src="interactions.js"></script>
|
|
|
+</body>
|
|
|
+</html>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 4:修改 MainWindow 使用 WebViewHost**
|
|
|
+
|
|
|
+`cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml`:
|
|
|
+
|
|
|
+```xml
|
|
|
+<Window x:Class="XafPet.Desktop.MainWindow"
|
|
|
+ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
|
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
|
+ xmlns:pet="clr-namespace:XafPet.Desktop.PetEngine"
|
|
|
+ Title="浠艾福桌面宠物"
|
|
|
+ Width="200" Height="200"
|
|
|
+ WindowStyle="None"
|
|
|
+ AllowsTransparency="True"
|
|
|
+ Background="Transparent"
|
|
|
+ Topmost="True"
|
|
|
+ ShowInTaskbar="False"
|
|
|
+ ResizeMode="NoResize">
|
|
|
+ <Grid x:Name="RootGrid" Background="Transparent">
|
|
|
+ <pet:WebViewHost x:Name="PetHost" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"/>
|
|
|
+ </Grid>
|
|
|
+</Window>
|
|
|
+```
|
|
|
+
|
|
|
+`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('<svg xmlns="http://www.w3.org/2000/svg" width="120" height="140">' +
|
|
|
+ '<text y="100" font-size="90" text-anchor="middle">🦜</text></svg>');
|
|
|
+
|
|
|
+ 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` 的 `<ItemGroup>` 追加:
|
|
|
+
|
|
|
+```xml
|
|
|
+ <ItemGroup>
|
|
|
+ <FrameworkReference Include="Microsoft.WindowsDesktop.App.WindowsForms" />
|
|
|
+ </ItemGroup>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 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<CfcTask>(), 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;
|
|
|
+
|
|
|
+/// <summary>简单的单实例检测(按窗口标题匹配进程)</summary>
|
|
|
+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
|
|
|
+<Window x:Class="XafPet.Desktop.SettingsWindow"
|
|
|
+ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
|
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
|
+ Title="浠艾福桌面宠物 - 设置"
|
|
|
+ Width="360" Height="340"
|
|
|
+ WindowStartupLocation="CenterScreen">
|
|
|
+ <StackPanel Margin="20">
|
|
|
+ <TextBlock Text="后端地址" FontWeight="SemiBold" Margin="0,0,0,6"/>
|
|
|
+ <TextBox x:Name="BaseUrlBox" Text="http://localhost:9082" Padding="6"/>
|
|
|
+
|
|
|
+ <TextBlock Text="JWT Token" FontWeight="SemiBold" Margin="0,16,0,6"/>
|
|
|
+ <PasswordBox x:Name="TokenBox" Padding="6"/>
|
|
|
+
|
|
|
+ <TextBlock Text="宠物形象" FontWeight="SemiBold" Margin="0,16,0,6"/>
|
|
|
+ <ComboBox x:Name="PetCombo" Padding="6" MinWidth="120"/>
|
|
|
+
|
|
|
+ <Button Content="保存" Click="OnSaveClick" Margin="0,24,0,0" Padding="10,6"
|
|
|
+ HorizontalAlignment="Right" Width="100"/>
|
|
|
+ </StackPanel>
|
|
|
+</Window>
|
|
|
+```
|
|
|
+
|
|
|
+`cfc-desktop-pet/src/XafPet.Desktop/SettingsWindow.xaml.cs`:
|
|
|
+
|
|
|
+```csharp
|
|
|
+using System.IO;
|
|
|
+using System.Text.Json;
|
|
|
+using System.Windows;
|
|
|
+using XafPet.Desktop.PetEngine;
|
|
|
+
|
|
|
+namespace XafPet.Desktop;
|
|
|
+
|
|
|
+public partial class SettingsWindow : Window
|
|
|
+{
|
|
|
+ public SettingsWindow(string? currentToken, string currentBaseUrl)
|
|
|
+ {
|
|
|
+ InitializeComponent();
|
|
|
+ BaseUrlBox.Text = currentBaseUrl;
|
|
|
+ if (!string.IsNullOrEmpty(currentToken)) TokenBox.Password = currentToken;
|
|
|
+ LoadPets();
|
|
|
+ }
|
|
|
+
|
|
|
+ private void LoadPets()
|
|
|
+ {
|
|
|
+ var dirs = new List<string>();
|
|
|
+ var builtin = Path.Combine(AppContext.BaseDirectory, "DefaultPets");
|
|
|
+ if (Directory.Exists(builtin)) dirs.AddRange(Directory.GetDirectories(builtin));
|
|
|
+ var userDir = Path.Combine(
|
|
|
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
|
+ "XafPet", "pets");
|
|
|
+ if (Directory.Exists(userDir)) dirs.AddRange(Directory.GetDirectories(userDir));
|
|
|
+
|
|
|
+ foreach (var d in dirs)
|
|
|
+ {
|
|
|
+ if (File.Exists(Path.Combine(d, "pet-config.json")))
|
|
|
+ PetCombo.Items.Add(Path.GetFileName(d));
|
|
|
+ }
|
|
|
+ if (PetCombo.Items.Count > 0) PetCombo.SelectedIndex = 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ public string BaseUrl => BaseUrlBox.Text.Trim();
|
|
|
+ public string? Token => TokenBox.Password.Length > 0 ? TokenBox.Password : null;
|
|
|
+
|
|
|
+ private void OnSaveClick(object sender, RoutedEventArgs e)
|
|
|
+ {
|
|
|
+ DialogResult = true;
|
|
|
+ Close();
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 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: Token 存储 + 设置窗口"
|
|
|
+```
|
|
|
+
|
|
|
+## 任务 10:端到端集成验证 + 发布打包
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-desktop-pet/src/XafPet.Desktop/XafPet.Desktop.csproj`(发布配置)
|
|
|
+- 创建:`cfc-desktop-pet/README.md`
|
|
|
+
|
|
|
+- [ ] **步骤 1:添加发布配置(单文件 exe,Self-Contained)**
|
|
|
+
|
|
|
+在 `XafPet.Desktop.csproj` 追加:
|
|
|
+
|
|
|
+```xml
|
|
|
+ <PropertyGroup>
|
|
|
+ <PublishSingleFile>true</PublishSingleFile>
|
|
|
+ <SelfContained>true</SelfContained>
|
|
|
+ <RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
|
|
+ <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
|
|
+ </PropertyGroup>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:完整构建 + 运行所有测试**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-desktop-pet
|
|
|
+dotnet build XafPet.Desktop.sln -c Release
|
|
|
+dotnet test tests/XafPet.Desktop.Tests -c Release -v
|
|
|
+```
|
|
|
+
|
|
|
+预期:全部测试 PASS,0 构建错误。
|
|
|
+
|
|
|
+- [ ] **步骤 3:发布 exe**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-desktop-pet
|
|
|
+dotnet publish src/XafPet.Desktop/XafPet.Desktop.csproj -c Release -r win-x64 --self-contained true
|
|
|
+```
|
|
|
+
|
|
|
+预期:输出位于 `src/XafPet.Desktop/bin/Release/net6.0-windows/win-x64/publish/XafPet.Desktop.exe`。
|
|
|
+
|
|
|
+- [ ] **步骤 4:编写 README**
|
|
|
+
|
|
|
+`cfc-desktop-pet/README.md`:
|
|
|
+
|
|
|
+```markdown
|
|
|
+# 浠艾福桌面宠物 (XafPet.Desktop)
|
|
|
+
|
|
|
+Windows 桌面宠物应用,实时反映 CFC 健康陪跑状态。
|
|
|
+
|
|
|
+## 功能
|
|
|
+
|
|
|
+- 显示今日任务进度 / 五维能量 / 健康打卡状态
|
|
|
+- 喝水提醒互动(点击宠物确认打卡)
|
|
|
+- 支持浠宝/福宝及自定义形象(PNG + pet-config.json)
|
|
|
+- 支持用户自定义状态(custom_state_1 ~ custom_state_10)
|
|
|
+
|
|
|
+## 使用
|
|
|
+
|
|
|
+1. 发布:`dotnet publish src/XafPet.Desktop/XafPet.Desktop.csproj -c Release -r win-x64`
|
|
|
+2. 运行 `XafPet.Desktop.exe`
|
|
|
+3. 首次启动 → 右键托盘图标 → 设置 → 填入后端地址 + JWT Token
|
|
|
+4. 宠物出现在桌面右下角,可拖拽移动
|
|
|
+
|
|
|
+## 自定义宠物
|
|
|
+
|
|
|
+1. 在 `%APPDATA%\XafPet\pets\<名称>\` 放置 `standing.png` + `pet-config.json`
|
|
|
+2. 重启应用,设置中选择形象
|
|
|
+
|
|
|
+## 开发
|
|
|
+
|
|
|
+```bash
|
|
|
+dotnet build XafPet.Desktop.sln
|
|
|
+dotnet test tests/XafPet.Desktop.Tests
|
|
|
+```
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 5:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-desktop-pet/
|
|
|
+git commit -m "feat: 桌面宠物 v1.0 完成 (发布配置 + README)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 自检清单
|
|
|
+
|
|
|
+| 规格章节 | 对应任务 | 状态 |
|
|
|
+|---------|---------|------|
|
|
|
+| 二、系统架构(WPF+WebView2) | 任务 1, 6 | ✅ |
|
|
|
+| 三、数据流(API→状态映射) | 任务 3, 4, 8 | ✅ |
|
|
|
+| 四、形象配置 JSON Schema | 任务 2, 5 | ✅ |
|
|
|
+| 五、交互规则与动作库 | 任务 7(CSS 动画 + JS 状态机) | ✅ |
|
|
|
+| 六.1 拖拽与置顶 | 任务 1(DragMove + Topmost) | ✅ |
|
|
|
+| 六.2 五维能量可视化 | 任务 7(颜色联动待增强) | ⚠️ 部分 |
|
|
|
+| 六.3 声音策略 | 任务 7(sounds 已定义,播放器待扩展) | ⚠️ 部分 |
|
|
|
+| 六.4 游戏模式 | 未覆盖 | ⚠️ 缺失(v1.1 迭代) |
|
|
|
+| 用户自定义状态 | 任务 2(PetConfig.CustomStateCount)+ 任务 7(setCustomState) | ✅ |
|
|
|
+| 顽皮行为(偷图标/吃文档) | 未覆盖 | ⚠️ 缺失(v1.1 迭代) |
|
|
|
+
|
|
|
+**类型一致性检查:**
|
|
|
+- `PetState` 枚举 → `StateManager.StateToCamelCase` → JS `setState` ✅
|
|
|
+- `PetConfig.Parse(json)` → `PetHost.LoadPetConfig()` → JS `loadConfig` ✅
|
|
|
+- `ApiClient.PostAsync<T>` → `ApiResult<T>` → `IsSuccess` ✅
|
|
|
+- `WebViewHost.NotifyState/SetCustomState/ShowBubble/LoadPetConfig` — 命名一致 ✅
|
|
|
+- `CfcHostBridge.onInteract` ↔ `interactions.js` `chrome.webview.hostObjects.sync.cfcHost.onInteract` ✅
|
|
|
+
|
|
|
+**占位符扫描:** 无 "待定"/"TODO"/"后续实现"。注释 `"TODO: 任务9"` 已随任务 9 实现移除。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 执行交接
|
|
|
+
|
|
|
+计划已完成并保存到 `docs/superpowers/plans/2026-10-10-desktop-pet.md`。
|
|
|
+
|
|
|
+两种执行方式:
|
|
|
+
|
|
|
+**1. 子代理驱动(推荐)** — 每个任务调度一个新的子代理,任务间进行审查,快速迭代
|
|
|
+
|
|
|
+**2. 内联执行** — 在当前会话中使用 executing-plans 执行任务,批量执行并设有检查点
|
|
|
+
|
|
|
+选哪种方式?
|