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