This commit is contained in:
ditiqi 2025-01-26 19:33:45 +08:00
parent 262dfade4e
commit 8ce5d689c2
9 changed files with 185 additions and 202 deletions

View File

@ -4,7 +4,7 @@ import {
CheckCircleOutlined, CheckCircleOutlined,
DeleteOutlined, DeleteOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { Upload, Progress, Button } from "antd"; import { Upload, Progress, Button } from "antd";
import type { UploadFile } from "antd"; import type { UploadFile } from "antd";
import { useTusUpload } from "@web/src/hooks/useTusUpload"; import { useTusUpload } from "@web/src/hooks/useTusUpload";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
@ -13,6 +13,7 @@ export interface TusUploaderProps {
value?: string[]; value?: string[];
onChange?: (value: string[]) => void; onChange?: (value: string[]) => void;
} }
interface UploadingFile { interface UploadingFile {
name: string; name: string;
progress: number; progress: number;
@ -33,106 +34,92 @@ export const TusUploader = ({ value = [], onChange }: TusUploaderProps) => {
fileId, fileId,
})) || [] })) || []
); );
// 恢复使用 uploadResults 状态跟踪最新结果
const [uploadResults, setUploadResults] = useState<string[]>(value || []); const [uploadResults, setUploadResults] = useState<string[]>(value || []);
const handleRemoveFile = useCallback( const handleRemoveFile = useCallback(
(fileId: string) => { (fileId: string) => {
setCompletedFiles((prev) => setCompletedFiles((prev) =>
prev.filter((f) => f.fileId !== fileId) prev.filter((f) => f.fileId !== fileId)
); );
const newResults = uploadResults.filter((id) => id !== fileId); // 使用函数式更新保证获取最新状态
setUploadResults(newResults); setUploadResults((prev) => {
onChange?.(newResults); const newValue = prev.filter((id) => id !== fileId);
onChange?.(newValue); // 同步更新父组件
return newValue;
});
}, },
[uploadResults, onChange] [onChange]
); );
const handleChange = useCallback( const handleBeforeUpload = useCallback(
async (fileList: UploadFile | UploadFile[]) => { (file: File) => {
const files = Array.isArray(fileList) ? fileList : [fileList]; const fileKey = `${file.name}-${Date.now()}`;
console.log("文件", files);
if (!files.every((f) => f instanceof File)) { setUploadingFiles((prev) => [
toast.error("无效的文件格式"); ...prev,
return false; {
} name: file.name,
progress: 0,
status: "uploading",
fileKey,
},
]);
const newFiles: UploadingFile[] = files.map((f) => ({ handleFileUpload(
name: f.name, file,
progress: 0, (result) => {
status: "uploading" as const, setCompletedFiles((prev) => [
fileKey: `${f.name}-${Date.now()}`, // 为每个文件创建唯一标识 ...prev,
})); {
name: file.name,
progress: 100,
status: "done",
fileId: result.fileId,
},
]);
setUploadingFiles((prev) => [...prev, ...newFiles]); setUploadingFiles((prev) =>
prev.filter((f) => f.fileKey !== fileKey)
const newUploadResults: string[] = [];
try {
for (const [index, f] of files.entries()) {
if (!f) {
throw new Error(`文件 ${f.name} 无效`);
}
const fileKey = newFiles[index].fileKey!;
const fileId = await new Promise<string>(
(resolve, reject) => {
handleFileUpload(
f as File,
(result) => {
console.log("上传成功:", result);
const completedFile = {
name: f.name,
progress: 100,
status: "done" as const,
fileId: result.fileId,
};
setCompletedFiles((prev) => [
...prev,
completedFile,
]);
setUploadingFiles((prev) =>
prev.filter(
(file) => file.fileKey !== fileKey
)
);
resolve(result.fileId);
},
(error) => {
console.error("上传错误:", error);
reject(error);
},
fileKey
);
}
); );
newUploadResults.push(fileId);
}
const newValue = Array.from( // 正确的状态更新方式
new Set([...uploadResults, ...newUploadResults]) setUploadResults((prev) => {
); const newValue = [...prev, result.fileId];
setUploadResults(newValue); onChange?.(newValue); // 传递值而非函数
onChange?.(newValue); return newValue;
} catch (error) { });
console.error("上传错误详情:", error); },
toast.error( (error) => {
`上传失败: ${error instanceof Error ? error.message : "未知错误"}` console.error("上传错误:", error);
); toast.error(
setUploadingFiles((prev) => `上传失败: ${
prev.map((f) => ({ ...f, status: "error" })) error instanceof Error ? error.message : "未知错误"
); }`
} );
setUploadingFiles((prev) =>
prev.map((f) =>
f.fileKey === fileKey
? { ...f, status: "error" }
: f
)
);
},
fileKey
);
return false; return false;
}, },
[uploadResults, onChange, handleFileUpload] [handleFileUpload, onChange]
); );
return ( return (
<div className="space-y-1"> <div className="space-y-1">
<Upload.Dragger <Upload.Dragger
name="files" name="files"
multiple
showUploadList={false} showUploadList={false}
style={{ background: "transparent", borderStyle: "none" }} style={{ background: "transparent", borderStyle: "none" }}
beforeUpload={handleChange}> beforeUpload={handleBeforeUpload}>
<p className="ant-upload-drag-icon"> <p className="ant-upload-drag-icon">
<UploadOutlined /> <UploadOutlined />
</p> </p>
@ -140,64 +127,58 @@ export const TusUploader = ({ value = [], onChange }: TusUploaderProps) => {
</p> </p>
<p className="ant-upload-hint"></p> <p className="ant-upload-hint"></p>
{/* 正在上传的文件 */}
{(uploadingFiles.length > 0 || completedFiles.length > 0) && (
<div className=" px-2 py-0 rounded mt-1 ">
{uploadingFiles.map((file) => (
<div
key={file.fileKey}
className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<div className="text-sm">{file.name}</div>
</div>
<Progress {/* 上传状态展示 */}
className="flex-1 w-full" <div className="px-2 py-0 rounded mt-1">
percent={ {/* 上传中的文件 */}
file.status === "done" {uploadingFiles.map((file) => (
? 100 <div
: Math.round( key={file.fileKey}
uploadProgress?.[ className="flex flex-col gap-1 mb-2">
file?.fileKey <div className="flex items-center gap-2">
] || 0 <span className="text-sm">{file.name}</span>
)
}
status={
file.status === "error"
? "exception"
: file.status === "done"
? "success"
: "active"
}
/>
</div> </div>
))} <Progress
{completedFiles.length > 0 && percent={
completedFiles.map((file, index) => ( file.status === "done"
<div ? 100
key={index} : Math.round(
className="flex items-center justify-between gap-2"> uploadProgress?.[
<div className="flex items-center gap-2"> file.fileKey!
<CheckCircleOutlined className="text-green-500" /> ] || 0
<div className="text-sm"> )
{file.name} }
</div> status={
</div> file.status === "error"
<Button ? "exception"
type="text" : "active"
danger }
icon={<DeleteOutlined />} />
onClick={(e) => { </div>
e.stopPropagation(); // 阻止事件冒泡 ))}
if (file.fileId) {
handleRemoveFile(file.fileId); // 只删除文件 {/* 已完成的文件 */}
} {completedFiles.map((file) => (
}} <div
/> key={file.fileId}
</div> className="flex items-center justify-between gap-2 mb-2">
))} <div className="flex items-center gap-2">
</div> <CheckCircleOutlined className="text-green-500" />
)} <span className="text-sm">{file.name}</span>
</div>
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={(e) => {
e.stopPropagation();
if (file.fileId)
handleRemoveFile(file.fileId);
}}
/>
</div>
))}
</div>
</Upload.Dragger> </Upload.Dragger>
</div> </div>
); );

View File

@ -1,52 +1,26 @@
import { Link, NavLink, useNavigate } from "react-router-dom"; import { Link, NavLink, useNavigate } from "react-router-dom";
import { memo, useMemo } from "react"; import { memo } from "react";
import { SearchBar } from "./SearchBar"; import { SearchBar } from "./SearchBar";
import Navigation from "./navigation"; import Navigation from "./navigation";
import { useAuth } from "@web/src/providers/auth-provider"; import { useAuth } from "@web/src/providers/auth-provider";
import { UserOutlined } from "@ant-design/icons"; import { UserOutlined } from "@ant-design/icons";
import { UserMenu } from "../element/usermenu/usermenu"; import { UserMenu } from "../element/usermenu/usermenu";
import { api, useAppConfig } from "@nice/client";
import { env } from "@web/src/env";
export const Header = memo(function Header() { export const Header = memo(function Header() {
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const { logo } = useAppConfig();
const { data: logoRes, isLoading } = api.resource.findFirst.useQuery(
{
where: {
fileId: logo,
},
select: {
id: true,
url: true,
},
},
{
enabled: !!logo,
}
);
const logoUrl: string = useMemo(() => {
return `http://${env.SERVER_IP}/uploads/${logoRes?.url}`;
}, [logoRes]);
return ( return (
<header className="sticky top-0 z-50 bg-gradient-to-br from-primary-500 to-primary-800 text-white shadow-lg"> <header className="sticky top-0 z-50 bg-gradient-to-br from-primary-500 to-primary-800 text-white shadow-lg">
<div className=" mx-auto px-4"> <div className=" mx-auto px-4">
<div className="py-2"> <div className="py-4">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className=""> <div className="">
{/** 在这里放置logo */} <span className="text-xl font-bold">
{isLoading ? (
<div className="w-48 h-24 bg-gray-300 animate-pulse"></div> </span>
) : ( <p className=" text-sm text-secondary-50">
logoUrl && ( 怀
<img </p>
src={logoUrl}
alt="Logo"
style={{ width: 192, height: 72 }}
className=" w-full h-full"
/>
)
)}
</div> </div>
<div className="flex-grow max-w-2xl"> <div className="flex-grow max-w-2xl">
<SearchBar /> <SearchBar />

View File

@ -49,7 +49,9 @@ export function LetterCard({ letter }: LetterCardProps) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<UserOutlined className="text-primary text-base" /> <UserOutlined className="text-primary text-base" />
<Text className="text-primary font-medium"> <Text className="text-primary font-medium">
{letter.author?.showname || "匿名用户"} {letter?.meta?.signature ||
letter.author?.showname ||
"匿名用户"}
</Text> </Text>
</div> </div>

View File

@ -37,14 +37,18 @@ export default function PostCommentCard({
src={post.author?.avatar} src={post.author?.avatar}
size={50} size={50}
name={!post.author?.avatar && post.author?.showname} name={!post.author?.avatar && post.author?.showname}
ip={post?.meta?.ip}></CustomAvatar> randomString={
post?.meta?.signature || post?.meta?.ip
}></CustomAvatar>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className={`flex-1 min-w-0 `}> <div className={`flex-1 min-w-0 `}>
<div className="flex flex-1 justify-between "> <div className="flex flex-1 justify-between ">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span className="flex font-medium text-slate-900"> <span className="flex font-medium text-slate-900">
{post.author?.showname || "匿名用户"} {post?.meta?.signature ||
post.author?.showname ||
"匿名用户"}
</span> </span>
<span className="flex text-sm text-slate-500 "> <span className="flex text-sm text-slate-500 ">
{dayjs(post?.createdAt).format( {dayjs(post?.createdAt).format(

View File

@ -1,6 +1,6 @@
import React, { useContext, useState } from "react"; import React, { useContext, useState } from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { Button, Tabs } from "antd"; import { Button, Input, Tabs } from "antd";
import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor"; import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor";
import { PostDetailContext } from "./context/PostDetailContext"; import { PostDetailContext } from "./context/PostDetailContext";
import { usePost } from "@nice/client"; import { usePost } from "@nice/client";
@ -9,13 +9,14 @@ import toast from "react-hot-toast";
import { isContentEmpty } from "./utils"; import { isContentEmpty } from "./utils";
import { SendOutlined } from "@ant-design/icons"; import { SendOutlined } from "@ant-design/icons";
import { TusUploader } from "@web/src/components/common/uploader/TusUploader"; import { TusUploader } from "@web/src/components/common/uploader/TusUploader";
import { CustomAvatar } from "@web/src/components/presentation/CustomAvatar";
const { TabPane } = Tabs; const { TabPane } = Tabs;
export default function PostCommentEditor() { export default function PostCommentEditor() {
const { post } = useContext(PostDetailContext); const { post } = useContext(PostDetailContext);
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [isPreview, setIsPreview] = useState(false); const [signature, setSignature] = useState<string | undefined>(undefined);
const [fileIds, setFileIds] = useState<string[]>([]); const [fileIds, setFileIds] = useState<string[]>([]);
const { create } = usePost(); const { create } = usePost();
@ -38,10 +39,14 @@ export default function PostCommentEditor() {
fileId: id, fileId: id,
})), })),
}, },
meta: {
signature,
},
}, },
}); });
toast.success("发布成功!"); toast.success("发布成功!");
setContent(""); setContent("");
setFileIds([]);
} catch (error) { } catch (error) {
toast.error("发布失败,请稍后重试"); toast.error("发布失败,请稍后重试");
console.error("Error posting comment:", error); console.error("Error posting comment:", error);
@ -82,6 +87,7 @@ export default function PostCommentEditor() {
<div className="relative rounded-xl border border-white hover:ring-1 ring-white transition-all duration-300 ease-in-out bg-slate-100"> <div className="relative rounded-xl border border-white hover:ring-1 ring-white transition-all duration-300 ease-in-out bg-slate-100">
<TusUploader <TusUploader
onChange={(value) => { onChange={(value) => {
console.log("ids", value);
setFileIds(value); setFileIds(value);
}} }}
/> />
@ -90,12 +96,23 @@ export default function PostCommentEditor() {
</Tabs> </Tabs>
{!isContentEmpty(content) && ( {!isContentEmpty(content) && (
<div className="flex items-center justify-end"> <div className="flex items-center justify-end gap-2">
<CustomAvatar randomString={signature}></CustomAvatar>
<Input
maxLength={10}
style={{
width: 150,
}}
onChange={(e) => {
setSignature(e.target.value);
}}
showCount
placeholder="签名"
/>
<div> <div>
<Button <Button
type="primary" type="primary"
htmlType="submit" htmlType="submit"
size="large"
disabled={isContentEmpty(content)} disabled={isContentEmpty(content)}
className="flex items-center space-x-2 bg-primary" className="flex items-center space-x-2 bg-primary"
icon={<SendOutlined />}> icon={<SendOutlined />}>

View File

@ -26,16 +26,15 @@ export default function Header() {
</h1> </h1>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Space className="mr-4"> <Space className="mr-4">
<span className="text-white"></span> <span className="text-white"></span>
<Text className="text-white" strong> <Text className="text-white" strong>
{post?.author?.showname || "匿名用户"} {post?.meta?.signature ||
post?.author?.showname ||
"匿名用户"}
</Text> </Text>
</Space> </Space>
<Space className="mr-4"> <Space className="mr-4">
<span className="text-white"> </span> <span className="text-white"> </span>
{post?.receivers?.map((receiver, index) => ( {post?.receivers?.map((receiver, index) => (
@ -43,27 +42,23 @@ export default function Header() {
strong strong
className="text-white" className="text-white"
key={`${index}`}> key={`${index}`}>
{receiver?.meta?.rank} {receiver?.showname || '匿名用户'} {receiver?.meta?.rank}{" "}
{receiver?.showname || "匿名用户"}
</Text> </Text>
))} ))}
</Space> </Space>
{/* Date Info Badge */} {/* Date Info Badge */}
<Space className="mr-4"> <Space className="mr-4">
<span className="text-white"></span> <span className="text-white"></span>
<Text className="text-white"> <Text className="text-white">
{dayjs(post?.createdAt).format("YYYY-MM-DD")} {dayjs(post?.createdAt).format("YYYY-MM-DD")}
</Text> </Text>
</Space> </Space>
{/* Last Updated Badge */} {/* Last Updated Badge */}
<Space className="mr-4"> <Space className="mr-4">
<span className="text-white"></span> <span className="text-white"></span>
<Text className="text-white"> <Text className="text-white">
{dayjs(post?.updatedAt).format("YYYY-MM-DD")} {dayjs(post?.updatedAt).format("YYYY-MM-DD")}
</Text> </Text>
</Space> </Space>

View File

@ -36,8 +36,8 @@ export default function PostHateButton({ post }: { post: PostDto }) {
type={post?.hated ? "primary" : "default"} type={post?.hated ? "primary" : "default"}
style={{ style={{
backgroundColor: post?.hated ? "#ff4d4f" : "#fff", backgroundColor: post?.hated ? "#ff4d4f" : "#fff",
borderColor: post?.hated ? "transparent" : "#ff4d4f", borderColor: post?.hated ? "transparent" : "",
color: post?.hated ? "#fff" : "#ff4d4f", color: post?.hated ? "#fff" : "#000",
boxShadow: "none", // 去除阴影 boxShadow: "none", // 去除阴影
}} }}
shape="round" shape="round"

View File

@ -79,8 +79,7 @@ export function LetterBasicForm() {
</TabPane> </TabPane>
<TabPane tab="附件" key="2"> <TabPane tab="附件" key="2">
<Form.Item name="resources" required={false}> <Form.Item name="resources" required={false}>
<div className="rounded-xl border border-white hover:ring-1 ring-white transition-all duration-300 ease-in-out bg-slate-100"> <div className="rounded-xl border border-white hover:ring-1 ring-white transition-all duration-300 ease-in-out bg-slate-100">
<TusUploader <TusUploader
onChange={(resources) => onChange={(resources) =>
form.setFieldValue( form.setFieldValue(
@ -89,8 +88,7 @@ export function LetterBasicForm() {
) )
} }
/> />
</div>
</div>
</Form.Item> </Form.Item>
</TabPane> </TabPane>
</Tabs> </Tabs>
@ -101,14 +99,26 @@ export function LetterBasicForm() {
</Checkbox> </Checkbox>
</Form.Item> </Form.Item>
<Button <div className="flex gap-2 ">
type="primary" <Form.Item name={["meta", "signature"]}>
onClick={() => form.submit()} <Input
size="large" maxLength={10}
icon={<SendOutlined />} style={{
className="w-full sm:w-40"> width: 150,
}}
</Button> showCount
placeholder="签名"
/>
</Form.Item>
<Button
type="primary"
onClick={() => form.submit()}
size="large"
icon={<SendOutlined />}
className="w-full sm:w-40">
</Button>
</div>
</div> </div>
</Form> </Form>
</div> </div>

View File

@ -6,14 +6,14 @@ import multiavatar from "@multiavatar/multiavatar";
interface CustomAvatarProps extends Omit<AvatarProps, "children"> { interface CustomAvatarProps extends Omit<AvatarProps, "children"> {
src?: string; src?: string;
name?: string; name?: string;
ip?: string; randomString?: string;
} }
export function CustomAvatar({ export function CustomAvatar({
src, src,
name, name,
className = "", className = "",
ip, randomString,
...props ...props
}: CustomAvatarProps) { }: CustomAvatarProps) {
// 获取名字的第一个字符,如果没有名字则显示"匿" // 获取名字的第一个字符,如果没有名字则显示"匿"
@ -39,7 +39,7 @@ export function CustomAvatar({
const avatarSrc = const avatarSrc =
src || (name && name !== "匿名用户") src || (name && name !== "匿名用户")
? src ? src
: generateAvatarFromIp(ip || "default"); : generateAvatarFromIp(randomString || "default");
return ( return (
<Avatar <Avatar