add 0125-2239
This commit is contained in:
parent
8f43808ea2
commit
b8a0721358
|
@ -30,12 +30,13 @@
|
|||
"@floating-ui/react": "^0.26.25",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@multiavatar/multiavatar": "^1.0.7",
|
||||
"@nice/client": "workspace:^",
|
||||
"@nice/common": "workspace:^",
|
||||
"@nice/iconer": "workspace:^",
|
||||
"@nice/theme": "workspace:^",
|
||||
"@nice/utils": "workspace:^",
|
||||
"@nice/ui": "workspace:^",
|
||||
"@nice/utils": "workspace:^",
|
||||
"@tanstack/query-async-storage-persister": "^5.51.9",
|
||||
"@tanstack/react-query": "^5.51.21",
|
||||
"@tanstack/react-query-persist-client": "^5.51.9",
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
UploadOutlined,
|
||||
CheckCircleOutlined,
|
||||
|
@ -23,13 +23,14 @@ interface UploadingFile {
|
|||
export const TusUploader = ({ value = [], onChange }: TusUploaderProps) => {
|
||||
const { handleFileUpload } = useTusUpload();
|
||||
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([]);
|
||||
const [completedFiles, setCompletedFiles] = useState<UploadingFile[]>(() =>
|
||||
value?.map(fileId => ({
|
||||
name: `File ${fileId}`, // We could fetch the actual filename if needed
|
||||
progress: 1,
|
||||
status: 'done' as const,
|
||||
fileId
|
||||
})) || []
|
||||
const [completedFiles, setCompletedFiles] = useState<UploadingFile[]>(
|
||||
() =>
|
||||
value?.map((fileId) => ({
|
||||
name: `文件 ${fileId}`, // 可以根据需要获取实际文件名
|
||||
progress: 1,
|
||||
status: "done" as const,
|
||||
fileId,
|
||||
})) || []
|
||||
);
|
||||
const [uploadResults, setUploadResults] = useState<string[]>(value || []);
|
||||
|
||||
|
@ -38,7 +39,7 @@ export const TusUploader = ({ value = [], onChange }: TusUploaderProps) => {
|
|||
setCompletedFiles((prev) =>
|
||||
prev.filter((f) => f.fileId !== fileId)
|
||||
);
|
||||
const newResults = uploadResults.filter(id => id !== fileId);
|
||||
const newResults = uploadResults.filter((id) => id !== fileId);
|
||||
setUploadResults(newResults);
|
||||
onChange?.(newResults);
|
||||
},
|
||||
|
@ -48,10 +49,10 @@ export const TusUploader = ({ value = [], onChange }: TusUploaderProps) => {
|
|||
const handleChange = useCallback(
|
||||
async (fileList: UploadFile | UploadFile[]) => {
|
||||
const files = Array.isArray(fileList) ? fileList : [fileList];
|
||||
console.log("files", files);
|
||||
// 验证文件对象
|
||||
console.log("文件", files);
|
||||
|
||||
if (!files.every((f) => f instanceof File)) {
|
||||
message.error("Invalid file format");
|
||||
message.error("无效的文件格式");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -61,20 +62,19 @@ export const TusUploader = ({ value = [], onChange }: TusUploaderProps) => {
|
|||
status: "uploading" as const,
|
||||
}));
|
||||
setUploadingFiles((prev) => [...prev, ...newFiles]);
|
||||
const newUploadResults: string[] = [];
|
||||
|
||||
const newUploadResults: string[] = [];
|
||||
try {
|
||||
for (const [index, f] of files.entries()) {
|
||||
if (!f) {
|
||||
throw new Error(`File ${f.name} is invalid`);
|
||||
throw new Error(`文件 ${f.name} 无效`);
|
||||
}
|
||||
|
||||
const fileId = await new Promise<string>(
|
||||
(resolve, reject) => {
|
||||
handleFileUpload(
|
||||
f as File,
|
||||
(result) => {
|
||||
console.log("Upload success:", result);
|
||||
console.log("上传成功:", result);
|
||||
const completedFile = {
|
||||
name: f.name,
|
||||
progress: 1,
|
||||
|
@ -91,7 +91,7 @@ export const TusUploader = ({ value = [], onChange }: TusUploaderProps) => {
|
|||
resolve(result.fileId);
|
||||
},
|
||||
(error) => {
|
||||
console.error("Upload error:", error);
|
||||
console.error("上传错误:", error);
|
||||
reject(error);
|
||||
}
|
||||
);
|
||||
|
@ -100,97 +100,93 @@ export const TusUploader = ({ value = [], onChange }: TusUploaderProps) => {
|
|||
newUploadResults.push(fileId);
|
||||
}
|
||||
|
||||
// Update with all uploaded files
|
||||
const newValue = Array.from(new Set([...uploadResults, ...newUploadResults]));
|
||||
const newValue = Array.from(
|
||||
new Set([...uploadResults, ...newUploadResults])
|
||||
);
|
||||
setUploadResults(newValue);
|
||||
onChange?.(newValue);
|
||||
message.success(`${files.length} files uploaded successfully`);
|
||||
message.success(`${files.length} 个文件上传成功`);
|
||||
} catch (error) {
|
||||
console.error("Upload error details:", error);
|
||||
console.error("上传错误详情:", error);
|
||||
message.error(
|
||||
`Upload failed: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||
`上传失败: ${error instanceof Error ? error.message : "未知错误"}`
|
||||
);
|
||||
setUploadingFiles((prev) =>
|
||||
prev.map((f) => ({ ...f, status: "error" }))
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[uploadResults, onChange, handleFileUpload]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Upload.Dragger
|
||||
name="files"
|
||||
multiple
|
||||
showUploadList={false}
|
||||
beforeUpload={handleChange}
|
||||
style={{
|
||||
border: "2px dashed #1677ff",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: "#f0f8ff",
|
||||
}}>
|
||||
style={{ background: "white" }}
|
||||
beforeUpload={handleChange}>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<UploadOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">
|
||||
Click or drag file to this area to upload
|
||||
</p>
|
||||
<p className="ant-upload-hint">
|
||||
Support for a single or bulk upload of files
|
||||
点击或拖拽文件到此区域进行上传
|
||||
</p>
|
||||
<p className="ant-upload-hint">支持单个或批量上传文件</p>
|
||||
{/* 正在上传的文件 */}
|
||||
{(uploadingFiles.length > 0 || completedFiles.length > 0) && (
|
||||
<div className=" p-2 border rounded bg-white mt-1">
|
||||
{uploadingFiles.length > 0 &&
|
||||
uploadingFiles.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm">
|
||||
{file.name}
|
||||
</div>
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round(
|
||||
file.progress * 100
|
||||
)}
|
||||
status={
|
||||
file.status === "error"
|
||||
? "exception"
|
||||
: file.status === "done"
|
||||
? "success"
|
||||
: "active"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{completedFiles.length > 0 &&
|
||||
completedFiles.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between gap-2 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircleOutlined className="text-green-500" />
|
||||
<div className="text-sm">
|
||||
{file.name}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() =>
|
||||
file.fileId &&
|
||||
handleRemoveFile(file.fileId)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Upload.Dragger>
|
||||
|
||||
{/* Uploading Files */}
|
||||
{uploadingFiles.length > 0 && (
|
||||
<div className="space-y-2 p-4 border rounded">
|
||||
<div className="font-medium">Uploading Files</div>
|
||||
{uploadingFiles.map((file, index) => (
|
||||
<div key={index} className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm">{file.name}</div>
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round(file.progress * 100)}
|
||||
status={
|
||||
file.status === "error"
|
||||
? "exception"
|
||||
: file.status === "done"
|
||||
? "success"
|
||||
: "active"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completed Files */}
|
||||
{completedFiles.length > 0 && (
|
||||
<div className="space-y-2 p-4 border rounded">
|
||||
<div className="font-medium">Uploaded Files</div>
|
||||
{completedFiles.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between gap-2 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircleOutlined className="text-green-500" />
|
||||
<div className="text-sm">{file.name}</div>
|
||||
</div>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() =>
|
||||
file.fileId && handleRemoveFile(file.fileId)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -4,7 +4,7 @@ import dayjs from "dayjs";
|
|||
|
||||
import { Avatar } from "antd";
|
||||
import { useVisitor } from "@nice/client";
|
||||
import { useContext, useState } from "react";
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import { PostDetailContext } from "./context/PostDetailContext";
|
||||
import { LikeFilled, LikeOutlined } from "@ant-design/icons";
|
||||
import PostLikeButton from "./PostHeader/PostLikeButton";
|
||||
|
@ -24,51 +24,55 @@ export default function PostCommentCard({
|
|||
<motion.div
|
||||
className="bg-white rounded-lg shadow-sm border border-slate-200 p-4"
|
||||
layout>
|
||||
<div className="flex items-start space-x-3 gap-4">
|
||||
<div className="flex items-start space-x-2 gap-2">
|
||||
<div className="flex-shrink-0">
|
||||
<CustomAvatar
|
||||
src={post.author?.avatar}
|
||||
size={40}
|
||||
name={
|
||||
!post.author?.avatar && post.author?.showname
|
||||
}></CustomAvatar>
|
||||
size={50}
|
||||
name={!post.author?.avatar && post.author?.showname}
|
||||
ip={post?.meta?.ip}></CustomAvatar>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-1 justify-between ">
|
||||
<div className="flex space-x-2" style={{ height: 40 }}>
|
||||
<span className="flex font-medium text-slate-900">
|
||||
{post.author?.showname || "匿名用户"}
|
||||
</span>
|
||||
<span className="flex text-sm text-slate-500">
|
||||
{dayjs(post?.createdAt).format(
|
||||
"YYYY-MM-DD HH:mm"
|
||||
<div className="flex-1">
|
||||
<div className={`flex-1 min-w-0 `}>
|
||||
<div className="flex flex-1 justify-between ">
|
||||
<div className="flex space-x-2">
|
||||
<span className="flex font-medium text-slate-900">
|
||||
{post.author?.showname || "匿名用户"}
|
||||
</span>
|
||||
<span className="flex text-sm text-slate-500 ">
|
||||
{dayjs(post?.createdAt).format(
|
||||
"YYYY-MM-DD HH:mm"
|
||||
)}
|
||||
</span>
|
||||
{isReceiverComment && (
|
||||
<div className=" ">
|
||||
<span className=" py-0.5 px-2 text-xs rounded-full bg-blue-100 text-blue-800">
|
||||
官方回复
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
{isReceiverComment && (
|
||||
<div className=" ">
|
||||
<span className=" px-2 text-sm rounded-full bg-blue-100 text-blue-800">
|
||||
官方回复
|
||||
</span>
|
||||
</div>
|
||||
{/* 添加有帮助按钮 */}
|
||||
<div>
|
||||
<div className="flex justify-center items-center gap-2">
|
||||
<span className=" text-sm text-slate-500">{`#${index + 1}`}</span>
|
||||
<PostLikeButton
|
||||
post={post}></PostLikeButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 添加有帮助按钮 */}
|
||||
<div>
|
||||
<div className="flex justify-center items-center gap-2">
|
||||
<span className=" text-sm text-slate-500">{`#${index + 1}`}</span>
|
||||
<PostLikeButton post={post}></PostLikeButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="ql-editor text-slate-800"
|
||||
style={{
|
||||
padding: 0,
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: post.content || "" }}
|
||||
/>
|
||||
<PostResources post={post}></PostResources>
|
||||
<div
|
||||
className="ql-editor text-slate-800 mt-1"
|
||||
style={{
|
||||
padding: 0,
|
||||
}}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: post.content || "",
|
||||
}}
|
||||
/>
|
||||
<PostResources post={post}></PostResources>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import React, { useContext, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { Button } from "antd";
|
||||
import { Button, Tabs } from "antd";
|
||||
import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor";
|
||||
import { PostDetailContext } from "./context/PostDetailContext";
|
||||
import { usePost } from "@nice/client";
|
||||
|
@ -10,12 +9,16 @@ import toast from "react-hot-toast";
|
|||
import { isContentEmpty } from "./utils";
|
||||
import { SendOutlined } from "@ant-design/icons";
|
||||
import { TusUploader } from "@web/src/components/common/uploader/TusUploader";
|
||||
|
||||
const { TabPane } = Tabs;
|
||||
|
||||
export default function PostCommentEditor() {
|
||||
const { post } = useContext(PostDetailContext);
|
||||
const [content, setContent] = useState("");
|
||||
const [isPreview, setIsPreview] = useState(false);
|
||||
const [fileIds, setFileIds] = useState<string[]>([]);
|
||||
const { create } = usePost();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (isContentEmpty(content)) {
|
||||
|
@ -49,56 +52,51 @@ export default function PostCommentEditor() {
|
|||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="w-full mx-auto mt-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div className="relative rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||
{!isPreview ? (
|
||||
<QuillEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
placeholder="写下你的回复..."
|
||||
className="bg-transparent"
|
||||
theme="snow"
|
||||
minRows={6}
|
||||
maxRows={12}
|
||||
modules={{
|
||||
toolbar: [
|
||||
["bold", "italic", "strike"],
|
||||
["blockquote", "code-block"],
|
||||
[{ list: "ordered" }, { list: "bullet" }],
|
||||
["link"],
|
||||
["clean"],
|
||||
],
|
||||
<div className="w-full mx-auto mt-1">
|
||||
<form onSubmit={handleSubmit} className="space-y-2">
|
||||
<Tabs defaultActiveKey="1">
|
||||
<TabPane tab="回复" key="1">
|
||||
<div className="relative rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||
<QuillEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
placeholder="写下你的回复..."
|
||||
className="bg-transparent"
|
||||
theme="snow"
|
||||
minRows={6}
|
||||
maxRows={12}
|
||||
modules={{
|
||||
toolbar: [
|
||||
["bold", "italic", "strike"],
|
||||
["blockquote", "code-block"],
|
||||
[
|
||||
{ list: "ordered" },
|
||||
{ list: "bullet" },
|
||||
],
|
||||
["link"],
|
||||
["clean"],
|
||||
],
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--ql-border-color": "transparent",
|
||||
"--ql-toolbar-bg": "rgb(248, 250, 252)", // slate-50
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TabPane>
|
||||
<TabPane tab="附件" key="2">
|
||||
<TusUploader
|
||||
onChange={(value) => {
|
||||
setFileIds(value);
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--ql-border-color": "transparent",
|
||||
"--ql-toolbar-bg": "rgb(248, 250, 252)", // slate-50
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="ql-editor p-4 min-h-[120px] text-slate-800 prose prose-slate max-w-none quill-editor-container"
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TusUploader
|
||||
onChange={(value) => {
|
||||
setFileIds(value);
|
||||
}}></TusUploader>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}>
|
||||
<div>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
|
@ -107,9 +105,9 @@ export default function PostCommentEditor() {
|
|||
icon={<SendOutlined />}>
|
||||
提交
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,10 +1,4 @@
|
|||
import React, {
|
||||
useContext,
|
||||
useMemo,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { useContext, useMemo, useEffect } from "react";
|
||||
import { PostDetailContext } from "./context/PostDetailContext";
|
||||
import { api, useVisitor } from "@nice/client";
|
||||
import { postDetailSelect, PostDto, PostType, Prisma } from "@nice/common";
|
||||
|
@ -12,8 +6,6 @@ import { motion, AnimatePresence } from "framer-motion";
|
|||
import PostCommentCard from "./PostCommentCard";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import { LoadingCard } from "@web/src/components/models/post/detail/LoadingCard";
|
||||
import { Button } from "antd";
|
||||
|
||||
export default function PostCommentList() {
|
||||
const { post } = useContext(PostDetailContext);
|
||||
const { ref: loadMoreRef, inView } = useInView();
|
||||
|
@ -155,7 +147,7 @@ export default function PostCommentList() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 mt-6">
|
||||
<div className="space-y-2 mt-2">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{items.map((comment, index) => (
|
||||
<motion.div
|
||||
|
|
|
@ -2,48 +2,59 @@ import { useContext } from "react";
|
|||
import { useState, useRef, useEffect } from "react";
|
||||
import { PostDetailContext } from "../context/PostDetailContext";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { StatsSection } from "./StatsSection";
|
||||
|
||||
import PostResources from "../PostResources";
|
||||
|
||||
export default function Content() {
|
||||
const { post, user } = useContext(PostDetailContext);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const contentRef = useRef(null);
|
||||
const contentWrapperRef = useRef(null);
|
||||
const [shouldCollapse, setShouldCollapse] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (contentRef.current) {
|
||||
const shouldCollapse = contentRef.current.scrollHeight > 300; // 300px threshold
|
||||
if (contentWrapperRef.current) {
|
||||
const shouldCollapse = contentWrapperRef.current.scrollHeight > 100;
|
||||
setShouldCollapse(shouldCollapse);
|
||||
}
|
||||
}, [post?.content]);
|
||||
|
||||
return (
|
||||
<div className="relative bg-white rounded-b-xl p-6 pt-2 shadow-lg border border-[#97A9C4]/30">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="text-secondary-700">
|
||||
<div className="relative bg-white rounded-b-xl p-4 pt-2 shadow-lg border border-[#97A9C4]/30">
|
||||
<div className="text-secondary-700">
|
||||
{/* 包装整个内容区域的容器 */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={`ql-editor p-0 space-y-1 leading-relaxed duration-300 ${
|
||||
ref={contentWrapperRef}
|
||||
className={`duration-300 ${
|
||||
shouldCollapse && !isExpanded
|
||||
? "max-h-[300px] overflow-hidden"
|
||||
? "max-h-[100px] overflow-hidden relative"
|
||||
: ""
|
||||
}`}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: post?.content || "",
|
||||
}}
|
||||
/>
|
||||
}`}>
|
||||
{/* 内容区域 */}
|
||||
<div
|
||||
className="ql-editor p-0 space-y-1 leading-relaxed"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: post?.content || "",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* PostResources 组件 */}
|
||||
<PostResources post={post} />
|
||||
|
||||
{/* 渐变遮罩 */}
|
||||
{shouldCollapse && !isExpanded && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-20 bg-gradient-to-t from-white to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 展开/收起按钮 */}
|
||||
{shouldCollapse && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="mt-2 text-blue-500 hover:text-blue-700">
|
||||
{isExpanded ? "Collapse" : "Expand"}
|
||||
{isExpanded ? "收起" : "展开"}
|
||||
</button>
|
||||
)}
|
||||
<PostResources post={post} />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Stats Section */}
|
||||
<StatsSection />
|
||||
|
|
|
@ -17,7 +17,7 @@ const { Title, Paragraph, Text } = Typography;
|
|||
export default function Header() {
|
||||
const { post, user } = useContext(PostDetailContext);
|
||||
return (
|
||||
<header className="rounded-t-xl bg-gradient-to-r from-primary to-primary-400 text-white p-6 relative">
|
||||
<header className="rounded-t-xl bg-gradient-to-r from-primary to-primary-400 text-white p-4 relative">
|
||||
{/* 右上角标签 */}
|
||||
{/* <CornerBadge type="state" value={post?.state}></CornerBadge> */}
|
||||
|
||||
|
|
|
@ -28,12 +28,12 @@ export default function PostResources({ post }: { post: PostDto }) {
|
|||
}, [post]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col ">
|
||||
<div className="flex flex-col ">
|
||||
{resources
|
||||
?.filter((resource) => resource.isImage)
|
||||
.map((resource) => (
|
||||
<div key={resource.url} className="mt-2">
|
||||
<div key={resource.url} className="mt-0.5">
|
||||
<Image
|
||||
src={resource.url}
|
||||
alt={resource.title}
|
||||
|
|
|
@ -40,11 +40,11 @@ export function LetterBasicForm() {
|
|||
placeholder="请输入信件标题"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* Tags Input */}
|
||||
<Form.Item name={["meta", "tags"]} className="mb-6">
|
||||
<Select
|
||||
mode="tags"
|
||||
showSearch={false}
|
||||
placeholder="输入标签后按回车添加"
|
||||
value={form.getFieldValue(["meta", "tags"]) || []}
|
||||
onChange={(value) =>
|
||||
|
@ -93,10 +93,7 @@ export function LetterBasicForm() {
|
|||
</Form.Item>
|
||||
{/* Footer Actions */}
|
||||
<div className="flex flex-col-reverse sm:flex-row items-center justify-between gap-4 mt-6">
|
||||
<Form.Item
|
||||
name="isPublic"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Form.Item name="isPublic" valuePropName="checked">
|
||||
<Checkbox className="text-gray-600 hover:text-gray-900 transition-colors text-sm">
|
||||
是否公开
|
||||
</Checkbox>
|
||||
|
|
|
@ -1,33 +1,57 @@
|
|||
import React, { useMemo } from "react";
|
||||
import { Avatar } from "antd";
|
||||
import { AvatarProps } from "antd/lib/avatar";
|
||||
import multiavatar from "@multiavatar/multiavatar";
|
||||
|
||||
interface CustomAvatarProps extends Omit<AvatarProps, "children"> {
|
||||
src?: string;
|
||||
name?: string;
|
||||
ip?: string;
|
||||
}
|
||||
|
||||
export function CustomAvatar({
|
||||
src,
|
||||
name,
|
||||
className = "",
|
||||
ip,
|
||||
...props
|
||||
}: CustomAvatarProps) {
|
||||
// 获取名字的第一个字符,如果没有名字则显示"匿"
|
||||
const firstChar = name ? name.charAt(0) : "匿";
|
||||
|
||||
// 如果没有 src,且 name 不存在或为 "匿名用户",则使用 ip 生成随机头像
|
||||
const generateAvatarFromIp = (ip: string) => {
|
||||
// 使用 multiavatar 生成 SVG 字符串
|
||||
const svgString = multiavatar(ip);
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(svgString)}`;
|
||||
};
|
||||
// 判断头像模式
|
||||
const avatarMode = useMemo(() => {
|
||||
if (src) {
|
||||
return "avatar"; // 使用 src 提供的头像
|
||||
}
|
||||
if (name && name !== "匿名用户") {
|
||||
return "name"; // 使用名称的首字母
|
||||
}
|
||||
return "random"; // 使用随机头像(基于 ip)
|
||||
}, [src, name]);
|
||||
// 判断是否需要使用 ip 生成头像
|
||||
const avatarSrc =
|
||||
src || (name && name !== "匿名用户")
|
||||
? src
|
||||
: generateAvatarFromIp(ip || "default");
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
className={`ring-2 ring-primary/50
|
||||
bg-primary-300
|
||||
text-white
|
||||
className={`
|
||||
${avatarMode ? "bg-primary-300text-white" : ""}
|
||||
|
||||
transition-all duration-200 ease-in-out shadow-md
|
||||
hover:shadow-lg
|
||||
${className}`}
|
||||
shape="square"
|
||||
src={src}
|
||||
size={40}
|
||||
src={avatarSrc}
|
||||
{...props}>
|
||||
{!src && firstChar}
|
||||
{!avatarSrc && firstChar}
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ importers:
|
|||
dependencies:
|
||||
'@nestjs/bullmq':
|
||||
specifier: ^10.2.0
|
||||
version: 10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(bullmq@5.34.8)
|
||||
version: 10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))(bullmq@5.34.8)
|
||||
'@nestjs/common':
|
||||
specifier: ^10.3.10
|
||||
version: 10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
||||
|
@ -33,7 +33,7 @@ importers:
|
|||
version: 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@10.4.15)(rxjs@7.8.1)
|
||||
'@nestjs/schedule':
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.2(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)
|
||||
version: 4.1.2(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))
|
||||
'@nestjs/websockets':
|
||||
specifier: ^10.3.10
|
||||
version: 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(@nestjs/platform-socket.io@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
||||
|
@ -148,7 +148,7 @@ importers:
|
|||
version: 10.2.3(chokidar@3.6.0)(typescript@5.7.2)
|
||||
'@nestjs/testing':
|
||||
specifier: ^10.0.0
|
||||
version: 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(@nestjs/platform-express@10.4.15)
|
||||
version: 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15))
|
||||
'@types/exceljs':
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.2
|
||||
|
@ -284,6 +284,9 @@ importers:
|
|||
'@hookform/resolvers':
|
||||
specifier: ^3.9.1
|
||||
version: 3.10.0(react-hook-form@7.54.2(react@18.2.0))
|
||||
'@multiavatar/multiavatar':
|
||||
specifier: ^1.0.7
|
||||
version: 1.0.7
|
||||
'@nice/client':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/client
|
||||
|
@ -1979,6 +1982,9 @@ packages:
|
|||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@multiavatar/multiavatar@1.0.7':
|
||||
resolution: {integrity: sha512-Yg9Uw57bmlErsWL0CSv4d6D4ZqVBE00OZmYr9MRgygoXZdboNtsEI6FbBRw1AY8l88Sm1ARcyojtlm2uwUn0Zg==}
|
||||
|
||||
'@nestjs/bull-shared@10.2.3':
|
||||
resolution: {integrity: sha512-XcgAjNOgq6b5DVCytxhR5BKiwWo7hsusVeyE7sfFnlXRHeEtIuC2hYWBr/ZAtvL/RH0/O0tqtq0rVl972nbhJw==}
|
||||
peerDependencies:
|
||||
|
@ -8638,15 +8644,17 @@ snapshots:
|
|||
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
|
||||
optional: true
|
||||
|
||||
'@nestjs/bull-shared@10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)':
|
||||
'@multiavatar/multiavatar@1.0.7': {}
|
||||
|
||||
'@nestjs/bull-shared@10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))':
|
||||
dependencies:
|
||||
'@nestjs/common': 10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
||||
'@nestjs/core': 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@nestjs/bullmq@10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(bullmq@5.34.8)':
|
||||
'@nestjs/bullmq@10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))(bullmq@5.34.8)':
|
||||
dependencies:
|
||||
'@nestjs/bull-shared': 10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)
|
||||
'@nestjs/bull-shared': 10.2.3(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))
|
||||
'@nestjs/common': 10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
||||
'@nestjs/core': 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
||||
bullmq: 5.34.8
|
||||
|
@ -8743,7 +8751,7 @@ snapshots:
|
|||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
'@nestjs/schedule@4.1.2(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)':
|
||||
'@nestjs/schedule@4.1.2(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))':
|
||||
dependencies:
|
||||
'@nestjs/common': 10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
||||
'@nestjs/core': 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
||||
|
@ -8761,7 +8769,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- chokidar
|
||||
|
||||
'@nestjs/testing@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(@nestjs/platform-express@10.4.15)':
|
||||
'@nestjs/testing@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15))':
|
||||
dependencies:
|
||||
'@nestjs/common': 10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
||||
'@nestjs/core': 10.4.15(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(@nestjs/websockets@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1)
|
||||
|
|
Loading…
Reference in New Issue