feat(ui): implement keyword highlighting and improve search experience
All checks were successful
Frontend CI / ota-ios (push) Successful in 1m33s
Frontend CI / ota-android (push) Successful in 1m34s
Frontend CI / build-and-push-web (push) Successful in 2m31s
Frontend CI / build-android-apk (push) Successful in 1h2m56s

- Add keyword highlighting in `PostCard` and `PostContentRenderer` using `HighlightText`.
- Implement smart excerpt logic in `PostCard` to show relevant context around search keywords.
- Update `SearchBar` styles for better visual feedback and consistency.
- Enhance `SearchScreen` with modern tab variants and improved tag styling.
- Add support for updating group descriptions in `GroupInfoScreen` and `GroupService`.
- Refactor various UI components for improved layout stability and typography.
This commit is contained in:
2026-05-07 01:08:58 +08:00
parent 7f606e14dd
commit ea9e51b0b0
8 changed files with 122 additions and 46 deletions

View File

@@ -474,6 +474,13 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
};
const getResponsiveMaxLength = () => {
// 搜索高亮模式下展示更多内容
if (highlightKeyword) {
if (isWideScreen) return 500;
if (isDesktop) return 400;
if (isTablet) return 350;
return 220;
}
if (isWideScreen) return 300;
if (isDesktop) return 250;
if (isTablet) return 200;
@@ -481,15 +488,53 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
};
const contentNumberOfLines = useMemo(() => {
if (highlightKeyword) {
if (isWideScreen) return 12;
if (isDesktop) return 10;
if (isTablet) return 8;
return 5;
}
if (isWideScreen) return 8;
if (isDesktop) return 6;
if (isTablet) return 5;
return 3;
}, [isWideScreen, isDesktop, isTablet]);
}, [isWideScreen, isDesktop, isTablet, highlightKeyword]);
const getSearchExcerpt = (value: string, keyword: string): string => {
const maxLength = getResponsiveMaxLength();
if (value.length <= maxLength) return value;
const lowerValue = value.toLowerCase();
const lowerKeyword = keyword.toLowerCase();
const index = lowerValue.indexOf(lowerKeyword);
if (index === -1) {
return `${value.substring(0, maxLength)}...`;
}
const context = Math.floor((maxLength - keyword.length) / 2);
let start = Math.max(0, index - context);
let end = Math.min(value.length, index + keyword.length + context);
if (start > 0) {
while (start > 0 && value[start] !== ' ' && value[start] !== '\n') start--;
if (value[start] === ' ' || value[start] === '\n') start++;
}
if (end < value.length) {
while (end < value.length && value[end] !== ' ' && value[end] !== '\n') end++;
}
const prefix = start > 0 ? '...' : '';
const suffix = end < value.length ? '...' : '';
return `${prefix}${value.substring(start, end)}${suffix}`;
};
const getTruncatedContent = (value: string): string => {
const maxLength = getResponsiveMaxLength();
if (value.length <= maxLength || isExpanded) return value;
if (highlightKeyword) {
return getSearchExcerpt(value, highlightKeyword);
}
return `${value.substring(0, maxLength)}...`;
};
@@ -666,6 +711,8 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
style={styles.contentRenderer}
textStyle={styles.content}
highlightKeyword={highlightKeyword}
highlightStyle={styles.highlight}
/>
) : (
<Text

View File

@@ -4,10 +4,11 @@
* 降级:如果 segments 为空但 content 非空,直接显示纯文本
*/
import React from 'react';
import { View, TouchableOpacity, StyleSheet } from 'react-native';
import React, { useMemo } from 'react';
import { View, TouchableOpacity, StyleSheet, TextStyle, StyleProp } from 'react-native';
import Text from '../common/Text';
import HighlightText from '../common/HighlightText';
import { useAppColors } from '../../theme';
import { MessageSegment, AtSegmentData, VoteSegmentData, PostRefSegmentData } from '../../types';
import PostRefCard from './PostRefCard';
@@ -20,6 +21,8 @@ interface PostContentRendererProps {
onPostRefPress?: (postId: string) => void;
style?: any;
textStyle?: any;
highlightKeyword?: string;
highlightStyle?: StyleProp<TextStyle>;
}
const PostContentRenderer: React.FC<PostContentRendererProps> = ({
@@ -30,13 +33,19 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
onPostRefPress,
style,
textStyle,
highlightKeyword,
highlightStyle,
}) => {
const colors = useAppColors();
if (!segments || segments.length === 0) {
return (
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
{content || ''}
{highlightKeyword && content ? (
<HighlightText text={content} keyword={highlightKeyword} style={highlightStyle} />
) : (
content || ''
)}
</Text>
);
}
@@ -53,7 +62,11 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
if (text) {
elements.push(
<Text key={key} selectable style={textStyle}>
{text}
{highlightKeyword ? (
<HighlightText text={text} keyword={highlightKeyword} style={highlightStyle} />
) : (
text
)}
</Text>
);
}
@@ -126,7 +139,11 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
if (elements.length === 0) {
return (
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
{content || ''}
{highlightKeyword && content ? (
<HighlightText text={content} keyword={highlightKeyword} style={highlightStyle} />
) : (
content || ''
)}
</Text>
);
}

View File

@@ -24,22 +24,22 @@ function createSearchBarStyles(colors: AppColors, compact: boolean) {
container: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'transparent',
backgroundColor: `${colors.primary.main}08`,
borderRadius: borderRadius.full,
paddingHorizontal: compact ? spacing.sm : spacing.xs,
height: compact ? 38 : 46,
borderWidth: compact ? 1 : 1,
borderColor: colors.divider,
paddingHorizontal: compact ? spacing.sm : spacing.md,
height: compact ? 38 : 48,
borderWidth: 1.5,
borderColor: 'transparent',
},
containerFocused: {
borderColor: colors.primary.main,
backgroundColor: 'transparent',
borderColor: `${colors.primary.main}50`,
backgroundColor: `${colors.primary.main}10`,
},
searchIconWrap: {
width: compact ? 22 : 30,
height: compact ? 22 : 30,
marginLeft: compact ? 2 : spacing.xs,
marginRight: compact ? 6 : spacing.xs,
width: compact ? 22 : 32,
height: compact ? 22 : 32,
marginLeft: compact ? 2 : 0,
marginRight: compact ? 6 : spacing.sm,
borderRadius: borderRadius.full,
backgroundColor: 'transparent',
alignItems: 'center',
@@ -50,17 +50,18 @@ function createSearchBarStyles(colors: AppColors, compact: boolean) {
},
input: {
flex: 1,
fontSize: compact ? fontSizes.md : fontSizes.md,
fontSize: compact ? fontSizes.md : fontSizes.lg,
color: colors.text.primary,
paddingVertical: compact ? 0 : spacing.sm + 1,
paddingHorizontal: compact ? 2 : spacing.xs,
paddingVertical: compact ? 0 : spacing.sm,
paddingHorizontal: compact ? 2 : 0,
fontWeight: '500',
},
clearButton: {
width: 24,
height: 24,
marginHorizontal: spacing.xs,
width: 22,
height: 22,
marginLeft: spacing.xs,
borderRadius: borderRadius.full,
backgroundColor: `${colors.text.secondary}14`,
backgroundColor: `${colors.text.secondary}20`,
alignItems: 'center',
justifyContent: 'center',
},

View File

@@ -265,7 +265,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
post={post}
onAction={(action) => handlePostAction(post, action)}
variant="list"
features={isMobile ? 'compact' : 'full'}
features="full"
highlightKeyword={currentKeyword}
/>
))}
@@ -288,7 +288,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
post={item}
onAction={(action) => handlePostAction(item, action)}
variant="list"
features="compact"
features="full"
highlightKeyword={currentKeyword}
/>
)}
@@ -542,11 +542,12 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
activeIndex={activeIndex}
onTabChange={(index) => {
setActiveIndex(index);
// 如果已经搜索过切换Tab时重新搜索
if (currentKeyword && hasSearched) {
performSearch(currentKeyword);
}
}}
variant="modern"
icons={['file-document-outline', 'account-outline']}
/>
</View>
@@ -571,22 +572,12 @@ function createSearchScreenStyles(colors: AppColors) {
},
searchShell: {
flex: 1,
borderRadius: borderRadius.xl,
backgroundColor: `${colors.primary.main}08`,
paddingHorizontal: spacing.xs,
paddingVertical: spacing.xs,
// 移除阴影效果
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
cancelButton: {
backgroundColor: `${colors.primary.main}14`,
backgroundColor: `${colors.primary.main}12`,
borderRadius: borderRadius.full,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
paddingVertical: spacing.sm,
},
cancelText: {
fontSize: fontSizes.md,
@@ -610,7 +601,7 @@ function createSearchScreenStyles(colors: AppColors) {
marginBottom: spacing.sm,
},
sectionTitle: {
fontWeight: '600',
fontWeight: '700',
color: colors.text.primary,
},
tagContainer: {
@@ -620,13 +611,14 @@ function createSearchScreenStyles(colors: AppColors) {
tag: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
borderWidth: 1,
borderColor: colors.divider,
backgroundColor: `${colors.primary.main}10`,
borderRadius: borderRadius.full,
borderWidth: 0,
},
tagText: {
marginLeft: spacing.xs,
color: colors.primary.main,
fontWeight: '500',
},
userCard: {
backgroundColor: colors.background.paper,

View File

@@ -297,6 +297,11 @@ const GroupInfoScreen: React.FC = () => {
await groupService.setGroupAvatar(groupId, editAvatar);
}
// 如果描述有变化,更新群描述
if (editDescription !== (group?.description || '')) {
await groupService.setGroupDescription(groupId, editDescription.trim());
}
// 重新获取群信息
const updatedGroup = await groupManager.getGroup(groupId, true);
setGroup(updatedGroup);

View File

@@ -297,7 +297,7 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
},
senderName: {
fontSize: 14,
color: colors.chat.textSecondary, // 使用主题次要文字颜色
color: textPrimary,
marginBottom: 4,
marginLeft: 4,
fontWeight: '600',

View File

@@ -104,7 +104,7 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
<EmptyState
title={emptyTitle}
description={emptyDesc}
icon={activeTab === 0 ? 'file-document-edit-outline' : 'bookmark-heart-outline'}
icon={activeTab === 0 ? 'file-document-edit-outline' : 'bookmark-outline'}
variant="modern"
/>
);

View File

@@ -301,6 +301,20 @@ class GroupService {
return normalizeGroup(response.data);
}
/**
* 设置群描述
* PUT /api/v1/groups/:id/description
*/
async setGroupDescription(groupId: string, description: string): Promise<GroupResponse> {
const response = await api.put<GroupResponse>(
`/groups/${encodeURIComponent(groupId)}/description`,
{
description,
}
);
return normalizeGroup(response.data);
}
// ==================== 群公告 ====================
/**