文档目录

AGENTS

这份文档是日常工作写前端后,总结出来的个人觉得比较好用的AGENTS文件,哪怕让AI重零生成,代码质量也不会太差

这是我的个人前端通用规范,对所有前端项目生效,不绑定任何单一仓库。

  • 加载位置:DSH 用户级全局指令 ~/.dsh/AGENTS.md。
  • 沟通、注释、UI 文案用中文;代码标识符用英文。
  • 本文件写原则与契约;各项目的技术栈版本、命令、历史目录映射,写在该项目根目录的 AGENTS.md。
  • 冲突裁决:项目文件可以补充/收紧本规范,但不得放宽第 0 节的十条铁律。
  • 本文件是规范,不代表现存代码已达标:新代码按规范写;改到老代码时顺手收敛,但禁止做与当前任务无关的批量重命名。
  • 禁止手工修改生成文件:*.g.dart、*.freezed.dart、*.gen.ts、自动生成的 *.d.ts、pubspec.lock、package-lock.json、pnpm-lock.yaml。要变就改源文件并重跑代码生成。

0. 十条铁律(TL;DR)

  1. 三态闭环:任何请求 / 查询 / 写入都必须有 loading → success → error 三态,且三态都有明确处理——查询失败要显示错误信息 + 重试入口;写入失败要弹 toast;成功要有反馈。
  2. 目录分层:common / core / feature 三分;feature 内部严格 data → application → presentation,依赖方向不可逆。
  3. 不造轮子:优先用项目所选组件库。颜色/字体/圆角/间距一律走设计 token,禁止魔数(12、13、16 这类裸数字)。
  4. JSON 必须建类:禁止 json['text'] / data.text 裸取后直接塞进 remote 层。必须有 XxxRequest / XxxResponse 与之对应的解析层。
  5. 复杂参数必须建 DTO:参数 >3 个、或参数之间有内聚语义时,建 XxxDto。禁止 saveBook(id, name, title, paths) 这种长参数列表。
  6. 结果必须包装:可能失败的操作返回 Result<T>,禁止 void saveBook(...)。Result 含 isSuccess / isError / data / error。
  7. 异常按业务建模:全局 GlobalException(message / exception / stackTrace),业务异常继承它(如 RemoteException,默认文案「远程请求错误」);层间用 Result.error(RemoteException(message: '缺少参数')) 传递,不靠裸 throw。
  8. 禁止字符串代替枚举:后端返回的可枚举字符串必须建枚举并集中做解析映射,禁止 if (status === 'completed')。
  9. viewmodel 只管 UI:viewmodel 只做 UI 相关逻辑(展示状态、表单校验、展示态映射、交互触发);复杂处理逻辑必须落在 service,viewmodel 里不允许出现大段业务分支。
  10. service / repository / datasource 不许臃肿:严禁单个类无限膨胀——千行级 book_service、或一个 book_local_datasource 管十几张表,都是标准反例。逻辑/表变多必须按能力域或数据边界拆分(book_read_service / book_sync_service;book_local_datasource / book_file_local_datasource),并且在动手写之前就规划好怎么拆,不要等涨到千行再回头拆。

1. 通用约定

  • 语言:标识符、文件名、commit message 用英文;注释、文档、UI 文案用中文。
  • 不私自加依赖:新增第三方库前先确认组件库/标准库是否已能覆盖;能用现有依赖解决就不引入新包。
  • 一处定义,多处复用:同一个常量、同一段错误文案、同一个状态判断,只允许有一个定义点。
  • 可失败操作不裸抛:跨层用 Result 通信(第 5 节),异常只在边界捕获并翻译成业务异常。
  • 日志不吞不刷:不吞异常(catch {} 空实现);不用 print / console.log 当业务日志,走项目统一 logger。
  • 文件末尾留空行,缩进跟随项目 formatter(Dart: dart format;TS: 项目 prettier/eslint 配置)。

2. 项目结构

2.1 三层根目录

<src>/
├── common/     # 全局通用、无业务语义:主题/设计 token、全局配置、通用组件
├── core/       # 核心能力、不含具体业务:网络封装、路由、存储、工具、异常定义
└── feature/    # 各业务模块,一个模块一个目录

判定归属:

  • 只被一个 feature 用 → 放该 feature 内。
  • 被 ≥2 个 feature 用且不含业务语义 → common 或 core。
  • 含业务语义(如「书籍」「订单」)→ feature;纯能力(如「网络」「路由」「Result」)→ core。

