feat(ui): implement keyword highlighting and improve search experience
- 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:
@@ -474,6 +474,13 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getResponsiveMaxLength = () => {
|
const getResponsiveMaxLength = () => {
|
||||||
|
// 搜索高亮模式下展示更多内容
|
||||||
|
if (highlightKeyword) {
|
||||||
|
if (isWideScreen) return 500;
|
||||||
|
if (isDesktop) return 400;
|
||||||
|
if (isTablet) return 350;
|
||||||
|
return 220;
|
||||||
|
}
|
||||||
if (isWideScreen) return 300;
|
if (isWideScreen) return 300;
|
||||||
if (isDesktop) return 250;
|
if (isDesktop) return 250;
|
||||||
if (isTablet) return 200;
|
if (isTablet) return 200;
|
||||||
@@ -481,15 +488,53 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const contentNumberOfLines = useMemo(() => {
|
const contentNumberOfLines = useMemo(() => {
|
||||||
|
if (highlightKeyword) {
|
||||||
|
if (isWideScreen) return 12;
|
||||||
|
if (isDesktop) return 10;
|
||||||
|
if (isTablet) return 8;
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
if (isWideScreen) return 8;
|
if (isWideScreen) return 8;
|
||||||
if (isDesktop) return 6;
|
if (isDesktop) return 6;
|
||||||
if (isTablet) return 5;
|
if (isTablet) return 5;
|
||||||
return 3;
|
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 getTruncatedContent = (value: string): string => {
|
||||||
const maxLength = getResponsiveMaxLength();
|
const maxLength = getResponsiveMaxLength();
|
||||||
if (value.length <= maxLength || isExpanded) return value;
|
if (value.length <= maxLength || isExpanded) return value;
|
||||||
|
if (highlightKeyword) {
|
||||||
|
return getSearchExcerpt(value, highlightKeyword);
|
||||||
|
}
|
||||||
return `${value.substring(0, maxLength)}...`;
|
return `${value.substring(0, maxLength)}...`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -666,6 +711,8 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
|
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
|
||||||
style={styles.contentRenderer}
|
style={styles.contentRenderer}
|
||||||
textStyle={styles.content}
|
textStyle={styles.content}
|
||||||
|
highlightKeyword={highlightKeyword}
|
||||||
|
highlightStyle={styles.highlight}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
@@ -4,10 +4,11 @@
|
|||||||
* 降级:如果 segments 为空但 content 非空,直接显示纯文本
|
* 降级:如果 segments 为空但 content 非空,直接显示纯文本
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { View, TouchableOpacity, StyleSheet } from 'react-native';
|
import { View, TouchableOpacity, StyleSheet, TextStyle, StyleProp } from 'react-native';
|
||||||
|
|
||||||
import Text from '../common/Text';
|
import Text from '../common/Text';
|
||||||
|
import HighlightText from '../common/HighlightText';
|
||||||
import { useAppColors } from '../../theme';
|
import { useAppColors } from '../../theme';
|
||||||
import { MessageSegment, AtSegmentData, VoteSegmentData, PostRefSegmentData } from '../../types';
|
import { MessageSegment, AtSegmentData, VoteSegmentData, PostRefSegmentData } from '../../types';
|
||||||
import PostRefCard from './PostRefCard';
|
import PostRefCard from './PostRefCard';
|
||||||
@@ -20,6 +21,8 @@ interface PostContentRendererProps {
|
|||||||
onPostRefPress?: (postId: string) => void;
|
onPostRefPress?: (postId: string) => void;
|
||||||
style?: any;
|
style?: any;
|
||||||
textStyle?: any;
|
textStyle?: any;
|
||||||
|
highlightKeyword?: string;
|
||||||
|
highlightStyle?: StyleProp<TextStyle>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PostContentRenderer: React.FC<PostContentRendererProps> = ({
|
const PostContentRenderer: React.FC<PostContentRendererProps> = ({
|
||||||
@@ -30,13 +33,19 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
|
|||||||
onPostRefPress,
|
onPostRefPress,
|
||||||
style,
|
style,
|
||||||
textStyle,
|
textStyle,
|
||||||
|
highlightKeyword,
|
||||||
|
highlightStyle,
|
||||||
}) => {
|
}) => {
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
|
|
||||||
if (!segments || segments.length === 0) {
|
if (!segments || segments.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
|
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
|
||||||
{content || ''}
|
{highlightKeyword && content ? (
|
||||||
|
<HighlightText text={content} keyword={highlightKeyword} style={highlightStyle} />
|
||||||
|
) : (
|
||||||
|
content || ''
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -53,7 +62,11 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
|
|||||||
if (text) {
|
if (text) {
|
||||||
elements.push(
|
elements.push(
|
||||||
<Text key={key} selectable style={textStyle}>
|
<Text key={key} selectable style={textStyle}>
|
||||||
{text}
|
{highlightKeyword ? (
|
||||||
|
<HighlightText text={text} keyword={highlightKeyword} style={highlightStyle} />
|
||||||
|
) : (
|
||||||
|
text
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -126,7 +139,11 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
|
|||||||
if (elements.length === 0) {
|
if (elements.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
|
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
|
||||||
{content || ''}
|
{highlightKeyword && content ? (
|
||||||
|
<HighlightText text={content} keyword={highlightKeyword} style={highlightStyle} />
|
||||||
|
) : (
|
||||||
|
content || ''
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,22 +24,22 @@ function createSearchBarStyles(colors: AppColors, compact: boolean) {
|
|||||||
container: {
|
container: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: `${colors.primary.main}08`,
|
||||||
borderRadius: borderRadius.full,
|
borderRadius: borderRadius.full,
|
||||||
paddingHorizontal: compact ? spacing.sm : spacing.xs,
|
paddingHorizontal: compact ? spacing.sm : spacing.md,
|
||||||
height: compact ? 38 : 46,
|
height: compact ? 38 : 48,
|
||||||
borderWidth: compact ? 1 : 1,
|
borderWidth: 1.5,
|
||||||
borderColor: colors.divider,
|
borderColor: 'transparent',
|
||||||
},
|
},
|
||||||
containerFocused: {
|
containerFocused: {
|
||||||
borderColor: colors.primary.main,
|
borderColor: `${colors.primary.main}50`,
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: `${colors.primary.main}10`,
|
||||||
},
|
},
|
||||||
searchIconWrap: {
|
searchIconWrap: {
|
||||||
width: compact ? 22 : 30,
|
width: compact ? 22 : 32,
|
||||||
height: compact ? 22 : 30,
|
height: compact ? 22 : 32,
|
||||||
marginLeft: compact ? 2 : spacing.xs,
|
marginLeft: compact ? 2 : 0,
|
||||||
marginRight: compact ? 6 : spacing.xs,
|
marginRight: compact ? 6 : spacing.sm,
|
||||||
borderRadius: borderRadius.full,
|
borderRadius: borderRadius.full,
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -50,17 +50,18 @@ function createSearchBarStyles(colors: AppColors, compact: boolean) {
|
|||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: compact ? fontSizes.md : fontSizes.md,
|
fontSize: compact ? fontSizes.md : fontSizes.lg,
|
||||||
color: colors.text.primary,
|
color: colors.text.primary,
|
||||||
paddingVertical: compact ? 0 : spacing.sm + 1,
|
paddingVertical: compact ? 0 : spacing.sm,
|
||||||
paddingHorizontal: compact ? 2 : spacing.xs,
|
paddingHorizontal: compact ? 2 : 0,
|
||||||
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
clearButton: {
|
clearButton: {
|
||||||
width: 24,
|
width: 22,
|
||||||
height: 24,
|
height: 22,
|
||||||
marginHorizontal: spacing.xs,
|
marginLeft: spacing.xs,
|
||||||
borderRadius: borderRadius.full,
|
borderRadius: borderRadius.full,
|
||||||
backgroundColor: `${colors.text.secondary}14`,
|
backgroundColor: `${colors.text.secondary}20`,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
|||||||
post={post}
|
post={post}
|
||||||
onAction={(action) => handlePostAction(post, action)}
|
onAction={(action) => handlePostAction(post, action)}
|
||||||
variant="list"
|
variant="list"
|
||||||
features={isMobile ? 'compact' : 'full'}
|
features="full"
|
||||||
highlightKeyword={currentKeyword}
|
highlightKeyword={currentKeyword}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -288,7 +288,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
|||||||
post={item}
|
post={item}
|
||||||
onAction={(action) => handlePostAction(item, action)}
|
onAction={(action) => handlePostAction(item, action)}
|
||||||
variant="list"
|
variant="list"
|
||||||
features="compact"
|
features="full"
|
||||||
highlightKeyword={currentKeyword}
|
highlightKeyword={currentKeyword}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -542,11 +542,12 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
|||||||
activeIndex={activeIndex}
|
activeIndex={activeIndex}
|
||||||
onTabChange={(index) => {
|
onTabChange={(index) => {
|
||||||
setActiveIndex(index);
|
setActiveIndex(index);
|
||||||
// 如果已经搜索过,切换Tab时重新搜索
|
|
||||||
if (currentKeyword && hasSearched) {
|
if (currentKeyword && hasSearched) {
|
||||||
performSearch(currentKeyword);
|
performSearch(currentKeyword);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
variant="modern"
|
||||||
|
icons={['file-document-outline', 'account-outline']}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -571,22 +572,12 @@ function createSearchScreenStyles(colors: AppColors) {
|
|||||||
},
|
},
|
||||||
searchShell: {
|
searchShell: {
|
||||||
flex: 1,
|
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: {
|
cancelButton: {
|
||||||
backgroundColor: `${colors.primary.main}14`,
|
backgroundColor: `${colors.primary.main}12`,
|
||||||
borderRadius: borderRadius.full,
|
borderRadius: borderRadius.full,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: spacing.xs,
|
paddingVertical: spacing.sm,
|
||||||
},
|
},
|
||||||
cancelText: {
|
cancelText: {
|
||||||
fontSize: fontSizes.md,
|
fontSize: fontSizes.md,
|
||||||
@@ -610,7 +601,7 @@ function createSearchScreenStyles(colors: AppColors) {
|
|||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.sm,
|
||||||
},
|
},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
fontWeight: '600',
|
fontWeight: '700',
|
||||||
color: colors.text.primary,
|
color: colors.text.primary,
|
||||||
},
|
},
|
||||||
tagContainer: {
|
tagContainer: {
|
||||||
@@ -620,13 +611,14 @@ function createSearchScreenStyles(colors: AppColors) {
|
|||||||
tag: {
|
tag: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: colors.background.paper,
|
backgroundColor: `${colors.primary.main}10`,
|
||||||
borderRadius: borderRadius.lg,
|
borderRadius: borderRadius.full,
|
||||||
borderWidth: 1,
|
borderWidth: 0,
|
||||||
borderColor: colors.divider,
|
|
||||||
},
|
},
|
||||||
tagText: {
|
tagText: {
|
||||||
marginLeft: spacing.xs,
|
marginLeft: spacing.xs,
|
||||||
|
color: colors.primary.main,
|
||||||
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
userCard: {
|
userCard: {
|
||||||
backgroundColor: colors.background.paper,
|
backgroundColor: colors.background.paper,
|
||||||
|
|||||||
@@ -297,6 +297,11 @@ const GroupInfoScreen: React.FC = () => {
|
|||||||
await groupService.setGroupAvatar(groupId, editAvatar);
|
await groupService.setGroupAvatar(groupId, editAvatar);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果描述有变化,更新群描述
|
||||||
|
if (editDescription !== (group?.description || '')) {
|
||||||
|
await groupService.setGroupDescription(groupId, editDescription.trim());
|
||||||
|
}
|
||||||
|
|
||||||
// 重新获取群信息
|
// 重新获取群信息
|
||||||
const updatedGroup = await groupManager.getGroup(groupId, true);
|
const updatedGroup = await groupManager.getGroup(groupId, true);
|
||||||
setGroup(updatedGroup);
|
setGroup(updatedGroup);
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
|
|||||||
},
|
},
|
||||||
senderName: {
|
senderName: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: colors.chat.textSecondary, // 使用主题次要文字颜色
|
color: textPrimary,
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
marginLeft: 4,
|
marginLeft: 4,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
title={emptyTitle}
|
title={emptyTitle}
|
||||||
description={emptyDesc}
|
description={emptyDesc}
|
||||||
icon={activeTab === 0 ? 'file-document-edit-outline' : 'bookmark-heart-outline'}
|
icon={activeTab === 0 ? 'file-document-edit-outline' : 'bookmark-outline'}
|
||||||
variant="modern"
|
variant="modern"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -301,6 +301,20 @@ class GroupService {
|
|||||||
return normalizeGroup(response.data);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 群公告 ====================
|
// ==================== 群公告 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user