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:
Developer
2026-05-22 23:14:38 +08:00
parent 1a42e347cf
commit 85d856f0ed
161 changed files with 9058 additions and 3459 deletions

836
scripts/theme_audit.dart Normal file
View 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
View 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);
}