2.2 feature 内部分层(统一模型)

feature/<module>/
├── data/                       # 数据层:只跟数据源打交道
│   ├── local/                  # 本地持久化(数据库 / localStorage / 文件);按表域拆成多个 datasource
│   ├── remote/                 # 远程接口(HTTP / RPC)
│   ├── runtime/                # 运行时内存态;对其他模块暴露只读流/查询
│   └── model/
│       ├── dto/                # 跨层入参对象
│       ├── request/            # 出站请求体,与后端字段一一对应
│       ├── response/           # 入站响应体,与后端字段一一对应
│       ├── vo/                 # View Object:UI 展示 / 跨模块返回
│       ├── bo/                 # Business Object:模块内部业务对象
│       ├── entity/ | table/    # 持久化实体(ORM 表 / 行数据)
│       └── state/              # viewmodel 的不可变状态
├── application/                # 应用层:编排
│   ├── repository/             # 数据编排、事务、缓存;可暴露给其他模块
│   └── service/                # 本模块业务用例;只给本模块 viewmodel 用
├── enum/                       # 模块枚举 + 字符串↔枚举解析
└── presentation/               # 展示层
    ├── view/                   # 页面(路由入口)
    ├── widget/ | components/   # 本模块可复用组件
    └── viewmodel/              # 视图模型(状态与交互)

历史项目命名兼容:若某项目既有的目录名不同(常见映射:datasource ≡ data、model 提到 feature 根级 ≡ data/model、ui ≡ presentation、provider/store/hooks ≡ viewmodel),以该项目 AGENTS.md 的映射表为准:

  • 禁止为了对齐命名而批量重命名既有目录(污染 diff、破坏 import)。
  • 新增文件跟随该 feature 现有兄弟文件的目录与命名,不要在同一 feature 内混用两套目录名。
  • 无论目录叫什么,依赖方向与分层职责(第 3 节)不可违反。

2.3 双栈目录示例

# Flutter / Dart
lib/
├── common/   core/   feature/book/{data,application,presentation,enum}

# TypeScript / Web
src/
├── common/   core/   feature/book/{data,application,presentation,enum}

3. 依赖方向(硬约束)

data  →  repository  →  service  →  viewmodel  →  view
(数据单向向上流;上层只能向下依赖,下游不认识上游)
层 可以做 禁止
view 订阅 viewmodel 状态、渲染三态、触发交互 import repository / service / datasource;写业务判断
viewmodel 调本模块 service;读其他模块 service / runtime 暴露的只读流;只维护 UI 状态与展示逻辑 直接访问数据库 / HTTP;持有 DOM / BuildContext;承载复杂业务逻辑(必须下沉 service)
service 编排 1..N 个 repository;业务规则校验 依赖 UI 框架;直接用 HTTP / DB 客户端
repository 组合 local / remote / runtime 数据源;事务;实体↔模型映射 出现 UI 概念;被非本模块直接使用
datasource 单一数据源读写;remote 只收发 request/response 跨数据源编排;返回 UI 模型

跨模块访问:只允许依赖对方的 service(或 runtime 暴露的只读流)。禁止 import 其他 feature 的 repository / data / presentation。

3.1 viewmodel 只管 UI,复杂逻辑落 service

  • viewmodel 负责:维护展示状态、表单校验与格式化、把领域结果映射成展示态、触发 toast / 弹窗 / 导航、防抖节流等纯交互。
  • viewmodel 不负责:业务规则判断、多步流程编排、数据聚合与计算、跨数据源协调——这些必须放在 service。
  • 自检问句:这段逻辑「换一套 UI / 换一个平台之后,是否还要原样存在」?
    • 要 → 属于 service;
    • 不要(只关乎「怎么显示」)→ 才允许留在 viewmodel。
  • ❌ 反例:viewmodel 里几十行业务分支、手写同步/冲突/重试流程、自己拼 SQL / URL / 文件路径。

3.2 禁止臃肿:service / repository / datasource 必须按能力域拆分

严禁把整个模块的逻辑堆进一个 XxxService(千行级 book_service 是标准反例)。逻辑变多时必须拆:

// ❌ 一个文件吃掉整个模块 / 整个数据库
class BookService { /* 900+ 行:增删改查 + 同步 + 导入导出 + 封面生成 */ }
class BookLocalDatasource { /* 一个类管 book、book_file、book_tag… 十几张表 */ }

