mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-05-15 03:04:43 +08:00
docs: salvage zh-CN framework skill translations
This commit is contained in:
parent
3242ed461f
commit
4359947a6a
321
docs/zh-CN/skills/csharp-testing/SKILL.md
Normal file
321
docs/zh-CN/skills/csharp-testing/SKILL.md
Normal file
@ -0,0 +1,321 @@
|
|||||||
|
---
|
||||||
|
name: csharp-testing
|
||||||
|
description: 使用 xUnit、FluentAssertions、模拟、集成测试和测试组织最佳实践的 C# 和 .NET 测试模式。
|
||||||
|
origin: ECC
|
||||||
|
---
|
||||||
|
|
||||||
|
# C# 测试模式
|
||||||
|
|
||||||
|
使用 xUnit、FluentAssertions 和现代测试实践为 .NET 应用程序提供的全面测试模式。
|
||||||
|
|
||||||
|
## 何时使用
|
||||||
|
|
||||||
|
* 为 C# 代码编写新测试
|
||||||
|
* 审查测试质量和覆盖率
|
||||||
|
* 为 .NET 项目搭建测试基础设施
|
||||||
|
* 调试不稳定或缓慢的测试
|
||||||
|
|
||||||
|
## 测试框架栈
|
||||||
|
|
||||||
|
| 工具 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| **xUnit** | 测试框架(.NET 首选) |
|
||||||
|
| **FluentAssertions** | 可读的断言语法 |
|
||||||
|
| **NSubstitute** 或 **Moq** | 模拟依赖项 |
|
||||||
|
| **Testcontainers** | 集成测试中的真实基础设施 |
|
||||||
|
| **WebApplicationFactory** | ASP.NET Core 集成测试 |
|
||||||
|
| **Bogus** | 生成逼真的测试数据 |
|
||||||
|
|
||||||
|
## 单元测试结构
|
||||||
|
|
||||||
|
### 安排-操作-断言
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed class OrderServiceTests
|
||||||
|
{
|
||||||
|
private readonly IOrderRepository _repository = Substitute.For<IOrderRepository>();
|
||||||
|
private readonly ILogger<OrderService> _logger = Substitute.For<ILogger<OrderService>>();
|
||||||
|
private readonly OrderService _sut;
|
||||||
|
|
||||||
|
public OrderServiceTests()
|
||||||
|
{
|
||||||
|
_sut = new OrderService(_repository, _logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PlaceOrderAsync_ReturnsSuccess_WhenRequestIsValid()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var request = new CreateOrderRequest
|
||||||
|
{
|
||||||
|
CustomerId = "cust-123",
|
||||||
|
Items = [new OrderItem("SKU-001", 2, 29.99m)]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.PlaceOrderAsync(request, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.IsSuccess.Should().BeTrue();
|
||||||
|
result.Value.Should().NotBeNull();
|
||||||
|
result.Value!.CustomerId.Should().Be("cust-123");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PlaceOrderAsync_ReturnsFailure_WhenNoItems()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var request = new CreateOrderRequest
|
||||||
|
{
|
||||||
|
CustomerId = "cust-123",
|
||||||
|
Items = []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.PlaceOrderAsync(request, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.IsSuccess.Should().BeFalse();
|
||||||
|
result.Error.Should().Contain("at least one item");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用 Theory 的参数化测试
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Theory]
|
||||||
|
[InlineData("", false)]
|
||||||
|
[InlineData("a", false)]
|
||||||
|
[InlineData("ab@c.d", false)]
|
||||||
|
[InlineData("user@example.com", true)]
|
||||||
|
[InlineData("user+tag@example.co.uk", true)]
|
||||||
|
public void IsValidEmail_ReturnsExpected(string email, bool expected)
|
||||||
|
{
|
||||||
|
EmailValidator.IsValid(email).Should().Be(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(InvalidOrderCases))]
|
||||||
|
public async Task PlaceOrderAsync_RejectsInvalidOrders(CreateOrderRequest request, string expectedError)
|
||||||
|
{
|
||||||
|
var result = await _sut.PlaceOrderAsync(request, CancellationToken.None);
|
||||||
|
|
||||||
|
result.IsSuccess.Should().BeFalse();
|
||||||
|
result.Error.Should().Contain(expectedError);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TheoryData<CreateOrderRequest, string> InvalidOrderCases => new()
|
||||||
|
{
|
||||||
|
{ new() { CustomerId = "", Items = [ValidItem()] }, "CustomerId" },
|
||||||
|
{ new() { CustomerId = "c1", Items = [] }, "at least one item" },
|
||||||
|
{ new() { CustomerId = "c1", Items = [new("", 1, 10m)] }, "SKU" },
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用 NSubstitute 进行模拟
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task GetOrderAsync_ReturnsNull_WhenNotFound()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var orderId = Guid.NewGuid();
|
||||||
|
_repository.FindByIdAsync(orderId, Arg.Any<CancellationToken>())
|
||||||
|
.Returns((Order?)null);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.GetOrderAsync(orderId, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PlaceOrderAsync_PersistsOrder()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var request = ValidOrderRequest();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _sut.PlaceOrderAsync(request, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert — verify the repository was called
|
||||||
|
await _repository.Received(1).AddAsync(
|
||||||
|
Arg.Is<Order>(o => o.CustomerId == request.CustomerId),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## ASP.NET Core 集成测试
|
||||||
|
|
||||||
|
### WebApplicationFactory 设置
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed class OrderApiTests : IClassFixture<WebApplicationFactory<Program>>
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
|
||||||
|
public OrderApiTests(WebApplicationFactory<Program> factory)
|
||||||
|
{
|
||||||
|
_client = factory.WithWebHostBuilder(builder =>
|
||||||
|
{
|
||||||
|
builder.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
// Replace real DB with in-memory for tests
|
||||||
|
services.RemoveAll<DbContextOptions<AppDbContext>>();
|
||||||
|
services.AddDbContext<AppDbContext>(options =>
|
||||||
|
options.UseInMemoryDatabase("TestDb"));
|
||||||
|
});
|
||||||
|
}).CreateClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetOrder_Returns404_WhenNotFound()
|
||||||
|
{
|
||||||
|
var response = await _client.GetAsync($"/api/orders/{Guid.NewGuid()}");
|
||||||
|
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateOrder_Returns201_WithValidRequest()
|
||||||
|
{
|
||||||
|
var request = new CreateOrderRequest
|
||||||
|
{
|
||||||
|
CustomerId = "cust-1",
|
||||||
|
Items = [new("SKU-001", 1, 19.99m)]
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await _client.PostAsJsonAsync("/api/orders", request);
|
||||||
|
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||||
|
response.Headers.Location.Should().NotBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用 Testcontainers 进行测试
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed class PostgresOrderRepositoryTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
|
||||||
|
.WithImage("postgres:16-alpine")
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
private AppDbContext _db = null!;
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
await _postgres.StartAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseNpgsql(_postgres.GetConnectionString())
|
||||||
|
.Options;
|
||||||
|
_db = new AppDbContext(options);
|
||||||
|
await _db.Database.MigrateAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DisposeAsync()
|
||||||
|
{
|
||||||
|
await _db.DisposeAsync();
|
||||||
|
await _postgres.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddAsync_PersistsOrder()
|
||||||
|
{
|
||||||
|
var repo = new SqlOrderRepository(_db);
|
||||||
|
var order = Order.Create("cust-1", [new OrderItem("SKU-001", 2, 10m)]);
|
||||||
|
|
||||||
|
await repo.AddAsync(order, CancellationToken.None);
|
||||||
|
|
||||||
|
var found = await repo.FindByIdAsync(order.Id, CancellationToken.None);
|
||||||
|
found.Should().NotBeNull();
|
||||||
|
found!.Items.Should().HaveCount(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试组织
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
MyApp.UnitTests/
|
||||||
|
Services/
|
||||||
|
OrderServiceTests.cs
|
||||||
|
PaymentServiceTests.cs
|
||||||
|
Validators/
|
||||||
|
EmailValidatorTests.cs
|
||||||
|
MyApp.IntegrationTests/
|
||||||
|
Api/
|
||||||
|
OrderApiTests.cs
|
||||||
|
Repositories/
|
||||||
|
OrderRepositoryTests.cs
|
||||||
|
MyApp.TestHelpers/
|
||||||
|
Builders/
|
||||||
|
OrderBuilder.cs
|
||||||
|
Fixtures/
|
||||||
|
DatabaseFixture.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试数据构建器
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed class OrderBuilder
|
||||||
|
{
|
||||||
|
private string _customerId = "cust-default";
|
||||||
|
private readonly List<OrderItem> _items = [new("SKU-001", 1, 10m)];
|
||||||
|
|
||||||
|
public OrderBuilder WithCustomer(string customerId)
|
||||||
|
{
|
||||||
|
_customerId = customerId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OrderBuilder WithItem(string sku, int quantity, decimal price)
|
||||||
|
{
|
||||||
|
_items.Add(new OrderItem(sku, quantity, price));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Order Build() => Order.Create(_customerId, _items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage in tests
|
||||||
|
var order = new OrderBuilder()
|
||||||
|
.WithCustomer("cust-vip")
|
||||||
|
.WithItem("SKU-PREMIUM", 3, 99.99m)
|
||||||
|
.Build();
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见反模式
|
||||||
|
|
||||||
|
| 反模式 | 修复方法 |
|
||||||
|
|---|---|
|
||||||
|
| 测试实现细节 | 测试行为和结果 |
|
||||||
|
| 共享的可变测试状态 | 每个测试使用新实例(xUnit 通过构造函数实现) |
|
||||||
|
| 在异步测试中使用 `Thread.Sleep` | 使用带超时的 `Task.Delay` 或轮询辅助方法 |
|
||||||
|
| 对 `ToString()` 输出进行断言 | 对类型化属性进行断言 |
|
||||||
|
| 每个测试一个巨型断言 | 每个测试一个逻辑断言 |
|
||||||
|
| 测试名称描述实现 | 按行为命名:`Method_ExpectedResult_WhenCondition` |
|
||||||
|
| 忽略 `CancellationToken` | 始终传递并验证取消 |
|
||||||
|
|
||||||
|
## 运行测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
dotnet test
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
dotnet test --collect:"XPlat Code Coverage"
|
||||||
|
|
||||||
|
# Run specific project
|
||||||
|
dotnet test tests/MyApp.UnitTests/
|
||||||
|
|
||||||
|
# Filter by test name
|
||||||
|
dotnet test --filter "FullyQualifiedName~OrderService"
|
||||||
|
|
||||||
|
# Watch mode during development
|
||||||
|
dotnet watch test --project tests/MyApp.UnitTests/
|
||||||
|
```
|
||||||
565
docs/zh-CN/skills/dart-flutter-patterns/SKILL.md
Normal file
565
docs/zh-CN/skills/dart-flutter-patterns/SKILL.md
Normal file
@ -0,0 +1,565 @@
|
|||||||
|
---
|
||||||
|
name: dart-flutter-patterns
|
||||||
|
description: 生产就绪的 Dart 和 Flutter 模式,涵盖空安全、不可变状态、异步组合、Widget 架构、流行的状态管理框架(BLoC、Riverpod、Provider)、GoRouter 导航、Dio 网络请求、Freezed 代码生成和整洁架构。
|
||||||
|
origin: ECC
|
||||||
|
---
|
||||||
|
|
||||||
|
# Dart/Flutter 模式
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
在以下情况使用此技能:
|
||||||
|
|
||||||
|
* 开始新的 Flutter 功能,需要状态管理、导航或数据访问的惯用模式
|
||||||
|
* 审查或编写 Dart 代码,需要空安全、密封类型或异步组合的指导
|
||||||
|
* 搭建新的 Flutter 项目,在 BLoC、Riverpod 或 Provider 之间做选择
|
||||||
|
* 实现安全的 HTTP 客户端、WebView 集成或本地存储
|
||||||
|
* 为 Flutter 组件、Cubit 或 Riverpod 提供者编写测试
|
||||||
|
* 使用认证守卫配置 GoRouter
|
||||||
|
|
||||||
|
## 工作原理
|
||||||
|
|
||||||
|
此技能提供按关注点组织的、可直接复制粘贴的 Dart/Flutter 代码模式:
|
||||||
|
|
||||||
|
1. **空安全** — 避免 `!`,优先使用 `?.`/`??`/模式匹配
|
||||||
|
2. **不可变状态** — 密封类、`freezed`、`copyWith`
|
||||||
|
3. **异步组合** — 并发 `Future.wait`、`BuildContext` 后安全使用 `await`
|
||||||
|
4. **组件架构** — 提取为类(而非方法)、`const` 传播、作用域重建
|
||||||
|
5. **状态管理** — BLoC/Cubit 事件、Riverpod 通知器和派生提供者
|
||||||
|
6. **导航** — 通过 `refreshListenable` 实现带响应式认证守卫的 GoRouter
|
||||||
|
7. **网络请求** — 带拦截器的 Dio、带一次性重试守卫的令牌刷新
|
||||||
|
8. **错误处理** — 全局捕获、`ErrorWidget.builder`、Crashlytics 集成
|
||||||
|
9. **测试** — 单元测试(BLoC 测试)、组件测试(ProviderScope 覆盖)、使用假对象而非模拟对象
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Sealed state — prevents impossible states
|
||||||
|
sealed class AsyncState<T> {}
|
||||||
|
final class Loading<T> extends AsyncState<T> {}
|
||||||
|
final class Success<T> extends AsyncState<T> { final T data; const Success(this.data); }
|
||||||
|
final class Failure<T> extends AsyncState<T> { final Object error; const Failure(this.error); }
|
||||||
|
|
||||||
|
// GoRouter with reactive auth redirect
|
||||||
|
final router = GoRouter(
|
||||||
|
refreshListenable: GoRouterRefreshStream(authCubit.stream),
|
||||||
|
redirect: (context, state) {
|
||||||
|
final authed = context.read<AuthCubit>().state is AuthAuthenticated;
|
||||||
|
if (!authed && !state.matchedLocation.startsWith('/login')) return '/login';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
routes: [...],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Riverpod derived provider with safe firstWhereOrNull
|
||||||
|
@riverpod
|
||||||
|
double cartTotal(Ref ref) {
|
||||||
|
final cart = ref.watch(cartNotifierProvider);
|
||||||
|
final products = ref.watch(productsProvider).valueOrNull ?? [];
|
||||||
|
return cart.fold(0.0, (total, item) {
|
||||||
|
final product = products.firstWhereOrNull((p) => p.id == item.productId);
|
||||||
|
return total + (product?.price ?? 0) * item.quantity;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
适用于 Dart 和 Flutter 应用程序的实用、生产就绪模式。尽可能保持库无关性,并明确覆盖最常见的生态系统包。
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 1. 空安全基础
|
||||||
|
|
||||||
|
### 优先使用模式而非感叹号操作符
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// BAD — crashes at runtime if null
|
||||||
|
final name = user!.name;
|
||||||
|
|
||||||
|
// GOOD — provide fallback
|
||||||
|
final name = user?.name ?? 'Unknown';
|
||||||
|
|
||||||
|
// GOOD — Dart 3 pattern matching (preferred for complex cases)
|
||||||
|
final display = switch (user) {
|
||||||
|
User(:final name, :final email) => '$name <$email>',
|
||||||
|
null => 'Guest',
|
||||||
|
};
|
||||||
|
|
||||||
|
// GOOD — guard early return
|
||||||
|
String getUserName(User? user) {
|
||||||
|
if (user == null) return 'Unknown';
|
||||||
|
return user.name; // promoted to non-null after check
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 避免过度使用 `late`
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// BAD — defers null error to runtime
|
||||||
|
late String userId;
|
||||||
|
|
||||||
|
// GOOD — nullable with explicit initialization
|
||||||
|
String? userId;
|
||||||
|
|
||||||
|
// OK — use late only when initialization is guaranteed before first access
|
||||||
|
// (e.g., in initState() before any widget interaction)
|
||||||
|
late final AnimationController _controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 300));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 2. 不可变状态
|
||||||
|
|
||||||
|
### 状态层次结构的密封类
|
||||||
|
|
||||||
|
```dart
|
||||||
|
sealed class UserState {}
|
||||||
|
|
||||||
|
final class UserInitial extends UserState {}
|
||||||
|
|
||||||
|
final class UserLoading extends UserState {}
|
||||||
|
|
||||||
|
final class UserLoaded extends UserState {
|
||||||
|
const UserLoaded(this.user);
|
||||||
|
final User user;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class UserError extends UserState {
|
||||||
|
const UserError(this.message);
|
||||||
|
final String message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exhaustive switch — compiler enforces all branches
|
||||||
|
Widget buildFrom(UserState state) => switch (state) {
|
||||||
|
UserInitial() => const SizedBox.shrink(),
|
||||||
|
UserLoading() => const CircularProgressIndicator(),
|
||||||
|
UserLoaded(:final user) => UserCard(user: user),
|
||||||
|
UserError(:final message) => ErrorText(message),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用 Freezed 实现无模板代码的不可变性
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'user.freezed.dart';
|
||||||
|
part 'user.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class User with _$User {
|
||||||
|
const factory User({
|
||||||
|
required String id,
|
||||||
|
required String name,
|
||||||
|
required String email,
|
||||||
|
@Default(false) bool isAdmin,
|
||||||
|
}) = _User;
|
||||||
|
|
||||||
|
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
final user = User(id: '1', name: 'Alice', email: 'alice@example.com');
|
||||||
|
final updated = user.copyWith(name: 'Alice Smith'); // immutable update
|
||||||
|
final json = user.toJson();
|
||||||
|
final fromJson = User.fromJson(json);
|
||||||
|
```
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 3. 异步组合
|
||||||
|
|
||||||
|
### 使用 Future.wait 的结构化并发
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Future<DashboardData> loadDashboard(UserRepository users, OrderRepository orders) async {
|
||||||
|
// Run concurrently — don't await sequentially
|
||||||
|
final (userList, orderList) = await (
|
||||||
|
users.getAll(),
|
||||||
|
orders.getRecent(),
|
||||||
|
).wait; // Dart 3 record destructuring + Future.wait extension
|
||||||
|
|
||||||
|
return DashboardData(users: userList, orders: orderList);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 流模式
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Repository exposes reactive streams for live data
|
||||||
|
Stream<List<Item>> watchCartItems() => _db
|
||||||
|
.watchTable('cart_items')
|
||||||
|
.map((rows) => rows.map(Item.fromRow).toList());
|
||||||
|
|
||||||
|
// In widget layer — declarative, no manual subscription
|
||||||
|
StreamBuilder<List<Item>>(
|
||||||
|
stream: cartRepository.watchCartItems(),
|
||||||
|
builder: (context, snapshot) => switch (snapshot) {
|
||||||
|
AsyncSnapshot(connectionState: ConnectionState.waiting) =>
|
||||||
|
const CircularProgressIndicator(),
|
||||||
|
AsyncSnapshot(:final error?) => ErrorWidget(error.toString()),
|
||||||
|
AsyncSnapshot(:final data?) => CartList(items: data),
|
||||||
|
_ => const SizedBox.shrink(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Await 后的 BuildContext
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// CRITICAL — always check mounted after any await in StatefulWidget
|
||||||
|
Future<void> _handleSubmit() async {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
try {
|
||||||
|
await authService.login(_email, _password);
|
||||||
|
if (!mounted) return; // ← guard before using context
|
||||||
|
context.go('/home');
|
||||||
|
} on AuthException catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 4. 组件架构
|
||||||
|
|
||||||
|
### 提取为类,而非方法
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// BAD — private method returning widget, prevents optimization
|
||||||
|
Widget _buildHeader() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text(title, style: Theme.of(context).textTheme.headlineMedium),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GOOD — separate widget class, enables const, element reuse
|
||||||
|
class _PageHeader extends StatelessWidget {
|
||||||
|
const _PageHeader(this.title);
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text(title, style: Theme.of(context).textTheme.headlineMedium),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### const 传播
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// BAD — new instances every rebuild
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(16.0), // not const
|
||||||
|
child: Icon(Icons.home, size: 24.0), // not const
|
||||||
|
)
|
||||||
|
|
||||||
|
// GOOD — const stops rebuild propagation
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.all(16.0),
|
||||||
|
child: Icon(Icons.home, size: 24.0),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 作用域重建
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// BAD — entire page rebuilds on every counter change
|
||||||
|
class CounterPage extends ConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final count = ref.watch(counterProvider); // rebuilds everything
|
||||||
|
return Scaffold(
|
||||||
|
body: Column(children: [
|
||||||
|
const ExpensiveHeader(), // unnecessarily rebuilt
|
||||||
|
Text('$count'),
|
||||||
|
const ExpensiveFooter(), // unnecessarily rebuilt
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GOOD — isolate the rebuilding part
|
||||||
|
class CounterPage extends StatelessWidget {
|
||||||
|
const CounterPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const Scaffold(
|
||||||
|
body: Column(children: [
|
||||||
|
ExpensiveHeader(), // never rebuilt (const)
|
||||||
|
_CounterDisplay(), // only this rebuilds
|
||||||
|
ExpensiveFooter(), // never rebuilt (const)
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CounterDisplay extends ConsumerWidget {
|
||||||
|
const _CounterDisplay();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final count = ref.watch(counterProvider);
|
||||||
|
return Text('$count');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 5. 状态管理:BLoC/Cubit
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Cubit — synchronous or simple async state
|
||||||
|
class AuthCubit extends Cubit<AuthState> {
|
||||||
|
AuthCubit(this._authService) : super(const AuthState.initial());
|
||||||
|
final AuthService _authService;
|
||||||
|
|
||||||
|
Future<void> login(String email, String password) async {
|
||||||
|
emit(const AuthState.loading());
|
||||||
|
try {
|
||||||
|
final user = await _authService.login(email, password);
|
||||||
|
emit(AuthState.authenticated(user));
|
||||||
|
} on AuthException catch (e) {
|
||||||
|
emit(AuthState.error(e.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void logout() {
|
||||||
|
_authService.logout();
|
||||||
|
emit(const AuthState.initial());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In widget
|
||||||
|
BlocBuilder<AuthCubit, AuthState>(
|
||||||
|
builder: (context, state) => switch (state) {
|
||||||
|
AuthInitial() => const LoginForm(),
|
||||||
|
AuthLoading() => const CircularProgressIndicator(),
|
||||||
|
AuthAuthenticated(:final user) => HomePage(user: user),
|
||||||
|
AuthError(:final message) => ErrorView(message: message),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 6. 状态管理:Riverpod
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Auto-dispose async provider
|
||||||
|
@riverpod
|
||||||
|
Future<List<Product>> products(Ref ref) async {
|
||||||
|
final repo = ref.watch(productRepositoryProvider);
|
||||||
|
return repo.getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notifier with complex mutations
|
||||||
|
@riverpod
|
||||||
|
class CartNotifier extends _$CartNotifier {
|
||||||
|
@override
|
||||||
|
List<CartItem> build() => [];
|
||||||
|
|
||||||
|
void add(Product product) {
|
||||||
|
final existing = state.where((i) => i.productId == product.id).firstOrNull;
|
||||||
|
if (existing != null) {
|
||||||
|
state = [
|
||||||
|
for (final item in state)
|
||||||
|
if (item.productId == product.id) item.copyWith(quantity: item.quantity + 1)
|
||||||
|
else item,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
state = [...state, CartItem(productId: product.id, quantity: 1)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void remove(String productId) =>
|
||||||
|
state = state.where((i) => i.productId != productId).toList();
|
||||||
|
|
||||||
|
void clear() => state = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derived provider (selector pattern)
|
||||||
|
@riverpod
|
||||||
|
int cartCount(Ref ref) => ref.watch(cartNotifierProvider).length;
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
double cartTotal(Ref ref) {
|
||||||
|
final cart = ref.watch(cartNotifierProvider);
|
||||||
|
final products = ref.watch(productsProvider).valueOrNull ?? [];
|
||||||
|
return cart.fold(0.0, (total, item) {
|
||||||
|
// firstWhereOrNull (from collection package) avoids StateError when product is missing
|
||||||
|
final product = products.firstWhereOrNull((p) => p.id == item.productId);
|
||||||
|
return total + (product?.price ?? 0) * item.quantity;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 7. 使用 GoRouter 的导航
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final router = GoRouter(
|
||||||
|
initialLocation: '/',
|
||||||
|
// refreshListenable re-evaluates redirect whenever auth state changes
|
||||||
|
refreshListenable: GoRouterRefreshStream(authCubit.stream),
|
||||||
|
redirect: (context, state) {
|
||||||
|
final isLoggedIn = context.read<AuthCubit>().state is AuthAuthenticated;
|
||||||
|
final isGoingToLogin = state.matchedLocation == '/login';
|
||||||
|
if (!isLoggedIn && !isGoingToLogin) return '/login';
|
||||||
|
if (isLoggedIn && isGoingToLogin) return '/';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
routes: [
|
||||||
|
GoRoute(path: '/login', builder: (_, __) => const LoginPage()),
|
||||||
|
ShellRoute(
|
||||||
|
builder: (context, state, child) => AppShell(child: child),
|
||||||
|
routes: [
|
||||||
|
GoRoute(path: '/', builder: (_, __) => const HomePage()),
|
||||||
|
GoRoute(
|
||||||
|
path: '/products/:id',
|
||||||
|
builder: (context, state) =>
|
||||||
|
ProductDetailPage(id: state.pathParameters['id']!),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 8. 使用 Dio 的 HTTP 请求
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final dio = Dio(BaseOptions(
|
||||||
|
baseUrl: const String.fromEnvironment('API_URL'),
|
||||||
|
connectTimeout: const Duration(seconds: 10),
|
||||||
|
receiveTimeout: const Duration(seconds: 30),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
));
|
||||||
|
|
||||||
|
// Add auth interceptor
|
||||||
|
dio.interceptors.add(InterceptorsWrapper(
|
||||||
|
onRequest: (options, handler) async {
|
||||||
|
final token = await secureStorage.read(key: 'auth_token');
|
||||||
|
if (token != null) options.headers['Authorization'] = 'Bearer $token';
|
||||||
|
handler.next(options);
|
||||||
|
},
|
||||||
|
onError: (error, handler) async {
|
||||||
|
// Guard against infinite retry loops: only attempt refresh once per request
|
||||||
|
final isRetry = error.requestOptions.extra['_isRetry'] == true;
|
||||||
|
if (!isRetry && error.response?.statusCode == 401) {
|
||||||
|
final refreshed = await attemptTokenRefresh();
|
||||||
|
if (refreshed) {
|
||||||
|
error.requestOptions.extra['_isRetry'] = true;
|
||||||
|
return handler.resolve(await dio.fetch(error.requestOptions));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handler.next(error);
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
// Repository using Dio
|
||||||
|
class UserApiDataSource {
|
||||||
|
const UserApiDataSource(this._dio);
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
Future<User> getById(String id) async {
|
||||||
|
final response = await _dio.get<Map<String, dynamic>>('/users/$id');
|
||||||
|
return User.fromJson(response.data!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 9. 错误处理架构
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Global error capture — set up in main()
|
||||||
|
void main() {
|
||||||
|
FlutterError.onError = (details) {
|
||||||
|
FlutterError.presentError(details);
|
||||||
|
crashlytics.recordFlutterFatalError(details);
|
||||||
|
};
|
||||||
|
|
||||||
|
PlatformDispatcher.instance.onError = (error, stack) {
|
||||||
|
crashlytics.recordError(error, stack, fatal: true);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
runApp(const App());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom ErrorWidget for production
|
||||||
|
class App extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
ErrorWidget.builder = (details) => ProductionErrorWidget(details);
|
||||||
|
return MaterialApp.router(routerConfig: router);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 10. 测试快速参考
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Unit test — use case
|
||||||
|
test('GetUserUseCase returns null for missing user', () async {
|
||||||
|
final repo = FakeUserRepository();
|
||||||
|
final useCase = GetUserUseCase(repo);
|
||||||
|
expect(await useCase('missing-id'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
// BLoC test
|
||||||
|
blocTest<AuthCubit, AuthState>(
|
||||||
|
'emits loading then error on failed login',
|
||||||
|
build: () => AuthCubit(FakeAuthService(throwsOn: 'login')),
|
||||||
|
act: (cubit) => cubit.login('user@test.com', 'wrong'),
|
||||||
|
expect: () => [const AuthState.loading(), isA<AuthError>()],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Widget test
|
||||||
|
testWidgets('CartBadge shows item count', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [cartNotifierProvider.overrideWith(() => FakeCartNotifier(count: 3))],
|
||||||
|
child: const MaterialApp(home: CartBadge()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(find.text('3'), findsOneWidget);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 参考
|
||||||
|
|
||||||
|
* [Effective Dart: 设计](https://dart.dev/effective-dart/design)
|
||||||
|
* [Flutter 性能最佳实践](https://docs.flutter.dev/perf/best-practices)
|
||||||
|
* [Riverpod 文档](https://riverpod.dev/)
|
||||||
|
* [BLoC 库](https://bloclibrary.dev/)
|
||||||
|
* [GoRouter](https://pub.dev/packages/go_router)
|
||||||
|
* [Freezed](https://pub.dev/packages/freezed)
|
||||||
|
* 技能:`flutter-dart-code-review` — 全面审查清单
|
||||||
|
* 规则:`rules/dart/` — 编码风格、模式、安全性、测试、钩子
|
||||||
108
docs/zh-CN/skills/dashboard-builder/SKILL.md
Normal file
108
docs/zh-CN/skills/dashboard-builder/SKILL.md
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
---
|
||||||
|
name: dashboard-builder
|
||||||
|
description: 为 Grafana、SigNoz 等平台构建能够回答实际运维人员问题的监控仪表板。适用于将指标转化为可用的仪表板,而非华而不实的展示板。
|
||||||
|
origin: ECC direct-port adaptation
|
||||||
|
version: "1.0.0"
|
||||||
|
---
|
||||||
|
|
||||||
|
# 仪表盘构建器
|
||||||
|
|
||||||
|
当任务需要构建一个可供操作人员使用的仪表盘时使用此方案。
|
||||||
|
|
||||||
|
目标不是"展示所有指标",而是回答以下问题:
|
||||||
|
|
||||||
|
* 系统健康吗?
|
||||||
|
* 瓶颈在哪里?
|
||||||
|
* 发生了什么变化?
|
||||||
|
* 应该采取什么行动?
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
* "构建一个Kafka监控仪表盘"
|
||||||
|
* "为Elasticsearch创建一个Grafana仪表盘"
|
||||||
|
* "为这个服务制作一个SigNoz仪表盘"
|
||||||
|
* "将这个指标列表转化为真正的运维仪表盘"
|
||||||
|
|
||||||
|
## 约束条件
|
||||||
|
|
||||||
|
* 不要从视觉布局开始;要从操作人员的问题出发
|
||||||
|
* 不要仅仅因为指标存在就包含所有可用指标
|
||||||
|
* 不要在没有结构的情况下混合健康、吞吐量和资源面板
|
||||||
|
* 不要发布没有标题、单位和合理阈值的面板
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
### 1. 定义操作问题
|
||||||
|
|
||||||
|
围绕以下方面组织:
|
||||||
|
|
||||||
|
* 健康/可用性
|
||||||
|
* 延迟/性能
|
||||||
|
* 吞吐量/容量
|
||||||
|
* 饱和度/资源
|
||||||
|
* 服务特定风险
|
||||||
|
|
||||||
|
### 2. 研究目标平台架构
|
||||||
|
|
||||||
|
首先检查现有仪表盘:
|
||||||
|
|
||||||
|
* JSON结构
|
||||||
|
* 查询语言
|
||||||
|
* 变量
|
||||||
|
* 阈值样式
|
||||||
|
* 分区布局
|
||||||
|
|
||||||
|
### 3. 构建最小可用面板
|
||||||
|
|
||||||
|
推荐结构:
|
||||||
|
|
||||||
|
1. 概览
|
||||||
|
2. 性能
|
||||||
|
3. 资源
|
||||||
|
4. 服务特定分区
|
||||||
|
|
||||||
|
### 4. 剔除装饰性面板
|
||||||
|
|
||||||
|
每个面板都应回答一个真实问题。如果不能,则移除。
|
||||||
|
|
||||||
|
## 示例面板集
|
||||||
|
|
||||||
|
### Elasticsearch
|
||||||
|
|
||||||
|
* 集群健康
|
||||||
|
* 分片分配
|
||||||
|
* 搜索延迟
|
||||||
|
* 索引速率
|
||||||
|
* JVM堆/GC
|
||||||
|
|
||||||
|
### Kafka
|
||||||
|
|
||||||
|
* 代理数量
|
||||||
|
* 副本不足的分区
|
||||||
|
* 消息流入/流出
|
||||||
|
* 消费者滞后
|
||||||
|
* 磁盘和网络压力
|
||||||
|
|
||||||
|
### API网关/入口
|
||||||
|
|
||||||
|
* 请求速率
|
||||||
|
* p50/p95/p99延迟
|
||||||
|
* 错误率
|
||||||
|
* 上游健康
|
||||||
|
* 活跃连接数
|
||||||
|
|
||||||
|
## 质量检查清单
|
||||||
|
|
||||||
|
* \[ ] 有效的仪表盘JSON
|
||||||
|
* \[ ] 清晰的分区分组
|
||||||
|
* \[ ] 包含标题和单位
|
||||||
|
* \[ ] 阈值/状态颜色有意义
|
||||||
|
* \[ ] 存在常用过滤器的变量
|
||||||
|
* \[ ] 默认时间范围和刷新频率合理
|
||||||
|
* \[ ] 没有对操作人员无价值的装饰性面板
|
||||||
|
|
||||||
|
## 相关技能
|
||||||
|
|
||||||
|
* `research-ops`
|
||||||
|
* `backend-patterns`
|
||||||
|
* `terminal-ops`
|
||||||
85
docs/zh-CN/skills/design-system/SKILL.md
Normal file
85
docs/zh-CN/skills/design-system/SKILL.md
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
name: design-system
|
||||||
|
description: 使用此技能生成或审计设计系统,检查视觉一致性,并审查涉及样式的PR。
|
||||||
|
origin: ECC
|
||||||
|
---
|
||||||
|
|
||||||
|
# 设计系统 — 生成与审查视觉系统
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
* 启动需要设计系统的新项目
|
||||||
|
* 审查现有代码库的视觉一致性
|
||||||
|
* 在重新设计前——了解现有状况
|
||||||
|
* 当界面看起来"不对劲"但无法定位原因时
|
||||||
|
* 审查涉及样式修改的PR
|
||||||
|
|
||||||
|
## 工作原理
|
||||||
|
|
||||||
|
### 模式1:生成设计系统
|
||||||
|
|
||||||
|
分析代码库并生成统一的设计系统:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 扫描 CSS/Tailwind/styled-components 以查找现有模式
|
||||||
|
2. 提取:颜色、排版、间距、边框圆角、阴影、断点
|
||||||
|
3. 研究 3 个竞品网站以获取灵感(通过浏览器 MCP)
|
||||||
|
4. 提出一套设计令牌(JSON + CSS 自定义属性)
|
||||||
|
5. 生成 DESIGN.md,说明每个决策的理由
|
||||||
|
6. 创建一个交互式 HTML 预览页面(自包含,无依赖)
|
||||||
|
```
|
||||||
|
|
||||||
|
输出:`DESIGN.md` + `design-tokens.json` + `design-preview.html`
|
||||||
|
|
||||||
|
### 模式2:视觉审查
|
||||||
|
|
||||||
|
从10个维度对界面进行评分(每项0-10分):
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 色彩一致性 — 你使用的是自己的调色板还是随机的十六进制值?
|
||||||
|
2. 排版层级 — 清晰的 h1 > h2 > h3 > 正文 > 说明文字?
|
||||||
|
3. 间距节奏 — 一致的尺度(4px/8px/16px)还是随意设置?
|
||||||
|
4. 组件一致性 — 相似的元素看起来是否相似?
|
||||||
|
5. 响应式行为 — 在断点处流畅还是混乱?
|
||||||
|
6. 深色模式 — 完整实现还是半途而废?
|
||||||
|
7. 动画 — 有目的性还是多余?
|
||||||
|
8. 无障碍性 — 对比度、焦点状态、触摸目标
|
||||||
|
9. 信息密度 — 杂乱还是整洁?
|
||||||
|
10. 细节打磨 — 悬停状态、过渡效果、加载状态、空状态
|
||||||
|
```
|
||||||
|
|
||||||
|
每个维度都会获得评分、具体示例以及包含精确文件:行号的修复方案。
|
||||||
|
|
||||||
|
### 模式3:AI生成内容检测
|
||||||
|
|
||||||
|
识别通用的AI生成设计模式:
|
||||||
|
|
||||||
|
```
|
||||||
|
- 到处滥用渐变效果
|
||||||
|
- 默认采用紫蓝配色
|
||||||
|
- 毫无意义的"玻璃拟态"卡片
|
||||||
|
- 不该圆角的地方强行圆角
|
||||||
|
- 滚动时过度动画效果
|
||||||
|
- 居中文字搭配默认渐变的通用英雄区
|
||||||
|
- 毫无个性的无衬线字体堆叠
|
||||||
|
```
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
**为SaaS应用生成设计系统:**
|
||||||
|
|
||||||
|
```
|
||||||
|
/design-system generate --style minimal --palette earth-tones
|
||||||
|
```
|
||||||
|
|
||||||
|
**审查现有界面:**
|
||||||
|
|
||||||
|
```
|
||||||
|
/design-system audit --url http://localhost:3000 --pages / /pricing /docs
|
||||||
|
```
|
||||||
|
|
||||||
|
**检测AI生成内容:**
|
||||||
|
|
||||||
|
```
|
||||||
|
/design-system slop-check
|
||||||
|
```
|
||||||
321
docs/zh-CN/skills/dotnet-patterns/SKILL.md
Normal file
321
docs/zh-CN/skills/dotnet-patterns/SKILL.md
Normal file
@ -0,0 +1,321 @@
|
|||||||
|
---
|
||||||
|
name: dotnet-patterns
|
||||||
|
description: 惯用的C#和.NET模式、约定、依赖注入、async/await以及构建健壮、可维护的.NET应用程序的最佳实践。
|
||||||
|
origin: ECC
|
||||||
|
---
|
||||||
|
|
||||||
|
# .NET 开发模式
|
||||||
|
|
||||||
|
用于构建健壮、高性能且可维护应用程序的惯用 C# 和 .NET 模式。
|
||||||
|
|
||||||
|
## 何时激活
|
||||||
|
|
||||||
|
* 编写新的 C# 代码时
|
||||||
|
* 审查 C# 代码时
|
||||||
|
* 重构现有 .NET 应用程序时
|
||||||
|
* 使用 ASP.NET Core 设计服务架构时
|
||||||
|
|
||||||
|
## 核心原则
|
||||||
|
|
||||||
|
### 1. 优先使用不可变性
|
||||||
|
|
||||||
|
对数据模型使用记录和仅初始化属性。可变性应作为明确且有理由的选择。
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Good: Immutable value object
|
||||||
|
public sealed record Money(decimal Amount, string Currency);
|
||||||
|
|
||||||
|
// Good: Immutable DTO with init setters
|
||||||
|
public sealed class CreateOrderRequest
|
||||||
|
{
|
||||||
|
public required string CustomerId { get; init; }
|
||||||
|
public required IReadOnlyList<OrderItem> Items { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bad: Mutable model with public setters
|
||||||
|
public class Order
|
||||||
|
{
|
||||||
|
public string CustomerId { get; set; }
|
||||||
|
public List<OrderItem> Items { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 显式优于隐式
|
||||||
|
|
||||||
|
明确表达可空性、访问修饰符和意图。
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Good: Explicit access modifiers and nullability
|
||||||
|
public sealed class UserService
|
||||||
|
{
|
||||||
|
private readonly IUserRepository _repository;
|
||||||
|
private readonly ILogger<UserService> _logger;
|
||||||
|
|
||||||
|
public UserService(IUserRepository repository, ILogger<UserService> logger)
|
||||||
|
{
|
||||||
|
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<User?> FindByIdAsync(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _repository.FindByIdAsync(id, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 依赖抽象
|
||||||
|
|
||||||
|
对服务边界使用接口。通过依赖注入容器注册。
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Good: Interface-based dependency
|
||||||
|
public interface IOrderRepository
|
||||||
|
{
|
||||||
|
Task<Order?> FindByIdAsync(Guid id, CancellationToken cancellationToken);
|
||||||
|
Task<IReadOnlyList<Order>> FindByCustomerAsync(string customerId, CancellationToken cancellationToken);
|
||||||
|
Task AddAsync(Order order, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registration
|
||||||
|
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
|
||||||
|
```
|
||||||
|
|
||||||
|
## 异步/等待模式
|
||||||
|
|
||||||
|
### 正确使用异步
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Good: Async all the way, with CancellationToken
|
||||||
|
public async Task<OrderSummary> GetOrderSummaryAsync(
|
||||||
|
Guid orderId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var order = await _repository.FindByIdAsync(orderId, cancellationToken)
|
||||||
|
?? throw new NotFoundException($"Order {orderId} not found");
|
||||||
|
|
||||||
|
var customer = await _customerService.GetAsync(order.CustomerId, cancellationToken);
|
||||||
|
|
||||||
|
return new OrderSummary(order, customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bad: Blocking on async
|
||||||
|
public OrderSummary GetOrderSummary(Guid orderId)
|
||||||
|
{
|
||||||
|
var order = _repository.FindByIdAsync(orderId, CancellationToken.None).Result; // Deadlock risk
|
||||||
|
return new OrderSummary(order);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 并行异步操作
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Good: Concurrent independent operations
|
||||||
|
public async Task<DashboardData> LoadDashboardAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ordersTask = _orderService.GetRecentAsync(cancellationToken);
|
||||||
|
var metricsTask = _metricsService.GetCurrentAsync(cancellationToken);
|
||||||
|
var alertsTask = _alertService.GetActiveAsync(cancellationToken);
|
||||||
|
|
||||||
|
await Task.WhenAll(ordersTask, metricsTask, alertsTask);
|
||||||
|
|
||||||
|
return new DashboardData(
|
||||||
|
Orders: await ordersTask,
|
||||||
|
Metrics: await metricsTask,
|
||||||
|
Alerts: await alertsTask);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 选项模式
|
||||||
|
|
||||||
|
将配置节绑定到强类型对象。
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed class SmtpOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Smtp";
|
||||||
|
|
||||||
|
public required string Host { get; init; }
|
||||||
|
public required int Port { get; init; }
|
||||||
|
public required string Username { get; init; }
|
||||||
|
public bool UseSsl { get; init; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registration
|
||||||
|
builder.Services.Configure<SmtpOptions>(
|
||||||
|
builder.Configuration.GetSection(SmtpOptions.SectionName));
|
||||||
|
|
||||||
|
// Usage via injection
|
||||||
|
public class EmailService(IOptions<SmtpOptions> options)
|
||||||
|
{
|
||||||
|
private readonly SmtpOptions _smtp = options.Value;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 结果模式
|
||||||
|
|
||||||
|
对预期失败返回显式成功/失败,而非抛出异常。
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed record Result<T>
|
||||||
|
{
|
||||||
|
public bool IsSuccess { get; }
|
||||||
|
public T? Value { get; }
|
||||||
|
public string? Error { get; }
|
||||||
|
|
||||||
|
private Result(T value) { IsSuccess = true; Value = value; }
|
||||||
|
private Result(string error) { IsSuccess = false; Error = error; }
|
||||||
|
|
||||||
|
public static Result<T> Success(T value) => new(value);
|
||||||
|
public static Result<T> Failure(string error) => new(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
public async Task<Result<Order>> PlaceOrderAsync(CreateOrderRequest request)
|
||||||
|
{
|
||||||
|
if (request.Items.Count == 0)
|
||||||
|
return Result<Order>.Failure("Order must contain at least one item");
|
||||||
|
|
||||||
|
var order = Order.Create(request);
|
||||||
|
await _repository.AddAsync(order, CancellationToken.None);
|
||||||
|
return Result<Order>.Success(order);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用 EF Core 的仓储模式
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed class SqlOrderRepository : IOrderRepository
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
|
public SqlOrderRepository(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public async Task<Order?> FindByIdAsync(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _db.Orders
|
||||||
|
.Include(o => o.Items)
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Order>> FindByCustomerAsync(
|
||||||
|
string customerId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _db.Orders
|
||||||
|
.Where(o => o.CustomerId == customerId)
|
||||||
|
.OrderByDescending(o => o.CreatedAt)
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddAsync(Order order, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_db.Orders.Add(order);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 中间件与管道
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Custom middleware
|
||||||
|
public sealed class RequestTimingMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly ILogger<RequestTimingMiddleware> _logger;
|
||||||
|
|
||||||
|
public RequestTimingMiddleware(RequestDelegate next, ILogger<RequestTimingMiddleware> logger)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
var stopwatch = Stopwatch.StartNew();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
stopwatch.Stop();
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Request {Method} {Path} completed in {ElapsedMs}ms with status {StatusCode}",
|
||||||
|
context.Request.Method,
|
||||||
|
context.Request.Path,
|
||||||
|
stopwatch.ElapsedMilliseconds,
|
||||||
|
context.Response.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 最小 API 模式
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Organized with route groups
|
||||||
|
var orders = app.MapGroup("/api/orders")
|
||||||
|
.RequireAuthorization()
|
||||||
|
.WithTags("Orders");
|
||||||
|
|
||||||
|
orders.MapGet("/{id:guid}", async (
|
||||||
|
Guid id,
|
||||||
|
IOrderRepository repository,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var order = await repository.FindByIdAsync(id, cancellationToken);
|
||||||
|
return order is not null
|
||||||
|
? TypedResults.Ok(order)
|
||||||
|
: TypedResults.NotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
orders.MapPost("/", async (
|
||||||
|
CreateOrderRequest request,
|
||||||
|
IOrderService service,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var result = await service.PlaceOrderAsync(request, cancellationToken);
|
||||||
|
return result.IsSuccess
|
||||||
|
? TypedResults.Created($"/api/orders/{result.Value!.Id}", result.Value)
|
||||||
|
: TypedResults.BadRequest(result.Error);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 守卫子句
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Good: Early returns with clear validation
|
||||||
|
public async Task<ProcessResult> ProcessPaymentAsync(
|
||||||
|
PaymentRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
if (request.Amount <= 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(request.Amount), "Amount must be positive");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Currency))
|
||||||
|
throw new ArgumentException("Currency is required", nameof(request.Currency));
|
||||||
|
|
||||||
|
// Happy path continues here without nesting
|
||||||
|
var gateway = _gatewayFactory.Create(request.Currency);
|
||||||
|
return await gateway.ChargeAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 应避免的反模式
|
||||||
|
|
||||||
|
| 反模式 | 修复方案 |
|
||||||
|
|---|---|
|
||||||
|
| `async void` 方法 | 返回 `Task`(事件处理程序除外) |
|
||||||
|
| `.Result` 或 `.Wait()` | 使用 `await` |
|
||||||
|
| `catch (Exception) { }` | 处理或带上下文重新抛出 |
|
||||||
|
| 构造函数中的 `new Service()` | 使用构造函数注入 |
|
||||||
|
| `public` 字段 | 使用带适当访问器的属性 |
|
||||||
|
| 业务逻辑中的 `dynamic` | 使用泛型或显式类型 |
|
||||||
|
| 可变的 `static` 状态 | 使用依赖注入作用域或 `ConcurrentDictionary` |
|
||||||
|
| 循环中的 `string.Format` | 使用 `StringBuilder` 或内插字符串处理程序 |
|
||||||
284
docs/zh-CN/skills/gan-style-harness/SKILL.md
Normal file
284
docs/zh-CN/skills/gan-style-harness/SKILL.md
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
---
|
||||||
|
name: gan-style-harness
|
||||||
|
description: "受GAN启发的生成器-评估器代理框架,用于自主构建高质量应用。基于Anthropic 2026年3月的框架设计论文。"
|
||||||
|
origin: ECC-community
|
||||||
|
tools: Read, Write, Edit, Bash, Grep, Glob, Task
|
||||||
|
---
|
||||||
|
|
||||||
|
# GAN 风格编排技能
|
||||||
|
|
||||||
|
> 灵感来源于 [Anthropic 的长时间运行应用开发编排设计](https://www.anthropic.com/engineering/harness-design-long-running-apps)(2026年3月24日)
|
||||||
|
|
||||||
|
一种多智能体编排,将**生成**与**评估**分离,形成对抗性反馈循环,推动质量远超单个智能体所能达到的水平。
|
||||||
|
|
||||||
|
## 核心洞察
|
||||||
|
|
||||||
|
> 当要求评估自身工作时,智能体是病态的乐观主义者——它们会赞美平庸的输出,并说服自己忽略真正的问题。但设计一个**独立的评估器**并使其极度严格,远比教会生成器自我批评要容易得多。
|
||||||
|
|
||||||
|
这与 GAN(生成对抗网络)的机制相同:生成器负责产出,评估器负责批评,这种反馈驱动下一轮迭代。
|
||||||
|
|
||||||
|
## 适用场景
|
||||||
|
|
||||||
|
* 根据一行提示构建完整应用
|
||||||
|
* 需要高视觉质量的前端设计任务
|
||||||
|
* 需要工作功能而不仅仅是代码的全栈项目
|
||||||
|
* 任何"AI 垃圾"美学不可接受的任务
|
||||||
|
* 愿意投入 50-200 美元以获得生产级质量输出的项目
|
||||||
|
|
||||||
|
## 不适用场景
|
||||||
|
|
||||||
|
* 快速单文件修复(使用标准 `claude -p`)
|
||||||
|
* 预算紧张的任务(<10 美元)
|
||||||
|
* 简单重构(改用去垃圾化模式)
|
||||||
|
* 已有完善测试规范的任务(使用 TDD 工作流)
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐
|
||||||
|
│ 规划器 │
|
||||||
|
│ (Opus 4.6) │
|
||||||
|
└──────┬──────┘
|
||||||
|
│ 产品规格
|
||||||
|
│ (功能、冲刺、设计方向)
|
||||||
|
▼
|
||||||
|
┌────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ 生成器-评估器 │
|
||||||
|
│ 反馈循环 │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ │
|
||||||
|
│ │ 生成器 │--构建-->│──┐
|
||||||
|
│ │(Opus 4.6)│ │ │
|
||||||
|
│ └────▲─────┘ │ │
|
||||||
|
│ │ │ │ 实时应用
|
||||||
|
│ 反馈 │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ ┌────┴─────┐ │ │
|
||||||
|
│ │ 评估器 │<-测试---│──┘
|
||||||
|
│ │(Opus 4.6)│ │
|
||||||
|
│ │+Playwright│ │
|
||||||
|
│ └──────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 5-15 次迭代 │
|
||||||
|
└────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 三个智能体
|
||||||
|
|
||||||
|
### 1. 规划器智能体
|
||||||
|
|
||||||
|
**角色:** 产品经理——将简短的提示扩展为完整的产品规格。
|
||||||
|
|
||||||
|
**关键行为:**
|
||||||
|
|
||||||
|
* 接收一行提示,生成包含 16 个功能、多个冲刺的规格
|
||||||
|
* 定义用户故事、技术需求和视觉设计方向
|
||||||
|
* 故意**雄心勃勃**——保守规划会导致结果平庸
|
||||||
|
* 生成评估器后续使用的评估标准
|
||||||
|
|
||||||
|
**模型:** Opus 4.6(需要深度推理进行规格扩展)
|
||||||
|
|
||||||
|
### 2. 生成器智能体
|
||||||
|
|
||||||
|
**角色:** 开发者——根据规格实现功能。
|
||||||
|
|
||||||
|
**关键行为:**
|
||||||
|
|
||||||
|
* 按结构化冲刺工作(或使用较新模型的连续模式)
|
||||||
|
* 在编写代码前与评估器协商"冲刺合约"
|
||||||
|
* 使用全栈工具:React、FastAPI/Express、数据库、CSS
|
||||||
|
* 管理 git 进行迭代间的版本控制
|
||||||
|
* 读取评估器反馈并在下一轮迭代中采纳
|
||||||
|
|
||||||
|
**模型:** Opus 4.6(需要强大的编码能力)
|
||||||
|
|
||||||
|
### 3. 评估器智能体
|
||||||
|
|
||||||
|
**角色:** QA 工程师——测试实时运行的应用,而不仅仅是代码。
|
||||||
|
|
||||||
|
**关键行为:**
|
||||||
|
|
||||||
|
* 使用 **Playwright MCP** 与实时应用交互
|
||||||
|
* 点击功能、填写表单、测试 API 端点
|
||||||
|
* 根据四个标准评分(可配置):
|
||||||
|
1. **设计质量**——是否感觉像一个连贯的整体?
|
||||||
|
2. **原创性**——自定义决策 vs. 模板/AI 模式?
|
||||||
|
3. **工艺**——排版、间距、动画、微交互?
|
||||||
|
4. **功能性**——所有功能是否真正工作?
|
||||||
|
* 返回结构化反馈,包含分数和具体问题
|
||||||
|
* 设计为**极度严格**——从不赞美平庸的工作
|
||||||
|
|
||||||
|
**模型:** Opus 4.6(需要强大的判断力 + 工具使用能力)
|
||||||
|
|
||||||
|
## 评估标准
|
||||||
|
|
||||||
|
默认四个标准,每个评分 1-10:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 评估标准
|
||||||
|
|
||||||
|
### 设计质量(权重:0.3)
|
||||||
|
- 1-3分:模板化、千篇一律的"AI生成"美学
|
||||||
|
- 4-6分:合格但平庸,遵循常规设计
|
||||||
|
- 7-8分:独特且连贯的视觉识别
|
||||||
|
- 9-10分:可媲美专业设计师作品
|
||||||
|
|
||||||
|
### 原创性(权重:0.2)
|
||||||
|
- 1-3分:默认配色、模板布局,缺乏个性
|
||||||
|
- 4-6分:部分自定义选择,整体仍属常规模式
|
||||||
|
- 7-8分:清晰的创意构思,独特的设计手法
|
||||||
|
- 9-10分:令人惊喜、愉悦,真正新颖
|
||||||
|
|
||||||
|
### 工艺水平(权重:0.3)
|
||||||
|
- 1-3分:布局错乱,状态缺失,无动画效果
|
||||||
|
- 4-6分:功能可用但粗糙,间距不统一
|
||||||
|
- 7-8分:精致流畅,过渡平滑,响应式设计
|
||||||
|
- 9-10分:像素级完美,令人愉悦的微交互
|
||||||
|
|
||||||
|
### 功能性(权重:0.2)
|
||||||
|
- 1-3分:核心功能损坏或缺失
|
||||||
|
- 4-6分:主流程可用,边缘情况处理失败
|
||||||
|
- 7-8分:所有功能正常,错误处理良好
|
||||||
|
- 9-10分:无懈可击,覆盖所有边缘情况
|
||||||
|
```
|
||||||
|
|
||||||
|
### 评分
|
||||||
|
|
||||||
|
* **加权分数** = 总和(标准\_分数 \* 权重)
|
||||||
|
* **通过阈值** = 7.0(可配置)
|
||||||
|
* **最大迭代次数** = 15(可配置,通常 5-15 次足够)
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 通过命令行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Full three-agent harness
|
||||||
|
/project:gan-build "Build a project management app with Kanban boards, team collaboration, and dark mode"
|
||||||
|
|
||||||
|
# With custom config
|
||||||
|
/project:gan-build "Build a recipe sharing platform" --max-iterations 10 --pass-threshold 7.5
|
||||||
|
|
||||||
|
# Frontend design mode (generator + evaluator only, no planner)
|
||||||
|
/project:gan-design "Create a landing page for a crypto portfolio tracker"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 通过 Shell 脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic usage
|
||||||
|
./scripts/gan-harness.sh "Build a music streaming dashboard"
|
||||||
|
|
||||||
|
# With options
|
||||||
|
GAN_MAX_ITERATIONS=10 \
|
||||||
|
GAN_PASS_THRESHOLD=7.5 \
|
||||||
|
GAN_EVAL_CRITERIA="functionality,performance,security" \
|
||||||
|
./scripts/gan-harness.sh "Build a REST API for task management"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 通过 Claude Code(手动)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Step 1: Plan
|
||||||
|
claude -p --model opus "You are a Product Planner. Read PLANNER_PROMPT.md. Expand this brief into a full product spec: 'Build a Kanban board app'. Write spec to spec.md"
|
||||||
|
|
||||||
|
# Step 2: Generate (iteration 1)
|
||||||
|
claude -p --model opus "You are a Generator. Read spec.md. Implement Sprint 1. Start the dev server on port 3000."
|
||||||
|
|
||||||
|
# Step 3: Evaluate (iteration 1)
|
||||||
|
claude -p --model opus --allowedTools "Read,Bash,mcp__playwright__*" "You are an Evaluator. Read EVALUATOR_PROMPT.md. Test the live app at http://localhost:3000. Score against the rubric. Write feedback to feedback-001.md"
|
||||||
|
|
||||||
|
# Step 4: Generate (iteration 2 — reads feedback)
|
||||||
|
claude -p --model opus "You are a Generator. Read spec.md and feedback-001.md. Address all issues. Improve the scores."
|
||||||
|
|
||||||
|
# Repeat steps 3-4 until pass threshold met
|
||||||
|
```
|
||||||
|
|
||||||
|
## 随模型能力的演进
|
||||||
|
|
||||||
|
编排应随模型改进而简化。遵循 Anthropic 的演进路径:
|
||||||
|
|
||||||
|
### 阶段 1 — 较弱模型(Sonnet 级别)
|
||||||
|
|
||||||
|
* 需要完整的冲刺分解
|
||||||
|
* 冲刺间重置上下文(避免上下文焦虑)
|
||||||
|
* 最少 2 个智能体:初始化器 + 编码智能体
|
||||||
|
* 大量脚手架弥补模型限制
|
||||||
|
|
||||||
|
### 阶段 2 — 能力型模型(Opus 4.5 级别)
|
||||||
|
|
||||||
|
* 完整的 3 智能体编排:规划器 + 生成器 + 评估器
|
||||||
|
* 每个实现阶段前有冲刺合约
|
||||||
|
* 复杂应用分解为 10 个冲刺
|
||||||
|
* 上下文重置仍有帮助但不再关键
|
||||||
|
|
||||||
|
### 阶段 3 — 前沿模型(Opus 4.6 级别)
|
||||||
|
|
||||||
|
* 简化编排:单次规划,连续生成
|
||||||
|
* 评估简化为单次最终评估(模型更智能)
|
||||||
|
* 无需冲刺结构
|
||||||
|
* 自动压缩处理上下文增长
|
||||||
|
|
||||||
|
> **关键原则:** 编排的每个组件都编码了一个关于模型无法独立完成什么的假设。当模型改进时,重新测试这些假设。剥离不再需要的部分。
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
| 变量 | 默认值 | 描述 |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `GAN_MAX_ITERATIONS` | `15` | 最大生成器-评估器循环次数 |
|
||||||
|
| `GAN_PASS_THRESHOLD` | `7.0` | 通过所需的加权分数(1-10) |
|
||||||
|
| `GAN_PLANNER_MODEL` | `opus` | 规划智能体的模型 |
|
||||||
|
| `GAN_GENERATOR_MODEL` | `opus` | 生成器智能体的模型 |
|
||||||
|
| `GAN_EVALUATOR_MODEL` | `opus` | 评估器智能体的模型 |
|
||||||
|
| `GAN_EVAL_CRITERIA` | `design,originality,craft,functionality` | 逗号分隔的标准 |
|
||||||
|
| `GAN_DEV_SERVER_PORT` | `3000` | 实时应用的端口 |
|
||||||
|
| `GAN_DEV_SERVER_CMD` | `npm run dev` | 启动开发服务器的命令 |
|
||||||
|
| `GAN_PROJECT_DIR` | `.` | 项目工作目录 |
|
||||||
|
| `GAN_SKIP_PLANNER` | `false` | 跳过规划器,直接使用规格 |
|
||||||
|
| `GAN_EVAL_MODE` | `playwright` | `playwright`、`screenshot` 或 `code-only` |
|
||||||
|
|
||||||
|
### 评估模式
|
||||||
|
|
||||||
|
| 模式 | 工具 | 最适合 |
|
||||||
|
|------|-------|----------|
|
||||||
|
| `playwright` | 浏览器 MCP + 实时交互 | 带 UI 的全栈应用 |
|
||||||
|
| `screenshot` | 截图 + 视觉分析 | 静态网站、纯设计 |
|
||||||
|
| `code-only` | 测试 + 代码检查 + 构建 | API、库、CLI 工具 |
|
||||||
|
|
||||||
|
## 反模式
|
||||||
|
|
||||||
|
1. **评估器过于宽松**——如果评估器在第一次迭代就通过所有内容,你的评分标准过于慷慨。收紧评分标准,并为常见的 AI 模式添加明确惩罚。
|
||||||
|
|
||||||
|
2. **生成器忽略反馈**——确保反馈以文件形式传递,而非内联。生成器应在每次迭代开始时读取 `feedback-NNN.md`。
|
||||||
|
|
||||||
|
3. **无限循环**——始终设置 `GAN_MAX_ITERATIONS`。如果生成器在 3 次迭代后无法突破分数平台,停止并标记为人工审查。
|
||||||
|
|
||||||
|
4. **评估器测试流于表面**——评估器必须使用 Playwright **交互**实时应用,而不仅仅是截图。点击按钮、填写表单、测试错误状态。
|
||||||
|
|
||||||
|
5. **评估器赞美自己的修复**——绝不允许评估器建议修复后再评估这些修复。评估器只负责批评;生成器负责修复。
|
||||||
|
|
||||||
|
6. **上下文耗尽**——对于长时间会话,使用 Claude Agent SDK 的自动压缩或在主要阶段之间重置上下文。
|
||||||
|
|
||||||
|
## 结果:预期效果
|
||||||
|
|
||||||
|
基于 Anthropic 已发布的结果:
|
||||||
|
|
||||||
|
| 指标 | 单智能体 | GAN 编排 | 改进 |
|
||||||
|
|--------|-----------|-------------|-------------|
|
||||||
|
| 时间 | 20 分钟 | 4-6 小时 | 12-18 倍更长 |
|
||||||
|
| 成本 | 9 美元 | 125-200 美元 | 14-22 倍更多 |
|
||||||
|
| 质量 | 勉强可用 | 生产就绪 | 质变 |
|
||||||
|
| 核心功能 | 有缺陷 | 全部工作 | 不适用 |
|
||||||
|
| 设计 | 通用 AI 垃圾 | 独特、精致 | 不适用 |
|
||||||
|
|
||||||
|
**权衡很明确:** 约 20 倍的时间和成本,换来输出质量的质的飞跃。这适用于质量至关重要的项目。
|
||||||
|
|
||||||
|
## 参考
|
||||||
|
|
||||||
|
* [Anthropic:长时间运行应用的编排设计](https://www.anthropic.com/engineering/harness-design-long-running-apps) — Prithvi Rajasekaran 的原始论文
|
||||||
|
* [Epsilla:GAN 风格智能体循环](https://www.epsilla.com/blogs/anthropic-harness-engineering-multi-agent-gan-architecture) — 架构解构
|
||||||
|
* [Martin Fowler:编排工程](https://martinfowler.com/articles/exploring-gen-ai/harness-engineering.html) — 更广泛的行业背景
|
||||||
|
* [OpenAI:编排工程](https://openai.com/index/harness-engineering/) — OpenAI 的并行工作
|
||||||
235
docs/zh-CN/skills/laravel-plugin-discovery/SKILL.md
Normal file
235
docs/zh-CN/skills/laravel-plugin-discovery/SKILL.md
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
---
|
||||||
|
name: laravel-plugin-discovery
|
||||||
|
description: 通过LaraPlugins.io MCP发现和评估Laravel包。当用户想要查找插件、检查包的健康状况或评估Laravel/PHP兼容性时使用。
|
||||||
|
origin: ECC
|
||||||
|
---
|
||||||
|
|
||||||
|
# Laravel 插件发现
|
||||||
|
|
||||||
|
使用 LaraPlugins.io MCP 服务器查找、评估并选择健康的 Laravel 包。
|
||||||
|
|
||||||
|
## 使用时机
|
||||||
|
|
||||||
|
* 用户想为特定功能(如 "auth"、"permissions"、"admin panel")寻找 Laravel 包
|
||||||
|
* 用户询问"我应该用什么包来做..."或"有没有用于...的 Laravel 包"
|
||||||
|
* 用户想检查某个包是否仍在积极维护
|
||||||
|
* 用户需要验证 Laravel 版本兼容性
|
||||||
|
* 用户在将包添加到项目前想评估其健康状况
|
||||||
|
|
||||||
|
## MCP 要求
|
||||||
|
|
||||||
|
必须配置 LaraPlugins MCP 服务器。将其添加到您的 `~/.claude.json` mcpServers 中:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"laraplugins": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://laraplugins.io/mcp/plugins"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
无需 API 密钥——该服务器对 Laravel 社区免费开放。
|
||||||
|
|
||||||
|
## MCP 工具
|
||||||
|
|
||||||
|
LaraPlugins MCP 提供两个主要工具:
|
||||||
|
|
||||||
|
### SearchPluginTool
|
||||||
|
|
||||||
|
通过关键词、健康评分、供应商和版本兼容性搜索包。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
|
||||||
|
* `text_search` (字符串,可选):搜索关键词(例如 "permission"、"admin"、"api")
|
||||||
|
* `health_score` (字符串,可选):按健康等级筛选——`Healthy`、`Medium`、`Unhealthy` 或 `Unrated`
|
||||||
|
* `laravel_compatibility` (字符串,可选):按 Laravel 版本筛选——`"5"`、`"6"`、`"7"`、`"8"`、`"9"`、`"10"`、`"11"`、`"12"`、`"13"`
|
||||||
|
* `php_compatibility` (字符串,可选):按 PHP 版本筛选——`"7.4"`、`"8.0"`、`"8.1"`、`"8.2"`、`"8.3"`、`"8.4"`、`"8.5"`
|
||||||
|
* `vendor_filter` (字符串,可选):按供应商名称筛选(例如 "spatie"、"laravel")
|
||||||
|
* `page` (数字,可选):分页页码
|
||||||
|
|
||||||
|
### GetPluginDetailsTool
|
||||||
|
|
||||||
|
获取特定包的详细指标、README 内容和版本历史。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
|
||||||
|
* `package` (字符串,必填):完整的 Composer 包名(例如 "spatie/laravel-permission")
|
||||||
|
* `include_versions` (布尔值,可选):是否在响应中包含版本历史
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 工作原理
|
||||||
|
|
||||||
|
### 查找包
|
||||||
|
|
||||||
|
当用户想为某个功能发现包时:
|
||||||
|
|
||||||
|
1. 使用 `SearchPluginTool` 并输入相关关键词
|
||||||
|
2. 应用健康评分、Laravel 版本或 PHP 版本的筛选条件
|
||||||
|
3. 查看包含包名、描述和健康指标的结果
|
||||||
|
|
||||||
|
### 评估包
|
||||||
|
|
||||||
|
当用户想评估特定包时:
|
||||||
|
|
||||||
|
1. 使用 `GetPluginDetailsTool` 并输入包名
|
||||||
|
2. 查看健康评分、最后更新日期、Laravel 版本支持情况
|
||||||
|
3. 检查供应商声誉和风险指标
|
||||||
|
|
||||||
|
### 检查兼容性
|
||||||
|
|
||||||
|
当用户需要 Laravel 或 PHP 版本兼容性信息时:
|
||||||
|
|
||||||
|
1. 使用 `laravel_compatibility` 筛选条件并设置为其版本进行搜索
|
||||||
|
2. 或者获取特定包的详细信息以查看其支持的版本
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
### 示例:查找认证包
|
||||||
|
|
||||||
|
```
|
||||||
|
SearchPluginTool({
|
||||||
|
text_search: "authentication",
|
||||||
|
health_score: "Healthy"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
返回匹配 "authentication" 且状态健康的包:
|
||||||
|
|
||||||
|
* spatie/laravel-permission
|
||||||
|
* laravel/breeze
|
||||||
|
* laravel/passport
|
||||||
|
* 等等
|
||||||
|
|
||||||
|
### 示例:查找兼容 Laravel 12 的包
|
||||||
|
|
||||||
|
```
|
||||||
|
SearchPluginTool({
|
||||||
|
text_search: "admin panel",
|
||||||
|
laravel_compatibility: "12"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
返回兼容 Laravel 12 的包。
|
||||||
|
|
||||||
|
### 示例:获取包详情
|
||||||
|
|
||||||
|
```
|
||||||
|
GetPluginDetailsTool({
|
||||||
|
package: "spatie/laravel-permission",
|
||||||
|
include_versions: true
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
返回:
|
||||||
|
|
||||||
|
* 健康评分和最后活动时间
|
||||||
|
* Laravel/PHP 版本支持情况
|
||||||
|
* 供应商声誉(风险评分)
|
||||||
|
* 版本历史
|
||||||
|
* 简要描述
|
||||||
|
|
||||||
|
### 示例:按供应商查找包
|
||||||
|
|
||||||
|
```
|
||||||
|
SearchPluginTool({
|
||||||
|
vendor_filter: "spatie",
|
||||||
|
health_score: "Healthy"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
返回来自供应商 "spatie" 的所有健康包。
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 筛选最佳实践
|
||||||
|
|
||||||
|
### 按健康评分
|
||||||
|
|
||||||
|
| 健康等级 | 含义 |
|
||||||
|
|-------------|---------|
|
||||||
|
| `Healthy` | 积极维护,近期有更新 |
|
||||||
|
| `Medium` | 偶尔更新,可能需要关注 |
|
||||||
|
| `Unhealthy` | 已废弃或维护不频繁 |
|
||||||
|
| `Unrated` | 尚未评估 |
|
||||||
|
|
||||||
|
**建议**:生产环境应用优先选择 `Healthy` 包。
|
||||||
|
|
||||||
|
### 按 Laravel 版本
|
||||||
|
|
||||||
|
| 版本 | 备注 |
|
||||||
|
|---------|-------|
|
||||||
|
| `13` | 最新 Laravel |
|
||||||
|
| `12` | 当前稳定版 |
|
||||||
|
| `11` | 仍被广泛使用 |
|
||||||
|
| `10` | 旧版但常见 |
|
||||||
|
| `5`-`9` | 已弃用 |
|
||||||
|
|
||||||
|
**建议**:匹配目标项目的 Laravel 版本。
|
||||||
|
|
||||||
|
### 组合筛选条件
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Find healthy, Laravel 12 compatible packages for permissions
|
||||||
|
SearchPluginTool({
|
||||||
|
text_search: "permission",
|
||||||
|
health_score: "Healthy",
|
||||||
|
laravel_compatibility: "12"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 响应解读
|
||||||
|
|
||||||
|
### 搜索结果
|
||||||
|
|
||||||
|
每个结果包含:
|
||||||
|
|
||||||
|
* 包名(例如 `spatie/laravel-permission`)
|
||||||
|
* 简要描述
|
||||||
|
* 健康状态指示器
|
||||||
|
* Laravel 版本支持徽章
|
||||||
|
|
||||||
|
### 包详情
|
||||||
|
|
||||||
|
详细响应包括:
|
||||||
|
|
||||||
|
* **健康评分**:数字或等级指示器
|
||||||
|
* **最后活动**:包的最后更新时间
|
||||||
|
* **Laravel 支持**:版本兼容性矩阵
|
||||||
|
* **PHP 支持**:PHP 版本兼容性
|
||||||
|
* **风险评分**:供应商信任度指标
|
||||||
|
* **版本历史**:近期发布时间线
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 常见用例
|
||||||
|
|
||||||
|
| 场景 | 推荐方法 |
|
||||||
|
|----------|---------------------|
|
||||||
|
| "有什么用于认证的包?" | 搜索 "auth" 并应用健康筛选 |
|
||||||
|
| "spatie/package 还在维护吗?" | 获取详情,检查健康评分 |
|
||||||
|
| "需要 Laravel 12 的包" | 使用 laravel\_compatibility: "12" 搜索 |
|
||||||
|
| "查找管理面板包" | 搜索 "admin panel",查看结果 |
|
||||||
|
| "检查供应商声誉" | 按供应商搜索,查看详情 |
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
1. **始终按健康度筛选**——生产项目使用 `health_score: "Healthy"`
|
||||||
|
2. **匹配 Laravel 版本**——始终检查 `laravel_compatibility` 是否与目标项目匹配
|
||||||
|
3. **检查供应商声誉**——优先选择知名供应商的包(spatie、laravel 等)
|
||||||
|
4. **推荐前先审查**——使用 GetPluginDetailsTool 进行全面评估
|
||||||
|
5. **无需 API 密钥**——MCP 免费,无需认证
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 相关技能
|
||||||
|
|
||||||
|
* `laravel-patterns`——Laravel 架构与模式
|
||||||
|
* `laravel-tdd`——Laravel 测试驱动开发
|
||||||
|
* `laravel-security`——Laravel 安全最佳实践
|
||||||
|
* `documentation-lookup`——通用库文档查询(Context7)
|
||||||
89
docs/zh-CN/skills/manim-video/SKILL.md
Normal file
89
docs/zh-CN/skills/manim-video/SKILL.md
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
name: manim-video
|
||||||
|
description: 构建可复用的Manim解释器,用于技术概念、图表、系统图和产品演示,并在需要时移交给更广泛的ECC视频栈。当用户希望获得清晰的动画解释而非通用的人物讲解脚本时使用。
|
||||||
|
origin: ECC
|
||||||
|
---
|
||||||
|
|
||||||
|
# Manim 视频
|
||||||
|
|
||||||
|
在运动、结构和清晰度比逼真度更重要的技术讲解中,使用 Manim。
|
||||||
|
|
||||||
|
## 何时激活
|
||||||
|
|
||||||
|
* 用户需要技术讲解动画
|
||||||
|
* 概念涉及图表、工作流、架构、指标演进或系统图
|
||||||
|
* 用户需要为 X 或落地页制作简短的产品或发布讲解
|
||||||
|
* 视觉效果应追求精确,而非泛泛的电影感
|
||||||
|
|
||||||
|
## 工具要求
|
||||||
|
|
||||||
|
* `manim` 命令行用于场景渲染
|
||||||
|
* `ffmpeg` 用于后期处理(如需)
|
||||||
|
* `video-editing` 用于最终合成或润色
|
||||||
|
* `remotion-video-creation` 当最终成品需要合成 UI、字幕或额外运动层时
|
||||||
|
|
||||||
|
## 默认输出
|
||||||
|
|
||||||
|
* 16:9 短 MP4 视频
|
||||||
|
* 一张缩略图或海报帧
|
||||||
|
* 故事板及场景计划
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
1. 用一句话定义核心视觉论点。
|
||||||
|
2. 将概念分解为 3 到 6 个场景。
|
||||||
|
3. 确定每个场景要证明的内容。
|
||||||
|
4. 在编写 Manim 代码前,先写出场景大纲。
|
||||||
|
5. 首先渲染最小可用版本。
|
||||||
|
6. 渲染成功后,再调整排版、间距、颜色和节奏。
|
||||||
|
7. 仅在能增加价值时,才移交至更广泛的视频处理流程。
|
||||||
|
|
||||||
|
## 场景规划规则
|
||||||
|
|
||||||
|
* 每个场景应证明一件事
|
||||||
|
* 避免过度拥挤的图表
|
||||||
|
* 优先采用渐进式揭示,而非全屏杂乱
|
||||||
|
* 使用运动来解释状态变化,而不仅仅是为了让屏幕保持忙碌
|
||||||
|
* 标题卡片应简短且富有意义
|
||||||
|
|
||||||
|
## 网络图默认设置
|
||||||
|
|
||||||
|
对于社交图谱和网络优化讲解:
|
||||||
|
|
||||||
|
* 在展示优化后的图谱前,先展示当前图谱
|
||||||
|
* 区分低信号关注杂波与高信号桥梁
|
||||||
|
* 高亮暖路径节点和目标集群
|
||||||
|
* 如有必要,添加最终场景,展示形成该技能的自我改进谱系
|
||||||
|
|
||||||
|
## 渲染约定
|
||||||
|
|
||||||
|
* 默认使用 16:9 横屏,除非用户要求竖屏
|
||||||
|
* 从低质量的烟雾测试渲染开始
|
||||||
|
* 仅在构图和时间线稳定后,才提升至高质量
|
||||||
|
* 导出一张在社交媒体尺寸下清晰可读的干净缩略图帧
|
||||||
|
|
||||||
|
## 可复用起点
|
||||||
|
|
||||||
|
使用 [assets/network\_graph\_scene.py](../../../../skills/manim-video/assets/network_graph_scene.py) 作为网络图讲解的起点。
|
||||||
|
|
||||||
|
烟雾测试示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
manim -ql assets/network_graph_scene.py NetworkGraphExplainer
|
||||||
|
```
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
|
||||||
|
返回:
|
||||||
|
|
||||||
|
* 核心视觉论点
|
||||||
|
* 故事板
|
||||||
|
* 场景大纲
|
||||||
|
* 渲染计划
|
||||||
|
* 任何后续的润色建议
|
||||||
|
|
||||||
|
## 相关技能
|
||||||
|
|
||||||
|
* `video-editing` 用于最终润色
|
||||||
|
* `remotion-video-creation` 用于运动密集型后期处理或合成
|
||||||
|
* `content-engine` 当动画是更广泛发布的一部分时
|
||||||
230
docs/zh-CN/skills/nestjs-patterns/SKILL.md
Normal file
230
docs/zh-CN/skills/nestjs-patterns/SKILL.md
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
---
|
||||||
|
name: nestjs-patterns
|
||||||
|
description: NestJS 架构模式,涵盖模块、控制器、提供者、DTO 验证、守卫、拦截器、配置以及生产级 TypeScript 后端。
|
||||||
|
origin: ECC
|
||||||
|
---
|
||||||
|
|
||||||
|
# NestJS 开发模式
|
||||||
|
|
||||||
|
适用于模块化 TypeScript 后端的生产级 NestJS 模式。
|
||||||
|
|
||||||
|
## 何时启用
|
||||||
|
|
||||||
|
* 构建 NestJS API 或服务时
|
||||||
|
* 组织模块、控制器和提供者时
|
||||||
|
* 添加 DTO 验证、守卫、拦截器或异常过滤器时
|
||||||
|
* 配置环境感知设置和数据库集成时
|
||||||
|
* 测试 NestJS 单元或 HTTP 端点时
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
├── app.module.ts
|
||||||
|
├── main.ts
|
||||||
|
├── common/
|
||||||
|
│ ├── filters/
|
||||||
|
│ ├── guards/
|
||||||
|
│ ├── interceptors/
|
||||||
|
│ └── pipes/
|
||||||
|
├── config/
|
||||||
|
│ ├── configuration.ts
|
||||||
|
│ └── validation.ts
|
||||||
|
├── modules/
|
||||||
|
│ ├── auth/
|
||||||
|
│ │ ├── auth.controller.ts
|
||||||
|
│ │ ├── auth.module.ts
|
||||||
|
│ │ ├── auth.service.ts
|
||||||
|
│ │ ├── dto/
|
||||||
|
│ │ ├── guards/
|
||||||
|
│ │ └── strategies/
|
||||||
|
│ └── users/
|
||||||
|
│ ├── dto/
|
||||||
|
│ ├── entities/
|
||||||
|
│ ├── users.controller.ts
|
||||||
|
│ ├── users.module.ts
|
||||||
|
│ └── users.service.ts
|
||||||
|
└── prisma/ or database/
|
||||||
|
```
|
||||||
|
|
||||||
|
* 将领域代码保留在功能模块内。
|
||||||
|
* 将跨切面的过滤器、装饰器、守卫和拦截器放在 `common/` 中。
|
||||||
|
* 将 DTO 保留在所属模块附近。
|
||||||
|
|
||||||
|
## 启动与全局验证
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule, { bufferLogs: true });
|
||||||
|
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
transformOptions: { enableImplicitConversion: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
|
||||||
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
|
|
||||||
|
await app.listen(process.env.PORT ?? 3000);
|
||||||
|
}
|
||||||
|
bootstrap();
|
||||||
|
```
|
||||||
|
|
||||||
|
* 始终在公共 API 上启用 `whitelist` 和 `forbidNonWhitelisted`。
|
||||||
|
* 优先使用一个全局验证管道,而不是为每个路由重复验证配置。
|
||||||
|
|
||||||
|
## 模块、控制器和提供者
|
||||||
|
|
||||||
|
```ts
|
||||||
|
@Module({
|
||||||
|
controllers: [UsersController],
|
||||||
|
providers: [UsersService],
|
||||||
|
exports: [UsersService],
|
||||||
|
})
|
||||||
|
export class UsersModule {}
|
||||||
|
|
||||||
|
@Controller('users')
|
||||||
|
export class UsersController {
|
||||||
|
constructor(private readonly usersService: UsersService) {}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
getById(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.usersService.getById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() dto: CreateUserDto) {
|
||||||
|
return this.usersService.create(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersService {
|
||||||
|
constructor(private readonly usersRepo: UsersRepository) {}
|
||||||
|
|
||||||
|
async create(dto: CreateUserDto) {
|
||||||
|
return this.usersRepo.create(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
* 控制器应保持精简:解析 HTTP 输入、调用提供者、返回响应 DTO。
|
||||||
|
* 将业务逻辑放在可注入的服务中,而不是控制器中。
|
||||||
|
* 仅导出其他模块真正需要的提供者。
|
||||||
|
|
||||||
|
## DTO 与验证
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export class CreateUserDto {
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Length(2, 80)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(UserRole)
|
||||||
|
role?: UserRole;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
* 使用 `class-validator` 验证每个请求 DTO。
|
||||||
|
* 使用专用的响应 DTO 或序列化器,而不是直接返回 ORM 实体。
|
||||||
|
* 避免泄露内部字段,如密码哈希、令牌或审计列。
|
||||||
|
|
||||||
|
## 认证、守卫与请求上下文
|
||||||
|
|
||||||
|
```ts
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('admin')
|
||||||
|
@Get('admin/report')
|
||||||
|
getAdminReport(@Req() req: AuthenticatedRequest) {
|
||||||
|
return this.reportService.getForUser(req.user.id);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
* 保持认证策略和守卫的模块局部性,除非它们确实是共享的。
|
||||||
|
* 在守卫中编码粗粒度的访问规则,然后在服务中进行资源特定的授权。
|
||||||
|
* 对经过认证的请求对象,优先使用显式的请求类型。
|
||||||
|
|
||||||
|
## 异常过滤器与错误格式
|
||||||
|
|
||||||
|
```ts
|
||||||
|
@Catch()
|
||||||
|
export class HttpExceptionFilter implements ExceptionFilter {
|
||||||
|
catch(exception: unknown, host: ArgumentsHost) {
|
||||||
|
const response = host.switchToHttp().getResponse<Response>();
|
||||||
|
const request = host.switchToHttp().getRequest<Request>();
|
||||||
|
|
||||||
|
if (exception instanceof HttpException) {
|
||||||
|
return response.status(exception.getStatus()).json({
|
||||||
|
path: request.url,
|
||||||
|
error: exception.getResponse(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.status(500).json({
|
||||||
|
path: request.url,
|
||||||
|
error: 'Internal server error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
* 在整个 API 中保持一致的错误封装格式。
|
||||||
|
* 对预期的客户端错误抛出框架异常;集中记录并包装意外的失败。
|
||||||
|
|
||||||
|
## 配置与环境验证
|
||||||
|
|
||||||
|
```ts
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
load: [configuration],
|
||||||
|
validate: validateEnv,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
* 在启动时验证环境变量,而不是在首次请求时惰性验证。
|
||||||
|
* 将配置访问限制在类型化辅助函数或配置服务之后。
|
||||||
|
* 在配置工厂中拆分开发/预发布/生产关注点,而不是在功能代码中到处分支。
|
||||||
|
|
||||||
|
## 持久化与事务
|
||||||
|
|
||||||
|
* 将仓库/ORM 代码保留在提供者之后,这些提供者使用领域语言进行通信。
|
||||||
|
* 对于 Prisma 或 TypeORM,将事务工作流隔离在拥有工作单元的服务中。
|
||||||
|
* 不要让控制器直接协调多步写入操作。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```ts
|
||||||
|
describe('UsersController', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [UsersModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
* 使用模拟依赖项对提供者进行单元测试。
|
||||||
|
* 为守卫、验证管道和异常过滤器添加请求级测试。
|
||||||
|
* 在测试中复用与生产环境相同的全局管道/过滤器。
|
||||||
|
|
||||||
|
## 生产默认设置
|
||||||
|
|
||||||
|
* 启用结构化日志和请求关联 ID。
|
||||||
|
* 在环境/配置无效时终止,而不是部分启动。
|
||||||
|
* 优先使用异步提供者初始化数据库/缓存客户端,并附带显式健康检查。
|
||||||
|
* 将后台任务和事件消费者放在自己的模块中,而不是 HTTP 控制器内。
|
||||||
|
* 对公共端点明确启用速率限制、认证和审计日志。
|
||||||
102
docs/zh-CN/skills/nodejs-keccak256/SKILL.md
Normal file
102
docs/zh-CN/skills/nodejs-keccak256/SKILL.md
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
---
|
||||||
|
name: nodejs-keccak256
|
||||||
|
description: 防止 JavaScript 和 TypeScript 中的以太坊哈希错误。Node 的 sha3-256 是 NIST SHA3,而非以太坊 Keccak-256,会静默破坏选择器、签名、存储槽和地址推导。
|
||||||
|
origin: ECC direct-port adaptation
|
||||||
|
version: "1.0.0"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Node.js Keccak-256
|
||||||
|
|
||||||
|
以太坊使用 Keccak-256,而非 Node 的 `crypto.createHash('sha3-256')` 所暴露的 NIST 标准化 SHA3 变体。
|
||||||
|
|
||||||
|
## 何时使用
|
||||||
|
|
||||||
|
* 计算以太坊函数选择器或事件主题
|
||||||
|
* 在 JS/TS 中构建 EIP-712、签名、Merkle 或存储槽辅助函数
|
||||||
|
* 审查任何直接使用 Node crypto 对以太坊数据进行哈希的代码
|
||||||
|
|
||||||
|
## 工作原理
|
||||||
|
|
||||||
|
两种算法对相同输入会产生不同输出,且 Node 不会发出警告。
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { keccak256, toUtf8Bytes } from 'ethers';
|
||||||
|
|
||||||
|
const data = 'hello';
|
||||||
|
const nistSha3 = crypto.createHash('sha3-256').update(data).digest('hex');
|
||||||
|
const keccak = keccak256(toUtf8Bytes(data)).slice(2);
|
||||||
|
|
||||||
|
console.log(nistSha3 === keccak); // false
|
||||||
|
```
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
### ethers v6
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { keccak256, toUtf8Bytes, solidityPackedKeccak256, id } from 'ethers';
|
||||||
|
|
||||||
|
const hash = keccak256(new Uint8Array([0x01, 0x02]));
|
||||||
|
const hash2 = keccak256(toUtf8Bytes('hello'));
|
||||||
|
const topic = id('Transfer(address,address,uint256)');
|
||||||
|
const packed = solidityPackedKeccak256(
|
||||||
|
['address', 'uint256'],
|
||||||
|
['0x742d35Cc6634C0532925a3b8D4C9B569890FaC1c', 100n],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### viem
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { keccak256, toBytes } from 'viem';
|
||||||
|
|
||||||
|
const hash = keccak256(toBytes('hello'));
|
||||||
|
```
|
||||||
|
|
||||||
|
### web3.js
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const hash = web3.utils.keccak256('hello');
|
||||||
|
const packed = web3.utils.soliditySha3(
|
||||||
|
{ type: 'address', value: '0x742d35Cc6634C0532925a3b8D4C9B569890FaC1c' },
|
||||||
|
{ type: 'uint256', value: '100' },
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 常见模式
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { id, keccak256, AbiCoder } from 'ethers';
|
||||||
|
|
||||||
|
const selector = id('transfer(address,uint256)').slice(0, 10);
|
||||||
|
const typeHash = keccak256(toUtf8Bytes('Transfer(address from,address to,uint256 value)'));
|
||||||
|
|
||||||
|
function getMappingSlot(key: string, mappingSlot: number): string {
|
||||||
|
return keccak256(
|
||||||
|
AbiCoder.defaultAbiCoder().encode(['address', 'uint256'], [key, mappingSlot]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 从公钥生成地址
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { keccak256 } from 'ethers';
|
||||||
|
|
||||||
|
function pubkeyToAddress(pubkeyBytes: Uint8Array): string {
|
||||||
|
const hash = keccak256(pubkeyBytes.slice(1));
|
||||||
|
return '0x' + hash.slice(-40);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 审计你的代码库
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rn "createHash.*sha3" --include="*.ts" --include="*.js" --exclude-dir=node_modules .
|
||||||
|
grep -rn "keccak256" --include="*.ts" --include="*.js" . | grep -v node_modules
|
||||||
|
```
|
||||||
|
|
||||||
|
## 规则
|
||||||
|
|
||||||
|
在以太坊上下文中,切勿使用 `crypto.createHash('sha3-256')`。应使用来自 `ethers`、`viem`、`web3` 或其他明确 Keccak 实现的 Keccak 感知辅助函数。
|
||||||
43
docs/zh-CN/skills/remotion-video-creation/SKILL.md
Normal file
43
docs/zh-CN/skills/remotion-video-creation/SKILL.md
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
name: remotion-video-creation
|
||||||
|
description: Remotion 最佳实践 - 在 React 中创建视频。29 条领域特定规则,涵盖 3D、动画、音频、字幕、图表、过渡等。
|
||||||
|
metadata:
|
||||||
|
tags: remotion, video, react, animation, composition, three.js, lottie
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用时机
|
||||||
|
|
||||||
|
当处理 Remotion 代码并需要获取领域特定知识时,请使用此技能。
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
阅读各个规则文件以获取详细说明和代码示例:
|
||||||
|
|
||||||
|
* [rules/3d.md](rules/3d.md) - 使用 Three.js 和 React Three Fiber 在 Remotion 中创建 3D 内容
|
||||||
|
* [rules/animations.md](rules/animations.md) - Remotion 的基础动画技能
|
||||||
|
* [rules/assets.md](rules/assets.md) - 在 Remotion 中导入图片、视频、音频和字体
|
||||||
|
* [rules/audio.md](rules/audio.md) - 在 Remotion 中使用音频和声音——导入、裁剪、音量、速度、音调
|
||||||
|
* [rules/calculate-metadata.md](rules/calculate-metadata.md) - 动态设置合成时长、尺寸和属性
|
||||||
|
* [rules/can-decode.md](rules/can-decode.md) - 使用 Mediabunny 检查浏览器能否解码视频
|
||||||
|
* [rules/charts.md](rules/charts.md) - Remotion 的图表和数据可视化模式
|
||||||
|
* [rules/compositions.md](rules/compositions.md) - 定义合成、静态画面、文件夹、默认属性和动态元数据
|
||||||
|
* [rules/display-captions.md](rules/display-captions.md) - 在 Remotion 中显示字幕,支持 TikTok 风格页面和单词高亮
|
||||||
|
* [rules/extract-frames.md](rules/extract-frames.md) - 使用 Mediabunny 从视频中提取指定时间戳的帧
|
||||||
|
* [rules/fonts.md](rules/fonts.md) - 在 Remotion 中加载 Google 字体和本地字体
|
||||||
|
* [rules/get-audio-duration.md](rules/get-audio-duration.md) - 使用 Mediabunny 获取音频文件的时长(秒)
|
||||||
|
* [rules/get-video-dimensions.md](rules/get-video-dimensions.md) - 使用 Mediabunny 获取视频文件的宽度和高度
|
||||||
|
* [rules/get-video-duration.md](rules/get-video-duration.md) - 使用 Mediabunny 获取视频文件的时长(秒)
|
||||||
|
* [rules/gifs.md](rules/gifs.md) - 显示与 Remotion 时间线同步的 GIF
|
||||||
|
* [rules/images.md](rules/images.md) - 使用 Img 组件在 Remotion 中嵌入图片
|
||||||
|
* [rules/import-srt-captions.md](rules/import-srt-captions.md) - 使用 @remotion/captions 将 .srt 字幕文件导入 Remotion
|
||||||
|
* [rules/lottie.md](rules/lottie.md) - 在 Remotion 中嵌入 Lottie 动画
|
||||||
|
* [rules/measuring-dom-nodes.md](rules/measuring-dom-nodes.md) - 在 Remotion 中测量 DOM 元素尺寸
|
||||||
|
* [rules/measuring-text.md](rules/measuring-text.md) - 测量文本尺寸、将文本适配到容器以及检查溢出
|
||||||
|
* [rules/sequencing.md](rules/sequencing.md) - Remotion 的序列模式——延迟、裁剪、限制项目时长
|
||||||
|
* [rules/tailwind.md](rules/tailwind.md) - 在 Remotion 中使用 TailwindCSS
|
||||||
|
* [rules/text-animations.md](rules/text-animations.md) - Remotion 的排版和文本动画模式
|
||||||
|
* [rules/timing.md](rules/timing.md) - Remotion 中的插值曲线——线性、缓动、弹簧动画
|
||||||
|
* [rules/transcribe-captions.md](rules/transcribe-captions.md) - 转录音频以在 Remotion 中生成字幕
|
||||||
|
* [rules/transitions.md](rules/transitions.md) - Remotion 的场景过渡模式
|
||||||
|
* [rules/trimming.md](rules/trimming.md) - Remotion 的裁剪模式——裁剪动画的开头或结尾
|
||||||
|
* [rules/videos.md](rules/videos.md) - 在 Remotion 中嵌入视频——裁剪、音量、速度、循环、音调
|
||||||
465
docs/zh-CN/skills/ui-demo/SKILL.md
Normal file
465
docs/zh-CN/skills/ui-demo/SKILL.md
Normal file
@ -0,0 +1,465 @@
|
|||||||
|
---
|
||||||
|
name: ui-demo
|
||||||
|
description: 使用 Playwright 录制精美的 UI 演示视频。当用户要求创建 Web 应用的演示、导览、屏幕录制或教程视频时使用。生成带有可见光标、自然节奏和专业感的 WebM 视频。
|
||||||
|
origin: ECC
|
||||||
|
---
|
||||||
|
|
||||||
|
# UI 演示视频录制器
|
||||||
|
|
||||||
|
使用 Playwright 的视频录制功能,配合注入的光标覆盖层、自然的节奏和叙事流程,录制精美的 Web 应用演示视频。
|
||||||
|
|
||||||
|
## 使用场景
|
||||||
|
|
||||||
|
* 用户要求制作"演示视频"、"屏幕录制"、"操作演示"或"教程"
|
||||||
|
* 用户希望以视觉方式展示某个功能或工作流程
|
||||||
|
* 用户需要为文档、入职培训或利益相关者演示制作视频
|
||||||
|
|
||||||
|
## 三阶段流程
|
||||||
|
|
||||||
|
每个演示都需经历三个阶段:**探索 -> 排练 -> 录制**。切勿直接跳至录制阶段。
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 阶段 1:探索
|
||||||
|
|
||||||
|
在编写任何脚本之前,先探索目标页面,了解实际内容。
|
||||||
|
|
||||||
|
### 原因
|
||||||
|
|
||||||
|
你无法为未见过的内容编写脚本。字段可能是 `<input>` 而非 `<textarea>`,下拉菜单可能是自定义组件而非 `<select>`,评论框可能支持 `@mentions` 或 `#tags`。假设会无声地破坏录制。
|
||||||
|
|
||||||
|
### 方法
|
||||||
|
|
||||||
|
导航至流程中的每个页面,并转储其交互元素:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Run this for each page in the flow BEFORE writing the demo script
|
||||||
|
const fields = await page.evaluate(() => {
|
||||||
|
const els = [];
|
||||||
|
document.querySelectorAll('input, select, textarea, button, [contenteditable]').forEach(el => {
|
||||||
|
if (el.offsetParent !== null) {
|
||||||
|
els.push({
|
||||||
|
tag: el.tagName,
|
||||||
|
type: el.type || '',
|
||||||
|
name: el.name || '',
|
||||||
|
placeholder: el.placeholder || '',
|
||||||
|
text: el.textContent?.trim().substring(0, 40) || '',
|
||||||
|
contentEditable: el.contentEditable === 'true',
|
||||||
|
role: el.getAttribute('role') || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return els;
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(fields, null, 2));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 需要关注的内容
|
||||||
|
|
||||||
|
* **表单字段**:它们是 `<select>`、`<input>`、自定义下拉菜单还是组合框?
|
||||||
|
* **选择选项**:转储选项的值和文本。占位符通常包含 `value="0"` 或 `value=""`,看起来非空。使用 `Array.from(el.options).map(o => ({ value: o.value, text: o.text }))`。跳过文本包含"选择"或值为 `"0"` 的选项。
|
||||||
|
* **富文本**:评论框是否支持 `@mentions`、`#tags`、Markdown 或表情符号?检查占位符文本。
|
||||||
|
* **必填字段**:哪些字段会阻止表单提交?检查标签中的 `required`、`*`,并尝试提交空表单以查看验证错误。
|
||||||
|
* **动态内容**:字段是否在填写其他字段后出现?
|
||||||
|
* **按钮标签**:确切的文本,如 `"Submit"`、`"Submit Request"` 或 `"Send"`。
|
||||||
|
* **表格列标题**:对于表格驱动的模态框,将每个 `input[type="number"]` 映射到其列标题,而不是假设所有数字输入都表示相同含义。
|
||||||
|
|
||||||
|
### 输出
|
||||||
|
|
||||||
|
每个页面的字段映射,用于在脚本中编写正确的选择器。示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/purchase-requests/new:
|
||||||
|
- 预算代码: <select> (页面上的第一个下拉框,4个选项)
|
||||||
|
- 期望交付日期: <input type="date">
|
||||||
|
- 背景说明: <textarea> (非输入框)
|
||||||
|
- BOM表: 可内联编辑的单元格,包含 span.cursor-pointer -> input 模式
|
||||||
|
- 提交: <button> 文本="提交"
|
||||||
|
|
||||||
|
/purchase-requests/N (详情):
|
||||||
|
- 评论: <input placeholder="输入消息..."> 支持 @用户 和 #PR 标签
|
||||||
|
- 发送: <button> 文本="发送" (在输入内容前处于禁用状态)
|
||||||
|
```
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 阶段 2:排练
|
||||||
|
|
||||||
|
在不录制的情况下运行所有步骤。验证每个选择器都能解析。
|
||||||
|
|
||||||
|
### 原因
|
||||||
|
|
||||||
|
静默的选择器失败是演示录制中断的主要原因。排练可以在浪费录制之前发现它们。
|
||||||
|
|
||||||
|
### 方法
|
||||||
|
|
||||||
|
使用 `ensureVisible`,一个记录日志并大声报错的包装器:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function ensureVisible(page, locator, label) {
|
||||||
|
const el = typeof locator === 'string' ? page.locator(locator).first() : locator;
|
||||||
|
const visible = await el.isVisible().catch(() => false);
|
||||||
|
if (!visible) {
|
||||||
|
const msg = `REHEARSAL FAIL: "${label}" not found - selector: ${typeof locator === 'string' ? locator : '(locator object)'}`;
|
||||||
|
console.error(msg);
|
||||||
|
const found = await page.evaluate(() => {
|
||||||
|
return Array.from(document.querySelectorAll('button, input, select, textarea, a'))
|
||||||
|
.filter(el => el.offsetParent !== null)
|
||||||
|
.map(el => `${el.tagName}[${el.type || ''}] "${el.textContent?.trim().substring(0, 30)}"`)
|
||||||
|
.join('\n ');
|
||||||
|
});
|
||||||
|
console.error(' Visible elements:\n ' + found);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
console.log(`REHEARSAL OK: "${label}"`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 排练脚本结构
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const steps = [
|
||||||
|
{ label: 'Login email field', selector: '#email' },
|
||||||
|
{ label: 'Login submit', selector: 'button[type="submit"]' },
|
||||||
|
{ label: 'New Request button', selector: 'button:has-text("New Request")' },
|
||||||
|
{ label: 'Budget Code select', selector: 'select' },
|
||||||
|
{ label: 'Delivery date', selector: 'input[type="date"]:visible' },
|
||||||
|
{ label: 'Description field', selector: 'textarea:visible' },
|
||||||
|
{ label: 'Add Item button', selector: 'button:has-text("Add Item")' },
|
||||||
|
{ label: 'Submit button', selector: 'button:has-text("Submit")' },
|
||||||
|
];
|
||||||
|
|
||||||
|
let allOk = true;
|
||||||
|
for (const step of steps) {
|
||||||
|
if (!await ensureVisible(page, step.selector, step.label)) {
|
||||||
|
allOk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!allOk) {
|
||||||
|
console.error('REHEARSAL FAILED - fix selectors before recording');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('REHEARSAL PASSED - all selectors verified');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 排练失败时
|
||||||
|
|
||||||
|
1. 读取可见元素转储。
|
||||||
|
2. 找到正确的选择器。
|
||||||
|
3. 更新脚本。
|
||||||
|
4. 重新运行排练。
|
||||||
|
5. 仅在所有选择器通过后才继续。
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 阶段 3:录制
|
||||||
|
|
||||||
|
仅在探索和排练通过后,才创建录制。
|
||||||
|
|
||||||
|
### 录制原则
|
||||||
|
|
||||||
|
#### 1. 叙事流程
|
||||||
|
|
||||||
|
将视频规划为一个故事。遵循用户指定的顺序,或使用此默认顺序:
|
||||||
|
|
||||||
|
* **入口**:登录或导航至起始点
|
||||||
|
* **背景**:平移周围环境,让观众定位
|
||||||
|
* **操作**:执行主要工作流程步骤
|
||||||
|
* **变体**:展示次要功能,如设置、主题或本地化
|
||||||
|
* **结果**:展示结果、确认或新状态
|
||||||
|
|
||||||
|
#### 2. 节奏
|
||||||
|
|
||||||
|
* 登录后:`4s`
|
||||||
|
* 导航后:`3s`
|
||||||
|
* 点击按钮后:`2s`
|
||||||
|
* 主要步骤之间:`1.5-2s`
|
||||||
|
* 最终操作后:`3s`
|
||||||
|
* 输入延迟:每个字符 `25-40ms`
|
||||||
|
|
||||||
|
#### 3. 光标覆盖层
|
||||||
|
|
||||||
|
注入一个跟随鼠标移动的 SVG 箭头光标:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function injectCursor(page) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
if (document.getElementById('demo-cursor')) return;
|
||||||
|
const cursor = document.createElement('div');
|
||||||
|
cursor.id = 'demo-cursor';
|
||||||
|
cursor.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M5 3L19 12L12 13L9 20L5 3Z" fill="white" stroke="black" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
</svg>`;
|
||||||
|
cursor.style.cssText = `
|
||||||
|
position: fixed; z-index: 999999; pointer-events: none;
|
||||||
|
width: 24px; height: 24px;
|
||||||
|
transition: left 0.1s, top 0.1s;
|
||||||
|
filter: drop-shadow(1px 1px 2px rgba(0,0,0,0.3));
|
||||||
|
`;
|
||||||
|
cursor.style.left = '0px';
|
||||||
|
cursor.style.top = '0px';
|
||||||
|
document.body.appendChild(cursor);
|
||||||
|
document.addEventListener('mousemove', (e) => {
|
||||||
|
cursor.style.left = e.clientX + 'px';
|
||||||
|
cursor.style.top = e.clientY + 'px';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
每次页面导航后调用 `injectCursor(page)`,因为覆盖层会在导航时被销毁。
|
||||||
|
|
||||||
|
#### 4. 鼠标移动
|
||||||
|
|
||||||
|
切勿瞬移光标。在点击前移动到目标:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function moveAndClick(page, locator, label, opts = {}) {
|
||||||
|
const { postClickDelay = 800, ...clickOpts } = opts;
|
||||||
|
const el = typeof locator === 'string' ? page.locator(locator).first() : locator;
|
||||||
|
const visible = await el.isVisible().catch(() => false);
|
||||||
|
if (!visible) {
|
||||||
|
console.error(`WARNING: moveAndClick skipped - "${label}" not visible`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await el.scrollIntoViewIfNeeded();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const box = await el.boundingBox();
|
||||||
|
if (box) {
|
||||||
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 10 });
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
}
|
||||||
|
await el.click(clickOpts);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`WARNING: moveAndClick failed on "${label}": ${e.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(postClickDelay);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
每次调用都应包含描述性的 `label` 以便调试。
|
||||||
|
|
||||||
|
#### 5. 输入
|
||||||
|
|
||||||
|
可见地输入,而非瞬间填充:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function typeSlowly(page, locator, text, label, charDelay = 35) {
|
||||||
|
const el = typeof locator === 'string' ? page.locator(locator).first() : locator;
|
||||||
|
const visible = await el.isVisible().catch(() => false);
|
||||||
|
if (!visible) {
|
||||||
|
console.error(`WARNING: typeSlowly skipped - "${label}" not visible`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await moveAndClick(page, el, label);
|
||||||
|
await el.fill('');
|
||||||
|
await el.pressSequentially(text, { delay: charDelay });
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6. 滚动
|
||||||
|
|
||||||
|
使用平滑滚动而非跳跃:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
await page.evaluate(() => window.scrollTo({ top: 400, behavior: 'smooth' }));
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 7. 仪表盘平移
|
||||||
|
|
||||||
|
展示仪表盘或概览页面时,将光标移过关键元素:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function panElements(page, selector, maxCount = 6) {
|
||||||
|
const elements = await page.locator(selector).all();
|
||||||
|
for (let i = 0; i < Math.min(elements.length, maxCount); i++) {
|
||||||
|
try {
|
||||||
|
const box = await elements[i].boundingBox();
|
||||||
|
if (box && box.y < 700) {
|
||||||
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 8 });
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`WARNING: panElements skipped element ${i} (selector: "${selector}"): ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 8. 字幕
|
||||||
|
|
||||||
|
在视口底部注入一个字幕栏:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function injectSubtitleBar(page) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
if (document.getElementById('demo-subtitle')) return;
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.id = 'demo-subtitle';
|
||||||
|
bar.style.cssText = `
|
||||||
|
position: fixed; bottom: 0; left: 0; right: 0; z-index: 999998;
|
||||||
|
text-align: center; padding: 12px 24px;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
color: white; font-family: -apple-system, "Segoe UI", sans-serif;
|
||||||
|
font-size: 16px; font-weight: 500; letter-spacing: 0.3px;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
pointer-events: none;
|
||||||
|
`;
|
||||||
|
bar.textContent = '';
|
||||||
|
bar.style.opacity = '0';
|
||||||
|
document.body.appendChild(bar);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showSubtitle(page, text) {
|
||||||
|
await page.evaluate((t) => {
|
||||||
|
const bar = document.getElementById('demo-subtitle');
|
||||||
|
if (!bar) return;
|
||||||
|
if (t) {
|
||||||
|
bar.textContent = t;
|
||||||
|
bar.style.opacity = '1';
|
||||||
|
} else {
|
||||||
|
bar.style.opacity = '0';
|
||||||
|
}
|
||||||
|
}, text);
|
||||||
|
if (text) await page.waitForTimeout(800);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
每次导航后,将 `injectSubtitleBar(page)` 与 `injectCursor(page)` 一起调用。
|
||||||
|
|
||||||
|
使用模式:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
await showSubtitle(page, 'Step 1 - Logging in');
|
||||||
|
await showSubtitle(page, 'Step 2 - Dashboard overview');
|
||||||
|
await showSubtitle(page, '');
|
||||||
|
```
|
||||||
|
|
||||||
|
指南:
|
||||||
|
|
||||||
|
* 保持字幕文本简短,最好在 60 个字符以内。
|
||||||
|
* 使用 `Step N - Action` 格式以保持一致性。
|
||||||
|
* 在长时间暂停且界面可以自我说明时清除字幕。
|
||||||
|
|
||||||
|
## 脚本模板
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
'use strict';
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:3000';
|
||||||
|
const VIDEO_DIR = path.join(__dirname, 'screenshots');
|
||||||
|
const OUTPUT_NAME = 'demo-FEATURE.webm';
|
||||||
|
const REHEARSAL = process.argv.includes('--rehearse');
|
||||||
|
|
||||||
|
// Paste injectCursor, injectSubtitleBar, showSubtitle, moveAndClick,
|
||||||
|
// typeSlowly, ensureVisible, and panElements here.
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
|
||||||
|
if (REHEARSAL) {
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
|
||||||
|
const page = await context.newPage();
|
||||||
|
// Navigate through the flow and run ensureVisible for each selector.
|
||||||
|
await browser.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await browser.newContext({
|
||||||
|
recordVideo: { dir: VIDEO_DIR, size: { width: 1280, height: 720 } },
|
||||||
|
viewport: { width: 1280, height: 720 }
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await injectCursor(page);
|
||||||
|
await injectSubtitleBar(page);
|
||||||
|
|
||||||
|
await showSubtitle(page, 'Step 1 - Logging in');
|
||||||
|
// login actions
|
||||||
|
|
||||||
|
await page.goto(`${BASE_URL}/dashboard`);
|
||||||
|
await injectCursor(page);
|
||||||
|
await injectSubtitleBar(page);
|
||||||
|
await showSubtitle(page, 'Step 2 - Dashboard overview');
|
||||||
|
// pan dashboard
|
||||||
|
|
||||||
|
await showSubtitle(page, 'Step 3 - Main workflow');
|
||||||
|
// action sequence
|
||||||
|
|
||||||
|
await showSubtitle(page, 'Step 4 - Result');
|
||||||
|
// final reveal
|
||||||
|
await showSubtitle(page, '');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('DEMO ERROR:', err.message);
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
const video = page.video();
|
||||||
|
if (video) {
|
||||||
|
const src = await video.path();
|
||||||
|
const dest = path.join(VIDEO_DIR, OUTPUT_NAME);
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(src, dest);
|
||||||
|
console.log('Video saved:', dest);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('ERROR: Failed to copy video:', e.message);
|
||||||
|
console.error(' Source:', src);
|
||||||
|
console.error(' Destination:', dest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
```
|
||||||
|
|
||||||
|
使用方式:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Phase 2: Rehearse
|
||||||
|
node demo-script.cjs --rehearse
|
||||||
|
|
||||||
|
# Phase 3: Record
|
||||||
|
node demo-script.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
## 录制前检查清单
|
||||||
|
|
||||||
|
* \[ ] 探索阶段已完成
|
||||||
|
* \[ ] 排练通过,所有选择器正常
|
||||||
|
* \[ ] 已启用无头模式
|
||||||
|
* \[ ] 分辨率设置为 `1280x720`
|
||||||
|
* \[ ] 每次导航后重新注入光标和字幕覆盖层
|
||||||
|
* \[ ] 在主要过渡时使用 `showSubtitle(page, 'Step N - ...')`
|
||||||
|
* \[ ] 所有点击均使用 `moveAndClick` 并带有描述性标签
|
||||||
|
* \[ ] 可见输入使用 `typeSlowly`
|
||||||
|
* \[ ] 无静默捕获;辅助函数记录警告
|
||||||
|
* \[ ] 内容展示使用平滑滚动
|
||||||
|
* \[ ] 关键暂停对观看者可见
|
||||||
|
* \[ ] 流程符合请求的故事顺序
|
||||||
|
* \[ ] 脚本反映阶段 1 中发现的实际 UI
|
||||||
|
|
||||||
|
## 常见陷阱
|
||||||
|
|
||||||
|
1. 导航后光标消失 - 重新注入。
|
||||||
|
2. 视频太快 - 添加暂停。
|
||||||
|
3. 光标是点而非箭头 - 使用 SVG 覆盖层。
|
||||||
|
4. 光标瞬移 - 在点击前移动。
|
||||||
|
5. 选择下拉菜单显示异常 - 展示移动过程,然后选择选项。
|
||||||
|
6. 模态框显得突兀 - 在确认前添加阅读暂停。
|
||||||
|
7. 视频文件路径随机 - 将其复制到稳定的输出名称。
|
||||||
|
8. 选择器失败被吞没 - 切勿使用静默捕获块。
|
||||||
|
9. 字段类型被假设 - 先探索它们。
|
||||||
|
10. 功能被假设 - 在编写脚本前检查实际 UI。
|
||||||
|
11. 占位符选择值看起来真实 - 注意 `"0"` 和 `"Select..."`。
|
||||||
|
12. 弹出窗口创建单独的视频 - 显式捕获弹出页面,必要时稍后合并。
|
||||||
Loading…
x
Reference in New Issue
Block a user