feat(content): add rich content rendering with @mentions and vote segments
- Add PostContentRenderer component for rendering posts/comments with segments - Add PostMentionInput component for @mention support in post/comment creation - Add VoteSegmentData and vote segment type for unified post creation API - Update post and comment services to support segments in API requests - Fix auth service to clear tokens before login/register - Improve database initialization with proper close/retry logic - Add verification check before allowing post creation - Add metro web shims for react-native-webrtc full package and requireNativeComponent - Update build config with git hash based version numbers
This commit is contained in:
@@ -1,10 +1,21 @@
|
||||
const appJson = require('./app.json');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const isDevVariant = process.env.APP_VARIANT === 'dev';
|
||||
const releaseApiBaseUrl = 'https://withyou.littlelan.cn/api/v1';
|
||||
const releaseUpdatesBaseUrl = 'https://updates.littlelan.cn';
|
||||
const devApiBaseUrl = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.31.238:8080/api/v1';
|
||||
|
||||
function getGitShortHash() {
|
||||
try {
|
||||
return execSync('git rev-parse --short=4 HEAD', { encoding: 'utf-8' }).trim();
|
||||
} catch {
|
||||
return '0000';
|
||||
}
|
||||
}
|
||||
|
||||
const gitBuildSuffix = getGitShortHash();
|
||||
|
||||
function toManifestUrl(baseUrl, portOverride) {
|
||||
const parsed = new URL(baseUrl);
|
||||
if (portOverride != null) {
|
||||
@@ -58,10 +69,14 @@ module.exports = {
|
||||
checkAutomatically: 'ON_LOAD',
|
||||
fallbackToCacheTimeout: 0,
|
||||
},
|
||||
ios: expo.ios,
|
||||
ios: {
|
||||
...expo.ios,
|
||||
buildNumber: gitBuildSuffix,
|
||||
},
|
||||
android: {
|
||||
...expo.android,
|
||||
package: 'skin.carrot.bbs',
|
||||
package: 'cn.qczlit.withyou',
|
||||
versionCode: parseInt(gitBuildSuffix, 16),
|
||||
},
|
||||
// Web 端使用过滤后的插件
|
||||
plugins: filteredPlugins,
|
||||
|
||||
4
app.json
4
app.json
@@ -1,8 +1,8 @@
|
||||
{
|
||||
{
|
||||
"expo": {
|
||||
"name": "威友",
|
||||
"slug": "qojo",
|
||||
"version": "0.1.sha",
|
||||
"version": "0.0.1",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
|
||||
@@ -29,13 +29,37 @@ const webShimPaths = [
|
||||
'react-native/Libraries/Utilities/codegenNativeComponent',
|
||||
'react-native/Libraries/Utilities/codegenNativeCommands',
|
||||
'react-native/Libraries/Types/CodegenTypes',
|
||||
'react-native/Libraries/ReactNative/requireNativeComponent',
|
||||
];
|
||||
|
||||
// Packages that should be entirely replaced with web shims on web platform.
|
||||
// These packages import requireNativeComponent directly from 'react-native'
|
||||
// (which resolves to react-native-web on web) and don't have web implementations.
|
||||
const webPackageShims = {
|
||||
'react-native-webrtc': path.resolve(__dirname, 'web-shims/react-native-webrtc/index.js'),
|
||||
};
|
||||
|
||||
const originalResolveRequest = config.resolver.resolveRequest;
|
||||
const shimDir = path.resolve(__dirname, 'web-shims');
|
||||
|
||||
config.resolver.resolveRequest = (context, moduleName, platform) => {
|
||||
// Redirect entire native-only packages to web shims
|
||||
if (platform === 'web' && webPackageShims[moduleName]) {
|
||||
return {
|
||||
type: 'sourceFile',
|
||||
filePath: webPackageShims[moduleName],
|
||||
};
|
||||
}
|
||||
|
||||
// Redirect specific React Native internal modules to web shims
|
||||
if (platform === 'web' && webShimPaths.some((p) => moduleName.endsWith(p))) {
|
||||
// For requireNativeComponent, the path doesn't follow the Libraries/* pattern
|
||||
if (moduleName.endsWith('react-native/Libraries/ReactNative/requireNativeComponent')) {
|
||||
return {
|
||||
type: 'sourceFile',
|
||||
filePath: path.join(shimDir, 'react-native', 'Libraries', 'ReactNative', 'requireNativeComponent.js'),
|
||||
};
|
||||
}
|
||||
const shimName = moduleName.replace('react-native/Libraries/', '');
|
||||
return {
|
||||
type: 'sourceFile',
|
||||
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -6,7 +6,7 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "with_you",
|
||||
"version": "0.1.sha",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.1.1",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "with_you",
|
||||
"version": "0.1.sha",
|
||||
"version": "0.0.1",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Comment, CommentImage } from '../../types';
|
||||
import Text from '../common/Text';
|
||||
import Avatar from '../common/Avatar';
|
||||
import { CompactImageGrid, ImageGridItem } from '../common';
|
||||
import PostContentRenderer from './PostContentRenderer';
|
||||
|
||||
interface CommentItemProps {
|
||||
comment: Comment;
|
||||
@@ -614,9 +615,17 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
|
||||
{/* 评论文本 - 非气泡样式 */}
|
||||
<View style={styles.commentContent}>
|
||||
<Text variant="body" color={colors.text.primary} style={styles.text}>
|
||||
{comment.content}
|
||||
</Text>
|
||||
{comment.segments && comment.segments.length > 0 ? (
|
||||
<PostContentRenderer
|
||||
content={comment.content}
|
||||
segments={comment.segments}
|
||||
textStyle={[styles.text, { color: colors.text.primary }]}
|
||||
/>
|
||||
) : (
|
||||
<Text variant="body" color={colors.text.primary} style={styles.text}>
|
||||
{comment.content}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 评论图片 */}
|
||||
|
||||
@@ -23,6 +23,7 @@ import Avatar from '../../common/Avatar';
|
||||
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../../theme';
|
||||
import { getPreviewImageUrl } from '../../../utils/imageHelper';
|
||||
import PostImages from './components/PostImages';
|
||||
import PostContentRenderer from '../PostContentRenderer';
|
||||
import { useResponsive } from '../../../hooks/useResponsive';
|
||||
import { formatPostCreatedAtString } from '../../../core/entities/Post';
|
||||
|
||||
@@ -208,6 +209,9 @@ function createPostCardStyles(colors: AppColors) {
|
||||
marginBottom: spacing.xs,
|
||||
lineHeight: fontSizes.md * 1.45,
|
||||
},
|
||||
contentRenderer: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
expandBtn: {
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
@@ -649,12 +653,22 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
|
||||
{!isCompact && !!content && (
|
||||
<>
|
||||
<Text
|
||||
style={styles.content}
|
||||
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
|
||||
>
|
||||
{highlightKeyword ? <HighlightText text={content} keyword={highlightKeyword} style={styles.highlight} /> : content}
|
||||
</Text>
|
||||
{post.segments && post.segments.length > 0 ? (
|
||||
<PostContentRenderer
|
||||
content={content}
|
||||
segments={post.segments}
|
||||
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
|
||||
style={styles.contentRenderer}
|
||||
textStyle={styles.content}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
style={styles.content}
|
||||
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
|
||||
>
|
||||
{highlightKeyword ? <HighlightText text={content} keyword={highlightKeyword} style={styles.highlight} /> : content}
|
||||
</Text>
|
||||
)}
|
||||
{shouldTruncate && (
|
||||
<TouchableOpacity onPress={() => setIsExpanded((prev) => !prev)} style={styles.expandBtn}>
|
||||
<Text style={styles.expandText}>{isExpanded ? '收起' : '展开全文'}</Text>
|
||||
|
||||
153
src/components/business/PostContentRenderer.tsx
Normal file
153
src/components/business/PostContentRenderer.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* PostContentRenderer - 帖子内容渲染器
|
||||
* 根据 segments 渲染富文本内容(@提及、投票等)
|
||||
* 降级:如果 segments 为空但 content 非空,直接显示纯文本
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
|
||||
import Text from '../common/Text';
|
||||
import { useAppColors } from '../../theme';
|
||||
import { MessageSegment, AtSegmentData, VoteSegmentData } from '../../types';
|
||||
|
||||
interface PostContentRendererProps {
|
||||
content?: string;
|
||||
segments?: MessageSegment[];
|
||||
numberOfLines?: number;
|
||||
onMentionPress?: (userId: string) => void;
|
||||
style?: any;
|
||||
textStyle?: any;
|
||||
}
|
||||
|
||||
const PostContentRenderer: React.FC<PostContentRendererProps> = ({
|
||||
content,
|
||||
segments,
|
||||
numberOfLines,
|
||||
onMentionPress,
|
||||
style,
|
||||
textStyle,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
|
||||
if (!segments || segments.length === 0) {
|
||||
return (
|
||||
<Text numberOfLines={numberOfLines} style={textStyle}>
|
||||
{content || ''}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const elements: React.ReactNode[] = [];
|
||||
let keyIndex = 0;
|
||||
|
||||
for (const segment of segments) {
|
||||
const key = `seg_${keyIndex++}`;
|
||||
|
||||
switch (segment.type) {
|
||||
case 'text': {
|
||||
const text = segment.data?.text || segment.data?.content || '';
|
||||
if (text) {
|
||||
elements.push(
|
||||
<Text key={key} style={textStyle}>
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'at': {
|
||||
const atData = segment.data as AtSegmentData;
|
||||
const userId = atData?.user_id;
|
||||
const isAtAll = userId === 'all';
|
||||
elements.push(
|
||||
<Text
|
||||
key={key}
|
||||
style={[
|
||||
textStyle,
|
||||
{
|
||||
color: colors.primary.main,
|
||||
fontWeight: '500',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{isAtAll ? '@所有人 ' : '@某人 '}
|
||||
</Text>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'vote': {
|
||||
const voteData = segment.data as VoteSegmentData;
|
||||
if (voteData?.options?.length) {
|
||||
elements.push(
|
||||
<View key={key} style={[styles.voteBlock, { borderColor: colors.divider }]}>
|
||||
<Text style={[styles.voteTitle, { color: colors.text.secondary }]}>
|
||||
📊 投票({voteData.options.length} 个选项)
|
||||
</Text>
|
||||
{voteData.options.map((opt, i) => (
|
||||
<Text key={`vote_opt_${i}`} style={[styles.voteOption, { color: colors.text.primary }]}>
|
||||
{i + 1}. {opt.content}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'image':
|
||||
case 'link':
|
||||
case 'face':
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (elements.length === 0) {
|
||||
return (
|
||||
<Text numberOfLines={numberOfLines} style={textStyle}>
|
||||
{content || ''}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (numberOfLines) {
|
||||
return (
|
||||
<View style={style}>
|
||||
<Text numberOfLines={numberOfLines} style={textStyle}>
|
||||
{elements}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return <View style={[styles.container, style]}>{elements}</View>;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
voteBlock: {
|
||||
width: '100%',
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
marginTop: 4,
|
||||
marginBottom: 4,
|
||||
},
|
||||
voteTitle: {
|
||||
fontSize: 13,
|
||||
marginBottom: 6,
|
||||
},
|
||||
voteOption: {
|
||||
fontSize: 14,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
});
|
||||
|
||||
export default PostContentRenderer;
|
||||
269
src/components/business/PostMentionInput.tsx
Normal file
269
src/components/business/PostMentionInput.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* PostMentionInput - 帖子内容输入框(支持 @提及)
|
||||
* 复用关注列表加载逻辑
|
||||
* 检测输入中的 @ 字符,弹出关注者列表供选择
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
TextInput,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
|
||||
import Text from '../common/Text';
|
||||
import Avatar from '../common/Avatar';
|
||||
import { useAppColors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { useAuthStore } from '../../stores';
|
||||
import { authService } from '../../services/auth';
|
||||
import { MessageSegment, AtSegmentData } from '../../types';
|
||||
|
||||
interface MentionUser {
|
||||
id: string;
|
||||
nickname: string;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
interface PostMentionInputProps {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
onSegmentsChange: (segments: MessageSegment[]) => void;
|
||||
placeholder?: string;
|
||||
maxLength?: number;
|
||||
style?: any;
|
||||
minHeight?: number;
|
||||
}
|
||||
|
||||
const PostMentionInput: React.FC<PostMentionInputProps> = ({
|
||||
value,
|
||||
onChangeText,
|
||||
onSegmentsChange,
|
||||
placeholder = '说点什么...',
|
||||
maxLength = 2000,
|
||||
style,
|
||||
minHeight = 100,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const currentUser = useAuthStore(s => s.currentUser);
|
||||
const [followingUsers, setFollowingUsers] = useState<MentionUser[]>([]);
|
||||
const [showMentions, setShowMentions] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState('');
|
||||
const [mentionStartIndex, setMentionStartIndex] = useState(-1);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded && currentUser?.id) {
|
||||
loadFollowing();
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [currentUser?.id, loaded]);
|
||||
|
||||
const loadFollowing = async () => {
|
||||
if (!currentUser?.id) return;
|
||||
try {
|
||||
const list = await authService.getFollowingList(currentUser.id, 1, 200);
|
||||
setFollowingUsers(
|
||||
list.map(u => ({
|
||||
id: u.id,
|
||||
nickname: u.nickname || u.username || '',
|
||||
avatar: u.avatar || null,
|
||||
}))
|
||||
);
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = followingUsers.filter(u =>
|
||||
u.nickname.toLowerCase().includes(mentionQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const activeMentions = useRef(new Map<number, { endIndex: number; userId: string; nickname: string }>());
|
||||
const segmentsRef = useRef<MessageSegment[]>([]);
|
||||
|
||||
const rebuildSegments = useCallback(
|
||||
(text: string) => {
|
||||
const mentions = activeMentions.current;
|
||||
if (mentions.size === 0) {
|
||||
onSegmentsChange(text.trim() ? [{ type: 'text', data: { text } }] : []);
|
||||
return;
|
||||
}
|
||||
|
||||
const segments: MessageSegment[] = [];
|
||||
const sorted = Array.from(mentions.entries()).sort((a, b) => a[0] - b[0]);
|
||||
let lastEnd = 0;
|
||||
|
||||
for (const [startIdx, mention] of sorted) {
|
||||
if (startIdx > lastEnd) {
|
||||
segments.push({ type: 'text', data: { text: text.slice(lastEnd, startIdx) } });
|
||||
}
|
||||
segments.push({
|
||||
type: 'at',
|
||||
data: { user_id: mention.userId, nickname: mention.nickname } as AtSegmentData,
|
||||
});
|
||||
lastEnd = mention.endIndex;
|
||||
}
|
||||
|
||||
if (lastEnd < text.length) {
|
||||
segments.push({ type: 'text', data: { text: text.slice(lastEnd) } });
|
||||
}
|
||||
|
||||
onSegmentsChange(segments);
|
||||
},
|
||||
[onSegmentsChange]
|
||||
);
|
||||
|
||||
const handleChangeText = useCallback(
|
||||
(text: string) => {
|
||||
onChangeText(text);
|
||||
|
||||
const cursorPos = text.length;
|
||||
let atStart = -1;
|
||||
|
||||
for (let i = cursorPos - 1; i >= 0; i--) {
|
||||
if (text[i] === '@') {
|
||||
atStart = i;
|
||||
break;
|
||||
}
|
||||
if (text[i] === ' ' || text[i] === '\n') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (atStart >= 0) {
|
||||
const query = text.slice(atStart + 1, cursorPos);
|
||||
if (!query.includes(' ') && !query.includes('\n')) {
|
||||
setMentionQuery(query);
|
||||
setMentionStartIndex(atStart);
|
||||
setShowMentions(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setShowMentions(false);
|
||||
rebuildSegments(text);
|
||||
},
|
||||
[onChangeText, rebuildSegments]
|
||||
);
|
||||
|
||||
const handleSelectMention = useCallback(
|
||||
(mentionUser: MentionUser) => {
|
||||
if (mentionStartIndex < 0) return;
|
||||
|
||||
const before = value.slice(0, mentionStartIndex);
|
||||
const mentionText = `@${mentionUser.nickname} `;
|
||||
const newText = before + mentionText;
|
||||
|
||||
activeMentions.current.set(mentionStartIndex, {
|
||||
endIndex: before.length + mentionText.length,
|
||||
userId: mentionUser.id,
|
||||
nickname: mentionUser.nickname,
|
||||
});
|
||||
|
||||
onChangeText(newText);
|
||||
setShowMentions(false);
|
||||
setMentionQuery('');
|
||||
setMentionStartIndex(-1);
|
||||
|
||||
rebuildSegments(newText);
|
||||
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
[value, mentionStartIndex, onChangeText, rebuildSegments]
|
||||
);
|
||||
|
||||
const renderMentionItem = ({ item }: { item: MentionUser }) => (
|
||||
<TouchableOpacity
|
||||
style={[styles.mentionItem, { borderBottomColor: colors.divider }]}
|
||||
onPress={() => handleSelectMention(item)}
|
||||
>
|
||||
<Avatar source={item.avatar} size={32} name={item.nickname} />
|
||||
<Text style={[styles.mentionName, { color: colors.text.primary }]}>
|
||||
{item.nickname}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={style}>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChangeText={handleChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.text.hint}
|
||||
maxLength={maxLength}
|
||||
multiline
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
color: colors.text.primary,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{showMentions && filteredUsers.length > 0 && (
|
||||
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
|
||||
<FlatList
|
||||
data={filteredUsers}
|
||||
renderItem={renderMentionItem}
|
||||
keyExtractor={item => item.id}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
nestedScrollEnabled
|
||||
style={styles.mentionList}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showMentions && filteredUsers.length === 0 && (
|
||||
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
|
||||
<Text style={[styles.emptyText, { color: colors.text.hint }]}>
|
||||
没有找到匹配的关注用户
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
input: {
|
||||
fontSize: fontSizes.md,
|
||||
padding: spacing.sm,
|
||||
borderRadius: borderRadius.md,
|
||||
minHeight: 100,
|
||||
textAlignVertical: 'top' as const,
|
||||
},
|
||||
mentionPanel: {
|
||||
borderWidth: 1,
|
||||
borderRadius: borderRadius.md,
|
||||
maxHeight: 200,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
mentionList: {
|
||||
maxHeight: 200,
|
||||
},
|
||||
mentionItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
mentionName: {
|
||||
fontSize: fontSizes.md,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: fontSizes.sm,
|
||||
padding: spacing.md,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default PostMentionInput;
|
||||
@@ -22,5 +22,7 @@ export { default as TabBar } from './TabBar';
|
||||
export { default as VoteCard } from './VoteCard';
|
||||
export { default as VoteEditor } from './VoteEditor';
|
||||
export { default as VotePreview } from './VotePreview';
|
||||
export { default as PostContentRenderer } from './PostContentRenderer';
|
||||
export { default as PostMentionInput } from './PostMentionInput';
|
||||
export { default as ReportDialog } from './ReportDialog';
|
||||
export { default as ShareSheet } from './ShareSheet';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
import { Platform } from 'react-native';
|
||||
import { getDbName } from './DatabaseConfig';
|
||||
import { runMigrations } from '../schema/MigrationManager';
|
||||
|
||||
@@ -43,34 +42,30 @@ class DatabaseManager {
|
||||
|
||||
private async doInitialize(targetUserId: string | null): Promise<void> {
|
||||
if (this.db) {
|
||||
this.forceClose(this.db);
|
||||
await this.closeAsync(this.db);
|
||||
this.db = null;
|
||||
this.currentUserId = null;
|
||||
}
|
||||
|
||||
const dbName = getDbName(targetUserId);
|
||||
const isWeb = Platform.OS === 'web';
|
||||
|
||||
if (isWeb) {
|
||||
try {
|
||||
const db = await this.tryOpenDatabase(dbName);
|
||||
await runMigrations(db);
|
||||
this.db = db;
|
||||
this.currentUserId = targetUserId;
|
||||
} catch (error) {
|
||||
console.warn('[DatabaseManager] 数据库初始化失败,使用内存数据库:', error);
|
||||
try {
|
||||
const db = await this.tryOpenDatabase(dbName);
|
||||
await runMigrations(db);
|
||||
this.db = db;
|
||||
this.currentUserId = targetUserId;
|
||||
} catch (error) {
|
||||
console.warn('[DatabaseManager] Web数据库打开失败,使用内存数据库:', error);
|
||||
const db = await SQLite.openDatabaseAsync(':memory:');
|
||||
await runMigrations(db);
|
||||
this.db = db;
|
||||
this.currentUserId = targetUserId;
|
||||
} catch (fallbackError) {
|
||||
console.error('[DatabaseManager] 内存数据库也无法初始化:', fallbackError);
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const db = await this.tryOpenDatabase(dbName);
|
||||
await runMigrations(db);
|
||||
this.db = db;
|
||||
this.currentUserId = targetUserId;
|
||||
}
|
||||
|
||||
private async tryOpenDatabase(dbName: string): Promise<SQLiteDatabase> {
|
||||
@@ -87,12 +82,12 @@ class DatabaseManager {
|
||||
}
|
||||
}
|
||||
|
||||
private forceClose(db: SQLiteDatabase): void {
|
||||
private async closeAsync(db: SQLiteDatabase): Promise<void> {
|
||||
try {
|
||||
db.closeSync();
|
||||
await db.closeAsync();
|
||||
} catch {
|
||||
try {
|
||||
db.closeAsync().catch(() => {});
|
||||
db.closeSync();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
@@ -103,7 +98,7 @@ class DatabaseManager {
|
||||
}
|
||||
|
||||
if (this.db) {
|
||||
this.forceClose(this.db);
|
||||
await this.closeAsync(this.db);
|
||||
this.db = null;
|
||||
this.currentUserId = null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* 发帖页 CreatePostScreen(响应式适配)
|
||||
* 威友 - 发布新帖子
|
||||
* 参考微博发帖界面设计
|
||||
@@ -38,8 +38,10 @@ import { channelService, postService, showPrompt, voteService } from '../../serv
|
||||
import { ApiError } from '@/services/core';
|
||||
import { uploadService } from '@/services/upload';
|
||||
import VoteEditor from '../../components/business/VoteEditor';
|
||||
import PostMentionInput from '../../components/business/PostMentionInput';
|
||||
import { useResponsive, useResponsiveValue } from '../../hooks';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { MessageSegment } from '../../types';
|
||||
|
||||
// Props 接口
|
||||
interface CreatePostScreenProps {
|
||||
@@ -106,6 +108,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [segments, setSegments] = useState<MessageSegment[]>([]);
|
||||
const [images, setImages] = useState<{ uri: string; uploading?: boolean }[]>([]);
|
||||
const [channelOptions, setChannelOptions] = useState<ChannelOption[]>([]);
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null);
|
||||
@@ -402,6 +405,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
const updated = await postService.updatePost(editPostID, {
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
segments: segments.length > 0 ? segments : undefined,
|
||||
images: imageUrls,
|
||||
});
|
||||
if (!updated) {
|
||||
@@ -415,10 +419,10 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
});
|
||||
router.replace(hrefs.hrefHome());
|
||||
} else {
|
||||
// 创建普通帖子
|
||||
await postService.createPost({
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
segments: segments.length > 0 ? segments : undefined,
|
||||
images: imageUrls,
|
||||
channel_id: selectedChannelId || undefined,
|
||||
});
|
||||
@@ -598,22 +602,18 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
||||
maxLength={MAX_TITLE_LENGTH}
|
||||
/>
|
||||
|
||||
{/* 正文输入 */}
|
||||
<TextInput
|
||||
ref={contentInputRef}
|
||||
{/* 正文输入(支持 @提及) */}
|
||||
<PostMentionInput
|
||||
value={content}
|
||||
onChangeText={setContent}
|
||||
onSegmentsChange={setSegments}
|
||||
placeholder="分享新鲜事..."
|
||||
maxLength={MAX_CONTENT_LENGTH}
|
||||
minHeight={contentInputMinHeight}
|
||||
style={[
|
||||
styles.contentInput,
|
||||
isWideScreen && styles.contentInputWide,
|
||||
{ minHeight: contentInputMinHeight },
|
||||
]}
|
||||
value={content}
|
||||
onChangeText={setContent}
|
||||
placeholder="分享新鲜事..."
|
||||
placeholderTextColor={colors.text.hint}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
maxLength={MAX_CONTENT_LENGTH}
|
||||
onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
|
||||
/>
|
||||
|
||||
{/* 作为正文容器的子模块,追加在文本后面 */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* 首页 HomeScreen
|
||||
* 威友 - 首页展示
|
||||
* 支持列表和多列网格模式(响应式布局)
|
||||
@@ -27,7 +27,7 @@ import { PagerView } from '../../components/common';
|
||||
import { useAppColors, useResolvedColorScheme, spacing, borderRadius, shadows, type AppColors } from '../../theme';
|
||||
import { Post } from '../../types';
|
||||
import { useUserStore, useHomeTabBarVisibilityStore, useHomeTabPressStore } from '../../stores';
|
||||
import { useCurrentUser } from '../../stores/auth';
|
||||
import { useCurrentUser, useIsAuthenticated, useIsVerified, useVerificationStore } from '../../stores/auth';
|
||||
import { channelService, postService } from '../../services';
|
||||
import { PostCard, TabBar, SearchBar, ShareSheet } from '../../components/business';
|
||||
import type { PostCardAction } from '../../components/business/PostCard';
|
||||
@@ -169,6 +169,10 @@ export const HomeScreen: React.FC = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { posts: storePosts } = useUserStore();
|
||||
const currentUser = useCurrentUser();
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const isVerified = useIsVerified();
|
||||
const fetchVerificationStatus = useVerificationStore((s) => s.fetchStatus);
|
||||
const hasFetchedVerification = useVerificationStore((s) => s.hasFetchedStatus);
|
||||
const colors = useAppColors();
|
||||
const resolvedScheme = useResolvedColorScheme();
|
||||
const styles = useMemo(() => createHomeStyles(colors), [colors]);
|
||||
@@ -207,6 +211,12 @@ export const HomeScreen: React.FC = () => {
|
||||
const [showShareSheet, setShowShareSheet] = useState(false);
|
||||
const [sharePost, setSharePost] = useState<Post | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && !hasFetchedVerification) {
|
||||
fetchVerificationStatus();
|
||||
}
|
||||
}, [isAuthenticated, hasFetchedVerification, fetchVerificationStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showCreatePost) {
|
||||
blurActiveElement();
|
||||
@@ -656,6 +666,14 @@ export const HomeScreen: React.FC = () => {
|
||||
|
||||
// 跳转到发帖页面(使用 Modal 方式)
|
||||
const handleCreatePost = () => {
|
||||
if (!isAuthenticated) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
if (!isVerified) {
|
||||
router.push(hrefs.hrefVerificationGuide());
|
||||
return;
|
||||
}
|
||||
setShowCreatePost(true);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* 帖子详情页 PostDetailScreen - QQ频道风格(响应式版本)
|
||||
* 威友 - 显示完整帖子内容和评论
|
||||
* 桌面端使用双栏布局,移动端使用单栏布局
|
||||
@@ -33,13 +33,13 @@ import {
|
||||
useAppColors,
|
||||
type AppColors,
|
||||
} from '../../theme';
|
||||
import { Post, Comment, VoteResultDTO, VoteOptionDTO } from '../../types';
|
||||
import { Post, Comment, VoteResultDTO, VoteOptionDTO, MessageSegment } from '../../types';
|
||||
import { useUserStore } from '../../stores';
|
||||
import { useCurrentUser } from '../../stores/auth';
|
||||
import { postService, commentService, uploadService, authService, showPrompt, voteService } from '../../services';
|
||||
import { postSyncService } from '@/services/post';
|
||||
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||||
import { CommentItem, VoteCard, ReportDialog, ShareSheet } from '../../components/business';
|
||||
import { CommentItem, VoteCard, ReportDialog, ShareSheet, PostContentRenderer, PostMentionInput } from '../../components/business';
|
||||
import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common';
|
||||
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive';
|
||||
import { handleError } from '@/services/core';
|
||||
@@ -191,6 +191,7 @@ export const PostDetailScreen: React.FC = () => {
|
||||
}
|
||||
}, [commentsError, postId]);
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const [commentSegments, setCommentSegments] = useState<MessageSegment[]>([]);
|
||||
const [showImageModal, setShowImageModal] = useState(false);
|
||||
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
||||
const [allImages, setAllImages] = useState<ImageGridItem[]>([]);
|
||||
@@ -741,6 +742,7 @@ export const PostDetailScreen: React.FC = () => {
|
||||
root_id: rootId,
|
||||
target_id: parentId,
|
||||
content: commentText.trim(),
|
||||
segments: commentSegments.length > 0 ? commentSegments : undefined,
|
||||
images: uploadedImageUrls.map(url => ({ url })),
|
||||
likes_count: 0,
|
||||
replies_count: 0,
|
||||
@@ -798,6 +800,7 @@ export const PostDetailScreen: React.FC = () => {
|
||||
const createdComment = await commentService.createComment({
|
||||
postId: post.id,
|
||||
content: commentText.trim(),
|
||||
segments: commentSegments.length > 0 ? commentSegments : undefined,
|
||||
parentId: parentId || undefined,
|
||||
images: uploadedImageUrls,
|
||||
});
|
||||
@@ -1097,18 +1100,17 @@ export const PostDetailScreen: React.FC = () => {
|
||||
|
||||
{/* 内容 - 增大字体 */}
|
||||
<View style={styles.contentContainer}>
|
||||
<Text
|
||||
variant="body"
|
||||
style={[
|
||||
<PostContentRenderer
|
||||
content={post.content}
|
||||
segments={post.segments}
|
||||
textStyle={[
|
||||
styles.content,
|
||||
{
|
||||
fontSize: isDesktop ? fontSizes.xl : isTablet ? fontSizes.lg : fontSizes.lg,
|
||||
lineHeight: isDesktop ? 32 : isTablet ? 30 : 28
|
||||
}
|
||||
]}
|
||||
>
|
||||
{post.content}
|
||||
</Text>
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 图片 - 详情页显示所有图片,不限制数量 */}
|
||||
@@ -1442,15 +1444,14 @@ export const PostDetailScreen: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Text Input */}
|
||||
<TextInput
|
||||
style={styles.expandedTextInput}
|
||||
placeholder="来说点什么吧!"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
<PostMentionInput
|
||||
value={commentText}
|
||||
onChangeText={setCommentText}
|
||||
multiline
|
||||
autoFocus
|
||||
onSegmentsChange={setCommentSegments}
|
||||
placeholder="来说点什么吧!"
|
||||
maxLength={500}
|
||||
minHeight={40}
|
||||
style={styles.expandedTextInput}
|
||||
/>
|
||||
|
||||
{/* Image preview */}
|
||||
|
||||
@@ -149,6 +149,9 @@ class AuthService {
|
||||
|
||||
// 用户登录(只管 API + Token,DB 操作交给 authStore)
|
||||
async login(data: LoginRequest): Promise<AuthResponse> {
|
||||
await api.clearToken();
|
||||
api.resetAuthState();
|
||||
|
||||
const response = await api.post<AuthResponse>('/auth/login', data);
|
||||
|
||||
if (!response.data) {
|
||||
@@ -161,13 +164,15 @@ class AuthService {
|
||||
if (refreshToken) {
|
||||
await api.setRefreshToken(refreshToken);
|
||||
}
|
||||
api.resetAuthState();
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 用户注册(只管 API + Token,DB 操作交给 authStore)
|
||||
async register(data: RegisterRequest): Promise<AuthResponse> {
|
||||
await api.clearToken();
|
||||
api.resetAuthState();
|
||||
|
||||
const response = await api.post<AuthResponse>('/auth/register', data);
|
||||
|
||||
if (!response.data) {
|
||||
@@ -180,7 +185,6 @@ class AuthService {
|
||||
if (refreshToken) {
|
||||
await api.setRefreshToken(refreshToken);
|
||||
}
|
||||
api.resetAuthState();
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { api, PaginatedData } from '../core/api';
|
||||
import { Comment, CreateCommentInput } from '@/types';
|
||||
import { Comment, CreateCommentInput, MessageSegment } from '@/types';
|
||||
import { CursorPaginationRequest, CursorPaginationResponse, CommentDTO } from '@/types/dto';
|
||||
|
||||
// 评论列表响应
|
||||
@@ -23,8 +23,9 @@ type CommentResponse = Comment;
|
||||
interface CreateCommentRequest {
|
||||
postId: string;
|
||||
content: string;
|
||||
segments?: MessageSegment[];
|
||||
parentId?: string;
|
||||
images?: string[]; // 图片URL列表
|
||||
images?: string[];
|
||||
replyToUserId?: string;
|
||||
}
|
||||
|
||||
@@ -130,12 +131,14 @@ class CommentService {
|
||||
content: data.content,
|
||||
};
|
||||
|
||||
// 如果有父评论ID,才添加 parent_id 字段
|
||||
if (data.segments && data.segments.length > 0) {
|
||||
requestBody.segments = data.segments;
|
||||
}
|
||||
|
||||
if (data.parentId) {
|
||||
requestBody.parent_id = data.parentId;
|
||||
}
|
||||
|
||||
// 如果有图片,添加 images 字段
|
||||
if (data.images && data.images.length > 0) {
|
||||
requestBody.images = data.images;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { api, PaginatedData } from '../core/api';
|
||||
import { Post, CreatePostInput } from '@/types';
|
||||
import { CursorPaginationRequest, CursorPaginationResponse } from '@/types/dto';
|
||||
import { CursorPaginationRequest, CursorPaginationResponse, MessageSegment } from '@/types/dto';
|
||||
|
||||
// 帖子列表响应
|
||||
interface PostListResponse {
|
||||
@@ -22,7 +22,8 @@ type PostResponse = Post;
|
||||
// 创建帖子请求
|
||||
interface CreatePostRequest {
|
||||
title: string;
|
||||
content: string;
|
||||
content?: string;
|
||||
segments?: MessageSegment[];
|
||||
images?: string[];
|
||||
channel_id?: string;
|
||||
}
|
||||
@@ -31,6 +32,7 @@ interface CreatePostRequest {
|
||||
interface UpdatePostRequest {
|
||||
title?: string;
|
||||
content?: string;
|
||||
segments?: MessageSegment[];
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { api } from '../core/api';
|
||||
import { Post, VoteResultDTO, CreateVotePostRequest } from '@/types';
|
||||
import { Post, VoteResultDTO, CreateVotePostRequest, MessageSegment } from '@/types';
|
||||
|
||||
// 投票响应
|
||||
interface VoteResponse {
|
||||
@@ -23,12 +23,9 @@ interface UpdateVoteOptionResponse {
|
||||
*/
|
||||
class VoteService {
|
||||
/**
|
||||
* 创建投票帖子
|
||||
* @param data 创建投票帖子请求数据
|
||||
* @returns 创建的帖子或null
|
||||
* 创建投票帖子(通过统一的 POST /posts 接口,投票选项作为 vote segment)
|
||||
*/
|
||||
async createVotePost(data: CreateVotePostRequest): Promise<Post | null> {
|
||||
// 验证投票选项数量
|
||||
if (!data.vote_options || data.vote_options.length < 2) {
|
||||
console.error('[VoteService] 投票选项至少需要2个');
|
||||
return null;
|
||||
@@ -38,7 +35,30 @@ class VoteService {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await api.post<Post>('/posts/vote', data);
|
||||
// 构建 segments:先放文本内容,再放投票 segment
|
||||
const segments: MessageSegment[] = [];
|
||||
if (data.content) {
|
||||
segments.push({ type: 'text', data: { text: data.content } });
|
||||
}
|
||||
segments.push({
|
||||
type: 'vote',
|
||||
data: {
|
||||
options: data.vote_options.map((opt, i) => ({
|
||||
id: `opt_${i + 1}`,
|
||||
content: typeof opt === 'string' ? opt : opt.content,
|
||||
})),
|
||||
max_choices: 1,
|
||||
is_multi_choice: false,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await api.post<Post>('/posts', {
|
||||
title: data.title,
|
||||
content: data.content || '',
|
||||
segments,
|
||||
images: data.images,
|
||||
channel_id: data.channel_id,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -141,8 +141,21 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
|
||||
const userId = String(user.id);
|
||||
|
||||
// 2. 初始化用户专属数据库
|
||||
await switchDatabase(userId);
|
||||
// 2. 关闭旧数据库连接后再初始化用户专属数据库
|
||||
try {
|
||||
await closeDatabase();
|
||||
} catch {}
|
||||
try {
|
||||
await switchDatabase(userId);
|
||||
} catch (dbErr) {
|
||||
console.warn('[AuthStore] switchDatabase 失败,将重试:', dbErr);
|
||||
try {
|
||||
await closeDatabase();
|
||||
await switchDatabase(userId);
|
||||
} catch (retryErr) {
|
||||
console.error('[AuthStore] switchDatabase 重试仍失败:', retryErr);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 持久化 userId(供下次冷启动提前初始化 DB 用)
|
||||
await saveUserId(userId);
|
||||
@@ -160,8 +173,10 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
error: null,
|
||||
});
|
||||
|
||||
// 6. 启动 SSE
|
||||
await startRealtime();
|
||||
// 6. 启动 SSE(不阻塞登录结果)
|
||||
startRealtime().catch((err) => {
|
||||
console.warn('[AuthStore] 启动实时服务失败:', err);
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
@@ -187,7 +202,21 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
|
||||
const userId = String(user.id);
|
||||
|
||||
await switchDatabase(userId);
|
||||
try {
|
||||
await closeDatabase();
|
||||
} catch {}
|
||||
try {
|
||||
await switchDatabase(userId);
|
||||
} catch (dbErr) {
|
||||
console.warn('[AuthStore] register switchDatabase 失败,将重试:', dbErr);
|
||||
try {
|
||||
await closeDatabase();
|
||||
await switchDatabase(userId);
|
||||
} catch (retryErr) {
|
||||
console.error('[AuthStore] register switchDatabase 重试仍失败:', retryErr);
|
||||
}
|
||||
}
|
||||
|
||||
await saveUserId(userId);
|
||||
await cacheUser(user);
|
||||
|
||||
@@ -201,7 +230,10 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
error: null,
|
||||
});
|
||||
|
||||
await startRealtime();
|
||||
// 启动 SSE(不阻塞注册结果)
|
||||
startRealtime().catch((err) => {
|
||||
console.warn('[AuthStore] 启动实时服务失败:', err);
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
@@ -275,8 +307,14 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
// 4. 确保 DB 已初始化(如果预初始化失败,这里会重试)
|
||||
const dbReady = isDbInitialized() && getCurrentDbUserId() === userId;
|
||||
if (!dbReady) {
|
||||
await switchDatabase(userId);
|
||||
|
||||
try {
|
||||
await closeDatabase();
|
||||
} catch {}
|
||||
try {
|
||||
await switchDatabase(userId);
|
||||
} catch (dbErr) {
|
||||
console.warn('[AuthStore] fetchCurrentUser switchDatabase 失败:', dbErr);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 更新持久化的 userId
|
||||
@@ -296,8 +334,10 @@ await switchDatabase(userId);
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
// 8. 启动 SSE
|
||||
await startRealtime();
|
||||
// 8. 启动 SSE(不阻塞状态设置)
|
||||
startRealtime().catch((err) => {
|
||||
console.warn('[AuthStore] 冷启动实时服务失败:', err);
|
||||
});
|
||||
} else {
|
||||
// Token 已失效或不存在
|
||||
await clearUserId();
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface PostDTO {
|
||||
user_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
segments?: MessageSegment[];
|
||||
images: PostImageDTO[];
|
||||
status?: string;
|
||||
likes_count: number;
|
||||
@@ -111,6 +112,7 @@ export interface CommentDTO {
|
||||
parent_id: string | null;
|
||||
root_id: string | null;
|
||||
content: string;
|
||||
segments?: MessageSegment[];
|
||||
images: CommentImageDTO[];
|
||||
likes_count: number;
|
||||
replies_count: number;
|
||||
@@ -148,7 +150,7 @@ export type MessageStatus = 'normal' | 'recalled' | 'deleted';
|
||||
// ==================== Message Segment Types ====================
|
||||
|
||||
// Segment类型
|
||||
export type SegmentType = 'text' | 'image' | 'voice' | 'video' | 'file' | 'at' | 'reply' | 'face' | 'link';
|
||||
export type SegmentType = 'text' | 'image' | 'voice' | 'video' | 'file' | 'at' | 'reply' | 'face' | 'link' | 'vote';
|
||||
|
||||
// 文本Segment数据
|
||||
export interface TextSegmentData {
|
||||
@@ -217,6 +219,19 @@ export interface LinkSegmentData {
|
||||
image?: string;
|
||||
}
|
||||
|
||||
// 投票选项数据
|
||||
export interface VoteOptionData {
|
||||
id: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
// 投票Segment数据
|
||||
export interface VoteSegmentData {
|
||||
options: VoteOptionData[];
|
||||
max_choices?: number;
|
||||
is_multi_choice?: boolean;
|
||||
}
|
||||
|
||||
// MessageSegment 联合类型 - 使用 discriminated union
|
||||
export type MessageSegment =
|
||||
| { type: 'text'; data: TextSegmentData }
|
||||
@@ -227,7 +242,8 @@ export type MessageSegment =
|
||||
| { type: 'at'; data: AtSegmentData }
|
||||
| { type: 'reply'; data: ReplySegmentData }
|
||||
| { type: 'face'; data: FaceSegmentData }
|
||||
| { type: 'link'; data: LinkSegmentData };
|
||||
| { type: 'link'; data: LinkSegmentData }
|
||||
| { type: 'vote'; data: VoteSegmentData };
|
||||
|
||||
// 消息响应
|
||||
export interface MessageResponse {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// ============================================
|
||||
// ============================================
|
||||
// WithYou - Core Type Definitions
|
||||
// 使用后端DTO类型,保持snake_case命名规范
|
||||
// ============================================
|
||||
@@ -37,43 +37,14 @@ import type {
|
||||
VoiceSegmentData,
|
||||
VideoSegmentData,
|
||||
FileSegmentData,
|
||||
AtSegmentData,
|
||||
AtSegmentData,
|
||||
ReplySegmentData,
|
||||
FaceSegmentData,
|
||||
LinkSegmentData,
|
||||
VoteSegmentData,
|
||||
VoteOptionData,
|
||||
} from './dto';
|
||||
|
||||
// 重新导出新的聊天系统类型
|
||||
export type {
|
||||
ConversationType,
|
||||
MessageContentType,
|
||||
MessageStatus,
|
||||
MessageResponse,
|
||||
ConversationResponse,
|
||||
ConversationParticipantResponse,
|
||||
ConversationDetailResponse,
|
||||
CreateConversationRequest,
|
||||
SendMessageRequest,
|
||||
MarkReadRequest,
|
||||
UnreadCountResponse,
|
||||
ConversationUnreadCountResponse,
|
||||
ConversationListResponse,
|
||||
MessageListResponse,
|
||||
MessageSyncResponse,
|
||||
// Segment 类型
|
||||
SegmentType,
|
||||
MessageSegment,
|
||||
TextSegmentData,
|
||||
ImageSegmentData,
|
||||
VoiceSegmentData,
|
||||
VideoSegmentData,
|
||||
FileSegmentData,
|
||||
AtSegmentData,
|
||||
ReplySegmentData,
|
||||
FaceSegmentData,
|
||||
LinkSegmentData,
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// User Types
|
||||
// ============================================
|
||||
@@ -101,8 +72,9 @@ export type { VoteOptionDTO as VoteOption, VoteResultDTO as VoteResult, CreateVo
|
||||
|
||||
export interface CreatePostInput {
|
||||
title: string;
|
||||
content: string;
|
||||
images?: string[]; // 本地图片路径或 base64
|
||||
content?: string;
|
||||
segments?: MessageSegment[];
|
||||
images?: string[];
|
||||
channel_id?: string;
|
||||
}
|
||||
|
||||
@@ -122,6 +94,7 @@ export interface Comment {
|
||||
parent_id: string | null;
|
||||
root_id?: string | null;
|
||||
content: string;
|
||||
segments?: MessageSegment[];
|
||||
images?: CommentImage[];
|
||||
likes_count: number;
|
||||
replies_count: number;
|
||||
|
||||
94
web-shims/react-native-webrtc/index.js
vendored
Normal file
94
web-shims/react-native-webrtc/index.js
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
const React = require('react');
|
||||
|
||||
const RTCView = React.forwardRef((props, ref) => {
|
||||
const { streamURL, mirror, objectFit, style, ...rest } = props;
|
||||
const src = streamURL || (props.stream && props.stream.toURL ? props.stream.toURL() : '');
|
||||
|
||||
if (src) {
|
||||
return React.createElement('video', {
|
||||
...rest,
|
||||
ref,
|
||||
src,
|
||||
autoPlay: true,
|
||||
playsInline: true,
|
||||
muted: props.muted || false,
|
||||
style: {
|
||||
...style,
|
||||
transform: mirror ? 'scaleX(-1)' : undefined,
|
||||
objectFit: objectFit || 'cover',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
});
|
||||
}
|
||||
return React.createElement('div', { ...rest, ref, style });
|
||||
});
|
||||
RTCView.displayName = 'RTCView';
|
||||
|
||||
const ScreenCapturePickerView = React.forwardRef((props, ref) => {
|
||||
return React.createElement('div', { ...props, ref, 'data-native-component': 'ScreenCapturePickerView' });
|
||||
});
|
||||
ScreenCapturePickerView.displayName = 'ScreenCapturePickerView';
|
||||
|
||||
const mediaDevices = typeof navigator !== 'undefined' && navigator.mediaDevices
|
||||
? navigator.mediaDevices
|
||||
: {
|
||||
getUserMedia: () => Promise.reject(new Error('mediaDevices not available')),
|
||||
getDisplayMedia: () => Promise.reject(new Error('mediaDevices not available')),
|
||||
enumerateDevices: () => Promise.resolve([]),
|
||||
};
|
||||
|
||||
const RTCPeerConnection = typeof window !== 'undefined' ? window.RTCPeerConnection : function () {};
|
||||
const RTCSessionDescription = typeof window !== 'undefined' ? window.RTCSessionDescription : function () {};
|
||||
const RTCIceCandidate = typeof window !== 'undefined' ? window.RTCIceCandidate : function () {};
|
||||
const MediaStream = typeof window !== 'undefined' ? window.MediaStream : function () {};
|
||||
const MediaStreamTrack = typeof window !== 'undefined' ? window.MediaStreamTrack : function () {};
|
||||
|
||||
function registerGlobals() {}
|
||||
|
||||
const permissions = {
|
||||
request: () => Promise.resolve(true),
|
||||
check: () => Promise.resolve(true),
|
||||
};
|
||||
|
||||
const RTCAudioSession = {
|
||||
setCategory: () => {},
|
||||
setActive: () => {},
|
||||
setMode: () => {},
|
||||
};
|
||||
|
||||
const RTCRtpReceiver = typeof window !== 'undefined' ? window.RTCRtpReceiver : function () {};
|
||||
const RTCRtpSender = typeof window !== 'undefined' ? window.RTCRtpSender : function () {};
|
||||
const RTCRtpTransceiver = typeof window !== 'undefined' ? window.RTCRtpTransceiver : function () {};
|
||||
const RTCErrorEvent = typeof window !== 'undefined' ? window.RTCErrorEvent : function () {};
|
||||
const MediaStreamTrackEvent = typeof window !== 'undefined' ? window.MediaStreamTrackEvent : function () {};
|
||||
|
||||
const RTCPIPView = React.forwardRef((props, ref) => {
|
||||
return React.createElement('div', { ...props, ref, 'data-native-component': 'RTCPIPView' });
|
||||
});
|
||||
RTCPIPView.displayName = 'RTCPIPView';
|
||||
|
||||
function startIOSPIP() {}
|
||||
function stopIOSPIP() {}
|
||||
|
||||
module.exports = {
|
||||
RTCView,
|
||||
ScreenCapturePickerView,
|
||||
RTCPeerConnection,
|
||||
RTCSessionDescription,
|
||||
RTCIceCandidate,
|
||||
MediaStream,
|
||||
MediaStreamTrack,
|
||||
MediaStreamTrackEvent,
|
||||
mediaDevices,
|
||||
permissions,
|
||||
registerGlobals,
|
||||
RTCAudioSession,
|
||||
RTCRtpReceiver,
|
||||
RTCRtpSender,
|
||||
RTCRtpTransceiver,
|
||||
RTCErrorEvent,
|
||||
RTCPIPView,
|
||||
startIOSPIP,
|
||||
stopIOSPIP,
|
||||
};
|
||||
18
web-shims/react-native/Libraries/ReactNative/requireNativeComponent.js
vendored
Normal file
18
web-shims/react-native/Libraries/ReactNative/requireNativeComponent.js
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
const React = require('react');
|
||||
|
||||
function requireNativeComponent(componentName) {
|
||||
const componentNameClean = componentName
|
||||
.replace(/^RNC/, '')
|
||||
.replace(/^RNS/, '')
|
||||
.replace(/^RN/, '');
|
||||
|
||||
const NativeComponent = React.forwardRef((props, ref) => {
|
||||
const { children, ...rest } = props;
|
||||
return React.createElement('div', { ...rest, ref, 'data-native-component': componentNameClean }, children);
|
||||
});
|
||||
NativeComponent.displayName = componentName;
|
||||
|
||||
return NativeComponent;
|
||||
}
|
||||
|
||||
module.exports = requireNativeComponent;
|
||||
Reference in New Issue
Block a user