Files
xianyan/scripts/theme_audit.dart
Developer 85d856f0ed chore: 完成多模块迭代优化与依赖更新
本次提交包含多项更新:
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. 为大量页面添加统一自适应返回按钮
2026-05-22 23:14:38 +08:00

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;
}