1. 概述
本笔记记录了搜索功能的完整开发过程,包括方案设计、技术选型、实现细节、架构决策等。搜索功能分为两个主要部分:文件搜索和聊天消息搜索。
2. 需求分析
2.1 功能需求
文件界面搜索:
- 在文件界面右上角添加搜索键
- 点击后进入新界面,支持关键词输入
- 支持当前文件夹范围搜索(默认)和全盘搜索
- 搜索结果展示文件名、路径等信息
聊天界面搜索:
- 不跳转新页面,直接在会话列表页面进行搜索
- 支持搜索会话标题和消息内容
- 标题匹配时高亮显示标题
- 消息内容匹配时显示匹配的消息内容并高亮关键词
- 消息过长时定位到关键词附近
2.2 非功能需求
- 搜索结果高亮显示(透明黄色背景)
- 搜索响应速度要快(支持延迟搜索)
- 支持关键词上下文提取(关键词前后各30字符)
3. 架构设计
3.1 整体架构
采用后端驱动的搜索架构:
- 后端:提供搜索API,处理文件和消息的搜索逻辑
- 前端:提供搜索界面和高亮显示组件
3.2 后端API设计
3.2.1 文件搜索API
- 路径:
/api/v1/files/search - 方法:GET
- 参数:
q:搜索关键词path(可选):搜索路径,限制搜索范围
- 返回:匹配的文件元数据列表
3.2.2 消息搜索API
- 路径:
/api/v1/send/search - 方法:GET
- 参数:
q:搜索关键词
- 返回:搜索结果列表,包含会话信息和匹配的消息信息
3.3 搜索结果模型
定义了 SearchResult 结构体:
type SearchResult struct {
SessionID string `json:"sessionId"`
SessionName string `json:"sessionName"`
MatchType string `json:"matchType"` // "title" or "message"
MessageID string `json:"messageId,omitempty"`
MessageType string `json:"messageType,omitempty"`
MatchedText string `json:"matchedText,omitempty"`
MatchedAt time.Time `json:"matchedAt,omitempty"`
SessionUpdatedAt time.Time `json:"sessionUpdatedAt"`
SessionMessageCount int `json:"sessionMessageCount"`
}4. 技术实现
4.1 后端实现
4.1.1 文件搜索实现
在 server.go 中添加 searchFiles 方法:
func (s *Server) searchFiles(c *gin.Context) {
query := strings.TrimSpace(c.Query("q"))
basePath := c.Query("path")
// 验证参数
if query == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "query is required"})
return
}
// 获取文件列表
files := s.listFiles(basePath)
var results []FileMetadata
queryLower := strings.ToLower(query)
for _, file := range files {
if strings.Contains(strings.ToLower(file.Name), queryLower) {
results = append(results, file)
}
// 限制结果数量
if len(results) >= 100 {
break
}
}
c.JSON(http.StatusOK, gin.H{
"files": results,
})
}4.1.2 消息搜索实现
在 server.go 中添加 searchSendMessages 方法:
func (s *Server) searchSendMessages(c *gin.Context) {
query := strings.TrimSpace(c.Query("q"))
if query == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "query is required"})
return
}
var results []SearchResult
queryLower := strings.ToLower(query)
// 搜索会话标题
for _, session := range s.sessions {
if strings.Contains(strings.ToLower(session.Name), queryLower) {
results = append(results, SearchResult{
SessionID: session.ID,
SessionName: session.Name,
MatchType: "title",
SessionUpdatedAt: session.UpdatedAt,
})
}
}
// 搜索消息内容
for _, session := range s.sessions {
for _, msg := range session.Messages {
if msg.Type == "text" && strings.Contains(strings.ToLower(msg.Text), queryLower) {
results = append(results, SearchResult{
SessionID: session.ID,
SessionName: session.Name,
MatchType: "message",
MessageID: msg.ID,
MessageType: msg.Type,
MatchedText: extractContext(msg.Text, query, 50),
MatchedAt: msg.CreatedAt,
SessionUpdatedAt: session.UpdatedAt,
})
}
}
}
// 去重并排序
results = deduplicateAndSort(results)
c.JSON(http.StatusOK, gin.H{
"results": results,
})
}4.1.3 上下文提取函数
func extractContext(text, keyword string, contextLen int) string {
lowerText := strings.ToLower(text)
lowerKeyword := strings.ToLower(keyword)
idx := strings.Index(lowerText, lowerKeyword)
if idx == -1 {
return text
}
start := idx - contextLen
if start < 0 {
start = 0
}
end := idx + len(keyword) + contextLen
if end > len(text) {
end = len(text)
}
result := text[start:end]
if start > 0 {
result = "..." + result
}
if end < len(text) {
result = result + "..."
}
return result
}4.2 前端实现
4.2.1 API客户端扩展
在 api_client.dart 中添加搜索相关的API方法:
/// 搜索文件
Future<ApiResult<List<FileMetadata>>> searchFiles({
required String query,
String? path,
}) async {
try {
final queryParams = <String, dynamic>{'q': query};
if (path != null && path.isNotEmpty) {
queryParams['path'] = path;
}
final response = await _dio.get(
'/api/v1/files/search',
queryParameters: queryParams,
);
final files = <FileMetadata>[];
if (response.data['files'] != null) {
for (final f in response.data['files'] as List) {
files.add(FileMetadata.fromJson(Map<String, dynamic>.from(f)));
}
}
return ApiResult.success(files);
} catch (e) {
return ApiResult.error(_parseError(e));
}
}
/// 搜索会话消息
Future<ApiResult<List<SendSearchResult>>> searchSendMessages(String query) async {
try {
final response = await _dio.get(
'/api/v1/send/search',
queryParameters: {'q': query},
);
final results = <SendSearchResult>[];
if (response.data['results'] != null) {
for (final r in response.data['results'] as List) {
results.add(SendSearchResult.fromJson(Map<String, dynamic>.from(r)));
}
}
return ApiResult.success(results);
} catch (e) {
return ApiResult.error(_parseError(e));
}
}4.2.2 搜索结果模型
在 send_message.dart 中添加 SendSearchResult 类:
/// 搜索结果
class SendSearchResult {
final String sessionId;
final String sessionName;
final String matchType; // "title" or "message"
final String? messageId;
final String? messageType;
final String? matchedText;
final DateTime? matchedAt;
final DateTime sessionUpdatedAt;
final int sessionMessageCount;
const SendSearchResult({
required this.sessionId,
required this.sessionName,
required this.matchType,
this.messageId,
this.messageType,
this.matchedText,
this.matchedAt,
required this.sessionUpdatedAt,
required this.sessionMessageCount,
});
factory SendSearchResult.fromJson(Map<String, dynamic> json) {
return SendSearchResult(
sessionId: json['sessionId'] ?? '',
sessionName: json['sessionName'] ?? '',
matchType: json['matchType'] ?? 'title',
messageId: json['messageId'],
messageType: json['messageType'],
matchedText: json['matchedText'],
matchedAt: json['matchedAt'] != null
? DateTime.tryParse(json['matchedAt'])
: null,
sessionUpdatedAt:
DateTime.tryParse(json['sessionUpdatedAt'] ?? '') ?? DateTime.now(),
sessionMessageCount: json['sessionMessageCount'] ?? 0,
);
}
}4.2.3 高亮文本组件
创建 highlight_text.dart 组件:
/// 关键词高亮文本组件
class HighlightText extends StatelessWidget {
final String text;
final String keyword;
final TextStyle? style;
final TextStyle? highlightStyle;
final int? maxLines;
final TextOverflow? overflow;
const HighlightText({
super.key,
required this.text,
required this.keyword,
this.style,
this.highlightStyle,
this.maxLines,
this.overflow,
});
@override
Widget build(BuildContext context) {
if (keyword.isEmpty) {
return Text(
text,
style: style,
maxLines: maxLines,
overflow: overflow,
);
}
final spans = <TextSpan>[];
final lowerText = text.toLowerCase();
final lowerKeyword = keyword.toLowerCase();
int start = 0;
while (start < text.length) {
final idx = lowerText.indexOf(lowerKeyword, start);
if (idx == -1) {
// 剩余部分
spans.add(TextSpan(text: text.substring(start)));
break;
}
// 关键词之前的文本
if (idx > start) {
spans.add(TextSpan(text: text.substring(start, idx)));
}
// 高亮的关键词
spans.add(TextSpan(
text: text.substring(idx, idx + keyword.length),
style: highlightStyle ??
TextStyle(
backgroundColor: Colors.yellow.withValues(alpha: 0.5),
fontWeight: FontWeight.w500,
),
));
start = idx + keyword.length;
}
return RichText(
text: TextSpan(
style: style ?? DefaultTextStyle.of(context).style,
children: spans,
),
maxLines: maxLines,
overflow: overflow ?? TextOverflow.ellipsis,
);
}
}
/// 提取关键词附近的上下文文本
/// 如果文本过长,只显示关键词附近的内容
String extractContextAround(String text, String keyword, {int contextLen = 30}) {
if (keyword.isEmpty || text.length <= contextLen * 2 + keyword.length) {
return text;
}
final lowerText = text.toLowerCase();
final lowerKeyword = keyword.toLowerCase();
final idx = lowerText.indexOf(lowerKeyword);
if (idx == -1) {
// 没找到,返回截断后的文本
if (text.length > contextLen * 2) {
return '${text.substring(0, contextLen * 2)}...';
}
return text;
}
final start = (idx - contextLen).clamp(0, text.length);
final end = (idx + keyword.length + contextLen).clamp(0, text.length);
final prefix = start > 0 ? '...' : '';
final suffix = end < text.length ? '...' : '';
return '$prefix${text.substring(start, end)}$suffix';
}4.2.4 文件搜索页面
创建 file_search_page.dart:
/// 文件搜索页面
class FileSearchPage extends StatefulWidget {
final String? initialPath;
const FileSearchPage({super.key, this.initialPath});
@override
State<FileSearchPage> createState() => _FileSearchPageState();
}
class _FileSearchPageState extends State<FileSearchPage> {
final _searchController = TextEditingController();
final _focusNode = FocusNode();
List<FileMetadata>? _results;
bool _isLoading = false;
String? _error;
bool _searchCurrentFolder = true;
@override
void initState() {
super.initState();
_searchCurrentFolder = widget.initialPath != null && widget.initialPath != '/';
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
@override
void dispose() {
_searchController.dispose();
_focusNode.dispose();
super.dispose();
}
Future<void> _performSearch() async {
final query = _searchController.text.trim();
if (query.isEmpty) {
setState(() {
_results = null;
_error = null;
});
return;
}
setState(() {
_isLoading = true;
_error = null;
});
final appState = context.read<AppState>();
final result = await appState.api.searchFiles(
query: query,
path: _searchCurrentFolder ? widget.initialPath : null,
);
if (!mounted) return;
setState(() {
_isLoading = false;
if (result.isSuccess) {
_results = result.data;
} else {
_error = result.error;
}
});
}
void _openFile(FileMetadata file) {
if (file.isDir) {
// 跳转到文件夹
Navigator.of(context).pop(file.path);
} else {
// 打开文件预览
_openFilePreview(file);
}
}
void _openFilePreview(FileMetadata file) {
final fileType = getFileType(file.name);
switch (fileType) {
case AppFileType.image:
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ImagePreviewPage(file: file),
),
);
break;
case AppFileType.video:
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => VideoPlayerPage(file: file),
),
);
break;
case AppFileType.document:
if (file.name.toLowerCase().endsWith('.pdf')) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PdfPreviewPage(file: file),
),
);
}
break;
case AppFileType.text:
case AppFileType.code:
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => TextEditorPage(file: file),
),
);
break;
default:
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => UnsupportedPreviewPage(file: file),
),
);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('搜索文件'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(60),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: TextField(
controller: _searchController,
focusNode: _focusNode,
decoration: InputDecoration(
hintText: '输入文件名关键词...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() {
_results = null;
_error = null;
});
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
filled: true,
fillColor: theme.colorScheme.surface,
),
onSubmitted: (_) => _performSearch(),
onChanged: (value) {
setState(() {}); // 刷新清除按钮状态
// 延迟搜索
Future.delayed(const Duration(milliseconds: 300), () {
if (_searchController.text == value) {
_performSearch();
}
});
},
),
),
),
),
body: Column(
children: [
// 筛选选项
if (widget.initialPath != null && widget.initialPath != '/')
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
FilterChip(
label: Text(_searchCurrentFolder ? '当前文件夹' : '全部文件'),
selected: _searchCurrentFolder,
onSelected: (selected) {
setState(() {
_searchCurrentFolder = selected;
});
if (_searchController.text.isNotEmpty) {
_performSearch();
}
},
),
if (_searchCurrentFolder) ...[
const SizedBox(width: 8),
Expanded(
child: Text(
widget.initialPath!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
),
],
],
),
),
// 搜索结果
Expanded(
child: _buildBody(theme),
),
],
),
);
}
Widget _buildBody(ThemeData theme) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: theme.colorScheme.error),
const SizedBox(height: 16),
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
],
),
);
}
if (_results == null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search, size: 64, color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5)),
const SizedBox(height: 16),
Text(
'输入关键词开始搜索',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
if (_results!.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 64, color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5)),
const SizedBox(height: 16),
Text(
'未找到匹配的文件',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
return ListView.builder(
itemCount: _results!.length,
itemBuilder: (context, index) {
final file = _results![index];
return _buildFileItem(file, theme);
},
);
}
Widget _buildFileItem(FileMetadata file, ThemeData theme) {
final keyword = _searchController.text.trim();
final fileType = getFileType(file.name, isDir: file.isDir);
final icon = getFileIcon(fileType);
final iconColor = getFileIconColor(fileType);
return ListTile(
leading: Icon(icon, color: iconColor),
title: HighlightText(
text: file.name,
keyword: keyword,
style: theme.textTheme.bodyLarge,
maxLines: 1,
),
subtitle: Text(
file.path,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: file.isDir
? null
: Text(
formatFileSize(file.size),
style: theme.textTheme.bodySmall,
),
onTap: () => _openFile(file),
);
}
}4.2.5 聊天页面搜索功能
修改 send_page.dart 实现搜索功能:
class _SendPageState extends State<SendPage> {
List<SendSession> _sessions = [];
bool _loading = true;
String? _error;
String? _lastS3ConfigId; // 记录上次加载时的 S3 配置 ID
// 搜索状态
bool _isSearching = false;
final _searchController = TextEditingController();
List<SendSearchResult>? _searchResults;
bool _searchLoading = false;
@override
void initState() {
super.initState();
_loadSessions();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _performSearch(String query) async {
if (query.isEmpty) {
setState(() {
_searchResults = null;
_searchLoading = false;
});
return;
}
setState(() => _searchLoading = true);
final appState = context.read<AppState>();
final result = await appState.api.searchSendMessages(query);
if (!mounted) return;
setState(() {
_searchLoading = false;
if (result.isSuccess) {
_searchResults = result.data;
}
});
}
void _exitSearch() {
setState(() {
_isSearching = false;
_searchController.clear();
_searchResults = null;
_searchLoading = false;
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Scaffold(
// 输入在对话框中,不需要让底层页面被键盘压缩
resizeToAvoidBottomInset: false,
backgroundColor: ChatColors.background(context),
appBar: _buildAppBar(isDark),
floatingActionButton: _isSearching ? null : _buildFAB(),
body: _isSearching
? _buildSearchResults(isDark)
: Consumer<AppState>(
builder: (context, appState, _) {
// 只有在"从未连接过"(未解锁且没有缓存)时才显示大的锁定提示
// 一旦有缓存,即使离线/未解锁,也正常显示列表(只用红色云朵提示)
final neverConnected = !appState.isUnlocked && _sessions.isEmpty && !_loading;
if (neverConnected) {
return _buildLockedState(isDark);
}
if (_loading) {
return const SessionListShimmer();
}
if (_error != null && _sessions.isEmpty) {
// 只有在没有缓存时才显示错误状态,有缓存时正常显示列表
return _buildErrorState(isDark);
}
if (_sessions.isEmpty) {
return _buildEmptyState(isDark);
}
return RefreshIndicator(
onRefresh: _loadSessions,
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: _sessions.length,
itemBuilder: (context, index) {
final session = _sessions[index];
return SessionListTile(
name: session.name,
subtitle: session.lastMessagePreview,
time: session.lastMessageAt,
unreadCount: 0,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ChatPage(session: session),
),
).then((_) => _loadSessions());
},
onLongPress: () => _showSessionMenu(session, isDark),
);
},
),
);
},
),
);
}
PreferredSizeWidget _buildAppBar(bool isDark) {
if (_isSearching) {
return AppBar(
backgroundColor: ChatColors.surface(context),
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: ChatColors.textPrimary(context)),
onPressed: _exitSearch,
),
title: TextField(
controller: _searchController,
autofocus: true,
style: TextStyle(color: ChatColors.textPrimary(context)),
decoration: InputDecoration(
hintText: '搜索会话或消息...',
hintStyle: TextStyle(color: ChatColors.textSecondary(context)),
border: InputBorder.none,
),
onChanged: (value) {
// 延迟搜索
Future.delayed(const Duration(milliseconds: 300), () {
if (_searchController.text == value) {
_performSearch(value);
}
});
},
onSubmitted: _performSearch,
),
actions: [
if (_searchController.text.isNotEmpty)
IconButton(
icon: Icon(Icons.clear, color: ChatColors.textSecondary(context)),
onPressed: () {
_searchController.clear();
setState(() {
_searchResults = null;
});
},
),
],
);
}
return buildUnifiedAppBar(
context,
title: '发送',
actions: [
IconButton(
icon: Icon(Icons.search, color: ChatColors.textPrimary(context)),
tooltip: '搜索',
onPressed: () {
setState(() => _isSearching = true);
},
),
],
);
}
Widget _buildSearchResults(bool isDark) {
if (_searchLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_searchResults == null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search,
size: 64,
color: ChatColors.textSecondary(context).withValues(alpha: 0.5),
),
const SizedBox(height: 16),
Text(
'输入关键词搜索会话或消息',
style: TextStyle(color: ChatColors.textSecondary(context)),
),
],
),
);
}
if (_searchResults!.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 64,
color: ChatColors.textSecondary(context).withValues(alpha: 0.5),
),
const SizedBox(height: 16),
Text(
'未找到匹配结果',
style: TextStyle(color: ChatColors.textSecondary(context)),
),
],
),
);
}
final keyword = _searchController.text.trim();
return ListView.builder(
padding: EdgeInsets.zero,
itemCount: _searchResults!.length,
itemBuilder: (context, index) {
final result = _searchResults![index];
final isMessageMatch = result.matchType == 'message';
// 构造高亮标题
Widget? titleWidget;
if (!isMessageMatch && keyword.isNotEmpty) {
// 标题匹配,高亮标题
titleWidget = HighlightText(
text: result.sessionName,
keyword: keyword,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: isDark
? ChatColors.darkTextPrimary
: ChatColors.lightTextPrimary,
),
maxLines: 1,
);
}
// 构造高亮副标题
Widget? subtitleWidget;
String subtitle;
if (isMessageMatch && result.matchedText != null) {
// 消息匹配,显示匹配的消息并高亮
subtitle = extractContextAround(result.matchedText!, keyword);
subtitleWidget = HighlightText(
text: subtitle,
keyword: keyword,
style: TextStyle(
fontSize: 14,
color: isDark
? ChatColors.darkTextSecondary
: ChatColors.lightTextSecondary,
),
maxLines: 1,
);
} else {
subtitle = result.matchedText ?? '暂无消息';
}
return SessionListTile(
name: result.sessionName,
subtitle: subtitle,
time: result.matchedAt ?? result.sessionUpdatedAt,
unreadCount: 0,
titleWidget: titleWidget,
subtitleWidget: subtitleWidget,
onTap: () {
// 查找对应的会话
final session = _sessions.firstWhere(
(s) => s.id == result.sessionId,
orElse: () => SendSession(
id: result.sessionId,
name: result.sessionName,
createdAt: DateTime.now(),
updatedAt: result.sessionUpdatedAt,
),
);
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ChatPage(session: session),
),
).then((_) {
_loadSessions();
if (_searchController.text.isNotEmpty) {
_performSearch(_searchController.text);
}
});
},
);
},
);
}
}4.2.6 会话列表项组件扩展
修改 chat_widgets.dart 中的 SessionListTile 以支持自定义标题和副标题组件:
class SessionListTile extends StatelessWidget {
final String name;
final String? subtitle;
final DateTime? time;
final int unreadCount;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final Widget? titleWidget; // 自定义标题组件(用于高亮)
final Widget? subtitleWidget; // 自定义副标题组件(用于高亮)
const SessionListTile({
super.key,
required this.name,
this.subtitle,
this.time,
this.unreadCount = 0,
this.onTap,
this.onLongPress,
this.titleWidget,
this.subtitleWidget,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return InkWell(
onTap: onTap,
onLongPress: onLongPress,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: isDark ? ChatColors.darkBorder : ChatColors.lightBorder,
width: 0.5,
),
),
),
child: Row(
children: [
// 头像
ChatAvatar(name: name, size: 52),
const SizedBox(width: 12),
// 名称和消息预览
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
titleWidget ?? Text(
name,
style: TextStyle(
fontSize: 16,
fontWeight: unreadCount > 0
? FontWeight.w600
: FontWeight.w500,
color: isDark
? ChatColors.darkTextPrimary
: ChatColors.lightTextPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
subtitleWidget ?? Text(
subtitle ?? '暂无消息',
style: TextStyle(
fontSize: 14,
color: isDark
? ChatColors.darkTextSecondary
: ChatColors.lightTextSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 8),
// 时间和未读
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (time != null)
Text(
_formatTime(time!),
style: TextStyle(
fontSize: 12,
color: isDark
? ChatColors.darkTextSecondary
: ChatColors.lightTextSecondary,
),
),
const SizedBox(height: 4),
if (unreadCount > 0)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: ChatColors.unreadBadge,
borderRadius: BorderRadius.circular(10),
),
child: Text(
unreadCount > 99 ? '99+' : unreadCount.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
],
),
),
);
}
String _formatTime(DateTime time) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final dateOnly = DateTime(time.year, time.month, time.day);
if (dateOnly == today) {
final hour = time.hour.toString().padLeft(2, '0');
final minute = time.minute.toString().padLeft(2, '0');
return '$hour:$minute';
} else if (now.difference(time).inDays < 7) {
const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
return weekdays[time.weekday - 1];
} else {
return '${time.month}/${time.day}';
}
}
}5. 集成与测试
5.1 文件页面集成
在 files_page.dart 中添加搜索按钮:
// 在AppBar的actions中添加
actions: [
IconButton(
icon: const Icon(Icons.search),
tooltip: '搜索文件',
onPressed: () async {
final state = context.read<AppState>();
final result = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (_) => FileSearchPage(
initialPath: state.currentPath,
),
),
);
// 如果返回了文件夹路径,导航到该文件夹
if (result != null && result.isNotEmpty) {
state.navigateTo(result);
}
},
),
// 其他按钮...
],5.2 代码质量检查
- 通过
flutter analyze验证代码质量 - 通过
go build验证后端代码可编译 - 修复了所有
withOpacity警告,使用withValues替代
6. 设计决策与权衡
6.1 架构选择
后端驱动 vs 前端驱动:
- 选择了后端驱动方案,因为:
- 搜索逻辑复杂,需要在后端处理
- 避免大量数据传输到前端
- 保持搜索性能
实时搜索 vs 按钮触发:
- 选择了实时搜索(带延迟),因为:
- 使用体验更好
- 减少不必要的按钮点击
- 300ms延迟避免频繁请求
6.2 性能优化
延迟搜索:
- 使用300ms延迟避免频繁API调用
- 防止输入内容时的性能问题
结果限制:
- 限制搜索结果数量(最多100条)
- 防止大量数据传输影响性能
上下文提取:
- 提取关键词前后50字符
- 平衡信息完整性和性能
6.3 使用体验
高亮显示:
- 使用透明黄色背景突出显示关键词
- 保持原有文本样式
搜索状态:
- 提供加载状态指示
- 显示搜索结果数量
错误处理:
- 提供清晰的错误信息
- 支持重试功能
7. 总结
搜索功能的实现遵循了”优雅简洁”的设计原则,通过后端API提供搜索能力,前端负责展示和交互。整个实现过程注重代码复用、性能优化和使用体验,提供了高效的文件和消息搜索功能。
主要成果包括:
- 完整的搜索API设计和实现
- 前端搜索界面和高亮组件
- 与现有系统的无缝集成
- 良好的使用体验和性能表现
这个实现为后续的搜索功能扩展奠定了坚实基础。