diff --git a/.env.local b/.env.local
index 459e423..28d0b93 100644
--- a/.env.local
+++ b/.env.local
@@ -44,5 +44,12 @@ PAYMENT_API_URL=http://127.0.0.1:8000
NEXT_PUBLIC_MIROTALK_URL=https://mirotalk.nomadro.cn
NEXT_PUBLIC_LOUNGE_URL=https://lounge.nomadro.cn
+NEXT_PUBLIC_LISTMONK_URL=https://listmonk.nomadro.cn
+LISTMONK_API_URL=https://listmonk.nomadro.cn
+
+NEXT_PUBLIC_NTFY_URL=https://ntfy.nomadro.cn
+NTFY_URL=https://ntfy.nomadro.cn
+NTFY_ENABLED=true
+
# 本地调试可自动完成支付(无需真实回调)
DEV_AUTO_PAY=true
diff --git a/app/[locale]/chat/[id]/page.tsx b/app/[locale]/chat/[id]/page.tsx
new file mode 100644
index 0000000..85ecb4f
--- /dev/null
+++ b/app/[locale]/chat/[id]/page.tsx
@@ -0,0 +1,243 @@
+"use client";
+
+import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
+import { Link } from "@/i18n/navigation";
+import { useLocale, useTranslation } from "@/i18n/navigation";
+import { useParams } from "next/navigation";
+import Footer from "@/app/components/Footer";
+import { fetchAuthMe } from "@/app/lib/api-client";
+import {
+ fetchConversation,
+ fetchMessages,
+ sendChatMessage,
+ type ChatMessage,
+ type ConversationItem,
+} from "@/app/lib/chat";
+import { mediaUrl } from "@/app/lib/media";
+
+const POLL_MS = 4000;
+
+function formatMessageTime(value?: string, locale = "zh"): string {
+ if (!value) return "";
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return "";
+ return date.toLocaleTimeString(locale === "zh" ? "zh-CN" : "en-US", {
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+export default function ChatThreadPage() {
+ const params = useParams();
+ const conversationId = String(params?.id || "");
+ const { t } = useTranslation("chat");
+ const locale = useLocale();
+
+ const [conversation, setConversation] = useState(null);
+ const [messages, setMessages] = useState([]);
+ const [draft, setDraft] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [sending, setSending] = useState(false);
+ const [error, setError] = useState("");
+ const [isLoggedIn, setIsLoggedIn] = useState(false);
+ const bottomRef = useRef(null);
+ const listRef = useRef(null);
+
+ const scrollToBottom = useCallback(() => {
+ bottomRef.current?.scrollIntoView({ behavior: "smooth" });
+ }, []);
+
+ const loadThread = useCallback(async () => {
+ if (!conversationId) return;
+ try {
+ const [meta, items] = await Promise.all([
+ fetchConversation(conversationId),
+ fetchMessages(conversationId),
+ ]);
+ setConversation(meta);
+ setMessages(items);
+ setError("");
+ } catch (err) {
+ setError(err instanceof Error ? err.message : t("loadError"));
+ } finally {
+ setLoading(false);
+ }
+ }, [conversationId, t]);
+
+ useEffect(() => {
+ fetchAuthMe().then((data) => setIsLoggedIn(Boolean(data?.user?.id)));
+ }, []);
+
+ useEffect(() => {
+ loadThread();
+ }, [loadThread]);
+
+ useEffect(() => {
+ if (!conversationId || !isLoggedIn) return undefined;
+ const timer = window.setInterval(() => {
+ fetchMessages(conversationId)
+ .then((items) => setMessages(items))
+ .catch(() => undefined);
+ }, POLL_MS);
+ return () => window.clearInterval(timer);
+ }, [conversationId, isLoggedIn]);
+
+ useEffect(() => {
+ scrollToBottom();
+ }, [messages, scrollToBottom]);
+
+ const handleSend = async (event: FormEvent) => {
+ event.preventDefault();
+ const body = draft.trim();
+ if (!body || sending || !conversationId) return;
+ setSending(true);
+ setError("");
+ try {
+ const message = await sendChatMessage(conversationId, body);
+ setMessages((prev) => [...prev, message]);
+ setDraft("");
+ setConversation((prev) =>
+ prev
+ ? {
+ ...prev,
+ lastMessagePreview: body.replace(/\n/g, " ").slice(0, 180),
+ lastMessageAt: message.created || prev.lastMessageAt,
+ }
+ : prev
+ );
+ } catch (err) {
+ setError(err instanceof Error ? err.message : t("sendError"));
+ } finally {
+ setSending(false);
+ }
+ };
+
+ const peerName = conversation?.peer?.name || (locale === "zh" ? "成员" : "Member");
+
+ return (
+
+
+
+
+ {!isLoggedIn ? (
+
+
+
{t("loginRequired")}
+
+ {t("loginAction")}
+
+
+
+ ) : loading ? (
+
+ ) : error && !messages.length ? (
+
+
+
{error}
+
+ {t("back")}
+
+
+
+ ) : (
+
+ {!messages.length ? (
+
+ ) : (
+ messages.map((message) => (
+
+
+
{message.body}
+
+ {formatMessageTime(message.created, locale)}
+
+
+
+ ))
+ )}
+
+
+ )}
+
+ {isLoggedIn && !loading ? (
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/app/[locale]/chat/page.tsx b/app/[locale]/chat/page.tsx
new file mode 100644
index 0000000..c1620de
--- /dev/null
+++ b/app/[locale]/chat/page.tsx
@@ -0,0 +1,159 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Link } from "@/i18n/navigation";
+import { useLocale, useTranslation } from "@/i18n/navigation";
+import Footer from "@/app/components/Footer";
+import { fetchAuthMe } from "@/app/lib/api-client";
+import { fetchConversations, type ConversationItem } from "@/app/lib/chat";
+import { mediaUrl } from "@/app/lib/media";
+
+function formatTime(value?: string, locale = "zh"): string {
+ if (!value) return "";
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return "";
+ const diff = Date.now() - date.getTime();
+ const minutes = Math.floor(diff / 60000);
+ if (minutes < 1) return locale === "zh" ? "刚刚" : "Now";
+ if (minutes < 60) return locale === "zh" ? `${minutes} 分钟前` : `${minutes}m`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return locale === "zh" ? `${hours} 小时前` : `${hours}h`;
+ const days = Math.floor(hours / 24);
+ if (days < 7) return locale === "zh" ? `${days} 天前` : `${days}d`;
+ return `${date.getMonth() + 1}/${date.getDate()}`;
+}
+
+function ConversationRow({ item, locale }: { item: ConversationItem; locale: string }) {
+ const peerName = item.peer?.name || (locale === "zh" ? "成员" : "Member");
+ const preview = item.lastMessagePreview || (locale === "zh" ? "开始聊天吧" : "Say hello");
+ const unread = Number(item.unreadCount || 0);
+
+ return (
+
+
+
})
+ {unread > 0 ? (
+
+ {unread > 9 ? "9+" : unread}
+
+ ) : null}
+
+
+
+
{peerName}
+
{formatTime(item.lastMessageAt, locale)}
+
+
+ {item.peer?.location ? `📍 ${item.peer.location} · ` : ""}
+ {preview}
+
+
+
+ );
+}
+
+export default function ChatListPage() {
+ const { t } = useTranslation("chat");
+ const locale = useLocale();
+ const [items, setItems] = useState([]);
+ const [unread, setUnread] = useState(0);
+ const [loading, setLoading] = useState(true);
+ const [isLoggedIn, setIsLoggedIn] = useState(false);
+
+ const load = () => {
+ setLoading(true);
+ fetchConversations()
+ .then((data) => {
+ setItems(data.items);
+ setUnread(data.unread);
+ })
+ .catch(() => {
+ setItems([]);
+ setUnread(0);
+ })
+ .finally(() => setLoading(false));
+ };
+
+ useEffect(() => {
+ fetchAuthMe().then((data) => setIsLoggedIn(Boolean(data?.user?.id)));
+ load();
+ }, []);
+
+ return (
+
+
+
+
+ {!isLoggedIn ? (
+
+
{t("loginRequired")}
+
+ {t("loginAction")}
+
+
+ ) : loading ? (
+
+ {[1, 2, 3].map((key) => (
+
+ ))}
+
+ ) : items.length ? (
+
+ {items.map((item) => (
+
+ ))}
+
+ ) : (
+
+
{t("emptyTitle")}
+
{t("emptySubtitle")}
+
+ {t("goMatching")}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/app/[locale]/dating/components/LikesPanel.tsx b/app/[locale]/dating/components/LikesPanel.tsx
index 87580ce..cd8d758 100644
--- a/app/[locale]/dating/components/LikesPanel.tsx
+++ b/app/[locale]/dating/components/LikesPanel.tsx
@@ -23,12 +23,10 @@ function ProfileRow({
badge?: string;
onSelect?: () => void;
}) {
- return (
-
+ >
);
+ if (onSelect) {
+ return (
+
+ );
+ }
+ return {content}
;
}
export default function LikesPanel({ likes, mutual, formatTime, t, onSelectProfile }: LikesPanelProps) {
@@ -93,15 +99,29 @@ export default function LikesPanel({ likes, mutual, formatTime, t, onSelectProfi
{mutual.length ? (
- mutual.map((profile) => (
-
onSelectProfile?.(profile)}
- />
- ))
+ mutual.map((profile) =>
+ profile.conversationId ? (
+
+
+
+ ) : (
+ onSelectProfile?.(profile)}
+ />
+ )
+ )
) : (
{t("noMutualYet")}
@@ -110,7 +130,7 @@ export default function LikesPanel({ likes, mutual, formatTime, t, onSelectProfi
{mutual.length > 0 ? (
{t("goMessages")}
diff --git a/app/[locale]/dating/components/MatchModal.tsx b/app/[locale]/dating/components/MatchModal.tsx
index 868c3c2..e6554cc 100644
--- a/app/[locale]/dating/components/MatchModal.tsx
+++ b/app/[locale]/dating/components/MatchModal.tsx
@@ -6,11 +6,12 @@ import type { DatingProfile } from "../types";
interface MatchModalProps {
profile: DatingProfile | null;
+ conversationId?: string;
onClose: () => void;
t: (key: string) => string;
}
-export default function MatchModal({ profile, onClose, t }: MatchModalProps) {
+export default function MatchModal({ profile, conversationId, onClose, t }: MatchModalProps) {
if (!profile) return null;
return (
@@ -33,7 +34,7 @@ export default function MatchModal({ profile, onClose, t }: MatchModalProps) {
📍 {profile.location}
diff --git a/app/[locale]/dating/page.tsx b/app/[locale]/dating/page.tsx
index 13712f0..7205c13 100644
--- a/app/[locale]/dating/page.tsx
+++ b/app/[locale]/dating/page.tsx
@@ -63,7 +63,10 @@ function DatingPageInner() {
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
const [showQuotaPrompt, setShowQuotaPrompt] = useState(false);
const [canUndo, setCanUndo] = useState(false);
- const [matchCelebration, setMatchCelebration] = useState(null);
+ const [matchCelebration, setMatchCelebration] = useState<{
+ profile: DatingProfile;
+ conversationId?: string;
+ } | null>(null);
const [mobilePanelOpen, setMobilePanelOpen] = useState(false);
const [dragX, setDragX] = useState(0);
const [isDragging, setIsDragging] = useState(false);
@@ -193,7 +196,10 @@ function DatingPageInner() {
const result = await saveSwipe(currentProfile.id, action, activeTab);
setCanUndo(true);
if (result.matched) {
- setMatchCelebration(currentProfile);
+ setMatchCelebration({
+ profile: currentProfile,
+ conversationId: result.match?.conversationId,
+ });
refreshSidePanels();
} else if (action === "like") {
refreshSidePanels();
@@ -467,7 +473,12 @@ function DatingPageInner() {
) : null}
- setMatchCelebration(null)} t={t} />
+ setMatchCelebration(null)}
+ t={t}
+ />
);
diff --git a/app/[locale]/dating/types.ts b/app/[locale]/dating/types.ts
index f5cd40d..486d5cf 100644
--- a/app/[locale]/dating/types.ts
+++ b/app/[locale]/dating/types.ts
@@ -16,6 +16,7 @@ export interface ProfileRecord {
matchedAt?: string;
intent?: MatchIntent;
connectionId?: string;
+ conversationId?: string;
}
export interface DatingProfile {
@@ -36,10 +37,16 @@ export interface DatingProfile {
likedAt?: string;
matchedAt?: string;
intent?: MatchIntent;
+ connectionId?: string;
+ conversationId?: string;
}
export interface SwipeResult {
ok: boolean;
matched: boolean;
- match?: Record | null;
+ match?: {
+ id?: string;
+ conversationId?: string;
+ [key: string]: unknown;
+ } | null;
}
diff --git a/app/[locale]/dating/utils.ts b/app/[locale]/dating/utils.ts
index 63e67c6..3a6e83f 100644
--- a/app/[locale]/dating/utils.ts
+++ b/app/[locale]/dating/utils.ts
@@ -24,6 +24,8 @@ export function mapProfile(record: ProfileRecord, index: number): DatingProfile
likedAt: record.likedAt,
matchedAt: record.matchedAt,
intent: record.intent,
+ connectionId: record.connectionId,
+ conversationId: record.conversationId,
};
}
diff --git a/app/[locale]/messages/page.tsx b/app/[locale]/messages/page.tsx
index 2d916ad..0d5319e 100644
--- a/app/[locale]/messages/page.tsx
+++ b/app/[locale]/messages/page.tsx
@@ -175,6 +175,12 @@ export default function MessagesPage() {
+
+ 私信聊天
+