2026-10-10-desktop-pet.md 66 KB

桌面宠物实现计划

面向 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:创建目录结构

    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:

<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:

<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:

using System.Windows;

namespace XafPet.Desktop;

public partial class App : Application
{
}
  • 步骤 4:创建主窗口(透明置顶,占位内容)

cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml:

<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:

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:

<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:创建解决方案文件并添加项目

    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:编译验证

    cd cfc-desktop-pet
    dotnet build XafPet.Desktop.sln
    

预期:Build succeeded,0 错误。

  • [ ] 步骤 8:Commit

    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:

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:运行测试确认失败

    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:

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:

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:运行测试验证通过

    cd cfc-desktop-pet
    dotnet test tests/XafPet.Desktop.Tests --filter "FullyQualifiedName~PetConfigTests" -v
    

预期:PASS — 4/4 通过。

  • [ ] 步骤 6:Commit

    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:

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:运行测试确认失败

    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:

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:

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:运行测试验证通过

    cd cfc-desktop-pet
    dotnet test tests/XafPet.Desktop.Tests --filter "FullyQualifiedName~ApiClientTests" -v
    

预期:PASS — 5/5 通过。

  • [ ] 步骤 6:Commit

    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:

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:运行测试确认失败

    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:

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:运行测试验证通过

    cd cfc-desktop-pet
    dotnet test tests/XafPet.Desktop.Tests --filter "FullyQualifiedName~StateManagerTests" -v
    

预期:PASS — 9/9 通过。

  • [ ] 步骤 5:Commit

    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:

{
  "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:

{
  "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 透明)

    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 解析

    cd cfc-desktop-pet
    dotnet test tests/XafPet.Desktop.Tests --filter "FullyQualifiedName~PetConfigTests" -v
    

预期:PASS(回归确认)。

  • [ ] 步骤 5:Commit

    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:

<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:

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:

<!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:

<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:

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:编译验证

    cd cfc-desktop-pet
    dotnet build XafPet.Desktop.sln
    

预期:Build succeeded,0 错误。

  • [ ] 步骤 6:Commit

    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:

/* ===== 浠艾福桌面宠物动画定义 ===== */
@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:

(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:

(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 不可用则跳过)

    node --check cfc-desktop-pet/src/XafPet.Desktop/Assets/pet-engine.js
    node --check cfc-desktop-pet/src/XafPet.Desktop/Assets/interactions.js
    
  • [ ] 步骤 5:编译验证

    cd cfc-desktop-pet
    dotnet build XafPet.Desktop.sln
    

预期:Build succeeded。

  • [ ] 步骤 6:Commit

    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> 追加:

  <ItemGroup>
    <FrameworkReference Include="Microsoft.WindowsDesktop.App.WindowsForms" />
  </ItemGroup>
  • 步骤 2:实现完整 MainWindow(含轮询 + 交互回调)

cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml.cs(完整替换):

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:

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:

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:编译验证

    cd cfc-desktop-pet
    dotnet build XafPet.Desktop.sln
    

预期:Build succeeded。

  • [ ] 步骤 6:Commit

    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:

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:

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:运行测试验证通过

    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:

<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:

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:编译验证

    cd cfc-desktop-pet
    dotnet build XafPet.Desktop.sln
    

预期:Build succeeded。

  • [ ] 步骤 6:Commit

    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 追加:

  <PropertyGroup>
    <PublishSingleFile>true</PublishSingleFile>
    <SelfContained>true</SelfContained>
    <RuntimeIdentifier>win-x64</RuntimeIdentifier>
    <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
  </PropertyGroup>
  • [ ] 步骤 2:完整构建 + 运行所有测试

    cd cfc-desktop-pet
    dotnet build XafPet.Desktop.sln -c Release
    dotnet test tests/XafPet.Desktop.Tests -c Release -v
    

预期:全部测试 PASS,0 构建错误。

  • [ ] 步骤 3:发布 exe

    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:

# 浠艾福桌面宠物 (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

    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 执行任务,批量执行并设有检查点

选哪种方式?