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. 为大量页面添加统一自适应返回按钮
This commit is contained in:
836
scripts/theme_audit.dart
Normal file
836
scripts/theme_audit.dart
Normal file
@@ -0,0 +1,836 @@
|
||||
/// ============================================================
|
||||
/// 闲言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;
|
||||
}
|
||||
298
scripts/verify_fonts.dart
Normal file
298
scripts/verify_fonts.dart
Normal file
@@ -0,0 +1,298 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 字体下载URL验证脚本
|
||||
/// 创建时间: 2026-05-22
|
||||
/// 更新时间: 2026-05-22
|
||||
/// 作用: 验证所有在线字体URL的可访问性和文件格式有效性
|
||||
/// 上次更新: 初始创建,验证8个字体+备用URL的下载和文件头校验
|
||||
/// 运行方式: dart run Scripts/verify_fonts.dart
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// 字体数据定义(与 font_models.dart 保持同步)
|
||||
const onlineFontData = [
|
||||
(
|
||||
'霞鹜文楷',
|
||||
'LXGWWenKai',
|
||||
'https://raw.githubusercontent.com/lxgw/LxgwWenKai/main/fonts/LXGWWenKai-Regular.ttf',
|
||||
'🖋️',
|
||||
),
|
||||
(
|
||||
'阿里巴巴普惠体',
|
||||
'AlibabaPuHuiTi',
|
||||
'https://puhuiti.oss-cn-hangzhou.aliyuncs.com/AlibabaPuHuiTi-3/AlibabaPuHuiTi-3-55-Regular/AlibabaPuHuiTi-3-55-Regular.ttf',
|
||||
'💼',
|
||||
),
|
||||
(
|
||||
'站酷快乐体',
|
||||
'ZCOOLKuaiLe',
|
||||
'https://raw.githubusercontent.com/googlefonts/zcool-kuaile/main/fonts/ttf/ZCOOLKuaiLe-Regular.ttf',
|
||||
'🎉',
|
||||
),
|
||||
(
|
||||
'站酷小薇',
|
||||
'ZCOOLXiaoWei',
|
||||
'https://raw.githubusercontent.com/googlefonts/zcool-xiaowei/main/fonts/ZCOOLXiaoWei-Regular.ttf',
|
||||
'🌸',
|
||||
),
|
||||
(
|
||||
'思源黑体',
|
||||
'NotoSansSC',
|
||||
'https://raw.githubusercontent.com/notofonts/noto-cjk/main/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf',
|
||||
'📐',
|
||||
),
|
||||
(
|
||||
'思源宋体',
|
||||
'NotoSerifSC',
|
||||
'https://raw.githubusercontent.com/notofonts/noto-cjk/main/Serif/OTF/SimplifiedChinese/NotoSerifCJKsc-Regular.otf',
|
||||
'📜',
|
||||
),
|
||||
(
|
||||
'更纱黑体',
|
||||
'SarasaGothicSC',
|
||||
'https://raw.githubusercontent.com/be5invis/Sarasa-Gothic/main/fonts/SarasaGothicSC-Regular.ttf',
|
||||
'🎯',
|
||||
),
|
||||
(
|
||||
'文泉驿微米黑',
|
||||
'WenQuanYiMicroHei',
|
||||
'https://raw.githubusercontent.com/niclas/wqy-microhei-font/master/wqy-microhei.ttc',
|
||||
'✒️',
|
||||
),
|
||||
];
|
||||
|
||||
const fontFallbackUrls = <String, List<String>>{
|
||||
'LXGWWenKai': [
|
||||
'https://cdn.jsdelivr.net/gh/lxgw/LxgwWenKai@v1.501/fonts/LXGWWenKai-Regular.ttf',
|
||||
'https://github.com/lxgw/LxgwWenKai/releases/download/v1.501/LXGWWenKai-Regular.ttf',
|
||||
],
|
||||
'AlibabaPuHuiTi': [
|
||||
'https://fonts.alicdn.com/font/AlibabaPuHuiTi-3/AlibabaPuHuiTi-3-55-Regular.ttf',
|
||||
],
|
||||
'ZCOOLKuaiLe': [
|
||||
'https://cdn.jsdelivr.net/gh/googlefonts/zcool-kuaile@main/fonts/ttf/ZCOOLKuaiLe-Regular.ttf',
|
||||
],
|
||||
'ZCOOLXiaoWei': [
|
||||
'https://cdn.jsdelivr.net/gh/googlefonts/zcool-xiaowei@main/fonts/ZCOOLXiaoWei-Regular.ttf',
|
||||
],
|
||||
'NotoSansSC': [
|
||||
'https://cdn.jsdelivr.net/gh/notofonts/noto-cjk@main/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf',
|
||||
],
|
||||
'NotoSerifSC': [
|
||||
'https://cdn.jsdelivr.net/gh/notofonts/noto-cjk@main/Serif/OTF/SimplifiedChinese/NotoSerifCJKsc-Regular.otf',
|
||||
],
|
||||
'SarasaGothicSC': [
|
||||
'https://github.com/be5invis/Sarasa-Gothic/releases/download/v1.0.19/SarasaGothicSC-Regular.ttf',
|
||||
],
|
||||
'WenQuanYiMicroHei': [
|
||||
'https://cdn.jsdelivr.net/gh/niclas/wqy-microhei-font@master/wqy-microhei.ttc',
|
||||
],
|
||||
};
|
||||
|
||||
/// 校验字体文件头是否为有效 TTF/OTF/TTC
|
||||
bool isValidFontFile(Uint8List bytes) {
|
||||
if (bytes.length < 4) return false;
|
||||
final h = bytes;
|
||||
final isTtf = (h[0] == 0x00 && h[1] == 0x01 && h[2] == 0x00 && h[3] == 0x00) ||
|
||||
(h[0] == 0x74 && h[1] == 0x72 && h[2] == 0x75 && h[3] == 0x65);
|
||||
final isOtf = h[0] == 0x4F && h[1] == 0x54 && h[2] == 0x54 && h[3] == 0x4F;
|
||||
final isTtc = h[0] == 0x74 && h[1] == 0x74 && h[2] == 0x63 && h[3] == 0x66;
|
||||
return isTtf || isOtf || isTtc;
|
||||
}
|
||||
|
||||
/// 检测字体格式类型
|
||||
String detectFontType(Uint8List bytes) {
|
||||
if (bytes.length < 4) return '未知(数据不足)';
|
||||
final h = bytes;
|
||||
if (h[0] == 0x00 && h[1] == 0x01 && h[2] == 0x00 && h[3] == 0x00) return 'TTF';
|
||||
if (h[0] == 0x74 && h[1] == 0x72 && h[2] == 0x75 && h[3] == 0x65) return 'TTF(true)';
|
||||
if (h[0] == 0x4F && h[1] == 0x54 && h[2] == 0x54 && h[3] == 0x4F) return 'OTF';
|
||||
if (h[0] == 0x74 && h[1] == 0x74 && h[2] == 0x63 && h[3] == 0x66) return 'TTC';
|
||||
return '未知(0x${h[0].toRadixString(16)}${h[1].toRadixString(16)}${h[2].toRadixString(16)}${h[3].toRadixString(16)})';
|
||||
}
|
||||
|
||||
/// 下载字体并验证
|
||||
Future<_FontVerifyResult> verifyFontUrl(
|
||||
String name,
|
||||
String fontFamily,
|
||||
String url,
|
||||
HttpClient client,
|
||||
) async {
|
||||
final result = _FontVerifyResult(name: name, fontFamily: fontFamily, url: url);
|
||||
|
||||
try {
|
||||
final request = await client.getUrl(Uri.parse(url));
|
||||
final response = await request.close();
|
||||
|
||||
result.statusCode = response.statusCode;
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
result.success = false;
|
||||
result.error = 'HTTP ${response.statusCode}';
|
||||
return result;
|
||||
}
|
||||
|
||||
final bytes = await consolidateBytes(response);
|
||||
result.fileSize = bytes.length;
|
||||
result.fontType = detectFontType(bytes);
|
||||
result.validFormat = isValidFontFile(bytes);
|
||||
result.success = result.validFormat;
|
||||
|
||||
if (!result.validFormat) {
|
||||
result.error = '文件头非 TTF/OTF/TTC 格式,检测为: ${result.fontType}';
|
||||
}
|
||||
} catch (e) {
|
||||
result.success = false;
|
||||
result.error = e.toString();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 合并 HttpClientResponse 的字节流
|
||||
Future<Uint8List> consolidateBytes(HttpClientResponse response) async {
|
||||
final builder = BytesBuilder();
|
||||
await for (final chunk in response) {
|
||||
builder.add(chunk);
|
||||
}
|
||||
return builder.toBytes();
|
||||
}
|
||||
|
||||
/// 验证结果
|
||||
class _FontVerifyResult {
|
||||
_FontVerifyResult({
|
||||
required this.name,
|
||||
required this.fontFamily,
|
||||
required this.url,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String fontFamily;
|
||||
final String url;
|
||||
bool success = false;
|
||||
int? statusCode;
|
||||
int? fileSize;
|
||||
String? fontType;
|
||||
bool validFormat = false;
|
||||
String? error;
|
||||
|
||||
String get fileSizeStr {
|
||||
if (fileSize == null) return 'N/A';
|
||||
if (fileSize! < 1024) return '$fileSize B';
|
||||
if (fileSize! < 1024 * 1024) return '${(fileSize! / 1024).toStringAsFixed(1)} KB';
|
||||
return '${(fileSize! / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> main() async {
|
||||
print('═══════════════════════════════════════════════════════════');
|
||||
print(' 闲言APP — 字体下载URL验证工具');
|
||||
print(' 运行时间: ${DateTime.now().toIso8601String()}');
|
||||
print('═══════════════════════════════════════════════════════════');
|
||||
print('');
|
||||
|
||||
final client = HttpClient();
|
||||
client.connectionTimeout = const Duration(seconds: 30);
|
||||
client.userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36';
|
||||
|
||||
final results = <_FontVerifyResult>[];
|
||||
|
||||
print('━━━ 阶段1: 主URL验证 ━━━');
|
||||
print('');
|
||||
|
||||
for (final font in onlineFontData) {
|
||||
final name = font.$1;
|
||||
final fontFamily = font.$2;
|
||||
final url = font.$3;
|
||||
|
||||
stdout.write(' $name ($fontFamily)... ');
|
||||
final result = await verifyFontUrl(name, fontFamily, url, client);
|
||||
results.add(result);
|
||||
|
||||
if (result.success) {
|
||||
print('✅ 通过 [${result.fontType}] ${result.fileSizeStr}');
|
||||
} else {
|
||||
print('❌ 失败 [${result.error}]');
|
||||
}
|
||||
}
|
||||
|
||||
print('');
|
||||
print('━━━ 阶段2: 备用URL验证 ━━━');
|
||||
print('');
|
||||
|
||||
for (final entry in fontFallbackUrls.entries) {
|
||||
final fontFamily = entry.key;
|
||||
final fallbacks = entry.value;
|
||||
final fontName = onlineFontData
|
||||
.where((f) => f.$2 == fontFamily)
|
||||
.map((f) => f.$1)
|
||||
.firstOrNull ??
|
||||
fontFamily;
|
||||
|
||||
for (int i = 0; i < fallbacks.length; i++) {
|
||||
final url = fallbacks[i];
|
||||
stdout.write(' $fontName 备用#${i + 1}... ');
|
||||
final result = await verifyFontUrl(fontName, fontFamily, url, client);
|
||||
results.add(result);
|
||||
|
||||
if (result.success) {
|
||||
print('✅ 通过 [${result.fontType}] ${result.fileSizeStr}');
|
||||
} else {
|
||||
print('❌ 失败 [${result.error}]');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.close();
|
||||
|
||||
print('');
|
||||
print('═══════════════════════════════════════════════════════════');
|
||||
print(' 验证结果汇总');
|
||||
print('═══════════════════════════════════════════════════════════');
|
||||
print('');
|
||||
|
||||
final primaryResults = results.sublist(0, onlineFontData.length);
|
||||
final fallbackResults = results.sublist(onlineFontData.length);
|
||||
|
||||
print(' 主URL结果:');
|
||||
for (final r in primaryResults) {
|
||||
final icon = r.success ? '✅' : '❌';
|
||||
print(' $icon ${r.name} — ${r.success ? '${r.fontType} ${r.fileSizeStr}' : r.error}');
|
||||
}
|
||||
|
||||
print('');
|
||||
print(' 备用URL结果:');
|
||||
for (final r in fallbackResults) {
|
||||
final icon = r.success ? '✅' : '❌';
|
||||
print(' $icon ${r.name} — ${r.success ? '${r.fontType} ${r.fileSizeStr}' : r.error}');
|
||||
}
|
||||
|
||||
final primaryPass = primaryResults.where((r) => r.success).length;
|
||||
final primaryTotal = primaryResults.length;
|
||||
final allPass = results.where((r) => r.success).length;
|
||||
final allTotal = results.length;
|
||||
|
||||
print('');
|
||||
print(' 主URL: $primaryPass/$primaryTotal 通过');
|
||||
print(' 全部: $allPass/$allTotal 通过');
|
||||
print('');
|
||||
|
||||
final failedPrimary = primaryResults.where((r) => !r.success).toList();
|
||||
if (failedPrimary.isNotEmpty) {
|
||||
print(' ⚠️ 以下字体主URL不可用,需要更新:');
|
||||
for (final r in failedPrimary) {
|
||||
final hasFallback = fallbackResults.any(
|
||||
(f) => f.fontFamily == r.fontFamily && f.success,
|
||||
);
|
||||
print(' - ${r.name}: ${r.error} ${hasFallback ? '(有可用备用URL)' : '(⚠️ 无可用备用URL!)'}');
|
||||
}
|
||||
} else {
|
||||
print(' 🎉 所有字体主URL验证通过!');
|
||||
}
|
||||
|
||||
print('');
|
||||
print('═══════════════════════════════════════════════════════════');
|
||||
|
||||
exit(failedPrimary.isNotEmpty ? 1 : 0);
|
||||
}
|
||||
Reference in New Issue
Block a user