Feat: 新增评论组件,细化通知组件样式,引入环境变量,替换图标组件

This commit is contained in:
2026-07-30 00:59:09 +08:00
parent d585fc909f
commit b66de3129d
18 changed files with 630 additions and 14 deletions
@@ -0,0 +1,178 @@
import { useState, type ChangeEvent, type SubmitEvent } from "react";
import { RiLoader5Fill } from "@remixicon/react";
import { add } from "~/components/ui/notice/utils";
import { useIsLogin } from "~/composables/use-is-login";
import { clsn } from "~/utils";
import { getAuthorInfo, setAuthorInfo } from "~/utils/comment";
export interface CommentFormSubmitValues {
name: string;
email: string;
site: string;
content: string;
root?: string;
parent?: string;
}
interface CommentFormProps {
className?: string;
parent?: string;
root?: string;
onSubmit: (values: CommentFormSubmitValues) => Promise<unknown>;
}
export default function CommentForm({ className, parent, root, onSubmit }: CommentFormProps) {
const isLogin = useIsLogin();
const [loading, setLoading] = useState(false);
const [input, setInput] = useState(() => ({
...getAuthorInfo(),
content: "",
}));
const onChange = (ev: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = ev.target;
setInput((prev) => ({ ...prev, [name]: value }));
};
const onFormSubmit = (ev: SubmitEvent) => {
ev.preventDefault();
if (!isLogin && (!input.name || !input.email || !input.content)) {
add({
title: "评论失败",
type: "error",
content: "缺少必填项",
});
return;
}
if (!input.content) {
add({
title: "评论失败",
type: "error",
content: "缺少必填项",
});
return;
}
setAuthorInfo({
name: input.name,
email: input.email,
site: input.site,
});
setLoading(true);
onSubmit({
...input,
root,
parent,
}).then(() => {
setInput((prev) => ({ ...prev, content: "" }));
setLoading(false);
}).catch(() => {
setLoading(false);
});
};
const inputClass = "w-full px-3 py-2.5 rounded-xl bg-white border-4 border-gray-200 focus:border-pink-400 outline-none transition-colors placeholder:text-neutral-400";
const labelClass = "block text-sm font-medium mb-2 text-neutral-500";
if (isLogin) {
return (
<form className={clsn("mb-8", className)} onSubmit={onFormSubmit}>
<div className="mb-4">
<h3 className="text-pink-400 font-bold text-lg mb-1">欢迎回来!</h3>
</div>
<div className="mb-4">
<textarea
name="content"
className={clsn(inputClass, "min-h-[7em] resize-y")}
placeholder="评论"
rows={3}
required
value={input.content}
onChange={onChange}
/>
</div>
<button
type="submit"
className="inline-flex items-center justify-center px-6 py-3 rounded-xl bg-pink-400 text-white font-medium hover:bg-pink-500 transition-colors cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed"
disabled={loading}
>
{loading && <RiLoader5Fill className="animate-spin mr-2" />}
提交
</button>
</form>
);
}
return (
<form className={clsn("mb-8", className)} onSubmit={onFormSubmit}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div className="flex flex-col gap-4">
<div>
<label className={labelClass}>昵称</label>
<input
type="text"
name="name"
className={inputClass}
placeholder="园友"
required
value={input.name}
onChange={onChange}
/>
</div>
<div>
<label className={labelClass}>邮箱</label>
<input
type="email"
name="email"
className={inputClass}
placeholder="user@example.com"
required
value={input.email}
onChange={onChange}
/>
</div>
<div>
<label className={labelClass}>站点</label>
<input
type="url"
name="site"
className={inputClass}
placeholder="https://example.com"
value={input.site}
onChange={onChange}
/>
</div>
</div>
<div className="flex flex-col gap-4 md:h-full">
<div className="flex flex-col flex-1 min-h-0">
<label className={labelClass}>评论</label>
<textarea
name="content"
className={clsn(inputClass, "md:flex-1 resize-y")}
placeholder="注意言行举止,来做评论排行榜的前十名吧~"
rows={3}
required
value={input.content}
onChange={onChange}
/>
</div>
<button
type="submit"
className="w-full inline-flex items-center justify-center px-6 py-2.5 rounded-xl border-4 border-transparent bg-pink-400 text-white font-medium hover:bg-pink-500 transition-colors cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed"
disabled={loading}
>
{loading && <RiLoader5Fill className="animate-spin mr-2" />}
提交
</button>
</div>
</div>
</form>
);
}
@@ -0,0 +1,78 @@
import { useState } from "react";
import { RiReplyFill, RiRobotFill } from "@remixicon/react";
import CommentForm, { type CommentFormSubmitValues } from "../comment-form";
interface CommentItemProps {
data: API.Comment.ICommentDetailData;
onSubmit: (values: CommentFormSubmitValues) => Promise<unknown>;
}
export default function CommentItem({ data, onSubmit }: CommentItemProps) {
const [active, setActive] = useState(false);
const onToggleCommentForm = () => setActive((prev) => !prev);
const onReplySubmit = (values: CommentFormSubmitValues) => {
return onSubmit(values).then(() => {
setActive(false);
});
};
return (
<div className="scroll-mt-28 mb-8 last:mb-0" id={`c${data.uuid}`}>
<div className="flex items-start gap-4 relative group">
<img
src={data.avatar || ""}
alt={data.name || ""}
className="w-12 h-12 rounded-xl bg-neutral-200 object-cover shrink-0"
/>
<div className="flex-1 min-w-0">
<a
className="inline-block text-pink-400 font-medium mb-1 hover:underline"
href={data.site || "#"}
target="_blank"
rel="nofollow noreferrer"
>
{data.name}
{data.is_ai_bot && (
<span className="ml-2 text-cyan-500" title="AI 机器人评论">
<RiRobotFill className="size-4" />
</span>
)}
</a>
<p className="whitespace-pre-wrap text-neutral-600 leading-relaxed">
{data.content}
{data.status === "waiting" && (
<span className="text-neutral-400">(审核中)</span>
)}
</p>
</div>
<button
className="absolute right-0 top-0 opacity-0 group-hover:opacity-100 transition-opacity text-pink-400 hover:text-pink-600 cursor-pointer"
onClick={onToggleCommentForm}
title="回复"
>
<RiReplyFill className="size-4" />
</button>
</div>
{active && (
<div className="mt-4 ml-16">
<CommentForm
parent={data.uuid}
root={data.root || data.uuid}
onSubmit={onReplySubmit}
/>
</div>
)}
{data.children && data.children.length > 0 && (
<div className="mt-4 ml-4 md:ml-16 pl-4 border-l-2 border-cyan-200">
{data.children.map((item) => (
<CommentItem key={item.uuid} data={item} onSubmit={onSubmit} />
))}
</div>
)}
</div>
);
}
+188
View File
@@ -0,0 +1,188 @@
import { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router";
import { add } from "~/components/ui/notice/utils";
import { useHydrated } from "~/hooks/use-hydrated";
import { clsn } from "~/utils";
import CommentForm, { type CommentFormSubmitValues } from "../comment-form";
import CommentItem from "../comment-item";
const { APP_APIURL } = import.meta.env;
interface CommentsProps {
className?: string;
refs: string;
}
export default function Comments({ className, refs }: CommentsProps) {
const wrapperRef = useRef<HTMLDivElement>(null);
const location = useLocation();
const hydrated = useHydrated();
const [list, setList] = useState<API.Comment.ICommentDetailData[]>([]);
const [loading, setLoading] = useState(true);
const prevRefsRef = useRef<string>("");
const scrollToComment = (hash: string) => {
const id = hash.replace(/^#/, "");
if (!id || !id.startsWith("c")) {
return;
}
const commentItem = document.querySelector<HTMLDivElement>(`#${id}`);
if (!commentItem) {
console.log("定位评论失败");
return;
}
console.log("定位评论成功");
commentItem.scrollIntoView();
commentItem.classList.add("animate-scale");
const onAnimationEnd = (ev: AnimationEvent) => {
const el = ev.target as HTMLDivElement;
if (el) {
el.classList.remove("animate-scale");
}
commentItem.removeEventListener("animationend", onAnimationEnd);
};
commentItem.addEventListener("animationend", onAnimationEnd);
};
const getCommentRequest = () => {
fetch(`${APP_APIURL}/api/comment/detail/?ref=${encodeURIComponent(refs)}`)
.then((res) => res.json())
.then((res: API.PageResponse<API.Comment.ICommentDetailData[]>) => {
if (res.status === "Success") {
setList(res.data);
setLoading(false);
}
if (location.hash.match(/^#c/)) {
scrollToComment(location.hash);
}
});
};
useEffect(() => {
if (!hydrated || !wrapperRef.current) {
return;
}
if (location.hash.match(/^#c/)) {
wrapperRef.current.scrollIntoView();
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && prevRefsRef.current !== refs) {
console.log("获取评论");
prevRefsRef.current = refs;
getCommentRequest();
}
});
},
{
threshold: 0.1,
},
);
observer.observe(wrapperRef.current);
return () => {
observer.disconnect();
};
}, [hydrated, refs, location.hash]);
useEffect(() => {
if (hydrated && location.hash.match(/^#c/) && wrapperRef.current) {
scrollToComment(location.hash);
}
}, [hydrated, location.hash]);
const addCommentRequest = (data: CommentFormSubmitValues) => {
return new Promise<void>((resolve, reject) => {
fetch(`${APP_APIURL}/api/comment/add`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...data, ref: refs }),
})
.then((res) => res.json())
.then((res: API.Response<API.Comment.ICommentDetailData>) => {
if (res.status === "Success") {
add({
title: "评论成功",
type: "success",
content: res.msg,
});
if (res.data) {
if (res.data.root) {
setList((prev) => {
const index = prev.findIndex((item) => item.uuid === res.data.root);
if (index > -1) {
const next = [...prev];
next[index] = {
...next[index],
children: [res.data, ...next[index].children],
};
return next;
}
return prev;
});
}
else {
setList((prev) => [{ ...res.data, children: [] }, ...prev]);
}
}
resolve();
}
else {
add({
title: "评论失败",
type: "error",
content: res.msg,
});
reject();
}
})
.catch(() => {
reject();
});
});
};
return (
<div ref={wrapperRef} className={clsn("bg-white rounded-xl border-b-4 border-b-cyan-200 p-5 md:p-8", className)} id="comments">
<h3 className="text-pink-400 text-2xl font-bold mb-6">
评论
</h3>
<CommentForm onSubmit={addCommentRequest} />
{list.length > 0 ? (
<div>
{list.map((item) => (
<CommentItem key={item.uuid} data={item} onSubmit={addCommentRequest} />
))}
</div>
) : (
<div className="text-center opacity-50 py-8">
{loading
? "评论获取中"
: list.length === 0
? "快来成为第一个评论的人吧"
: null}
</div>
)}
</div>
);
}