diff --git a/code/app/.codex/skills/ui-ux-pro-max/scripts/__pycache__/core.cpython-312.pyc b/code/app/.codex/skills/ui-ux-pro-max/scripts/__pycache__/core.cpython-312.pyc new file mode 100644 index 00000000..6a4c0c5a Binary files /dev/null and b/code/app/.codex/skills/ui-ux-pro-max/scripts/__pycache__/core.cpython-312.pyc differ diff --git a/code/app/.codex/skills/ui-ux-pro-max/scripts/__pycache__/design_system.cpython-312.pyc b/code/app/.codex/skills/ui-ux-pro-max/scripts/__pycache__/design_system.cpython-312.pyc new file mode 100644 index 00000000..53150111 Binary files /dev/null and b/code/app/.codex/skills/ui-ux-pro-max/scripts/__pycache__/design_system.cpython-312.pyc differ diff --git a/code/app/.workbuddy/memory/2026-06-26.md b/code/app/.workbuddy/memory/2026-06-26.md new file mode 100644 index 00000000..a85361b0 --- /dev/null +++ b/code/app/.workbuddy/memory/2026-06-26.md @@ -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 变化时(键盘弹起/收起)自动保持可见内容位置 diff --git a/code/app/app-go/internal/ws/handler.go b/code/app/app-go/internal/ws/handler.go index 3e62e736..0ce24f8e 100644 --- a/code/app/app-go/internal/ws/handler.go +++ b/code/app/app-go/internal/ws/handler.go @@ -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{ diff --git a/code/app/app-rn/app.json b/code/app/app-rn/app.json index 636864ef..2e576cc3 100644 --- a/code/app/app-rn/app.json +++ b/code/app/app-rn/app.json @@ -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 diff --git a/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx b/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx index d33dd7a8..04f918a9 100644 --- a/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx +++ b/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx @@ -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 ( - - {users.slice(0, 6).map((u, i) => ( + + {users.slice(0, 6).map((u) => ( router.navigate(`/family/member/${u.user_id}` as any)} > - 0 && "-ml-2")}> + {u.avatar ? ( ) : ( - + {u.nickname?.charAt(0) || "?"} )} @@ -50,7 +51,7 @@ function ChatOnlineBar({ users, onSearch }: { users: OnlineUser[]; onSearch: () ))} {users.length > 6 && ( - + +{users.length - 6} )} @@ -133,7 +134,7 @@ const ChatMessageItem = memo(function ChatMessageItem({ isSelf ? "bg-accent rounded-br-md" : "bg-white rounded-bl-md shadow-sm", )} > - + {item.content} @@ -155,7 +156,7 @@ const ChatMessageItem = memo(function ChatMessageItem({ {item.msg_type === ChatMsgType.Video && item.attachments?.length && ( - ▶ + {item.attachments[0].duration ? ( @@ -173,16 +174,16 @@ const ChatMessageItem = memo(function ChatMessageItem({ )} > - 📎 + {item.attachments[0].file_name} - + {item.attachments[0].file_size ? (item.attachments[0].file_size / 1024).toFixed(0) + " KB" : ""} @@ -195,7 +196,7 @@ const ChatMessageItem = memo(function ChatMessageItem({ 来自 {item.source_table} - + {(() => { try { const d = JSON.parse(item.source_data); @@ -212,7 +213,7 @@ const ChatMessageItem = memo(function ChatMessageItem({ {item.content ? ( - {item.content} + {item.content} ) : null} @@ -274,15 +275,142 @@ export default function ChatPage() { const [inputText, setInputText] = useState(""); const [adminRole, setAdminRole] = useState(0); const listRef = useRef(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([]); + const pendingTimers = useRef>>({}); + + 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>(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>(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>(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,9 +555,10 @@ export default function ChatPage() { behavior="padding" keyboardVerticalOffset={-60} > - - + + } footer={ - - + + + scrollToBottom(true)} + onSubmitEditing={handleSend} + returnKeyType="send" + multiline + maxLength={800} + textAlignVertical="center" + /> + - + @@ -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,18 +626,45 @@ export default function ChatPage() { 暂无消息,开始聊天吧 } - renderItem={({ item }) => ( - - )} + renderItem={({ item }: { item: any }) => { + const pending = item._pending as PendingMsg | undefined; + if (pending) { + return ( + + {pending.status === "sending" ? ( + + + + ) : ( + handleRetry(pending)}> + + + + + + )} + + + {pending.content} + + + + ); + } + return ( + + ); + }} /> + ); } diff --git a/code/app/app-rn/src/app/(main)/tools/index.tsx b/code/app/app-rn/src/app/(main)/tools/index.tsx index 585a0090..72a6228b 100644 --- a/code/app/app-rn/src/app/(main)/tools/index.tsx +++ b/code/app/app-rn/src/app/(main)/tools/index.tsx @@ -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(null); const headerPositions = useRef>({}); @@ -120,7 +121,7 @@ export default function Tools() { {/* 收藏分组特殊标记 */} {item.title === "收藏" && ( - + )} {/* 普通分组标记 */} @@ -175,8 +176,8 @@ export default function Tools() { > diff --git a/code/app/app-rn/src/components/DateTime.tsx b/code/app/app-rn/src/components/DateTime.tsx index 807fdd88..59f24a7b 100644 --- a/code/app/app-rn/src/components/DateTime.tsx +++ b/code/app/app-rn/src/components/DateTime.tsx @@ -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()); @@ -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)); }} diff --git a/code/app/app-rn/src/components/DateTimeRange.tsx b/code/app/app-rn/src/components/DateTimeRange.tsx index c54b884f..3e01a135 100644 --- a/code/app/app-rn/src/components/DateTimeRange.tsx +++ b/code/app/app-rn/src/components/DateTimeRange.tsx @@ -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)} /> diff --git a/code/app/app-rn/src/components/layout/tabbar.tsx b/code/app/app-rn/src/components/layout/tabbar.tsx index 0dc1978b..e6b69d3d 100644 --- a/code/app/app-rn/src/components/layout/tabbar.tsx +++ b/code/app/app-rn/src/components/layout/tabbar.tsx @@ -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({ {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", }, ]} diff --git a/code/app/app-rn/src/components/layout/view.tsx b/code/app/app-rn/src/components/layout/view.tsx index b516fce0..409cbbc5 100644 --- a/code/app/app-rn/src/components/layout/view.tsx +++ b/code/app/app-rn/src/components/layout/view.tsx @@ -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"; @@ -134,14 +133,13 @@ LayoutView.FlatList = forwardRef( paddingTop: padding.paddingTop, paddingHorizontal: padding.paddingHorizontal, }} + className="z-[999]" > {header} ) : null} - - - + {footer ? (