// ✅ 按能力域 / 数据边界拆,装配处按需依赖
class BookReadService { ... }
class BookWriteService { ... }
class BookSyncService { ... }
class BookExportService { ... }
class BookLocalDatasource { ... }         // 只管 book 表
class BookFileLocalDatasource { ... }     // 只管 book_file 表
class CollectionLocalDatasource { ... }   // 只管 collection 表
  • 按能力域拆,不按「增删改查」机械拆:book_read_service / book_write_service / book_sync_service / book_import_service / book_export_service / book_parse_service。
  • 先规划、再动手:新建模块时先列能力清单(读、写、同步、导入导出、解析、统计…),据此定下要建哪几个 service 及各自边界,然后再写代码。不要先堆一个「大 service」,等涨到千行再回头拆。
  • repository 同理:按聚合根 / 数据域拆,一个 repository 只服务一个数据边界。
  • datasource 尤其如此:一个 datasource 只服务一个数据边界(一张表 / 一组强相关表 / 一个接口域)。禁止「模块级 DAO 上帝类」——比如把 book 相关的十几张表全塞进一个 book_local_datasource,就是标准反例。
    • ✅ 按表域 / 聚合根拆:book_local_datasource(book 表)、book_file_local_datasource(book_file 表)、collection_local_datasource、collection_book_local_datasource(关联表)。
    • remote 同理:一个 xxx_remote_datasource 对应一个后端接口域,不要把全站接口塞进一个 api_datasource。
    • runtime 同理:按运行时数据域拆。
    • 表 / 实体的定义可以集中注册在数据库定义处,但读写方法必须按域分散到各自的 datasource。
    • 关联表 / 中间表跟随它服务的聚合根,不要单独攒一个 relation_datasource。
  • 命中任一信号就该拆:
    • 单文件超过 ~300–400 行,或单个 service 公开方法超过 ~8 个;
    • 文件里出现两个以上互不共享内部状态的能力域;
    • 一个 datasource 覆盖的表 / 接口超过一个聚合根,或出现「模块名 + LocalDatasource」这种概括性命名(book_local_datasource 管十几张表);
    • 不同调用方各自只用到其中一小部分方法;
    • 需要靠 Manager / Helper / UtilService 这类名字才能概括它。
  • 命名:<module>_<capability>_service.dart,capability 用能力名(read / write / sync / import / export / parse)。
  • 拆分纪律:
    • 拆出的 service 之间禁止循环调用;共享能力下沉到 repository 或抽成更小的 service。
    • 只有一个调用点、且没有独立变化理由的「能力」,不要为拆而拆。
    • 拆完后不要再造一个上帝对象把大家重新包起来。

4. 三态闭环(loading / success / error)

4.1 处理矩阵

场景 loading success error
查询(列表/详情) 首屏骨架屏或局部进度;不要无脑全屏转圈 渲染数据;空数据渲染空状态组件 渲染错误视图:可读文案 + 重试按钮
新增/修改 提交按钮 pending + 禁用,防重复提交 成功 toast / 提示 失败 toast,带具体原因
删除 二次确认 + 操作项 pending 成功 toast 失败 toast,保持或回滚列表数据
长任务(下载/解析/导出) 进度 + 可取消/可重试 成功 toast 失败 toast + 保留现场,提供重试入口

4.2 分层写法(Dart / Flutter)

// repository / service:只返回 Result,不把异常抛给上层
Future<Result<void>> saveBook(SaveBookDto dto) async {
  final prepared = await prepareBookImages(dto);
  if (prepared.isError) return Result.error(prepared.error!);
  await _bookLocalDatasource.insertBook(prepared.data!);
  return Result.success(null);
}

// viewmodel:把 Result 转成三态
@riverpod
class SaveBookController extends _$SaveBookController {
  @override
  FutureOr<void> build() {}

  Future<void> save(SaveBookDto dto) async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(() async {
      final result = await ref.read(bookServiceProvider).saveBook(dto);
      if (result.isError) throw result.error!; // 交给 view 反馈
    });
  }
}

// view:查询用 .when,写操作用 ref.listen
ref.watch(bookListProvider).when(
  loading: () => const Shimmer(),
  error: (e, st) => CustomErrorWidget(
    errorMessage: messageOf(e),
    onRetry: () => ref.invalidate(bookListProvider),
  ),
  data: (list) => list.isEmpty ? const CustomEmptyWidget() : BookListView(books: list),
);

