20260626221809

This commit is contained in:
oneao committed 2026-06-26 22:18:09 +08:00
1 parent 5983d29128
commit a45accd2de
12 files changed
+306 -92

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
# 2026-06-26
## chat/index.tsx 乱码修复
- 文件中所有中文字符(注释、placeholder、UI 文案)因编码问题出现 mojibake(乱码)
- 文件是 UTF-8 with BOM(EF BB BF),BOM 可能导致了编码识别问题
- 修复了所有 14 处乱码字符串,同时清除了文件头的 BOM
- 修复内容:在线栏、消息气泡、未知、来自、撤回、删除、页面、成员映射、加载历史、加载更多、合并消息(旧→新)、加载中...、说点什么...、暂无消息,开始聊天吧
## chat/index.tsx 输入框获焦滚动优化
- 将 `keyboardWillShow` 改为 `keyboardDidShow`,确保键盘动画完成后再滚动
- 添加 `InteractionManager.runAfterInteractions` 用于无延迟的滚动调用
- 为 FlatList 添加 `maintainVisibleContentPosition={{ minIndexForVisible: 0, autoscrollToTopThreshold: 10 }}`,让 FlatList 在 viewport 变化时(键盘弹起/收起)自动保持可见内容位置
+4 -3
View File
@@ -2,6 +2,7 @@ package ws
import ( import (
"allapp-go/internal/httpx" "allapp-go/internal/httpx"
"allapp-go/internal/middleware"
"allapp-go/pkg/db" "allapp-go/pkg/db"
"allapp-go/pkg/jwtx" "allapp-go/pkg/jwtx"
"allapp-go/pkg/uniqueid" "allapp-go/pkg/uniqueid"
@@ -195,7 +196,7 @@ func (c *Client) readPump() {
// handleSend 处理发送消息 // handleSend 处理发送消息
func (c *Client) handleSend(msg clientSendMsg) { func (c *Client) handleSend(msg clientSendMsg) {
ctx := context.Background() ctx := context.WithValue(context.Background(), middleware.CtxUserIDKey, c.userID)
dbClient := db.New() dbClient := db.New()
msgID := uniqueid.NextId() msgID := uniqueid.NextId()
@@ -293,7 +294,7 @@ func (c *Client) handleSend(msg clientSendMsg) {
// handleRecall 处理撤回(发送者 2 分钟内) // handleRecall 处理撤回(发送者 2 分钟内)
func (c *Client) handleRecall(msg clientSendMsg) { func (c *Client) handleRecall(msg clientSendMsg) {
ctx := context.Background() ctx := context.WithValue(context.Background(), middleware.CtxUserIDKey, c.userID)
dbClient := db.New() dbClient := db.New()
rows, err := dbClient.LoadData(ctx, "b_chat", rows, err := dbClient.LoadData(ctx, "b_chat",
@@ -337,7 +338,7 @@ func (c *Client) handleDelete(msg clientSendMsg) {
return return
} }
ctx := context.Background() ctx := context.WithValue(context.Background(), middleware.CtxUserIDKey, c.userID)
dbClient := db.New() dbClient := db.New()
if err := dbClient.Update(ctx, "b_chat", "id", map[string]any{ if err := dbClient.Update(ctx, "b_chat", "id", map[string]any{
+2 -2
View File
@@ -19,7 +19,7 @@
}, },
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {
"backgroundColor": "#E6F4FE", "backgroundColor": "#F0FDF4",
"foregroundImage": "./assets/images/android-icon-foreground.png", "foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png", "backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png" "monochromeImage": "./assets/images/android-icon-monochrome.png"
@@ -38,7 +38,7 @@
[ [
"expo-splash-screen", "expo-splash-screen",
{ {
"backgroundColor": "#208AEF", "backgroundColor": "#15803D",
"android": { "android": {
"image": "./assets/images/splash-icon.png", "image": "./assets/images/splash-icon.png",
"imageWidth": 76 "imageWidth": 76
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useRef, useState, memo } from "react"; import { useCallback, useEffect, useRef, useState, memo, useMemo } from "react";
import { Image, Text, TextInput, View, Keyboard } from "react-native"; import { Image, Text, TextInput, View, Keyboard, Animated } from "react-native";
import { KeyboardAvoidingView } from "react-native-keyboard-controller"; import { KeyboardAvoidingView } from "react-native-keyboard-controller";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { Menu, PressableFeedback, Spinner } from "heroui-native"; import { Menu, PressableFeedback, Spinner } from "heroui-native";
import { SendHorizonalIcon, SearchIcon } from "lucide-react-native"; import { AlertCircleIcon, PaperclipIcon, PlayIcon, SearchIcon, SendHorizonalIcon } from "lucide-react-native";
import { toast } from "sonner-native";
import LayoutView from "@/components/layout/view"; import LayoutView from "@/components/layout/view";
import EmptyState from "@/components/EmptyStatus"; import EmptyState from "@/components/EmptyStatus";
import { loadDataApi, loadDataPageApi } from "@/request/api"; import { loadDataApi, loadDataPageApi } from "@/request/api";
@@ -27,20 +28,20 @@ function ChatOnlineBar({ users, onSearch }: { users: OnlineUser[]; onSearch: ()
const router = useRouter(); const router = useRouter();
return ( return (
<View className="flex-row items-center justify-between px-4 h-12 bg-white/90 rounded-2xl shadow-sm"> <View className="flex-row items-center justify-between px-4 h-12 bg-white/90 rounded-2xl shadow-sm">
<View className="flex-row items-center flex-1"> <View className="flex-row items-center flex-1 gap-2">
{users.slice(0, 6).map((u, i) => ( {users.slice(0, 6).map((u) => (
<PressableFeedback <PressableFeedback
key={u.user_id} key={u.user_id}
onPress={() => router.navigate(`/family/member/${u.user_id}` as any)} onPress={() => router.navigate(`/family/member/${u.user_id}` as any)}
> >
<View className={cn(i > 0 && "-ml-2")}> <View>
{u.avatar ? ( {u.avatar ? (
<Image <Image
source={{ uri: buildFileUrl(u.avatar) }} source={{ uri: buildFileUrl(u.avatar) }}
className="w-8 h-8 rounded-full bg-gray-200 border-2 border-white" className="w-8 h-8 rounded-full bg-gray-200"
/> />
) : ( ) : (
<View className="w-8 h-8 rounded-full bg-accent/20 items-center justify-center border-2 border-white"> <View className="w-8 h-8 rounded-full bg-accent/20 items-center justify-center">
<Text className="text-xs font-medium text-accent">{u.nickname?.charAt(0) || "?"}</Text> <Text className="text-xs font-medium text-accent">{u.nickname?.charAt(0) || "?"}</Text>
</View> </View>
)} )}
@@ -50,7 +51,7 @@ function ChatOnlineBar({ users, onSearch }: { users: OnlineUser[]; onSearch: ()
</PressableFeedback> </PressableFeedback>
))} ))}
{users.length > 6 && ( {users.length > 6 && (
<View className="-ml-2 w-8 h-8 rounded-full bg-gray-100 items-center justify-center border-2 border-white"> <View className="w-8 h-8 rounded-full bg-gray-100 items-center justify-center">
<Text className="text-[10px] font-medium text-gray-500">+{users.length - 6}</Text> <Text className="text-[10px] font-medium text-gray-500">+{users.length - 6}</Text>
</View> </View>
)} )}
@@ -133,7 +134,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
isSelf ? "bg-accent rounded-br-md" : "bg-white rounded-bl-md shadow-sm", isSelf ? "bg-accent rounded-br-md" : "bg-white rounded-bl-md shadow-sm",
)} )}
> >
<Text className={cn("text-[15px] leading-[22px]", isSelf ? "text-white text-right" : "text-gray-900 text-left")}> <Text className={cn("text-[15px] leading-[22px] text-left", isSelf ? "text-white" : "text-gray-900")}>
{item.content} {item.content}
</Text> </Text>
</View> </View>
@@ -155,7 +156,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
{item.msg_type === ChatMsgType.Video && item.attachments?.length && ( {item.msg_type === ChatMsgType.Video && item.attachments?.length && (
<View className="w-48 h-32 rounded-xl bg-gray-800/90 items-center justify-center overflow-hidden"> <View className="w-48 h-32 rounded-xl bg-gray-800/90 items-center justify-center overflow-hidden">
<View className="w-10 h-10 rounded-full bg-white/20 items-center justify-center"> <View className="w-10 h-10 rounded-full bg-white/20 items-center justify-center">
<Text className="text-white text-lg ml-0.5">▶</Text> <PlayIcon size={20} color="white" fill="white" />
</View> </View>
{item.attachments[0].duration ? ( {item.attachments[0].duration ? (
<Text className="text-white/80 text-[10px] absolute bottom-2 right-2"> <Text className="text-white/80 text-[10px] absolute bottom-2 right-2">
@@ -173,16 +174,16 @@ const ChatMessageItem = memo(function ChatMessageItem({
)} )}
> >
<View className={cn("w-9 h-9 rounded-lg items-center justify-center", isSelf ? "bg-white/20" : "bg-gray-100")}> <View className={cn("w-9 h-9 rounded-lg items-center justify-center", isSelf ? "bg-white/20" : "bg-gray-100")}>
<Text className={cn("text-sm", isSelf ? "text-white" : "text-gray-500")}>📎</Text> <PaperclipIcon size={17} color={isSelf ? "white" : "#6B7280"} />
</View> </View>
<View className="flex-1"> <View className="flex-1">
<Text <Text
className={cn("text-sm font-medium", isSelf ? "text-white" : "text-gray-900")} className={cn("text-sm font-medium text-left", isSelf ? "text-white" : "text-gray-900")}
numberOfLines={1} numberOfLines={1}
> >
{item.attachments[0].file_name} {item.attachments[0].file_name}
</Text> </Text>
<Text className={cn("text-[11px]", isSelf ? "text-white/70" : "text-gray-400")}> <Text className={cn("text-[11px] text-left", isSelf ? "text-white/70" : "text-gray-400")}>
{item.attachments[0].file_size {item.attachments[0].file_size
? (item.attachments[0].file_size / 1024).toFixed(0) + " KB" ? (item.attachments[0].file_size / 1024).toFixed(0) + " KB"
: ""} : ""}
@@ -195,7 +196,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
<View className="max-w-[60%] rounded-xl overflow-hidden bg-white shadow-sm border border-gray-100"> <View className="max-w-[60%] rounded-xl overflow-hidden bg-white shadow-sm border border-gray-100">
<View className="px-3 py-2"> <View className="px-3 py-2">
<Text className="text-[11px] text-gray-400">来自 {item.source_table}</Text> <Text className="text-[11px] text-gray-400">来自 {item.source_table}</Text>
<Text className="text-sm text-gray-700 mt-1" numberOfLines={3}> <Text className="text-sm text-gray-700 mt-1 text-left" numberOfLines={3}>
{(() => { {(() => {
try { try {
const d = JSON.parse(item.source_data); const d = JSON.parse(item.source_data);
@@ -212,7 +213,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
</View> </View>
{item.content ? ( {item.content ? (
<View className="px-3 pb-2 border-t border-gray-50"> <View className="px-3 pb-2 border-t border-gray-50">
<Text className="text-sm text-gray-900">{item.content}</Text> <Text className="text-sm text-gray-900 text-left">{item.content}</Text>
</View> </View>
) : null} ) : null}
</View> </View>
@@ -274,15 +275,142 @@ export default function ChatPage() {
const [inputText, setInputText] = useState(""); const [inputText, setInputText] = useState("");
const [adminRole, setAdminRole] = useState(0); const [adminRole, setAdminRole] = useState(0);
const listRef = useRef<any>(null); const listRef = useRef<any>(null);
const opacity = useRef(new Animated.Value(0)).current;
const revealed = useRef(false);
// 键盘弹起时自动滚到底部 // ---- optimistic send ----
useEffect(() => { type PendingMsg = { localId: string; content: string; status: "sending" | "failed"; createTime: string };
const show = Keyboard.addListener("keyboardWillShow", () => { const [pending, setPending] = useState<PendingMsg[]>([]);
setTimeout(() => listRef.current?.scrollToEnd?.({ animated: true }), 100); const pendingTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
});
return () => show.remove(); const removePending = useCallback((localId: string) => {
setPending((prev) => prev.filter((p) => p.localId !== localId));
const t = pendingTimers.current[localId];
if (t) { clearTimeout(t); delete pendingTimers.current[localId]; }
}, []); }, []);
// Watch ws.messages — when a new message from self appears, resolve matching pending
const resolvedRef = useRef<Set<string>>(new Set());
useEffect(() => {
for (const m of ws.messages) {
if (m.user_id !== userId) continue;
const key = `${m.user_id}:${m.content}`;
if (resolvedRef.current.has(key)) continue;
resolvedRef.current.add(key);
const match = pending.find((p) => p.content === m.content && p.status === "sending");
if (match) {
removePending(match.localId);
toast.success("发送成功", { id: match.localId });
}
}
}, [ws.messages, userId, pending, removePending]);
// ---- toast: online / offline ----
const onlineIdsRef = useRef<Set<number>>(new Set());
useEffect(() => {
const curr = new Set(ws.onlineUsers.map((u) => u.user_id));
const prev = onlineIdsRef.current;
// joined
for (const id of curr) {
if (!prev.has(id) && prev.size > 0) {
const u = ws.onlineUsers.find((x) => x.user_id === id);
if (u && u.user_id !== userId) toast(`${u.nickname} 上线了`);
}
}
// left
for (const id of prev) {
if (!curr.has(id)) {
// look up nickname from members (offline event only gives user_id)
const m = members[id];
const name = m?.nickname || `用户${id}`;
toast(`${name} 下线了`);
}
}
onlineIdsRef.current = curr;
}, [ws.onlineUsers, userId, members]);
// ---- toast: new message from others ----
const seenMsgIds = useRef<Set<number>>(new Set());
// Sync history IDs into seen set so we don't toast on initial load
useEffect(() => {
for (const m of history) seenMsgIds.current.add(m.id);
}, [history]);
// Watch ws.messages for new arrivals from others
useEffect(() => {
if (loading) return; // skip during initial load
for (const m of ws.messages) {
if (seenMsgIds.current.has(m.id)) continue;
seenMsgIds.current.add(m.id);
if (m.user_id !== userId) {
const name = m.nickname || members[m.user_id]?.nickname || "用户";
const preview = m.content.length > 20 ? m.content.slice(0, 20) + "…" : m.content;
toast(`${name}: ${preview}`);
}
}
}, [ws.messages, userId, members, loading]);
// Merge pending messages into the display list
const allMessages = useMemo(() => {
const ids = new Set(history.map((m) => m.id));
const merged: (ChatMessage & { _pending?: PendingMsg })[] = [...history];
for (const m of ws.messages) {
if (!ids.has(m.id)) { merged.push(m); ids.add(m.id); }
}
// Append pending (sending/failed) at the end
for (const p of pending) {
merged.push({
id: -(Date.now() % 100000), // negative ID for local-only items
family_id: familyId ?? 0,
user_id: userId,
nickname: "",
avatar: "",
content: p.content,
msg_type: ChatMsgType.Text,
source_table: null,
source_id: null,
source_data: null,
attachments: null,
recalled: 0,
deleted: 0,
create_time: p.createTime,
_pending: p,
} as any);
}
return merged;
}, [history, ws.messages, pending, familyId, userId]);
// Non-inverted: scrollToEnd = latest messages
const scrollToBottom = useCallback(
(animated = true) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
listRef.current?.scrollToEnd?.({ animated });
});
});
},
[],
);
const revealAtBottom = useCallback(() => {
requestAnimationFrame(() => {
listRef.current?.scrollToEnd?.({ animated: false });
requestAnimationFrame(() => {
listRef.current?.scrollToEnd?.({ animated: false });
opacity.setValue(1);
});
});
}, [opacity]);
// Keyboard appears → double-tap scroll: first to catch the new viewport,
// second as a safety net after FlatList internal measurements settle.
useEffect(() => {
const sub = Keyboard.addListener("keyboardDidShow", () => {
setTimeout(() => scrollToBottom(true), 200);
setTimeout(() => scrollToBottom(true), 400);
});
return () => sub.remove();
}, [scrollToBottom]);
// 成员映射 // 成员映射
useEffect(() => { useEffect(() => {
if (!familyId) return; if (!familyId) return;
@@ -364,27 +492,54 @@ export default function ChatPage() {
}); });
}, [loadingMore, hasMore, page, loadHistory, runLoadMore]); }, [loadingMore, hasMore, page, loadHistory, runLoadMore]);
// 合并消息 (旧→新) // Send the current message draft (optimistic).
const allMessages = (() => { function handleSend() {
const ids = new Set(history.map((m) => m.id));
const merged = [...history];
for (const m of ws.messages) {
if (!ids.has(m.id)) {
merged.push(m);
ids.add(m.id);
}
}
return merged;
})();
// 发送
const handleSend = useCallback(() => {
const text = inputText.trim(); const text = inputText.trim();
if (!text) return; if (!text) return;
setInputText(""); setInputText("");
Keyboard.dismiss(); Keyboard.dismiss();
const localId = `${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
const now = new Date().toISOString();
// Show immediately in the list
setPending((prev) => [...prev, { localId, content: text, status: "sending", createTime: now }]);
toast.loading("正在发送", { id: localId });
// Send via WebSocket
ws.send({ type: "send", content: text }); ws.send({ type: "send", content: text });
}, [inputText, ws]);
// 10-second timeout → mark as failed
pendingTimers.current[localId] = setTimeout(() => {
setPending((prev) => prev.map((p) => (p.localId === localId ? { ...p, status: "failed" } : p)));
toast.error("发送失败", { id: localId });
}, 10_000);
}
// Retry a failed message
function handleRetry(p: PendingMsg) {
setPending((prev) => prev.map((x) => (x.localId === p.localId ? { ...x, status: "sending" } : x)));
toast.loading("正在发送", { id: p.localId });
ws.send({ type: "send", content: p.content });
pendingTimers.current[p.localId] = setTimeout(() => {
setPending((prev) => prev.map((x) => (x.localId === p.localId ? { ...x, status: "failed" } : x)));
toast.error("发送失败", { id: p.localId });
}, 10_000);
}
// Auto-scroll when a new message arrives (WebSocket push)
const msgCountRef = useRef(0);
useEffect(() => {
if (msgCountRef.current === 0 && allMessages.length > 0) {
// Initial data just arrived — seed count, don't scroll
msgCountRef.current = allMessages.length;
return;
}
if (allMessages.length > msgCountRef.current) {
scrollToBottom(true);
}
msgCountRef.current = allMessages.length;
}, [allMessages.length, scrollToBottom]);
if (loading) { if (loading) {
return ( return (
@@ -400,9 +555,10 @@ export default function ChatPage() {
behavior="padding" behavior="padding"
keyboardVerticalOffset={-60} keyboardVerticalOffset={-60}
> >
<LayoutView> <Animated.View style={{ flex: 1, opacity }}>
<LayoutView.FlatList <LayoutView>
ref={listRef} <LayoutView.FlatList
ref={listRef}
header={ header={
<ChatOnlineBar <ChatOnlineBar
users={ws.onlineUsers} users={ws.onlineUsers}
@@ -410,24 +566,32 @@ export default function ChatPage() {
/> />
} }
footer={ footer={
<View className="flex-row items-center gap-2 px-3 py-2.5 bg-white/95 rounded-2xl shadow-sm"> <View className="flex-row items-center gap-2 px-2.5 py-1.5 bg-white/95 rounded-2xl shadow-sm">
<TextInput <View className="flex-1 min-h-9 max-h-20 rounded-xl bg-background px-3 justify-center">
className="flex-1 h-10 bg-gray-100 rounded-full px-4 text-[15px]" <TextInput
placeholder="输入消息..." className="min-h-8 max-h-20 text-[15px] leading-[20px] text-foreground"
placeholderTextColor="#9CA3AF" placeholder="说点什么..."
value={inputText} placeholderTextColor="#86A19C"
onChangeText={setInputText} value={inputText}
onSubmitEditing={handleSend} onChangeText={setInputText}
returnKeyType="send" onFocus={() => scrollToBottom(true)}
/> onSubmitEditing={handleSend}
returnKeyType="send"
multiline
maxLength={800}
textAlignVertical="center"
/>
</View>
<PressableFeedback onPress={handleSend} isDisabled={!inputText.trim()}> <PressableFeedback onPress={handleSend} isDisabled={!inputText.trim()}>
<View <View
className={cn( className={cn(
"w-10 h-10 rounded-full items-center justify-center", "w-9 h-9 rounded-full items-center justify-center",
inputText.trim() ? "bg-accent" : "bg-gray-200", inputText.trim()
? "bg-accent shadow-sm"
: "bg-background",
)} )}
> >
<SendHorizonalIcon size={18} color={inputText.trim() ? "white" : "#9CA3AF"} /> <SendHorizonalIcon size={17} color={inputText.trim() ? "white" : "#86A19C"} />
</View> </View>
<PressableFeedback.Ripple /> <PressableFeedback.Ripple />
</PressableFeedback> </PressableFeedback>
@@ -435,6 +599,7 @@ export default function ChatPage() {
} }
data={allMessages} data={allMessages}
keyExtractor={(item) => String(item.id)} keyExtractor={(item) => String(item.id)}
contentContainerStyle={{ paddingBottom: 12 }}
onScroll={({ nativeEvent }) => { onScroll={({ nativeEvent }) => {
if (nativeEvent.contentOffset.y < 30 && hasMore && !loadingMore) { if (nativeEvent.contentOffset.y < 30 && hasMore && !loadingMore) {
handleLoadMore(); handleLoadMore();
@@ -442,8 +607,12 @@ export default function ChatPage() {
}} }}
scrollEventThrottle={200} scrollEventThrottle={200}
onContentSizeChange={() => { onContentSizeChange={() => {
if (loadingMore) return; if (!revealed.current) {
listRef.current?.scrollToEnd({ animated: false }); revealed.current = true;
revealAtBottom();
return;
}
if (!loadingMore) scrollToBottom(false);
}} }}
ListHeaderComponent={ ListHeaderComponent={
loadingMore ? ( loadingMore ? (
@@ -457,18 +626,45 @@ export default function ChatPage() {
<Text className="text-gray-400 text-sm">暂无消息,开始聊天吧</Text> <Text className="text-gray-400 text-sm">暂无消息,开始聊天吧</Text>
</View> </View>
} }
renderItem={({ item }) => ( renderItem={({ item }: { item: any }) => {
<ChatMessageItem const pending = item._pending as PendingMsg | undefined;
item={item} if (pending) {
isSelf={item.user_id === userId} return (
members={members} <View className="flex-row items-center px-3 py-1.5 justify-end">
isAdmin={adminRole === 0} {pending.status === "sending" ? (
onRecall={ws.recall} <View className="mr-1">
onDelete={ws.deleteMessage} <Spinner size="sm" color="#9CA3AF" />
/> </View>
)} ) : (
<PressableFeedback onPress={() => handleRetry(pending)}>
<View className="mr-1 p-1">
<AlertCircleIcon size={16} color="#EF4444" />
</View>
<PressableFeedback.Ripple />
</PressableFeedback>
)}
<View className="max-w-[60%] rounded-2xl rounded-br-md px-3.5 py-2 bg-accent">
<Text className="text-[15px] leading-[22px] text-white">
{pending.content}
</Text>
</View>
</View>
);
}
return (
<ChatMessageItem
item={item}
isSelf={item.user_id === userId}
members={members}
isAdmin={adminRole === 0}
onRecall={ws.recall}
onDelete={ws.deleteMessage}
/>
);
}}
/> />
</LayoutView> </LayoutView>
</Animated.View>
</KeyboardAvoidingView> </KeyboardAvoidingView>
); );
} }
@@ -1,5 +1,5 @@
import LayoutView from "@/components/layout/view"; import LayoutView from "@/components/layout/view";
import { SearchField, CloseButton, PressableFeedback } from "heroui-native"; import { SearchField, CloseButton, PressableFeedback, useThemeColor } from "heroui-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { View, Text } from "react-native"; import { View, Text } from "react-native";
import { Stack, useRouter } from "expo-router"; import { Stack, useRouter } from "expo-router";
@@ -14,6 +14,7 @@ import { Heart } from "lucide-react-native";
export default function Tools() { export default function Tools() {
const router = useRouter(); const router = useRouter();
const safeAreaInsets = useSafeAreaInsets(); const safeAreaInsets = useSafeAreaInsets();
const accentColor = useThemeColor("accent");
const scrollRef = useRef<any>(null); const scrollRef = useRef<any>(null);
const headerPositions = useRef<Record<string, number>>({}); const headerPositions = useRef<Record<string, number>>({});
@@ -120,7 +121,7 @@ export default function Tools() {
{/* 收藏分组特殊标记 */} {/* 收藏分组特殊标记 */}
{item.title === "收藏" && ( {item.title === "收藏" && (
<View className="w-5 h-5 rounded-full bg-accent/15 items-center justify-center mr-1.5"> <View className="w-5 h-5 rounded-full bg-accent/15 items-center justify-center mr-1.5">
<Heart size={10} fill="#0d9488" color="#0d9488" /> <Heart size={10} fill={accentColor} color={accentColor} />
</View> </View>
)} )}
{/* 普通分组标记 */} {/* 普通分组标记 */}
@@ -175,8 +176,8 @@ export default function Tools() {
> >
<Heart <Heart
size={18} size={18}
fill={isFav ? "#0d9488" : "none"} fill={isFav ? accentColor : "none"}
color={isFav ? "#0d9488" : "#9ca3af"} color={isFav ? accentColor : "#9ca3af"}
/> />
<PressableFeedback.Ripple /> <PressableFeedback.Ripple />
</PressableFeedback> </PressableFeedback>
+3 -2
View File
@@ -1,6 +1,6 @@
import DateTimePicker from "react-native-dates-picker"; import DateTimePicker from "react-native-dates-picker";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { BottomSheet, Button, Spinner } from "heroui-native"; import { BottomSheet, Button, Spinner, useThemeColor } from "heroui-native";
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { CheckIcon } from "lucide-react-native"; import { CheckIcon } from "lucide-react-native";
@@ -49,6 +49,7 @@ export default function DateTime({
format, format,
}: DatePickerProps) { }: DatePickerProps) {
const finalFormat = format ?? defaultFormatMap[mode]; const finalFormat = format ?? defaultFormatMap[mode];
const accentColor = useThemeColor("accent");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [tempDate, setTempDate] = useState<dayjs.Dayjs | undefined>(dayjs()); const [tempDate, setTempDate] = useState<dayjs.Dayjs | undefined>(dayjs());
@@ -100,7 +101,7 @@ export default function DateTime({
date={tempDate} date={tempDate}
initialView={modeViewMap[mode]} initialView={modeViewMap[mode]}
timePicker={mode === "time" || mode === "datetime"} timePicker={mode === "time" || mode === "datetime"}
selectedItemColor="#0d9488" selectedItemColor={accentColor}
onChange={({ date }) => { onChange={({ date }) => {
if (date) setTempDate(dayjs(date)); if (date) setTempDate(dayjs(date));
}} }}
@@ -1,6 +1,6 @@
import DateTimePicker, { DateType } from "react-native-dates-picker"; import DateTimePicker, { DateType } from "react-native-dates-picker";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { BottomSheet, Button, Spinner } from "heroui-native"; import { BottomSheet, Button, Spinner, useThemeColor } from "heroui-native";
import { View } from "react-native"; import { View } from "react-native";
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { CheckIcon } from "lucide-react-native"; import { CheckIcon } from "lucide-react-native";
@@ -21,6 +21,8 @@ export default function DateTimeRange({
onClose, onClose,
onConfirm, onConfirm,
}: DateRangePickerProps) { }: DateRangePickerProps) {
const accentColor = useThemeColor("accent");
const accentSoftColor = useThemeColor("accent-soft");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [range, setRange] = useState<{ const [range, setRange] = useState<{
startDate: DateType; startDate: DateType;
@@ -75,8 +77,8 @@ export default function DateTimeRange({
mode="range" mode="range"
startDate={range.startDate} startDate={range.startDate}
endDate={range.endDate} endDate={range.endDate}
selectedItemColor="#0d9488" selectedItemColor={accentColor}
selectedRangeBackgroundColor="rgba(13,148,136,0.15)" selectedRangeBackgroundColor={accentSoftColor}
onChange={(params) => setRange(params)} onChange={(params) => setRange(params)}
/> />
@@ -7,13 +7,13 @@ import Animated, {
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useEffect } from "react"; import { useEffect } from "react";
import { useThemeColor } from "heroui-native";
// ── 视觉常量 ────────────────────────────────────── // ── 视觉常量 ──────────────────────────────────────
const ICON_SIZE = 20; const ICON_SIZE = 20;
const LABEL_SIZE = 11; const LABEL_SIZE = 11;
const BAR_HEIGHT = 56; const BAR_HEIGHT = 56;
const BAR_RADIUS = 16; const BAR_RADIUS = 16;
const COLOR_ACTIVE = "#10B981";
const COLOR_IDLE = "#9CA3AF"; const COLOR_IDLE = "#9CA3AF";
const BAR_BG = "#FFFFFF"; const BAR_BG = "#FFFFFF";
@@ -62,6 +62,7 @@ function TabItem({
onPress: () => void; onPress: () => void;
}) { }) {
const Icon = tab.icon; const Icon = tab.icon;
const activeColor = useThemeColor("accent");
const scale = useSharedValue(1); const scale = useSharedValue(1);
useEffect(() => { useEffect(() => {
@@ -86,7 +87,7 @@ function TabItem({
<Animated.View style={[s.itemInner, animatedStyle]}> <Animated.View style={[s.itemInner, animatedStyle]}>
<Icon <Icon
size={ICON_SIZE} size={ICON_SIZE}
color={active ? COLOR_ACTIVE : COLOR_IDLE} color={active ? activeColor : COLOR_IDLE}
strokeWidth={active ? 2.2 : 1.8} strokeWidth={active ? 2.2 : 1.8}
/> />
{tab.title ? ( {tab.title ? (
@@ -94,7 +95,7 @@ function TabItem({
style={[ style={[
s.label, s.label,
{ {
color: active ? COLOR_ACTIVE : COLOR_IDLE, color: active ? activeColor : COLOR_IDLE,
fontWeight: active ? "600" : "400", fontWeight: active ? "600" : "400",
}, },
]} ]}
@@ -8,7 +8,6 @@ import {
ViewStyle, ViewStyle,
} from "react-native"; } from "react-native";
import { KeyboardAwareScrollView } from "react-native-keyboard-controller"; import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
import { ScrollShadow } from "heroui-native"; import { ScrollShadow } from "heroui-native";
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
import { LegendList } from "@legendapp/list"; import { LegendList } from "@legendapp/list";
@@ -134,14 +133,13 @@ LayoutView.FlatList = forwardRef(
paddingTop: padding.paddingTop, paddingTop: padding.paddingTop,
paddingHorizontal: padding.paddingHorizontal, paddingHorizontal: padding.paddingHorizontal,
}} }}
className="z-[999]"
> >
{header} {header}
</View> </View>
) : null} ) : null}
<Wrapper> <Wrapper>
<Shadow> <FlatList ref={ref} {...props} contentContainerStyle={contentContainerStyle} />
<FlatList ref={ref} {...props} contentContainerStyle={contentContainerStyle} />
</Shadow>
</Wrapper> </Wrapper>
{footer ? ( {footer ? (
<View <View
+7 -5
View File
@@ -6,10 +6,12 @@
@layer theme { @layer theme {
:root { :root {
--background: oklch(0.98 0 0); @variant light {
--foreground: oklch(0.15 0 0); --background: oklch(0.98 0 0);
--foreground: oklch(0.15 0 0);
--accent: oklch(0.76 0.12 170); --accent: oklch(0.76 0.12 170);
--accent-foreground: oklch(0.98 0 0); --accent-foreground: oklch(0.98 0 0);
}
} }
} }