(null);
const load = () => {
if (!token) return;
- api.getMessages(token, convId).then(setMessages).catch(() => setMessages([]));
+ api
+ .getMessages(token, convId)
+ .then(setMessages)
+ .catch(() => setMessages([]))
+ .finally(() => setLoading(false));
};
useEffect(() => {
+ if (!token) {
+ setLoading(false);
+ return;
+ }
load();
const id = setInterval(load, 4000);
return () => clearInterval(id);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, convId]);
useEffect(() => {
@@ -30,14 +43,34 @@ export default function ChatThreadClient({ convId }: { convId: string }) {
}, [messages]);
const send = async () => {
- if (!token || !text.trim()) return;
+ if (!token || !text.trim() || sending) return;
+ setSending(true);
try {
await api.sendMessage(token, convId, text.trim());
setText("");
load();
- } catch { /* ignore */ }
+ } catch {
+ toast("发送失败,请稍后重试", "error");
+ } finally {
+ setSending(false);
+ }
};
+ if (!user || !token) {
+ return (
+
+
+
+
{t.chat.loginTitle}
+
+ {t.nav.login}
+
+
+
+
+ );
+ }
+
return (
@@ -45,6 +78,10 @@ export default function ChatThreadClient({ convId }: { convId: string }) {
← {t.chat.back}
+ {loading && messages.length === 0 &&
加载消息…
}
+ {!loading && messages.length === 0 && (
+
打个招呼开始对话吧
+ )}
{messages.map((m) => (
{m.body}
@@ -57,9 +94,12 @@ export default function ChatThreadClient({ convId }: { convId: string }) {
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={t.chat.placeholder}
- onKeyDown={(e) => e.key === "Enter" && send()}
+ onKeyDown={(e) => e.key === "Enter" && void send()}
+ disabled={sending}
/>
-
+
diff --git a/frontend/src/components/CommunityNewClient.tsx b/frontend/src/components/CommunityNewClient.tsx
index 4ac906a..bc5b88a 100644
--- a/frontend/src/components/CommunityNewClient.tsx
+++ b/frontend/src/components/CommunityNewClient.tsx
@@ -16,18 +16,35 @@ export default function CommunityNewClient() {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [category, setCategory] = useState("社区");
+ const [busy, setBusy] = useState(false);
const submit = async () => {
if (!token) {
router.push("/login?next=/community/new");
return;
}
+ const tTitle = title.trim();
+ const body = content.trim();
+ if (!tTitle || body.length < 10) {
+ toast("标题必填,正文至少 10 字", "info");
+ return;
+ }
+ setBusy(true);
try {
- const res = await api.createDiscussion(token, { title, content, excerpt: content, category });
+ const excerpt = body.slice(0, 1000);
+ const res = await api.createDiscussion(token, {
+ title: tTitle,
+ content: body,
+ excerpt,
+ category,
+ });
toast(t.community.createOk, "success");
- router.push(`/community/${res.discussion.id}`);
+ const id = res.discussion?.id;
+ router.push(id ? `/community/${id}` : "/community");
} catch {
toast(t.community.createFail, "error");
+ } finally {
+ setBusy(false);
}
};
@@ -40,14 +57,18 @@ export default function CommunityNewClient() {
- setTitle(e.target.value)} />
+ setTitle(e.target.value)} maxLength={120} />
-
diff --git a/frontend/src/components/DigitalCourseClient.tsx b/frontend/src/components/DigitalCourseClient.tsx
index e54962f..ac601ed 100644
--- a/frontend/src/components/DigitalCourseClient.tsx
+++ b/frontend/src/components/DigitalCourseClient.tsx
@@ -1,11 +1,24 @@
"use client";
+import { useEffect, useState } from "react";
import Link from "next/link";
import { useI18n } from "@/lib/i18n";
+import { useAuth } from "@/lib/auth";
+import { api } from "@/lib/api";
import type { DigitalCourse } from "@/lib/types";
export default function DigitalCourseClient({ course }: { course: DigitalCourse }) {
const { t } = useI18n();
+ const { token } = useAuth();
+ const [vip, setVip] = useState(false);
+
+ useEffect(() => {
+ if (!token) {
+ setVip(false);
+ return;
+ }
+ api.getVipStatus(token).then((s) => setVip(Boolean(s.vip))).catch(() => setVip(false));
+ }, [token]);
return (
@@ -16,27 +29,45 @@ export default function DigitalCourseClient({ course }: { course: DigitalCourse
{t.digital.courseTag}
{t.digital.course}
+ {vip ? (
+
✨ VIP 已解锁付费课时
+ ) : (
+
+ 免费课时可直接学 · VIP 课时需{" "}
+ 开通会员
+
+ )}
-
- {course.modules.map((mod, mi) => (
-
- {mod.title}
-
- {mod.lessons.map((lesson, li) => (
- -
-
- {lesson.title}
-
- {lesson.duration}
- {lesson.free ? ` · ${t.digital.free}` : ` · VIP`}
-
-
-
- ))}
-
-
- ))}
-
+ {course.modules.length === 0 ? (
+
课程内容准备中
+ ) : (
+
+ {course.modules.map((mod, mi) => (
+
+ {mod.title}
+
+ {mod.lessons.map((lesson, li) => {
+ const locked = !lesson.free && !vip;
+ return (
+ -
+
+
+ {locked ? "🔒 " : ""}
+ {lesson.title}
+
+
+ {lesson.duration}
+ {lesson.free ? ` · ${t.digital.free}` : " · VIP"}
+
+
+
+ );
+ })}
+
+
+ ))}
+
+ )}
);
diff --git a/frontend/src/components/DigitalJobsClient.tsx b/frontend/src/components/DigitalJobsClient.tsx
index e7c10bb..fc5ff51 100644
--- a/frontend/src/components/DigitalJobsClient.tsx
+++ b/frontend/src/components/DigitalJobsClient.tsx
@@ -3,9 +3,12 @@
import Link from "next/link";
import { useI18n } from "@/lib/i18n";
import type { DigitalJob } from "@/lib/types";
+import RingNext from "@/components/RingNext";
+import { useRingSteps } from "@/lib/rings";
export default function DigitalJobsClient({ jobs }: { jobs: DigitalJob[] }) {
const { t } = useI18n();
+ const rings = useRingSteps();
return (
@@ -17,23 +20,36 @@ export default function DigitalJobsClient({ jobs }: { jobs: DigitalJob[] }) {
{t.digital.jobsTag}
{t.digital.jobs}
{t.digital.jobsSubtitle}
+
{t.nav.gigs}
-
- {jobs.map((job) => (
-
- {job.title}
- {job.company} · {job.location}
- {job.type} · {job.salary}
-
- {job.tags.map((tag) => {tag})}
-
-
- {t.digital.apply}
-
-
- ))}
-
- {jobs.length === 0 && {t.digital.jobsEmpty}
}
+ {jobs.length === 0 ? (
+
+
{t.digital.jobsEmpty}
+
+ 去赏金任务
+ 社区求助
+
+
+ ) : (
+
+ {jobs.map((job) => (
+
+ {job.title}
+ {job.company} · {job.location}
+ {job.type} · {job.salary}
+
+ {job.tags.map((tag) => (
+ {tag}
+ ))}
+
+
+ {t.digital.apply}
+
+
+ ))}
+
+ )}
+
);
diff --git a/frontend/src/components/DigitalLessonClient.tsx b/frontend/src/components/DigitalLessonClient.tsx
index 457a992..381f809 100644
--- a/frontend/src/components/DigitalLessonClient.tsx
+++ b/frontend/src/components/DigitalLessonClient.tsx
@@ -1,21 +1,96 @@
"use client";
+import { useEffect, useState } from "react";
import Link from "next/link";
import { useI18n } from "@/lib/i18n";
-import type { DigitalLesson } from "@/lib/types";
+import { useAuth } from "@/lib/auth";
+import { api } from "@/lib/api";
+import type { DigitalCourse, DigitalLesson } from "@/lib/types";
export default function DigitalLessonClient({
- lesson,
- locked,
moduleIndex,
lessonIndex,
}: {
- lesson: DigitalLesson | null;
- locked: boolean;
moduleIndex: number;
lessonIndex: number;
}) {
const { t } = useI18n();
+ const { token } = useAuth();
+ const [lesson, setLesson] = useState(null);
+ const [locked, setLocked] = useState(false);
+ const [missing, setMissing] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const [hasNext, setHasNext] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ setLoading(true);
+ (async () => {
+ try {
+ const course: DigitalCourse = await api.getDigitalCourse();
+ const mod = course.modules[moduleIndex];
+ const meta = mod?.lessons?.[lessonIndex];
+ if (!meta) {
+ if (!cancelled) {
+ setMissing(true);
+ setLocked(false);
+ setLesson(null);
+ }
+ return;
+ }
+ if (!cancelled) {
+ setHasNext(Boolean(mod.lessons[lessonIndex + 1]));
+ }
+ try {
+ const data = await api.getDigitalLesson(moduleIndex, lessonIndex, token || undefined);
+ if (!cancelled) {
+ setLesson(data);
+ setLocked(false);
+ setMissing(false);
+ }
+ } catch {
+ if (!cancelled) {
+ setLesson(null);
+ setLocked(!meta.free);
+ setMissing(meta.free);
+ }
+ }
+ } catch {
+ if (!cancelled) {
+ setMissing(true);
+ setLesson(null);
+ }
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [moduleIndex, lessonIndex, token]);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (missing) {
+ return (
+
+
+
+
课时不存在
+ {t.digital.backCourse}
+
+
+
+ );
+ }
if (locked || !lesson) {
return (
@@ -24,8 +99,15 @@ export default function DigitalLessonClient({
{t.digital.locked}
{t.digital.lockedDesc}
-
{t.join.payBtn}
-
{t.digital.backCourse}
+
+ {!token && (
+
+ {t.nav.login}
+
+ )}
+ {t.join.payBtn}
+ {t.digital.backCourse}
+
@@ -33,7 +115,7 @@ export default function DigitalLessonClient({
}
const prev = lessonIndex > 0 ? `/digital/course/${moduleIndex}/${lessonIndex - 1}` : null;
- const next = `/digital/course/${moduleIndex}/${lessonIndex + 1}`;
+ const next = hasNext ? `/digital/course/${moduleIndex}/${lessonIndex + 1}` : null;
return (
@@ -45,13 +127,25 @@ export default function DigitalLessonClient({
{lesson.duration}
{lesson.title}
- {lesson.content.split("\n").map((p) => (
-
{p}
+ {lesson.content.split("\n").filter(Boolean).map((p, i) => (
+
{p}
))}
diff --git a/frontend/src/components/DiscussionDetailClient.tsx b/frontend/src/components/DiscussionDetailClient.tsx
index 562440d..1695bbd 100644
--- a/frontend/src/components/DiscussionDetailClient.tsx
+++ b/frontend/src/components/DiscussionDetailClient.tsx
@@ -7,11 +7,20 @@ import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
+import RingNext from "@/components/RingNext";
+import { useRingSteps } from "@/lib/rings";
-export default function DiscussionDetailClient({ discussionId, initial }: { discussionId: string; initial: import("@/lib/types").DiscussionDetail }) {
+export default function DiscussionDetailClient({
+ discussionId,
+ initial,
+}: {
+ discussionId: string;
+ initial: import("@/lib/types").DiscussionDetail;
+}) {
const { user, token } = useAuth();
const { toast } = useToast();
const { t } = useI18n();
+ const rings = useRingSteps();
const router = useRouter();
const [discussion, setDiscussion] = useState(initial);
const [reply, setReply] = useState("");
@@ -25,7 +34,14 @@ export default function DiscussionDetailClient({ discussionId, initial }: { disc
};
const submitReply = async () => {
- if (!token || !reply.trim()) return;
+ if (!token) {
+ router.push(`/login?next=/community/${discussionId}`);
+ return;
+ }
+ if (!reply.trim()) {
+ toast("请先写一点回复内容", "info");
+ return;
+ }
setLoading(true);
try {
await api.postDiscussionReply(token, discussionId, reply.trim());
@@ -47,9 +63,13 @@ export default function DiscussionDetailClient({ discussionId, initial }: { disc
try {
const res = await api.likeDiscussion(token, discussionId);
setLikes(res.like_count);
- } catch { /* ignore */ }
+ } catch {
+ /* ignore */
+ }
};
+ const body = discussion.excerpt || "";
+
return (
@@ -66,20 +86,33 @@ export default function DiscussionDetailClient({ discussionId, initial }: { disc
-
{discussion.excerpt}
+ {body.split("\n").filter(Boolean).map((p, i) => (
+
{p}
+ ))}
- 💬 {discussion.reply_count} {t.community.replies}
-
+
+ 💬 {discussion.reply_count} {t.community.replies}
+
+
- {t.community.replies} ({discussion.replies.length})
+
+ {t.community.replies} ({discussion.replies.length})
+
+ {discussion.replies.length === 0 && (
+ 还没有回复,来做第一个吧
+ )}
{discussion.replies.map((r) => (
- {r.author_emoji} {r.author}
+
+ {r.author_emoji} {r.author}
+
{r.content}
@@ -89,14 +122,27 @@ export default function DiscussionDetailClient({ discussionId, initial }: { disc
{user && token ? (
-
) : (
-
{t.nav.login}
+
{t.community.replyHint}
+
+ {t.nav.login}
+
)}
+
+
);
diff --git a/frontend/src/components/GigPostClient.tsx b/frontend/src/components/GigPostClient.tsx
index 612e7a4..559777d 100644
--- a/frontend/src/components/GigPostClient.tsx
+++ b/frontend/src/components/GigPostClient.tsx
@@ -17,18 +17,31 @@ export default function GigPostClient() {
const [description, setDescription] = useState("");
const [budget, setBudget] = useState("");
const [deadline, setDeadline] = useState("");
+ const [busy, setBusy] = useState(false);
const submit = async () => {
if (!token) {
router.push("/login?next=/gigs/post");
return;
}
+ if (!title.trim() || !description.trim()) {
+ toast("请填写标题和描述", "info");
+ return;
+ }
+ setBusy(true);
try {
- await api.createGig(token, { title, description, budget, deadline });
+ await api.createGig(token, {
+ title: title.trim(),
+ description: description.trim(),
+ budget: budget.trim() || "面议",
+ deadline: deadline || "",
+ });
toast(t.gigs.postOk, "success");
router.push("/gigs");
} catch {
toast(t.gigs.postFail, "error");
+ } finally {
+ setBusy(false);
}
};
@@ -41,11 +54,13 @@ export default function GigPostClient() {
{t.gigs.postTitle}
- setTitle(e.target.value)} />
- setDescription(e.target.value)} />
+ setTitle(e.target.value)} maxLength={120} />
+ setDescription(e.target.value)} maxLength={2000} />
setBudget(e.target.value)} />
setDeadline(e.target.value)} />
-
+
diff --git a/frontend/src/components/MeetupsHostClient.tsx b/frontend/src/components/MeetupsHostClient.tsx
index 4ee790e..5fc0602 100644
--- a/frontend/src/components/MeetupsHostClient.tsx
+++ b/frontend/src/components/MeetupsHostClient.tsx
@@ -13,6 +13,7 @@ export default function MeetupsHostClient() {
const router = useRouter();
const { toast } = useToast();
const { t } = useI18n();
+ const [busy, setBusy] = useState(false);
const [form, setForm] = useState({
title: "",
city: "线上",
@@ -33,12 +34,23 @@ export default function MeetupsHostClient() {
router.push("/login?next=/meetups/host");
return;
}
+ if (!form.title.trim() || !form.date) {
+ toast("请填写活动名称和日期", "info");
+ return;
+ }
+ setBusy(true);
try {
- const res = await api.createMeetup(token, form);
+ await api.createMeetup(token, {
+ ...form,
+ title: form.title.trim(),
+ description: form.description.trim(),
+ });
toast(t.meetups.hostOk, "success");
router.push("/meetups");
} catch {
toast(t.meetups.hostFail, "error");
+ } finally {
+ setBusy(false);
}
};
@@ -52,7 +64,7 @@ export default function MeetupsHostClient() {
- set("title", e.target.value)} />
+ set("title", e.target.value)} maxLength={120} />
set("city", e.target.value)} />
@@ -66,8 +78,16 @@ export default function MeetupsHostClient() {
set("time", e.target.value)} />
set("venue", e.target.value)} />
- set("description", e.target.value)} rows={4} placeholder={t.meetups.hostDesc} />
-
+ set("description", e.target.value)}
+ rows={4}
+ placeholder={t.meetups.hostDesc}
+ maxLength={2000}
+ />
+
diff --git a/frontend/src/components/NotificationSettingsClient.tsx b/frontend/src/components/NotificationSettingsClient.tsx
index dfcc407..2ac276f 100644
--- a/frontend/src/components/NotificationSettingsClient.tsx
+++ b/frontend/src/components/NotificationSettingsClient.tsx
@@ -2,31 +2,54 @@
import { useEffect, useState } from "react";
import Link from "next/link";
-import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
+const PREF_KEYS = ["match", "meetup", "community", "push", "email", "marketing"] as const;
+
+const DEFAULTS: Record<(typeof PREF_KEYS)[number], boolean> = {
+ match: true,
+ meetup: true,
+ community: true,
+ push: false,
+ email: true,
+ marketing: false,
+};
+
export default function NotificationSettingsClient() {
const { token } = useAuth();
- const router = useRouter();
const { toast } = useToast();
const { t } = useI18n();
- const [prefs, setPrefs] = useState>({});
+ const [prefs, setPrefs] = useState>(DEFAULTS);
+ const [loading, setLoading] = useState(Boolean(token));
+ const [saving, setSaving] = useState(false);
useEffect(() => {
- if (!token) return;
- api.getNotificationPrefs(token).then(setPrefs).catch(() => {});
+ if (!token) {
+ setLoading(false);
+ return;
+ }
+ setLoading(true);
+ api
+ .getNotificationPrefs(token)
+ .then((p) => setPrefs({ ...DEFAULTS, ...p }))
+ .catch(() => setPrefs(DEFAULTS))
+ .finally(() => setLoading(false));
}, [token]);
const save = async () => {
if (!token) return;
+ setSaving(true);
try {
- await api.setNotificationPrefs(token, prefs);
+ const saved = await api.setNotificationPrefs(token, prefs);
+ setPrefs({ ...DEFAULTS, ...saved });
toast(t.notifSettings.saved, "success");
} catch {
toast(t.notifSettings.fail, "error");
+ } finally {
+ setSaving(false);
}
};
@@ -35,7 +58,10 @@ export default function NotificationSettingsClient() {
- {t.nav.login}
+
{t.notifSettings.title}
+
+ {t.nav.login}
+
@@ -47,18 +73,26 @@ export default function NotificationSettingsClient() {
return (
-
+
{t.notifSettings.title}
- {(["match", "meetup", "community", "push", "email", "marketing"] as const).map((k) => (
-
- ))}
-
+ {loading ? (
+
加载偏好…
+ ) : (
+ PREF_KEYS.map((k) => (
+
+ ))
+ )}
+
diff --git a/frontend/src/components/ServicesClient.tsx b/frontend/src/components/ServicesClient.tsx
index 80521d1..664b5c6 100644
--- a/frontend/src/components/ServicesClient.tsx
+++ b/frontend/src/components/ServicesClient.tsx
@@ -6,20 +6,43 @@ import { api } from "@/lib/api";
import { useToast } from "@/lib/toast";
import { useI18n } from "@/lib/i18n";
import type { ServiceItem } from "@/lib/types";
+import RingNext from "@/components/RingNext";
+import { useRingSteps } from "@/lib/rings";
+
+type LeadForm = { name: string; email: string; message: string };
export default function ServicesClient({ services }: { services: ServiceItem[] }) {
const { toast } = useToast();
const { t } = useI18n();
- const [form, setForm] = useState>({});
+ const rings = useRingSteps();
+ const [form, setForm] = useState>({});
+ const [sent, setSent] = useState>({});
+ const [busyId, setBusyId] = useState(null);
+
+ const patch = (id: string, key: keyof LeadForm, value: string) => {
+ const cur = form[id] || { name: "", email: "", message: "" };
+ setForm({ ...form, [id]: { ...cur, [key]: value } });
+ };
const submit = async (serviceId: string) => {
const f = form[serviceId];
- if (!f?.name || !f?.email || !f?.message) return;
+ if (!f?.name?.trim() || !f?.email?.trim() || !f?.message?.trim()) {
+ toast("请填写姓名、邮箱和需求说明", "info");
+ return;
+ }
+ setBusyId(serviceId);
try {
- await api.submitServiceLead(serviceId, f);
+ await api.submitServiceLead(serviceId, {
+ name: f.name.trim(),
+ email: f.email.trim(),
+ message: f.message.trim(),
+ });
+ setSent((s) => ({ ...s, [serviceId]: true }));
toast(t.services.leadOk, "success");
} catch {
toast(t.services.leadFail, "error");
+ } finally {
+ setBusyId(null);
}
};
@@ -32,19 +55,54 @@ export default function ServicesClient({ services }: { services: ServiceItem[] }
{t.services.title}
{t.services.subtitle}
-
- {services.map((s) => (
-
-
{s.emoji} {s.title}
-
{s.description}
-
{s.price} · {s.provider}
-
setForm({ ...form, [s.id]: { ...form[s.id], name: e.target.value, email: form[s.id]?.email || "", message: form[s.id]?.message || "" } })} />
-
setForm({ ...form, [s.id]: { ...form[s.id], email: e.target.value, name: form[s.id]?.name || "", message: form[s.id]?.message || "" } })} />
-
setForm({ ...form, [s.id]: { ...form[s.id], message: e.target.value, name: form[s.id]?.name || "", email: form[s.id]?.email || "" } })} />
-
-
- ))}
-
+ {services.length === 0 ? (
+
+ ) : (
+
+ {services.map((s) => (
+
+
{s.emoji} {s.title}
+
{s.description}
+
{s.price} · {s.provider}
+ {sent[s.id] ? (
+
✓ 已提交咨询,我们会尽快联系你
+ ) : (
+ <>
+
patch(s.id, "name", e.target.value)}
+ />
+
patch(s.id, "email", e.target.value)}
+ />
+
patch(s.id, "message", e.target.value)}
+ />
+
+ >
+ )}
+
+ ))}
+
+ )}
+
);