ref.listen(saveBookControllerProvider, (prev, next) {
  next.whenOrNull(
    data: (_) {
      if (prev?.isLoading ?? false) showFToast(context: context, title: const Text('保存成功'));
    },
    error: (e, _) => showFToast(
      context: context,
      title: const Text('保存失败'),
      description: Text(messageOf(e)),
    ),
  );
});

4.3 分层写法(TypeScript / Web)

// viewmodel:三态状态机(状态库按项目所选实现,原则不变)
type AsyncState<T> =
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: GlobalException };

export function useBookList() {
  const [state, setState] = useState<AsyncState<BookVo[]>>({ status: 'loading' });

  const load = useCallback(async () => {
    setState({ status: 'loading' });
    const result = await bookService.list();
    if (result.isError) {
      setState({ status: 'error', error: result.error! });
      return;
    }
    setState({ status: 'success', data: result.data ?? [] });
  }, []);

  useEffect(() => { void load(); }, [load]);
  return { state, reload: load };
}
// view:三态缺一不可
const { state, reload } = useBookList();
if (state.status === 'loading') return <ListSkeleton />;
if (state.status === 'error') {
  return <ErrorView message={state.error.message} onRetry={reload} />;
}
if (state.data.length === 0) return <EmptyView />;
return <BookList books={state.data} />;
// 写操作:成功失败都要有反馈,pending 期间禁用按钮
const result = await bookService.save(dto);
if (result.isError) {
  toast.error(`保存失败:${result.error.message}`);
  return;
}
toast.success('保存成功');

4.4 反例

  • ❌ try { await save(); } catch {} —— 吞异常,用户毫无反馈。
  • ❌ 只在成功时提示,失败静默。
  • ❌ 用全屏 loading 遮罩处理按钮级 loading。
  • ❌ 组件内散落 isLoading / hasError 多个布尔(应是一个三态状态 + 空态)。

5. Result 与异常

5.1 Result<T>(双栈保持同一套 API)

Dart

class Result<T> {
  final T? data;
  final GlobalException? error;

  bool get isSuccess => error == null;
  bool get isError => error != null;

  static Result<T> success<T>(T data) => Result(data: data);
  static Result<T> error<T>(GlobalException error) => Result(error: error);
}

TypeScript

export class Result<T> {
  private constructor(
    readonly data: T | null,
    readonly error: GlobalException | null,
  ) {}

  get isSuccess(): boolean { return this.error === null; }
  get isError(): boolean { return this.error !== null; }

  static success<T>(data: T): Result<T> { return new Result<T>(data, null); }
  static error<T>(error: GlobalException): Result<T> { return new Result<T>(null, error); }
}

规则:

  • 所有可能失败的操作返回 Result<T>;纯计算 / 纯 getter 可直接返回值。
  • 无返回值但有失败可能的操作,用 Result<void>(TS: Result<void> / Result<null>),禁止 void doXxx() 后靠 throw 通信。
  • 上层拿到 Result 必须先判 isError,再取 data,禁止直接 ! / as 强行解包。
  • 响应式流(订阅、Stream、Signal)不适合包 Result 的场景,直接用流本身的三态承载,不要既包 Result 又包流。
  • 禁止写 Result 的变体或第二个类似类型(如 Either / ApiResponse)混用。

5.2 GlobalException 体系

Dart

sealed class GlobalException implements Exception {
  final String message;
  final Object? exception;   // 原始异常 / 详细信息
  final StackTrace? stackTrace;

  const GlobalException({
    required this.message,
    this.exception,
    this.stackTrace,
  });

  @override
  String toString() =>
      '$runtimeType: $message${exception == null ? '' : ' | cause: $exception'}';
}

class RemoteException extends GlobalException {
  const RemoteException({
    super.message = '远程请求错误',
    super.exception,
    super.stackTrace,
  });
}

class LocalStorageException extends GlobalException {
  const LocalStorageException({
    super.message = '本地数据操作失败',
    super.exception,
    super.stackTrace,
  });
}

class ValidationException extends GlobalException {
  const ValidationException({
    super.message = '参数不合法',
    super.exception,
    super.stackTrace,
  });
}

