本次提交包含多项更新: 1. 更新file_picker依赖到11.0.0-ohos.1版本 2. 清理SecureStorage、Catcher2配置冗余代码 3. 优化鸿蒙系统下HomeWidget调用方式 4. 重构编辑器导航栏图标与页面路由引用 5. 修复边框样式、简化空值判断逻辑 6. 移除冗余系统UI样式配置 7. 新增共享组件导出与自适应返回按钮 8. 批量替换路由引用为app_routes 9. 标记过时通知服务并补充注释 10. 新增引导页扫一扫功能卡片 11. 完善沉浸式状态栏配置逻辑 12. 为大量页面添加统一自适应返回按钮
837 lines
24 KiB
Dart
837 lines
24 KiB
Dart
/// ============================================================
|
|
/// 闲言APP — 主题令牌审计 CLI 脚本
|
|
/// 创建时间: 2026-05-22
|
|
/// 更新时间: 2026-05-22
|
|
/// 作用: 命令行运行主题令牌审计,检测硬编码颜色/间距/圆角/字号
|
|
/// 上次更新: 初始创建 — 自包含脚本,不依赖 Flutter 项目包
|
|
/// 运行方式: dart run scripts/theme_audit.dart [--category color|spacing|radius|fontSize] [--fix-hints] [--json]
|
|
/// ============================================================
|
|
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
// ============================================================
|
|
// 数据模型 (与 lib/features/mine/settings/theme_audit.dart 保持同步)
|
|
// ============================================================
|
|
|
|
enum AuditCategory {
|
|
color('颜色', '🎨'),
|
|
spacing('间距', '📏'),
|
|
radius('圆角', '🔘'),
|
|
fontSize('字号', '🔤');
|
|
|
|
const AuditCategory(this.label, this.emoji);
|
|
final String label;
|
|
final String emoji;
|
|
}
|
|
|
|
class AuditViolation {
|
|
const AuditViolation({
|
|
required this.filePath,
|
|
required this.lineNumber,
|
|
required this.category,
|
|
required this.currentCode,
|
|
required this.suggestion,
|
|
});
|
|
|
|
final String filePath;
|
|
final int lineNumber;
|
|
final AuditCategory category;
|
|
final String currentCode;
|
|
final String suggestion;
|
|
|
|
@override
|
|
String toString() {
|
|
final rel = relativePath(filePath);
|
|
return '${category.emoji} ${category.label} | $rel:$lineNumber\n'
|
|
' 当前: $currentCode\n'
|
|
' 建议: $suggestion';
|
|
}
|
|
|
|
static String relativePath(String path) {
|
|
const markers = ['lib\\', 'lib/', 'scripts\\', 'scripts/'];
|
|
for (final m in markers) {
|
|
final idx = path.indexOf(m);
|
|
if (idx >= 0) return path.substring(idx);
|
|
}
|
|
return path;
|
|
}
|
|
}
|
|
|
|
class AuditSummary {
|
|
const AuditSummary({required this.violations});
|
|
|
|
final List<AuditViolation> violations;
|
|
|
|
int get total => violations.length;
|
|
|
|
int countByCategory(AuditCategory cat) =>
|
|
violations.where((v) => v.category == cat).length;
|
|
|
|
Map<AuditCategory, int> get byCategory => {
|
|
for (final cat in AuditCategory.values) cat: countByCategory(cat),
|
|
};
|
|
|
|
int get fileCount =>
|
|
violations.map((v) => v.filePath).toSet().length;
|
|
|
|
@override
|
|
String toString() {
|
|
final buf = StringBuffer();
|
|
buf.writeln('═══════════════════════════════════════════');
|
|
buf.writeln(' 主题令牌审计报告');
|
|
buf.writeln('═══════════════════════════════════════════');
|
|
buf.writeln();
|
|
|
|
for (final cat in AuditCategory.values) {
|
|
final n = countByCategory(cat);
|
|
buf.writeln(' ${cat.emoji} ${cat.label}: $n 处违规');
|
|
}
|
|
|
|
buf.writeln();
|
|
buf.writeln(' 📄 涉及文件: $fileCount 个');
|
|
buf.writeln(' 📊 违规总数: $total 处');
|
|
buf.writeln('═══════════════════════════════════════════');
|
|
return buf.toString();
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// 审计引擎
|
|
// ============================================================
|
|
|
|
class ThemeAuditEngine {
|
|
ThemeAuditEngine({
|
|
required this.projectRoot,
|
|
List<String>? whitelistDirs,
|
|
}) : _whitelistDirs = whitelistDirs ?? _defaultWhitelist;
|
|
|
|
final String projectRoot;
|
|
final List<String> _whitelistDirs;
|
|
|
|
static final List<String> _defaultWhitelist = [
|
|
'lib${Platform.pathSeparator}core${Platform.pathSeparator}theme',
|
|
'lib${Platform.pathSeparator}l10n',
|
|
];
|
|
|
|
static const List<String> _generatedSuffixes = [
|
|
'.g.dart',
|
|
'.freezed.dart',
|
|
];
|
|
|
|
static const List<String> _themeFileNames = [
|
|
'app_colors.dart',
|
|
'app_radius.dart',
|
|
'app_spacing.dart',
|
|
'app_typography.dart',
|
|
'app_shadow.dart',
|
|
'glass_tokens.dart',
|
|
'app_theme.dart',
|
|
'theme.dart',
|
|
'color_tokens.dart',
|
|
'theme_audit.dart',
|
|
];
|
|
|
|
AuditSummary runAudit() {
|
|
final violations = <AuditViolation>[];
|
|
final libDir = Directory(
|
|
'$projectRoot${Platform.pathSeparator}lib',
|
|
);
|
|
|
|
if (!libDir.existsSync()) {
|
|
stderr.writeln('❌ 未找到 lib/ 目录: ${libDir.path}');
|
|
return AuditSummary(violations: violations);
|
|
}
|
|
|
|
final files = _collectDartFiles(libDir);
|
|
for (final file in files) {
|
|
violations.addAll(_auditFile(file));
|
|
}
|
|
|
|
violations.sort((a, b) {
|
|
final cmp = a.filePath.compareTo(b.filePath);
|
|
return cmp != 0 ? cmp : a.lineNumber.compareTo(b.lineNumber);
|
|
});
|
|
|
|
return AuditSummary(violations: violations);
|
|
}
|
|
|
|
List<File> _collectDartFiles(Directory dir) {
|
|
final result = <File>[];
|
|
for (final entity in dir.listSync(recursive: false)) {
|
|
if (entity is File) {
|
|
if (_shouldAuditFile(entity)) {
|
|
result.add(entity);
|
|
}
|
|
} else if (entity is Directory) {
|
|
if (!_isWhitelistedDir(entity)) {
|
|
result.addAll(_collectDartFiles(entity));
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
bool _shouldAuditFile(File file) {
|
|
final path = file.path;
|
|
if (!path.endsWith('.dart')) return false;
|
|
for (final suffix in _generatedSuffixes) {
|
|
if (path.endsWith(suffix)) return false;
|
|
}
|
|
final name = path.split(Platform.pathSeparator).last;
|
|
if (_themeFileNames.contains(name)) return false;
|
|
return true;
|
|
}
|
|
|
|
bool _isWhitelistedDir(Directory dir) {
|
|
final path = dir.path;
|
|
for (final wl in _whitelistDirs) {
|
|
if (path.contains(wl)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
List<AuditViolation> _auditFile(File file) {
|
|
final violations = <AuditViolation>[];
|
|
final lines = file.readAsLinesSync();
|
|
|
|
for (var i = 0; i < lines.length; i++) {
|
|
final line = lines[i];
|
|
final lineNum = i + 1;
|
|
|
|
if (_isCommentLine(line)) continue;
|
|
|
|
violations.addAll(_detectColors(file.path, lineNum, line));
|
|
violations.addAll(_detectSpacing(file.path, lineNum, line));
|
|
violations.addAll(_detectRadius(file.path, lineNum, line));
|
|
violations.addAll(_detectFontSize(file.path, lineNum, line));
|
|
}
|
|
|
|
return violations;
|
|
}
|
|
|
|
bool _isCommentLine(String line) {
|
|
final trimmed = line.trimLeft();
|
|
return trimmed.startsWith('///') ||
|
|
trimmed.startsWith('//') ||
|
|
trimmed.startsWith('*');
|
|
}
|
|
|
|
// ============================================================
|
|
// 颜色检测
|
|
// ============================================================
|
|
|
|
static final _colorHexPattern = RegExp(r"Color\(0x[0-9A-Fa-f]{8}\)");
|
|
static final _cupertinoColorsPattern = RegExp(r'CupertinoColors\.\w+');
|
|
static final _materialColorsPattern = RegExp(r'Colors\.\w+');
|
|
static final _hexStringPattern = RegExp(r"'#[0-9A-Fa-f]{6,8}'");
|
|
|
|
List<AuditViolation> _detectColors(
|
|
String path,
|
|
int lineNum,
|
|
String line,
|
|
) {
|
|
final violations = <AuditViolation>[];
|
|
|
|
for (final match in _colorHexPattern.allMatches(line)) {
|
|
final code = match.group(0)!;
|
|
if (_isInThemeDefinition(line)) continue;
|
|
violations.add(AuditViolation(
|
|
filePath: path,
|
|
lineNumber: lineNum,
|
|
category: AuditCategory.color,
|
|
currentCode: code,
|
|
suggestion: '使用 AppTheme.ext(context).xxx 或 AppColors 令牌替代',
|
|
));
|
|
}
|
|
|
|
for (final match in _cupertinoColorsPattern.allMatches(line)) {
|
|
final code = match.group(0)!;
|
|
if (_isAllowedCupertinoUsage(line)) continue;
|
|
violations.add(AuditViolation(
|
|
filePath: path,
|
|
lineNumber: lineNum,
|
|
category: AuditCategory.color,
|
|
currentCode: code,
|
|
suggestion: '使用 AppTheme.ext(context).xxx 替代 CupertinoColors',
|
|
));
|
|
}
|
|
|
|
for (final match in _materialColorsPattern.allMatches(line)) {
|
|
final code = match.group(0)!;
|
|
if (_isAllowedMaterialUsage(line)) continue;
|
|
violations.add(AuditViolation(
|
|
filePath: path,
|
|
lineNumber: lineNum,
|
|
category: AuditCategory.color,
|
|
currentCode: code,
|
|
suggestion: '使用 AppTheme.ext(context).xxx 替代 Colors',
|
|
));
|
|
}
|
|
|
|
for (final match in _hexStringPattern.allMatches(line)) {
|
|
violations.add(AuditViolation(
|
|
filePath: path,
|
|
lineNumber: lineNum,
|
|
category: AuditCategory.color,
|
|
currentCode: match.group(0)!,
|
|
suggestion: '使用 AppColors 令牌替代硬编码十六进制颜色字符串',
|
|
));
|
|
}
|
|
|
|
return violations;
|
|
}
|
|
|
|
bool _isInThemeDefinition(String line) {
|
|
return line.contains('AppThemeExtension') ||
|
|
line.contains('AppColors') ||
|
|
line.contains('LightColors') ||
|
|
line.contains('DarkColors') ||
|
|
line.contains('AmoledColors') ||
|
|
line.contains('GlassTokens') ||
|
|
line.contains('ColorScheme.') ||
|
|
line.contains('CupertinoThemeData') ||
|
|
line.contains('ThemeData(') ||
|
|
line.contains('_lightExtension') ||
|
|
line.contains('_darkExtension') ||
|
|
line.contains('_amoledExtension');
|
|
}
|
|
|
|
bool _isAllowedCupertinoUsage(String line) {
|
|
return line.contains('AppThemeExtension') ||
|
|
line.contains('AppTheme.') ||
|
|
line.contains('CupertinoThemeData') ||
|
|
line.contains('ThemeData(') ||
|
|
line.contains('cupertinoOverrideTheme') ||
|
|
line.contains('CupertinoTextThemeData') ||
|
|
line.contains('CupertinoPageTransitionsBuilder') ||
|
|
line.contains('CupertinoColors.system') ||
|
|
line.contains('CupertinoColors.activeBlue') ||
|
|
line.contains('CupertinoColors.activeOrange') ||
|
|
line.contains('CupertinoColors.separator');
|
|
}
|
|
|
|
bool _isAllowedMaterialUsage(String line) {
|
|
return line.contains('AppThemeExtension') ||
|
|
line.contains('AppTheme.') ||
|
|
line.contains('ThemeData(') ||
|
|
line.contains('ColorScheme.') ||
|
|
line.contains('Colors.transparent') ||
|
|
line.contains('Colors.white') ||
|
|
line.contains('Colors.black') ||
|
|
line.contains('WidgetStateProperty');
|
|
}
|
|
|
|
// ============================================================
|
|
// 间距检测
|
|
// ============================================================
|
|
|
|
static final _sizedBoxHeightPattern =
|
|
RegExp(r'SizedBox\(\s*height:\s*(\d+\.?\d*)\s*\)');
|
|
static final _sizedBoxWidthPattern =
|
|
RegExp(r'SizedBox\(\s*width:\s*(\d+\.?\d*)\s*\)');
|
|
static final _edgeInsetsAllPattern =
|
|
RegExp(r'EdgeInsets\.all\(\s*(\d+\.?\d*)\s*\)');
|
|
static final _edgeInsetsSymmetricPattern = RegExp(
|
|
r'EdgeInsets\.symmetric\('
|
|
r'(?:\s*horizontal:\s*(\d+\.?\d*)\s*)?'
|
|
r'(?:,\s*)?'
|
|
r'(?:\s*vertical:\s*(\d+\.?\d*)\s*)?'
|
|
r'\)',
|
|
);
|
|
static final _paddingPattern =
|
|
RegExp(r'padding:\s*EdgeInsets\.all\(\s*(\d+\.?\d*)\s*\)');
|
|
|
|
static final _appSpacingValues = {4.0, 8.0, 16.0, 24.0, 32.0, 48.0};
|
|
|
|
List<AuditViolation> _detectSpacing(
|
|
String path,
|
|
int lineNum,
|
|
String line,
|
|
) {
|
|
final violations = <AuditViolation>[];
|
|
|
|
if (line.contains('AppSpacing')) return violations;
|
|
|
|
for (final match in _sizedBoxHeightPattern.allMatches(line)) {
|
|
final val = double.tryParse(match.group(1) ?? '');
|
|
if (val == null || _isSpacingToken(val)) continue;
|
|
violations.add(AuditViolation(
|
|
filePath: path,
|
|
lineNumber: lineNum,
|
|
category: AuditCategory.spacing,
|
|
currentCode: 'SizedBox(height: ${match.group(1)})',
|
|
suggestion: _spacingSuggestion(val),
|
|
));
|
|
}
|
|
|
|
for (final match in _sizedBoxWidthPattern.allMatches(line)) {
|
|
final val = double.tryParse(match.group(1) ?? '');
|
|
if (val == null || _isSpacingToken(val)) continue;
|
|
violations.add(AuditViolation(
|
|
filePath: path,
|
|
lineNumber: lineNum,
|
|
category: AuditCategory.spacing,
|
|
currentCode: 'SizedBox(width: ${match.group(1)})',
|
|
suggestion: _spacingSuggestion(val),
|
|
));
|
|
}
|
|
|
|
for (final match in _edgeInsetsAllPattern.allMatches(line)) {
|
|
final val = double.tryParse(match.group(1) ?? '');
|
|
if (val == null || _isSpacingToken(val)) continue;
|
|
violations.add(AuditViolation(
|
|
filePath: path,
|
|
lineNumber: lineNum,
|
|
category: AuditCategory.spacing,
|
|
currentCode: 'EdgeInsets.all(${match.group(1)})',
|
|
suggestion: 'EdgeInsets.all(AppSpacing.${_spacingName(val)})',
|
|
));
|
|
}
|
|
|
|
for (final match in _paddingPattern.allMatches(line)) {
|
|
final val = double.tryParse(match.group(1) ?? '');
|
|
if (val == null || _isSpacingToken(val)) continue;
|
|
violations.add(AuditViolation(
|
|
filePath: path,
|
|
lineNumber: lineNum,
|
|
category: AuditCategory.spacing,
|
|
currentCode: 'padding: EdgeInsets.all(${match.group(1)})',
|
|
suggestion:
|
|
'padding: const EdgeInsets.all(AppSpacing.${_spacingName(val)})',
|
|
));
|
|
}
|
|
|
|
for (final match in _edgeInsetsSymmetricPattern.allMatches(line)) {
|
|
final hStr = match.group(1);
|
|
final vStr = match.group(2);
|
|
if (hStr != null) {
|
|
final val = double.tryParse(hStr);
|
|
if (val != null && !_isSpacingToken(val)) {
|
|
violations.add(AuditViolation(
|
|
filePath: path,
|
|
lineNumber: lineNum,
|
|
category: AuditCategory.spacing,
|
|
currentCode: 'horizontal: $hStr',
|
|
suggestion: 'horizontal: AppSpacing.${_spacingName(val)}',
|
|
));
|
|
}
|
|
}
|
|
if (vStr != null) {
|
|
final val = double.tryParse(vStr);
|
|
if (val != null && !_isSpacingToken(val)) {
|
|
violations.add(AuditViolation(
|
|
filePath: path,
|
|
lineNumber: lineNum,
|
|
category: AuditCategory.spacing,
|
|
currentCode: 'vertical: $vStr',
|
|
suggestion: 'vertical: AppSpacing.${_spacingName(val)}',
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
return violations;
|
|
}
|
|
|
|
bool _isSpacingToken(double val) => _appSpacingValues.contains(val);
|
|
|
|
String _spacingName(double val) => _findNearestSpacing(val);
|
|
|
|
String _spacingSuggestion(double val) {
|
|
final nearest = _findNearestSpacing(val);
|
|
return 'SizedBox(height: AppSpacing.$nearest) // $val → ${_spacingValueMap[nearest]}';
|
|
}
|
|
|
|
String _findNearestSpacing(double val) {
|
|
var bestName = 'md';
|
|
var bestDiff = double.infinity;
|
|
for (final entry in _spacingValueMap.entries) {
|
|
final diff = (entry.value - val).abs();
|
|
if (diff < bestDiff) {
|
|
bestDiff = diff;
|
|
bestName = entry.key;
|
|
}
|
|
}
|
|
return bestName;
|
|
}
|
|
|
|
static const _spacingValueMap = {
|
|
'xs': 4.0,
|
|
'sm': 8.0,
|
|
'md': 16.0,
|
|
'lg': 24.0,
|
|
'xl': 32.0,
|
|
'xxl': 48.0,
|
|
};
|
|
|
|
// ============================================================
|
|
// 圆角检测
|
|
// ============================================================
|
|
|
|
static final _borderRadiusCircularPattern =
|
|
RegExp(r'BorderRadius\.circular\(\s*(\d+\.?\d*)\s*\)');
|
|
static final _radiusCircularPattern =
|
|
RegExp(r'Radius\.circular\(\s*(\d+\.?\d*)\s*\)');
|
|
|
|
static final _appRadiusValues = {2.0, 4.0, 8.0, 12.0, 16.0, 999.0};
|
|
|
|
List<AuditViolation> _detectRadius(
|
|
String path,
|
|
int lineNum,
|
|
String line,
|
|
) {
|
|
final violations = <AuditViolation>[];
|
|
|
|
if (line.contains('AppRadius')) return violations;
|
|
|
|
for (final match in _borderRadiusCircularPattern.allMatches(line)) {
|
|
final val = double.tryParse(match.group(1) ?? '');
|
|
if (val == null || _appRadiusValues.contains(val)) continue;
|
|
violations.add(AuditViolation(
|
|
filePath: path,
|
|
lineNumber: lineNum,
|
|
category: AuditCategory.radius,
|
|
currentCode: 'BorderRadius.circular(${match.group(1)})',
|
|
suggestion: _radiusSuggestion(val, isBorderRadius: true),
|
|
));
|
|
}
|
|
|
|
for (final match in _radiusCircularPattern.allMatches(line)) {
|
|
final val = double.tryParse(match.group(1) ?? '');
|
|
if (val == null || _appRadiusValues.contains(val)) continue;
|
|
violations.add(AuditViolation(
|
|
filePath: path,
|
|
lineNumber: lineNum,
|
|
category: AuditCategory.radius,
|
|
currentCode: 'Radius.circular(${match.group(1)})',
|
|
suggestion: _radiusSuggestion(val, isBorderRadius: false),
|
|
));
|
|
}
|
|
|
|
return violations;
|
|
}
|
|
|
|
String _radiusSuggestion(double val, {required bool isBorderRadius}) {
|
|
final nearest = _findNearestRadius(val);
|
|
if (isBorderRadius) {
|
|
return 'BorderRadius.circular(AppRadius.$nearest) // $val → ${_radiusValueMap[nearest]}';
|
|
}
|
|
return 'Radius.circular(AppRadius.$nearest) // $val → ${_radiusValueMap[nearest]}';
|
|
}
|
|
|
|
String _findNearestRadius(double val) {
|
|
var bestName = 'md';
|
|
var bestDiff = double.infinity;
|
|
for (final entry in _radiusValueMap.entries) {
|
|
final diff = (entry.value - val).abs();
|
|
if (diff < bestDiff) {
|
|
bestDiff = diff;
|
|
bestName = entry.key;
|
|
}
|
|
}
|
|
return bestName;
|
|
}
|
|
|
|
static const _radiusValueMap = {
|
|
'xs': 2.0,
|
|
'sm': 4.0,
|
|
'md': 8.0,
|
|
'lg': 12.0,
|
|
'xl': 16.0,
|
|
'full': 999.0,
|
|
};
|
|
|
|
// ============================================================
|
|
// 字号检测
|
|
// ============================================================
|
|
|
|
static final _fontSizePattern = RegExp(r'fontSize:\s*(\d+\.?\d*)\s*[,)]');
|
|
|
|
static final _appTypographyValues = {
|
|
34.0,
|
|
28.0,
|
|
22.0,
|
|
20.0,
|
|
18.0,
|
|
16.0,
|
|
14.0,
|
|
13.0,
|
|
12.0,
|
|
11.0,
|
|
};
|
|
|
|
List<AuditViolation> _detectFontSize(
|
|
String path,
|
|
int lineNum,
|
|
String line,
|
|
) {
|
|
final violations = <AuditViolation>[];
|
|
|
|
if (line.contains('AppTypography')) return violations;
|
|
if (line.contains('fontScale')) return violations;
|
|
if (line.contains('fontDisplay') ||
|
|
line.contains('fontTitle') ||
|
|
line.contains('fontHeadline') ||
|
|
line.contains('fontBody') ||
|
|
line.contains('fontCallout') ||
|
|
line.contains('fontSubhead') ||
|
|
line.contains('fontFootnote') ||
|
|
line.contains('fontCaption')) {
|
|
return violations;
|
|
}
|
|
|
|
for (final match in _fontSizePattern.allMatches(line)) {
|
|
final val = double.tryParse(match.group(1) ?? '');
|
|
if (val == null || _appTypographyValues.contains(val)) continue;
|
|
violations.add(AuditViolation(
|
|
filePath: path,
|
|
lineNumber: lineNum,
|
|
category: AuditCategory.fontSize,
|
|
currentCode: 'fontSize: ${match.group(1)}',
|
|
suggestion: _fontSizeSuggestion(val),
|
|
));
|
|
}
|
|
|
|
return violations;
|
|
}
|
|
|
|
String _fontSizeSuggestion(double val) {
|
|
final nearest = _findNearestFontSize(val);
|
|
return 'fontSize: AppTypography.$nearest // $val → ${_typographyValueMap[nearest]}';
|
|
}
|
|
|
|
String _findNearestFontSize(double val) {
|
|
var bestName = 'fontBody';
|
|
var bestDiff = double.infinity;
|
|
for (final entry in _typographyValueMap.entries) {
|
|
final diff = (entry.value - val).abs();
|
|
if (diff < bestDiff) {
|
|
bestDiff = diff;
|
|
bestName = entry.key;
|
|
}
|
|
}
|
|
return bestName;
|
|
}
|
|
|
|
static const _typographyValueMap = {
|
|
'fontDisplay': 34.0,
|
|
'fontTitle1': 28.0,
|
|
'fontTitle2': 22.0,
|
|
'fontTitle3': 20.0,
|
|
'fontHeadline': 18.0,
|
|
'fontBody': 16.0,
|
|
'fontCallout': 16.0,
|
|
'fontSubhead': 14.0,
|
|
'fontFootnote': 13.0,
|
|
'fontCaption1': 12.0,
|
|
'fontCaption2': 11.0,
|
|
};
|
|
}
|
|
|
|
// ============================================================
|
|
// CLI 入口
|
|
// ============================================================
|
|
|
|
void main(List<String> args) {
|
|
final sw = Stopwatch()..start();
|
|
|
|
final config = _parseArgs(args);
|
|
|
|
final projectRoot = _findProjectRoot();
|
|
if (projectRoot == null) {
|
|
stderr.writeln('❌ 未找到项目根目录 (pubspec.yaml)');
|
|
exit(1);
|
|
}
|
|
|
|
stdout.writeln();
|
|
stdout.writeln('🔍 闲言APP — 主题令牌审计');
|
|
stdout.writeln(' 项目: $projectRoot');
|
|
stdout.writeln(' 类别: ${config.category ?? "全部"}');
|
|
stdout.writeln();
|
|
|
|
final engine = ThemeAuditEngine(projectRoot: projectRoot);
|
|
final summary = engine.runAudit();
|
|
|
|
sw.stop();
|
|
|
|
var filteredViolations = summary.violations;
|
|
if (config.category != null) {
|
|
final cat = _categoryFromString(config.category!);
|
|
if (cat != null) {
|
|
filteredViolations =
|
|
filteredViolations.where((v) => v.category == cat).toList();
|
|
}
|
|
}
|
|
|
|
final filteredSummary = AuditSummary(violations: filteredViolations);
|
|
|
|
if (config.jsonOutput) {
|
|
_outputJson(filteredSummary, sw.elapsedMilliseconds);
|
|
} else {
|
|
_outputHuman(filteredSummary, config, sw.elapsedMilliseconds);
|
|
}
|
|
|
|
if (filteredSummary.total > 0) {
|
|
exit(1);
|
|
} else {
|
|
exit(0);
|
|
}
|
|
}
|
|
|
|
void _outputHuman(
|
|
AuditSummary summary,
|
|
_CliConfig config,
|
|
int elapsedMs,
|
|
) {
|
|
if (summary.violations.isEmpty) {
|
|
stdout.writeln('✅ 未发现主题令牌违规,所有设计值均使用令牌定义!');
|
|
stdout.writeln(' 耗时: ${elapsedMs}ms');
|
|
return;
|
|
}
|
|
|
|
stdout.writeln(summary.toString());
|
|
stdout.writeln();
|
|
|
|
String? lastFile;
|
|
for (final v in summary.violations) {
|
|
final relPath = AuditViolation.relativePath(v.filePath);
|
|
if (lastFile != relPath) {
|
|
if (lastFile != null) stdout.writeln();
|
|
stdout.writeln('📄 $relPath');
|
|
lastFile = relPath;
|
|
}
|
|
|
|
stdout.writeln(
|
|
' L${v.lineNumber.toString().padLeft(4)} │ ${v.category.emoji} ${v.currentCode}');
|
|
|
|
if (config.fixHints) {
|
|
stdout.writeln(' │ 💡 ${v.suggestion}');
|
|
}
|
|
}
|
|
|
|
stdout.writeln();
|
|
stdout.writeln('───────────────────────────────────────────');
|
|
stdout.writeln(
|
|
' 总计: ${summary.total} 处违规 | ${summary.fileCount} 个文件 | ${elapsedMs}ms');
|
|
|
|
if (!config.fixHints) {
|
|
stdout.writeln(' 💡 使用 --fix-hints 查看修复建议');
|
|
}
|
|
|
|
stdout.writeln('───────────────────────────────────────────');
|
|
stdout.writeln();
|
|
stdout.writeln('⚠️ 发现 ${summary.total} 处主题令牌违规,请修复后重新审计');
|
|
}
|
|
|
|
void _outputJson(AuditSummary summary, int elapsedMs) {
|
|
final data = {
|
|
'total': summary.total,
|
|
'fileCount': summary.fileCount,
|
|
'elapsedMs': elapsedMs,
|
|
'byCategory': {
|
|
for (final cat in AuditCategory.values)
|
|
cat.name: summary.countByCategory(cat),
|
|
},
|
|
'violations': summary.violations
|
|
.map((v) => {
|
|
'file': AuditViolation.relativePath(v.filePath),
|
|
'line': v.lineNumber,
|
|
'category': v.category.name,
|
|
'currentCode': v.currentCode,
|
|
'suggestion': v.suggestion,
|
|
})
|
|
.toList(),
|
|
};
|
|
|
|
stdout.writeln(const JsonEncoder.withIndent(' ').convert(data));
|
|
}
|
|
|
|
_CliConfig _parseArgs(List<String> args) {
|
|
String? category;
|
|
var fixHints = false;
|
|
var jsonOutput = false;
|
|
|
|
for (var i = 0; i < args.length; i++) {
|
|
final arg = args[i];
|
|
if (arg == '--fix-hints') {
|
|
fixHints = true;
|
|
} else if (arg == '--json') {
|
|
jsonOutput = true;
|
|
} else if (arg.startsWith('--category=')) {
|
|
category = arg.substring('--category='.length);
|
|
} else if (arg == '--category' && i + 1 < args.length) {
|
|
category = args[++i];
|
|
} else if (arg == '--help' || arg == '-h') {
|
|
_printHelp();
|
|
exit(0);
|
|
}
|
|
}
|
|
|
|
return _CliConfig(
|
|
category: category,
|
|
fixHints: fixHints,
|
|
jsonOutput: jsonOutput,
|
|
);
|
|
}
|
|
|
|
void _printHelp() {
|
|
stdout.writeln('''
|
|
🔍 闲言APP — 主题令牌审计工具
|
|
|
|
用法:
|
|
dart run scripts/theme_audit.dart [选项]
|
|
|
|
选项:
|
|
--category=<type> 仅审计指定类别 (color|spacing|radius|fontSize)
|
|
--fix-hints 显示修复建议
|
|
--json JSON 格式输出 (适合 CI 集成)
|
|
--help, -h 显示帮助信息
|
|
|
|
退出码:
|
|
0 — 无违规
|
|
1 — 发现违规 (CI 可据此判断)
|
|
|
|
示例:
|
|
dart run scripts/theme_audit.dart
|
|
dart run scripts/theme_audit.dart --category=color --fix-hints
|
|
dart run scripts/theme_audit.dart --json > audit_report.json
|
|
''');
|
|
}
|
|
|
|
AuditCategory? _categoryFromString(String s) {
|
|
return switch (s.toLowerCase()) {
|
|
'color' => AuditCategory.color,
|
|
'spacing' => AuditCategory.spacing,
|
|
'radius' => AuditCategory.radius,
|
|
'fontsize' || 'fontsize' || 'font_size' => AuditCategory.fontSize,
|
|
_ => null,
|
|
};
|
|
}
|
|
|
|
String? _findProjectRoot() {
|
|
var dir = Directory.current;
|
|
for (var i = 0; i < 10; i++) {
|
|
if (File('${dir.path}${Platform.pathSeparator}pubspec.yaml')
|
|
.existsSync()) {
|
|
return dir.path;
|
|
}
|
|
final parent = dir.parent;
|
|
if (parent.path == dir.path) break;
|
|
dir = parent;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
class _CliConfig {
|
|
const _CliConfig({
|
|
this.category,
|
|
required this.fixHints,
|
|
required this.jsonOutput,
|
|
});
|
|
|
|
final String? category;
|
|
final bool fixHints;
|
|
final bool jsonOutput;
|
|
}
|