docs: salvage zh-CN framework skill translations

This commit is contained in:
Affaan Mustafa
2026-05-11 14:25:17 -04:00
committed by Affaan Mustafa
parent 3242ed461f
commit 4359947a6a
12 changed files with 2848 additions and 0 deletions

View 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/
```

View 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/` — 编码风格、模式、安全性、测试、钩子

View 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`

View 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. 细节打磨 — 悬停状态、过渡效果、加载状态、空状态
```
每个维度都会获得评分、具体示例以及包含精确文件:行号的修复方案。
### 模式3AI生成内容检测
识别通用的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
```

View 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` 或内插字符串处理程序 |

View 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 的原始论文
* [EpsillaGAN 风格智能体循环](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 的并行工作

View 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

View 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` 当动画是更广泛发布的一部分时

View 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 控制器内。
* 对公共端点明确启用速率限制、认证和审计日志。

View 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 感知辅助函数。

View 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 中嵌入视频——裁剪、音量、速度、循环、音调

View 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. 弹出窗口创建单独的视频 - 显式捕获弹出页面,必要时稍后合并。