class BusinessException extends GlobalException {
  const BusinessException({
    super.message = '业务处理失败',
    super.exception,
    super.stackTrace,
  });
}

TypeScript

export abstract class GlobalException extends Error {
  constructor(
    message: string,
    readonly exception?: unknown,
    readonly stackTrace?: string,
  ) {
    super(message);
    this.name = new.target.name;
    if (stackTrace) this.stack = stackTrace;
  }
}

export class RemoteException extends GlobalException {
  constructor(message = '远程请求错误', exception?: unknown, stackTrace?: string) {
    super(message, exception, stackTrace);
  }
}

export class LocalStorageException extends GlobalException {
  constructor(message = '本地数据操作失败', exception?: unknown, stackTrace?: string) {
    super(message, exception, stackTrace);
  }
}

export class ValidationException extends GlobalException {
  constructor(message = '参数不合法', exception?: unknown, stackTrace?: string) {
    super(message, exception, stackTrace);
  }
}

export class BusinessException extends GlobalException {
  constructor(message = '业务处理失败', exception?: unknown, stackTrace?: string) {
    super(message, exception, stackTrace);
  }
}

规则:

  • 每个业务异常类必须有默认中文文案,调用方只在需要时覆盖 message。
  • 上报问题写法:Result.error(RemoteException(message: '缺少参数'))。
  • 只在最外层边界捕获(datasource / api client / service 入口),把原始异常翻译成对应 GlobalException,并保留原始异常与堆栈:
// Dart
try {
  final res = await _dio.get(...);
  return Result.success(LoginResponse.fromJson(res.data));
} on DioException catch (e, st) {
  return Result.error(RemoteException(
    message: '登录失败:${e.message ?? '网络异常'}',
    exception: e,
    stackTrace: st,
  ));
} catch (e, st) {
  return Result.error(RemoteException(exception: e, stackTrace: st));
}
// TypeScript
try {
  const raw = await http.get('/auth/login', LoginRequest.toJson(req));
  return Result.success(parseLoginResponse(raw));
} catch (e) {
  return Result.error(new RemoteException('登录失败:网络异常', e, (e as Error)?.stack));
}
  • 禁止用 Exception('随便一句话') / new Error('xx') 作为跨层错误协议。
  • 禁止把 GlobalException 再包一层(Exception(e.toString()) / new Error(e.message)),会丢类型丢堆栈。
  • 新增业务异常类型时,继承 GlobalException,不要新建平行的错误类型体系。

5.3 统一错误文案提取

// Dart,放 core/util
String messageOf(Object error) =>
    error is GlobalException ? error.message : '发生未知错误';
// TypeScript,放 core/util
export function messageOf(error: unknown): string {
  return error instanceof GlobalException ? error.message : '发生未知错误';
}

禁止在 UI 各处用 String(e) / e.message / JSON.stringify(e) 自行拼错误文案。


6. 建模规范(dto / request / response / vo / bo / entity / state)

6.1 选型

类型 用途 位置
dto 跨层传递的入参对象 data/model/dto/
request 出站请求体,与后端字段一一对应 data/model/request/
response 入站响应体,与后端字段一一对应 data/model/response/
vo UI 展示 / 跨模块返回的组合对象 data/model/vo/
bo 模块内部业务对象 data/model/bo/
entity / table 持久化实体 data/model/entity/
state viewmodel 的不可变状态 data/model/state/

6.2 复杂参数必须建 DTO

❌ 反例:

Future<Result<void>> saveBook(int id, String name, String title, List<String> paths);
function saveBook(id: number, name: string, title: string, paths: string[]): Promise<Result<void>>;

✅ 正例:

// Dart
class SaveBookDto {
  final int? id;
  final String title;
  final List<String> paths;

  const SaveBookDto({this.id, required this.title, required this.paths});
}

Future<Result<void>> saveBook(SaveBookDto dto);
// TypeScript
export interface SaveBookDto {
  id?: number;
  title: string;
  paths: string[];
}

export function saveBook(dto: SaveBookDto): Promise<Result<void>>;

判定:参数 >3 个,或参数之间有内聚语义(title + paths 同属「一本书」)→ 必须建 DTO。 禁止在 dto 里塞 UI / 框架对象(BuildContext、Widget、Ref、DOM 元素、组件实例)。

6.3 组合实体用 VO

跨模块或 UI 需要「实体 + 统计 + 封面」这类聚合时建 VO,禁止把 Map / Record / 多维数组直接往上抛:

