搜索功能开发笔记 - 文件与聊天消息搜索

December 27, 2025
13 min read
By devshan

Table of Contents

This is a list of all the sections in this post. Click on any of them to jump to that section.

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设计和实现
  • 前端搜索界面和高亮组件
  • 与现有系统的无缝集成
  • 良好的使用体验和性能表现

这个实现为后续的搜索功能扩展奠定了坚实基础。