Merge branch 'master' of https://gitee.com/oneao/workspace
This commit is contained in:
commit
51622a4b1a
12 files changed
+283
-69
No files matched your search
Binary file not shown.
BIN
Binary file not shown.
@@ -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 变化时(键盘弹起/收起)自动保持可见内容位置
|
||||
@@ -2,6 +2,7 @@ package ws
|
||||
|
||||
import (
|
||||
"allapp-go/internal/httpx"
|
||||
"allapp-go/internal/middleware"
|
||||
"allapp-go/pkg/db"
|
||||
"allapp-go/pkg/jwtx"
|
||||
"allapp-go/pkg/uniqueid"
|
||||
@@ -195,7 +196,7 @@ func (c *Client) readPump() {
|
||||
|
||||
// handleSend 处理发送消息
|
||||
func (c *Client) handleSend(msg clientSendMsg) {
|
||||
ctx := context.Background()
|
||||
ctx := context.WithValue(context.Background(), middleware.CtxUserIDKey, c.userID)
|
||||
dbClient := db.New()
|
||||
|
||||
msgID := uniqueid.NextId()
|
||||
@@ -293,7 +294,7 @@ func (c *Client) handleSend(msg clientSendMsg) {
|
||||
|
||||
// handleRecall 处理撤回(发送者 2 分钟内)
|
||||
func (c *Client) handleRecall(msg clientSendMsg) {
|
||||
ctx := context.Background()
|
||||
ctx := context.WithValue(context.Background(), middleware.CtxUserIDKey, c.userID)
|
||||
dbClient := db.New()
|
||||
|
||||
rows, err := dbClient.LoadData(ctx, "b_chat",
|
||||
@@ -337,7 +338,7 @@ func (c *Client) handleDelete(msg clientSendMsg) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := context.WithValue(context.Background(), middleware.CtxUserIDKey, c.userID)
|
||||
dbClient := db.New()
|
||||
|
||||
if err := dbClient.Update(ctx, "b_chat", "id", map[string]any{
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"backgroundColor": "#F0FDF4",
|
||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
@@ -38,7 +38,7 @@
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"backgroundColor": "#208AEF",
|
||||
"backgroundColor": "#15803D",
|
||||
"android": {
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 76
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useEffect, useRef, useState, memo } from "react";
|
||||
import { Image, Text, TextInput, View, Keyboard } from "react-native";
|
||||
import { useCallback, useEffect, useRef, useState, memo, useMemo } from "react";
|
||||
import { Image, Text, TextInput, View, Keyboard, Animated } from "react-native";
|
||||
import { KeyboardAvoidingView } from "react-native-keyboard-controller";
|
||||
import { useRouter } from "expo-router";
|
||||
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 EmptyState from "@/components/EmptyStatus";
|
||||
import { loadDataApi, loadDataPageApi } from "@/request/api";
|
||||
@@ -27,20 +28,20 @@ function ChatOnlineBar({ users, onSearch }: { users: OnlineUser[]; onSearch: ()
|
||||
const router = useRouter();
|
||||
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 flex-1">
|
||||
{users.slice(0, 6).map((u, i) => (
|
||||
<View className="flex-row items-center flex-1 gap-2">
|
||||
{users.slice(0, 6).map((u) => (
|
||||
<PressableFeedback
|
||||
key={u.user_id}
|
||||
onPress={() => router.navigate(`/family/member/${u.user_id}` as any)}
|
||||
>
|
||||
<View className={cn(i > 0 && "-ml-2")}>
|
||||
<View>
|
||||
{u.avatar ? (
|
||||
<Image
|
||||
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>
|
||||
</View>
|
||||
)}
|
||||
@@ -50,7 +51,7 @@ function ChatOnlineBar({ users, onSearch }: { users: OnlineUser[]; onSearch: ()
|
||||
</PressableFeedback>
|
||||
))}
|
||||
{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>
|
||||
</View>
|
||||
)}
|
||||
@@ -133,7 +134,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
||||
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}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -155,7 +156,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
||||
{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-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>
|
||||
{item.attachments[0].duration ? (
|
||||
<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")}>
|
||||
<Text className={cn("text-sm", isSelf ? "text-white" : "text-gray-500")}>📎</Text>
|
||||
<PaperclipIcon size={17} color={isSelf ? "white" : "#6B7280"} />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<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}
|
||||
>
|
||||
{item.attachments[0].file_name}
|
||||
</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 / 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="px-3 py-2">
|
||||
<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 {
|
||||
const d = JSON.parse(item.source_data);
|
||||
@@ -212,7 +213,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
||||
</View>
|
||||
{item.content ? (
|
||||
<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>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -274,15 +275,142 @@ export default function ChatPage() {
|
||||
const [inputText, setInputText] = useState("");
|
||||
const [adminRole, setAdminRole] = useState(0);
|
||||
const listRef = useRef<any>(null);
|
||||
const opacity = useRef(new Animated.Value(0)).current;
|
||||
const revealed = useRef(false);
|
||||
|
||||
// 键盘弹起时自动滚到底部
|
||||
useEffect(() => {
|
||||
const show = Keyboard.addListener("keyboardWillShow", () => {
|
||||
setTimeout(() => listRef.current?.scrollToEnd?.({ animated: true }), 100);
|
||||
});
|
||||
return () => show.remove();
|
||||
// ---- optimistic send ----
|
||||
type PendingMsg = { localId: string; content: string; status: "sending" | "failed"; createTime: string };
|
||||
const [pending, setPending] = useState<PendingMsg[]>([]);
|
||||
const pendingTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||
|
||||
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(() => {
|
||||
if (!familyId) return;
|
||||
@@ -364,27 +492,54 @@ export default function ChatPage() {
|
||||
});
|
||||
}, [loadingMore, hasMore, page, loadHistory, runLoadMore]);
|
||||
|
||||
// 合并消息 (旧→新)
|
||||
const allMessages = (() => {
|
||||
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(() => {
|
||||
// Send the current message draft (optimistic).
|
||||
function handleSend() {
|
||||
const text = inputText.trim();
|
||||
if (!text) return;
|
||||
setInputText("");
|
||||
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 });
|
||||
}, [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) {
|
||||
return (
|
||||
@@ -400,6 +555,7 @@ export default function ChatPage() {
|
||||
behavior="padding"
|
||||
keyboardVerticalOffset={-60}
|
||||
>
|
||||
<Animated.View style={{ flex: 1, opacity }}>
|
||||
<LayoutView>
|
||||
<LayoutView.FlatList
|
||||
ref={listRef}
|
||||
@@ -410,24 +566,32 @@ export default function ChatPage() {
|
||||
/>
|
||||
}
|
||||
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">
|
||||
<View className="flex-1 min-h-9 max-h-20 rounded-xl bg-background px-3 justify-center">
|
||||
<TextInput
|
||||
className="flex-1 h-10 bg-gray-100 rounded-full px-4 text-[15px]"
|
||||
placeholder="输入消息..."
|
||||
placeholderTextColor="#9CA3AF"
|
||||
className="min-h-8 max-h-20 text-[15px] leading-[20px] text-foreground"
|
||||
placeholder="说点什么..."
|
||||
placeholderTextColor="#86A19C"
|
||||
value={inputText}
|
||||
onChangeText={setInputText}
|
||||
onFocus={() => scrollToBottom(true)}
|
||||
onSubmitEditing={handleSend}
|
||||
returnKeyType="send"
|
||||
multiline
|
||||
maxLength={800}
|
||||
textAlignVertical="center"
|
||||
/>
|
||||
</View>
|
||||
<PressableFeedback onPress={handleSend} isDisabled={!inputText.trim()}>
|
||||
<View
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-full items-center justify-center",
|
||||
inputText.trim() ? "bg-accent" : "bg-gray-200",
|
||||
"w-9 h-9 rounded-full items-center justify-center",
|
||||
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>
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
@@ -435,6 +599,7 @@ export default function ChatPage() {
|
||||
}
|
||||
data={allMessages}
|
||||
keyExtractor={(item) => String(item.id)}
|
||||
contentContainerStyle={{ paddingBottom: 12 }}
|
||||
onScroll={({ nativeEvent }) => {
|
||||
if (nativeEvent.contentOffset.y < 30 && hasMore && !loadingMore) {
|
||||
handleLoadMore();
|
||||
@@ -442,8 +607,12 @@ export default function ChatPage() {
|
||||
}}
|
||||
scrollEventThrottle={200}
|
||||
onContentSizeChange={() => {
|
||||
if (loadingMore) return;
|
||||
listRef.current?.scrollToEnd({ animated: false });
|
||||
if (!revealed.current) {
|
||||
revealed.current = true;
|
||||
revealAtBottom();
|
||||
return;
|
||||
}
|
||||
if (!loadingMore) scrollToBottom(false);
|
||||
}}
|
||||
ListHeaderComponent={
|
||||
loadingMore ? (
|
||||
@@ -457,7 +626,32 @@ export default function ChatPage() {
|
||||
<Text className="text-gray-400 text-sm">暂无消息,开始聊天吧</Text>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
renderItem={({ item }: { item: any }) => {
|
||||
const pending = item._pending as PendingMsg | undefined;
|
||||
if (pending) {
|
||||
return (
|
||||
<View className="flex-row items-center px-3 py-1.5 justify-end">
|
||||
{pending.status === "sending" ? (
|
||||
<View className="mr-1">
|
||||
<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}
|
||||
@@ -466,9 +660,11 @@ export default function ChatPage() {
|
||||
onRecall={ws.recall}
|
||||
onDelete={ws.deleteMessage}
|
||||
/>
|
||||
)}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</LayoutView>
|
||||
</Animated.View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { View, Text } from "react-native";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
@@ -14,6 +14,7 @@ import { Heart } from "lucide-react-native";
|
||||
export default function Tools() {
|
||||
const router = useRouter();
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
const accentColor = useThemeColor("accent");
|
||||
const scrollRef = useRef<any>(null);
|
||||
const headerPositions = useRef<Record<string, number>>({});
|
||||
|
||||
@@ -120,7 +121,7 @@ export default function Tools() {
|
||||
{/* 收藏分组特殊标记 */}
|
||||
{item.title === "收藏" && (
|
||||
<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>
|
||||
)}
|
||||
{/* 普通分组标记 */}
|
||||
@@ -175,8 +176,8 @@ export default function Tools() {
|
||||
>
|
||||
<Heart
|
||||
size={18}
|
||||
fill={isFav ? "#0d9488" : "none"}
|
||||
color={isFav ? "#0d9488" : "#9ca3af"}
|
||||
fill={isFav ? accentColor : "none"}
|
||||
color={isFav ? accentColor : "#9ca3af"}
|
||||
/>
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import DateTimePicker from "react-native-dates-picker";
|
||||
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 { View } from "react-native";
|
||||
import { CheckIcon } from "lucide-react-native";
|
||||
@@ -49,6 +49,7 @@ export default function DateTime({
|
||||
format,
|
||||
}: DatePickerProps) {
|
||||
const finalFormat = format ?? defaultFormatMap[mode];
|
||||
const accentColor = useThemeColor("accent");
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tempDate, setTempDate] = useState<dayjs.Dayjs | undefined>(dayjs());
|
||||
@@ -100,7 +101,7 @@ export default function DateTime({
|
||||
date={tempDate}
|
||||
initialView={modeViewMap[mode]}
|
||||
timePicker={mode === "time" || mode === "datetime"}
|
||||
selectedItemColor="#0d9488"
|
||||
selectedItemColor={accentColor}
|
||||
onChange={({ date }) => {
|
||||
if (date) setTempDate(dayjs(date));
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import DateTimePicker, { DateType } from "react-native-dates-picker";
|
||||
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 { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { CheckIcon } from "lucide-react-native";
|
||||
@@ -21,6 +21,8 @@ export default function DateTimeRange({
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: DateRangePickerProps) {
|
||||
const accentColor = useThemeColor("accent");
|
||||
const accentSoftColor = useThemeColor("accent-soft");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [range, setRange] = useState<{
|
||||
startDate: DateType;
|
||||
@@ -75,8 +77,8 @@ export default function DateTimeRange({
|
||||
mode="range"
|
||||
startDate={range.startDate}
|
||||
endDate={range.endDate}
|
||||
selectedItemColor="#0d9488"
|
||||
selectedRangeBackgroundColor="rgba(13,148,136,0.15)"
|
||||
selectedItemColor={accentColor}
|
||||
selectedRangeBackgroundColor={accentSoftColor}
|
||||
onChange={(params) => setRange(params)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -7,13 +7,13 @@ import Animated, {
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useEffect } from "react";
|
||||
import { useThemeColor } from "heroui-native";
|
||||
|
||||
// ── 视觉常量 ──────────────────────────────────────
|
||||
const ICON_SIZE = 20;
|
||||
const LABEL_SIZE = 11;
|
||||
const BAR_HEIGHT = 56;
|
||||
const BAR_RADIUS = 16;
|
||||
const COLOR_ACTIVE = "#10B981";
|
||||
const COLOR_IDLE = "#9CA3AF";
|
||||
const BAR_BG = "#FFFFFF";
|
||||
|
||||
@@ -62,6 +62,7 @@ function TabItem({
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const Icon = tab.icon;
|
||||
const activeColor = useThemeColor("accent");
|
||||
const scale = useSharedValue(1);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -86,7 +87,7 @@ function TabItem({
|
||||
<Animated.View style={[s.itemInner, animatedStyle]}>
|
||||
<Icon
|
||||
size={ICON_SIZE}
|
||||
color={active ? COLOR_ACTIVE : COLOR_IDLE}
|
||||
color={active ? activeColor : COLOR_IDLE}
|
||||
strokeWidth={active ? 2.2 : 1.8}
|
||||
/>
|
||||
{tab.title ? (
|
||||
@@ -94,7 +95,7 @@ function TabItem({
|
||||
style={[
|
||||
s.label,
|
||||
{
|
||||
color: active ? COLOR_ACTIVE : COLOR_IDLE,
|
||||
color: active ? activeColor : COLOR_IDLE,
|
||||
fontWeight: active ? "600" : "400",
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
ViewStyle,
|
||||
} from "react-native";
|
||||
import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
|
||||
|
||||
import { ScrollShadow } from "heroui-native";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { LegendList } from "@legendapp/list";
|
||||
@@ -167,14 +166,13 @@ LayoutView.FlatList = forwardRef(
|
||||
paddingTop: padding.paddingTop,
|
||||
paddingHorizontal: padding.paddingHorizontal,
|
||||
}}
|
||||
className="z-[999]"
|
||||
>
|
||||
{header}
|
||||
</View>
|
||||
) : null}
|
||||
<Wrapper>
|
||||
<Shadow>
|
||||
<FlatList ref={ref} {...props} contentContainerStyle={contentContainerStyle} />
|
||||
</Shadow>
|
||||
</Wrapper>
|
||||
{footer ? (
|
||||
<View
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
@layer theme {
|
||||
:root {
|
||||
@variant light {
|
||||
--background: oklch(0.98 0 0);
|
||||
--foreground: oklch(0.15 0 0);
|
||||
|
||||
@@ -13,3 +14,4 @@
|
||||
--accent-foreground: oklch(0.98 0 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user