class CollectionListItemVo {
  final CollectionEntity collection;
  final int count;
  final List<String> coverImages;
  const CollectionListItemVo({required this.collection, required this.count, required this.coverImages});
}
export interface CollectionListItemVo {
  collection: CollectionEntity;
  count: number;
  coverImages: string[];
}

6.4 JSON 一律建类

禁止 json['text']、data.text、(raw as any).xxx 在 remote / service / viewmodel 之间裸传。数据边界只认强类型对象。

Dart(json_serializable)

@JsonSerializable(fieldRename: FieldRename.snake)
class LoginRequest {
  final String connectionKey;
  final String deviceId;

  const LoginRequest({required this.connectionKey, required this.deviceId});

  factory LoginRequest.fromJson(Map<String, dynamic> json) => _$LoginRequestFromJson(json);
  Map<String, dynamic> toJson() => _$LoginRequestToJson(this);
}

@JsonSerializable(fieldRename: FieldRename.snake)
class LoginResponse {
  final String accessToken;
  final String refreshToken;

  const LoginResponse({required this.accessToken, required this.refreshToken});

  factory LoginResponse.fromJson(Map<String, dynamic> json) => _$LoginResponseFromJson(json);
  Map<String, dynamic> toJson() => _$LoginResponseToJson(this);
}

TypeScript(schema 校验 + 单一解析入口)

export interface LoginRequest {
  connectionKey: string;
  deviceId: string;
}

export interface LoginResponse {
  accessToken: string;
  refreshToken: string;
}

// 唯一解析入口:解析失败一律抛 ParsingException,禁止 any 穿透
export function parseLoginResponse(raw: unknown): LoginResponse {
  if (!isRecord(raw) || typeof raw.access_token !== 'string' || typeof raw.refresh_token !== 'string') {
    throw new ParsingException('登录响应格式不正确');
  }
  return { accessToken: raw.access_token, refreshToken: raw.refresh_token };
}
// Dart remote 层:只收发强类型对象
Future<Result<LoginResponse>> login(LoginRequest req) async {
  try {
    final res = await _dio.post('/auth/login', data: req.toJson());
    return Result.success(LoginResponse.fromJson(res.data as Map<String, dynamic>));
  } on DioException catch (e, st) {
    return Result.error(RemoteException(exception: e, stackTrace: st));
  }
}
// TS remote 层:只收发强类型对象
export async function login(req: LoginRequest): Promise<Result<LoginResponse>> {
  try {
    const raw = await http.post('/auth/login', LoginRequest.toJson(req));
    return Result.success(parseLoginResponse(raw));
  } catch (e) {
    return Result.error(new RemoteException(undefined, e, (e as Error)?.stack));
  }
}

规则:

  • 字段命名差异(snake_case ↔ camelCase)只在 request/response 的序列化层处理,不污染业务模型。
  • 可选字段用可空类型 + 明确默认值,禁止用魔法字符串 '' 表示「无」。
  • 解析失败要给出可定位的信息(哪个字段、期望什么),不要只抛「解析失败」。
  • 改完 request/response/dto 后必须重跑代码生成(Dart: dart run build_runner build --delete-conflicting-outputs)。

7. 枚举规范

禁止用字符串/数字字面量承载有限状态,尤其是后端返回的取值。

❌ 反例:

if (group['status'] == 'completed') { ... } else if (group['status'] == 'failed') { ... }
final label = status == 'downloading' ? '下载中' : '等待中';
if (group.status === 'completed') { ... }
const label = status === 'downloading' ? '下载中' : '等待中';

✅ 正例:

// Dart
enum DownloadStatus {
  pending('处理中'),
  downloading('下载中'),
  paused('已暂停'),
  completed('已完成'),
  failed('下载失败');

  final String description;
  const DownloadStatus(this.description);

  /// 后端字符串 → 枚举:唯一解析入口,未知值给安全默认
  static DownloadStatus fromCode(String? code) => switch (code) {
    'pending' => DownloadStatus.pending,
    'downloading' => DownloadStatus.downloading,
    'paused' => DownloadStatus.paused,
    'completed' => DownloadStatus.completed,
    'failed' => DownloadStatus.failed,
    _ => DownloadStatus.pending,
  };

  String get code => name;
}
// TypeScript
export enum DownloadStatus {
  Pending = 'pending',
  Downloading = 'downloading',
  Paused = 'paused',
  Completed = 'completed',
  Failed = 'failed',
}

