面向 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 双向通信。
技术栈:
构建环境: WPF 仅支持 Windows,需在 Windows 机器(.NET 6 SDK)上 dotnet build / dotnet test。本仓库其余部分为 Java/前端,桌面宠物是独立新目录 cfc-desktop-pet/,不影响现有模块。
规格文档: docs/superpowers/specs/2026-10-10-desktop-pet-design.md
文件:
cfc-desktop-pet/XafPet.Desktop.slncfc-desktop-pet/src/XafPet.Desktop/XafPet.Desktop.csprojcfc-desktop-pet/src/XafPet.Desktop/App.xaml / App.xaml.cscfc-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>
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
{
}
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();
};
}
}
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)"
文件:
cfc-desktop-pet/src/XafPet.Desktop/PetEngine/PetState.cscfc-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 类型不存在。
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 // 可选:任务列表/能量值等
);
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)"
文件:
cfc-desktop-pet/src/XafPet.Desktop/PetEngine/CfcApiModels.cscfc-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 不存在。
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;
}
}
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 客户端 (今日任务/能量概览/健康打卡)"
文件:
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 不存在。
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数据→宠物状态映射)"
文件:
cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/xibao/pet-config.jsoncfc-desktop-pet/src/XafPet.Desktop/DefaultPets/fubao/pet-config.jsoncfc-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
}
}
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: 默认宠物资产 (浠宝/福宝 占位配置)"
文件:
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>
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() { }
}
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>
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 注册"
文件:
cfc-desktop-pet/src/XafPet.Desktop/Assets/pet-engine.jscfc-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; }
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);
})();
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 动画库"
文件:
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>
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) { /* 默认浠宝 */ }
}
}
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;
}
}
}
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: 状态轮询 + 托盘 + 喝水提醒闭环 + 单实例"
文件:
cfc-desktop-pet/src/XafPet.Desktop/TokenStore.cscfc-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));
}
}
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 通过。
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 存储 + 设置窗口"
文件:
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。
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 执行任务,批量执行并设有检查点
选哪种方式?