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