/** 后端字符串 → 枚举:唯一解析入口,未知值给安全默认 */
export function parseDownloadStatus(code: string | null | undefined): DownloadStatus {
  return Object.values(DownloadStatus).includes(code as DownloadStatus)
    ? (code as DownloadStatus)
    : DownloadStatus.Pending;
}

规则:

  • 每个「有限取值集合」= 一个枚举,放 <feature>/enum/。
  • 解析只在枚举内做一次,其他任何地方都不得再写字符串/数字比较。
  • 未知取值:走 default 并记 warning 日志,或显式增加 unknown 枚举值;禁止静默当成正常值继续跑业务。
  • 展示文案放在枚举上(如 description),不要在 UI 里 switch 拼字符串。
  • 禁止用 int 魔数代替枚举(if (type === 2))。

8. UI 与设计规范

8.1 组件库优先(不造轮子)

  • 项目已选组件库能覆盖的(按钮/输入/弹窗/提示/开关/列表项/表格/日期选择等),必须用组件库组件,不要自己拿基础元素拼一个。
  • 组件库没有、但项目 common/ 已有封装的,用现成封装。
  • 都没有才新写,且新组件必须放对位置(通用 → common/;模块内 → presentation/widget/)。

8.2 设计 token:禁止魔数

❌ 反例:

Padding(padding: const EdgeInsets.all(12), child: ...)
SizedBox(height: 13)
Text('标题', style: TextStyle(fontSize: 17, color: Color(0xFF333333)))
<div style={{ padding: 12, marginBottom: 13, fontSize: 17, color: '#333333' }}>标题</div>

✅ 正例:

// Flutter:走主题
Padding(
  padding: context.theme.style.pagePadding, // 页面级留白
  child: Column(
    children: [
      Text(
        '标题',
        style: context.theme.typography.display.xs.copyWith(
          color: context.theme.colors.foreground,
        ),
      ),
      const SizedBox(height: AppSpacing.sm),
    ],
  ),
)
// Web:走 CSS 变量 / 设计 token
<div className="page">
  <h2 className="title">标题</h2>
