Files
xianyan/lib/features/discover/presentation/pages/tool/hanzi_tool_page.dart
Developer 63a0559721 refactor: 重构项目路由与模块结构,统一发现页命名与路径
1. 全局替换tool_center/inspiration为discover模块,统一路由路径
2. 调整AppRoutes路由常量,将discover作为主Tab页,inspiration作为子页面
3. 更新页面注册表与路由配置,修正跳转目标
4. 调整启动页可选配置项,修正路由ID对应关系
5. 新增翻译服务、内容发现、热搜相关工具类与数据模型
6. 修复缓存清理后未刷新统计的问题,调整x86_64架构注释
7. 更新AGENTS.md文档约束规则
8. 新增一批调试用截图资源文件
2026-05-28 06:42:20 +08:00

897 lines
26 KiB
Dart

/// ============================================================
/// 闲言APP — 汉语工具通用查询页
/// 创建时间: 2026-04-28
/// 更新时间: 2026-04-28
/// 作用: 17种汉语工具的通用查询界面 + 本地缓存 + 骨架屏
/// 上次更新: 迁移缓存到 Drift (HanziCaches表)
/// ============================================================
import 'package:xianyan/core/utils/data/pattern_utils.dart';
import 'dart:convert';
import 'package:flutter/cupertino.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:share_plus/share_plus.dart' as share_plus;
import 'package:xianyan/core/storage/kv_storage.dart';
import 'package:xianyan/core/storage/database/app_database.dart';
import 'package:xianyan/core/theme/app_theme.dart';
import 'package:xianyan/core/theme/app_spacing.dart';
import 'package:xianyan/core/theme/app_typography.dart';
import 'package:xianyan/core/theme/app_radius.dart';
import 'package:xianyan/core/utils/logger.dart';
import 'package:xianyan/core/utils/data/extensions.dart';
import 'package:xianyan/shared/widgets/adaptive/adaptive_back_button.dart';
import 'package:xianyan/shared/widgets/containers/app_sticky_header.dart';
import 'package:xianyan/shared/widgets/input/app_slidable.dart';
import 'package:xianyan/shared/widgets/display/skeleton.dart';
import 'package:xianyan/shared/widgets/feedback/app_toast.dart';
import 'package:xianyan/shared/widgets/input/app_markdown.dart';
import '../../../models/hanzi_result.dart';
import '../../../services/tool_api_service.dart';
import '../../../models/tool_item.dart';
import '../../widgets/common/data_source_badge.dart';
import '../../widgets/tool/tool_icon_helper.dart';
/// 汉语工具类型配置
class HanziToolConfig {
const HanziToolConfig({
required this.type,
required this.title,
required this.emoji,
required this.hintText,
required this.emptyMessage,
this.isSingleChar = true,
});
final String type;
final String title;
final String emoji;
final String hintText;
final String emptyMessage;
final bool isSingleChar;
}
/// 汉语工具查询状态
class HanziQueryState {
const HanziQueryState({
this.isLoading = false,
this.result,
this.error,
this.query = '',
});
final bool isLoading;
final Map<String, dynamic>? result;
final String? error;
final String query;
HanziQueryState copyWith({
bool? isLoading,
Map<String, dynamic>? result,
String? error,
String? query,
}) {
return HanziQueryState(
isLoading: isLoading ?? this.isLoading,
result: result ?? this.result,
error: error ?? this.error,
query: query ?? this.query,
);
}
}
/// 汉语工具查询 Provider
class HanziQueryNotifier extends Notifier<HanziQueryState> {
@override
HanziQueryState build() => const HanziQueryState();
HanziToolConfig _config = const HanziToolConfig(
type: 'zi',
title: '汉字查询',
emoji: '🔍',
hintText: '输入汉字',
emptyMessage: '暂无结果',
);
void setConfig(HanziToolConfig config) => _config = config;
static const _cachePrefix = 'hanzi_cache_';
Future<void> query(String keyword) async {
if (keyword.trim().isEmpty) return;
final trimmed = keyword.trim();
state = state.copyWith(isLoading: true, query: trimmed);
final cacheKey = '$_cachePrefix${_config.type}_$trimmed';
final cached = await _loadCache(cacheKey);
if (cached != null) {
state = state.copyWith(isLoading: false, result: cached);
return;
}
try {
final result = await ToolApiService.hanziSearch(
keyword: trimmed,
type: _config.type,
);
if (_config.type == 'zi' && trimmed.length == 1) {
try {
final bishunResult = await ToolApiService.bishun(trimmed);
final bishunData = bishunResult['data'];
if (bishunData is Map<String, dynamic>) {
final mergedData = <String, dynamic>{};
final existingData = result['data'];
if (existingData is Map<String, dynamic>) {
mergedData.addAll(existingData);
}
mergedData.addAll(bishunData);
result['data'] = mergedData;
}
} catch (e) {
Log.w('笔顺查询失败', e);
}
}
await _saveCache(cacheKey, _config.type, trimmed, result);
state = state.copyWith(isLoading: false, result: result);
} catch (e) {
Log.e('汉语工具查询失败: ${_config.type}', e);
state = state.copyWith(isLoading: false, error: '查询失败,请检查网络后重试');
}
}
Future<Map<String, dynamic>?> _loadCache(String cacheKey) async {
try {
final db = AppDatabase.instance;
final row = await db.getHanziCache(cacheKey);
if (row == null) return null;
if (row.expiresAt.isBefore(DateTime.now())) {
await db.deleteExpiredHanziCache();
return null;
}
return jsonDecode(row.resultJson) as Map<String, dynamic>;
} catch (e) {
Log.w('Drift缓存读取失败', e);
return null;
}
}
Future<void> _saveCache(
String cacheKey,
String queryType,
String keyword,
Map<String, dynamic> data,
) async {
try {
final db = AppDatabase.instance;
await db.saveHanziCache(
cacheKey: cacheKey,
queryType: queryType,
keyword: keyword,
resultJson: jsonEncode(data),
);
} catch (e) {
Log.w('Drift缓存保存失败', e);
}
}
void clearResult() {
state = const HanziQueryState();
}
}
/// 查询历史 Provider
class HanziHistoryNotifier extends Notifier<List<HanziQueryRecord>> {
@override
List<HanziQueryRecord> build() {
Future.microtask(_loadHistory).catchError((_) {});
return [];
}
static const _historyKey = 'hanzi_query_history';
static const _maxRecords = 50;
Future<void> _loadHistory() async {
try {
final raw = KvStorage.getStringList(_historyKey) ?? [];
state = raw
.map((r) {
try {
return HanziQueryRecord.fromJson(
jsonDecode(r) as Map<String, dynamic>,
);
} catch (_) {
return null;
}
})
.whereType<HanziQueryRecord>()
.toList();
} catch (e) {
Log.w('加载查询历史失败', e);
}
}
Future<void> addRecord(HanziQueryRecord record) async {
state = [
record,
...state.where(
(r) => !(r.type == record.type && r.query == record.query),
),
].take(_maxRecords).toList();
await _saveHistory();
}
Future<void> removeRecord(int index) async {
if (index < 0 || index >= state.length) return;
state = [...state]..removeAt(index);
await _saveHistory();
}
Future<void> clearHistory() async {
state = [];
await _saveHistory();
}
Future<void> _saveHistory() async {
try {
await KvStorage.setStringList(
_historyKey,
state.map((r) => jsonEncode(r.toJson())).toList(),
);
} catch (e) {
Log.w('保存查询历史失败', e);
}
}
}
final hanziHistoryProvider =
NotifierProvider<HanziHistoryNotifier, List<HanziQueryRecord>>(
HanziHistoryNotifier.new,
);
/// 汉语工具 Provider 工厂
final _hanziProviders =
<String, NotifierProvider<HanziQueryNotifier, HanziQueryState>>{};
NotifierProvider<HanziQueryNotifier, HanziQueryState> hanziQueryProvider(
HanziToolConfig config,
) {
return _hanziProviders.putIfAbsent(
config.type,
() => NotifierProvider<HanziQueryNotifier, HanziQueryState>(
HanziQueryNotifier.new,
),
);
}
/// 汉语工具通用查询页
class HanziToolPage extends ConsumerStatefulWidget {
const HanziToolPage({super.key, required this.config});
final HanziToolConfig config;
@override
ConsumerState<HanziToolPage> createState() => _HanziToolPageState();
}
class _HanziToolPageState extends ConsumerState<HanziToolPage> {
final _controller = TextEditingController();
final _focusNode = FocusNode();
@override
void dispose() {
_onDispose();
super.dispose();
}
void _onDispose() {
_controller.dispose();
_focusNode.dispose();
}
void _doQuery() {
final text = _controller.text.trim();
if (text.isEmpty) {
AppToast.showWarning('请输入查询内容');
return;
}
if (widget.config.isSingleChar && text.length > 1) {
AppToast.showWarning('该工具仅支持单字查询');
return;
}
final provider = hanziQueryProvider(widget.config);
ref.read(provider.notifier).query(text);
ref
.read(hanziHistoryProvider.notifier)
.addRecord(
HanziQueryRecord(
type: widget.config.type,
query: text,
timestamp: DateTime.now(),
),
);
_focusNode.unfocus();
}
@override
Widget build(BuildContext context) {
final ext = AppTheme.ext(context);
final provider = hanziQueryProvider(widget.config);
final queryState = ref.watch(provider);
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
ToolIconHelper.iconForHanziType(widget.config.type),
size: 18,
color: ext.textPrimary,
),
const SizedBox(width: 4),
Text(widget.config.title),
],
),
backgroundColor: ext.bgPrimary.withValues(alpha: 0.9),
border: null,
leading: const AdaptiveBackButton(),
trailing: DataSourceBadge(
toolType: ToolType.online,
apiPath: '/api/hanzi/${widget.config.type}',
compact: true,
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
children: [
_buildSearchBar(ext),
const SizedBox(height: AppSpacing.md),
Expanded(child: _buildResultArea(ext, queryState)),
],
),
),
),
);
}
Widget _buildSearchBar(AppThemeExtension ext) {
return Container(
decoration: BoxDecoration(
color: ext.bgSecondary,
borderRadius: AppRadius.lgBorder,
),
child: CupertinoTextField(
controller: _controller,
focusNode: _focusNode,
placeholder: widget.config.hintText,
placeholderStyle: AppTypography.body.copyWith(color: ext.textHint),
style: AppTypography.body.copyWith(color: ext.textPrimary),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm + 2,
),
decoration: const BoxDecoration(),
onSubmitted: (_) => _doQuery(),
suffix: Padding(
padding: const EdgeInsets.only(right: AppSpacing.sm),
child: CupertinoButton(
padding: const EdgeInsets.all(AppSpacing.sm),
minimumSize: Size.zero,
onPressed: _doQuery,
child: Icon(CupertinoIcons.search, color: ext.accent, size: 20),
),
),
),
);
}
Widget _buildResultArea(AppThemeExtension ext, HanziQueryState state) {
if (state.isLoading) {
return _buildLoadingSkeleton(ext);
}
if (state.error != null) {
return _buildErrorView(ext, state.error!);
}
if (state.result == null) {
return _buildEmptyView(ext);
}
return _buildResultContent(ext, state.result!);
}
Widget _buildLoadingSkeleton(AppThemeExtension ext) {
return ListView.separated(
itemCount: 3,
separatorBuilder: (_, __) => const SizedBox(height: AppSpacing.sm),
itemBuilder: (_, __) => const ListItemSkeleton(),
);
}
Widget _buildEmptyView(AppThemeExtension ext) {
final history = ref.watch(hanziHistoryProvider);
final filteredHistory = history
.where((r) => r.type == widget.config.type)
.toList();
return SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: AppSpacing.xl),
Text(widget.config.emoji, style: const TextStyle(fontSize: 48)),
const SizedBox(height: AppSpacing.md),
Text(
widget.config.emptyMessage,
style: AppTypography.subhead.copyWith(color: ext.textSecondary),
),
const SizedBox(height: AppSpacing.xl),
_buildQuickActions(ext),
if (filteredHistory.isNotEmpty) ...[
const SizedBox(height: AppSpacing.xl),
_buildHistorySection(ext, filteredHistory),
],
],
),
);
}
Widget _buildHistorySection(
AppThemeExtension ext,
List<HanziQueryRecord> records,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: Row(
children: [
Text(
'🕐 查询历史',
style: AppTypography.subhead.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
CupertinoButton(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
onPressed: () {
ref.read(hanziHistoryProvider.notifier).clearHistory();
AppToast.showSuccess('历史已清空');
},
child: Text(
'清空',
style: AppTypography.caption1.copyWith(color: ext.textHint),
),
),
],
),
),
const SizedBox(height: AppSpacing.sm),
...records.take(10).toList().asMap().entries.map((entry) {
final index = entry.key;
final record = entry.value;
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.xs / 2,
),
child: AppSlidable(
slideKey: ValueKey('history_$index'),
groupTag: 'hanzi_history',
rightActions: [
SlideActionConfig(
type: SlideActionType.delete,
onPressed: () {
final allHistory = ref.read(hanziHistoryProvider);
final globalIndex = allHistory.indexWhere(
(r) =>
r.type == record.type &&
r.query == record.query &&
r.timestamp == record.timestamp,
);
if (globalIndex >= 0) {
ref
.read(hanziHistoryProvider.notifier)
.removeRecord(globalIndex);
}
},
),
],
borderRadius: AppRadius.mdBorder,
child: GestureDetector(
onTap: () {
_controller.text = record.query;
_doQuery();
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: ext.bgSecondary,
borderRadius: AppRadius.mdBorder,
),
child: Row(
children: [
Text(
record.query,
style: AppTypography.body.copyWith(
color: ext.textPrimary,
),
),
const Spacer(),
Text(
record.displayType,
style: AppTypography.caption1.copyWith(
color: ext.textHint,
),
),
],
),
),
),
),
);
}),
],
);
}
Widget _buildQuickActions(AppThemeExtension ext) {
final quickChars = _getQuickChars();
if (quickChars.isEmpty) return const SizedBox.shrink();
return Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: quickChars.map((char) {
return CupertinoButton(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
minimumSize: Size.zero,
borderRadius: AppRadius.mdBorder,
color: ext.bgSecondary,
onPressed: () {
_controller.text = char;
_doQuery();
},
child: Text(
char,
style: AppTypography.callout.copyWith(color: ext.textPrimary),
),
);
}).toList(),
);
}
List<String> _getQuickChars() {
switch (widget.config.type) {
case 'zi':
return ['', '', '', '', '', ''];
case 'zuci':
return ['', '', '', '', '', ''];
case 'cidian':
return ['美丽', '快乐', '勇敢', '智慧'];
case 'chengyu':
return ['', '', '', ''];
case 'jinyici':
return ['快乐', '美丽', '勇敢', '聪明'];
case 'fanyici':
return ['快乐', '美丽', '勇敢', '聪明'];
case 'juzi':
return ['', '', '', ''];
default:
return [];
}
}
Widget _buildErrorView(AppThemeExtension ext, String error) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('😔', style: TextStyle(fontSize: 48)),
const SizedBox(height: AppSpacing.md),
Text(
error,
style: AppTypography.body.copyWith(color: ext.textSecondary),
),
const SizedBox(height: AppSpacing.md),
CupertinoButton(
onPressed: _doQuery,
child: Text('重试', style: TextStyle(color: ext.accent)),
),
],
),
);
}
Widget _buildResultContent(
AppThemeExtension ext,
Map<String, dynamic> result,
) {
final data = result['data'];
if (data is List) {
if (data.isEmpty) {
return Center(
child: Text(
'暂无结果',
style: AppTypography.body.copyWith(color: ext.textSecondary),
),
);
}
return _buildListResult(ext, data);
}
if (data is Map) {
return _buildMapResult(ext, data);
}
if (data is String) {
return SingleChildScrollView(child: AppMarkdownBody(data: data));
}
return _buildMapResult(ext, result);
}
Widget _buildListResult(AppThemeExtension ext, List<dynamic> data) {
if (data.length > 5) {
return _buildGroupedListResult(ext, data);
}
return ListView.separated(
itemCount: data.length,
separatorBuilder: (_, __) => const SizedBox(height: AppSpacing.sm),
itemBuilder: (context, index) {
final item = data[index];
if (item is Map<String, dynamic>) {
return _buildResultCard(ext, item);
}
return _buildSimpleCard(ext, item.toString());
},
);
}
Widget _buildGroupedListResult(AppThemeExtension ext, List<dynamic> data) {
final groups = <String, List<Map<String, dynamic>>>{};
for (final item in data) {
if (item is! Map<String, dynamic>) continue;
final pinyin = item['py']?.toString() ?? item['pinyin']?.toString() ?? '';
final letter = pinyin.isNotEmpty
? pinyin[0].toUpperCase()
: (item['zi']?.toString() ?? item['name']?.toString() ?? '#')[0]
.toUpperCase();
final key = regex(r'[A-Z]').hasMatch(letter) ? letter : '#';
groups.putIfAbsent(key, () => []).add(item);
}
final sortedKeys = groups.keys.toList()..sort();
return CustomScrollView(
slivers: [
for (final key in sortedKeys)
AppStickyHeaderSliver(
title: key,
emoji: '🔤',
trailing: '${groups[key]!.length}',
sliver: SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.xs,
),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: _buildResultCard(ext, groups[key]![index]),
),
childCount: groups[key]!.length,
),
),
),
),
],
);
}
Widget _buildMapResult(AppThemeExtension ext, Map<dynamic, dynamic> data) {
return SingleChildScrollView(
child: _buildResultCard(ext, Map<String, dynamic>.from(data)),
);
}
Widget _buildResultCard(AppThemeExtension ext, Map<String, dynamic> item) {
final entries = item.entries.where((e) {
final key = e.key.toLowerCase();
return key != 'id' && e.value != null && e.value.toString().isNotEmpty;
}).toList();
final shareText = entries
.map((e) => '${_formatLabel(e.key)}: ${e.value}')
.join('\n');
return AppSlidable(
slideKey: ValueKey(item.hashCode),
groupTag: 'hanzi_results',
rightActions: [
SlideActionConfig(
type: SlideActionType.share,
onPressed: () {
share_plus.SharePlus.instance.share(
share_plus.ShareParams(
text:
'${widget.config.emoji} ${widget.config.title}\n$shareText',
),
);
},
),
],
borderRadius: AppRadius.lgBorder,
child: Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: ext.bgCard,
borderRadius: AppRadius.lgBorder,
border: Border.all(
color: ext.textHint.withValues(alpha: 0.08),
width: 0.5,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: entries.map((entry) {
final label = _formatLabel(entry.key);
final value = entry.value;
if (value is List) {
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: AppTypography.caption1.copyWith(
color: ext.textSecondary,
),
),
const SizedBox(height: AppSpacing.xs),
Wrap(
spacing: AppSpacing.xs,
runSpacing: AppSpacing.xs,
children: value.map((v) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: ext.bgSecondary,
borderRadius: AppRadius.smBorder,
),
child: Text(
v.toString().cleanHtml,
style: AppTypography.footnote.copyWith(
color: ext.textPrimary,
),
),
);
}).toList(),
),
],
),
);
}
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 72,
child: Text(
label,
style: AppTypography.caption1.copyWith(
color: ext.textSecondary,
),
),
),
Expanded(
child: Text(
value.toString().cleanHtml,
style: AppTypography.body.copyWith(
color: ext.textPrimary,
),
),
),
],
),
);
}).toList(),
),
),
);
}
Widget _buildSimpleCard(AppThemeExtension ext, String text) {
return Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: ext.bgCard,
borderRadius: AppRadius.lgBorder,
),
child: Text(
text.cleanHtml,
style: AppTypography.body.copyWith(color: ext.textPrimary),
),
);
}
String _formatLabel(String key) {
const labelMap = {
'zi': '汉字',
'py': '拼音',
'pinyin': '拼音',
'bushou': '部首',
'bihua': '笔画',
'bishun': '笔顺',
'zuci': '组词',
'jijie': '简解',
'xiangjie': '详解',
'chengyu': '成语',
'jinyici': '近义词',
'fanyici': '反义词',
'juzi': '造句',
'nick': '网名',
'wubi': '五笔',
'cangjie': '仓颉',
'zhengma': '郑码',
'sijiao': '四角',
'uni': 'Unicode',
'name': '名称',
'title': '标题',
'content': '内容',
'desc': '描述',
'type': '类型',
'category': '分类',
'origin': '出处',
'author': '作者',
'meaning': '释义',
'example': '例句',
'source': '来源',
'color': '颜色',
'hex': 'HEX',
'rgb': 'RGB',
'cyjs': '成语解释',
'cyzy': '成语造句',
'cylz': '成语例句',
'sxckzle': '近反义词',
'czbj': '成语备注',
'chuchu': '出处',
'gushi': '典故',
'phonetic': '音标',
'translation': '翻译',
'full_name': '全称',
'abbreviation': '缩写',
'definition': '定义',
};
return labelMap[key.toLowerCase()] ?? key;
}
}