</div>
/* tokens.css */
:root { --space-sm: 8px; --color-fg: #111827; --font-body-md: 16px; }
/* 组件样式只引用 token */
.page { padding: var(--space-md); color: var(--color-fg); font-size: var(--font-body-md); }

规则:

  • 颜色、字体、圆角、边框、阴影、层级、动效时长 → 一律走设计 token。
  • 页面留白优先用主题提供的 page padding / 布局容器的既有间距。
  • 间距优先复用组件自带 padding;确实需要额外间距时用统一的间距 token(AppSpacing.xs/sm/md/lg / --space-*),禁止就地写 8 / 12 / 13 / 16 这类裸数字。
  • 深浅色主题适配:颜色必须来自 token,禁止硬编码色值(品牌色只在主题定义处集中出现)。
  • 禁止 margin: 13px 这种「看起来差不多」的随手值——如果 token 不够用,先补 token。

8.3 反馈与安全

  • 成功/失败反馈用组件库的 toast / message 能力,错误 toast 必须带具体原因。
  • 危险操作(删除/覆盖/清空)先二次确认再执行。
  • 提交类按钮在 pending 期间禁用,避免重复提交。
  • 所有面向用户的文案为中文,且给可执行信息(「保存失败:磁盘空间不足」优于「出错了」)。
  • 错误视图必须提供重试入口,不要只显示一句红字。

9. 视图模型与状态

  • 一个页面/模块的交互状态,集中在一个 viewmodel / store 中,UI 只做渲染与事件转发。
  • viewmodel 只承载 UI 逻辑:展示状态、表单校验、展示态映射、交互触发。复杂处理逻辑一律下沉 service(见 3.1);viewmodel 里出现大段业务分支、流程编排即为违规。
  • 状态用不可变结构 + 拷贝更新;禁止在 view 内直接改共享状态。
  • 只读数据用响应式流 / Future 承载,天然获得三态;写操作用独立的 mutation 单元(Controller / action)并暴露 pending / success / error。
  • 依赖装配集中在 provider / container / DI 定义处,禁止在方法体内临时 new SomeService() 取依赖。
  • view 中禁止:在渲染阶段发请求、改状态、做业务计算。
  • viewmodel 禁止持有 UI 上下文(BuildContext / DOM 节点 / 组件实例);toast、弹窗、导航一律在 view 层触发。
  • 生命周期:订阅/监听必须在销毁时释放(ref.onDispose / useEffect 清理函数)。

10. 命名与文件规范

  • 文件名 snake_case(Dart)或项目约定的 kebab-case/PascalCase(Web,跟随项目既有约定);一个文件一个主类型。
  • 类型后缀必须表达层次:XxxDto / XxxRequest / XxxResponse / XxxVo / XxxBo / XxxEntity / XxxState / XxxService / XxxRepository / XxxDatasource / XxxView / XxxWidget / XxxController。
  • service 命名必须体现能力域:<module>_<capability>_service(book_read_service、book_sync_service),而不是一个 book_service 包打天下。
  • datasource 命名必须体现数据边界:<module>_<domain>_local_datasource(book_local_datasource、book_file_local_datasource、collection_book_local_datasource),而不是一个 app_local_datasource 管全库。
  • 私有成员 _camelCase(Dart)/ #private 或项目约定(TS);常量 SCREAMING_SNAKE(TS)或 lowerCamelCase(Dart),跟随项目。
  • 数据 provider/查询命名用名词复数(books / booksProvider);写操作用动词 / Controller(saveBook / saveBookController)。
  • 注释写「为什么」,不复述代码;公开 API(service / repository 方法)必须有文档注释。
  • 不保留注释掉的死代码;不留 TODO 而不写清前置条件。

11. 常见反例速查

❌ 禁止 ✅ 改成
json['text'] / data.text 裸取 XxxResponse.fromJson / parseXxxResponse
Map / Record / any 跨层传递 明确的 dto / response / vo
void saveBook(...) Result<void> saveBook(SaveBookDto dto)
saveBook(id, name, title, paths) saveBook(SaveBookDto(...))
throw Exception('失败') / throw new Error('失败') Result.error(BusinessException(message: '失败'))
catch {} 空捕获 返回 Result.error(...) + 记日志
if (status == 'done') switch (Status.fromCode(status)) / parseStatus(status)
EdgeInsets.all(12) / padding: 12px 主题 page padding / AppSpacing.sm / var(--space-sm)
TextStyle(fontSize: 17) / font-size: 17px 主题 typography token
自写按钮 / 弹窗 / 输入框 组件库组件
print(...) / console.log(...) 项目统一 logger
业务状态用一堆布尔 / setState 散落 单一三态 viewmodel
viewmodel 里写大段业务分支 / 同步流程 下沉到对应 service,viewmodel 只做 UI 状态
一个 book_service 上千行 按能力拆 book_read_service / book_sync_service / book_export_service
一个 book_local_datasource 管十几张表 按表域/聚合根拆成多个 datasource,一个数据边界一个类
改生成文件 / lock 文件 改源文件后重跑生成
跨 feature import 对方 repository / data 依赖对方 service 或 runtime 只读流
新增依赖解决已有能力 复用现有依赖 / 组件库

12. 提交前自检清单

  • lint / format / type check 全部通过(如 flutter analyze、npm run lint、tsc --noEmit)。
  • 改过模型 / provider / 生成源后,跑过项目的代码生成命令。
  • 每个新增查询有 loading / success / error(+ 空态),错误有可读文案与重试入口。
  • 每个新增写操作有成功与失败反馈;危险操作有二次确认;pending 期间按钮禁用。
  • 所有可能失败的公开方法返回 Result<T>,没裸抛、没吞异常。
  • 无 json[...] / any 裸取;请求与响应均有类型化类与单一解析入口。
  • 参数 >3 的方法已改为 DTO。
  • 无新增字符串/数字状态判断,可枚举取值已建枚举并集中解析。
  • UI 全部走组件库与设计 token,无新增魔数、无硬编码颜色。
  • viewmodel 只含 UI 逻辑,复杂处理逻辑都在 service。
  • 新增/改动的 service、repository、datasource 未臃肿(未超 ~300–400 行 / ~8 个公开方法),按能力域或数据边界拆分且命名体现边界(一个 datasource 不管多张无关表)。
  • 依赖方向正确:view 不碰 service / repository;跨模块只走 service。
  • 目录与文件位置符合所属 feature 既有结构,未顺手重命名无关文件。
  • 未手工修改任何生成文件与 lock 文件。