Merge branch 'main' of http://113.45.157.195:3003/insiinc/leader-mail
This commit is contained in:
commit
7919edec06
|
@ -23,12 +23,12 @@ import { UploadModule } from './upload/upload.module';
|
|||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true, // 全局可用
|
||||
envFilePath: '.env'
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
JwtModule.register({
|
||||
global: true,
|
||||
secret: env.JWT_SECRET
|
||||
secret: env.JWT_SECRET,
|
||||
}),
|
||||
WebSocketModule,
|
||||
TrpcModule,
|
||||
|
@ -42,11 +42,13 @@ import { UploadModule } from './upload/upload.module';
|
|||
MinioModule,
|
||||
CollaborationModule,
|
||||
RealTimeModule,
|
||||
UploadModule
|
||||
UploadModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_FILTER,
|
||||
useClass: ExceptionsFilter,
|
||||
},
|
||||
],
|
||||
providers: [{
|
||||
provide: APP_FILTER,
|
||||
useClass: ExceptionsFilter,
|
||||
}],
|
||||
})
|
||||
export class AppModule { }
|
||||
export class AppModule {}
|
||||
|
|
|
@ -8,7 +8,7 @@ async function bootstrap() {
|
|||
|
||||
// 启用 CORS 并允许所有来源
|
||||
app.enableCors({
|
||||
origin: "*",
|
||||
origin: '*',
|
||||
});
|
||||
const wsService = app.get(WebSocketService);
|
||||
await wsService.initialize(app.getHttpServer());
|
||||
|
@ -18,6 +18,5 @@ async function bootstrap() {
|
|||
const port = process.env.SERVER_PORT || 3000;
|
||||
|
||||
await app.listen(port);
|
||||
|
||||
}
|
||||
bootstrap();
|
||||
|
|
|
@ -17,7 +17,7 @@ export class PostRouter {
|
|||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly postService: PostService,
|
||||
) { }
|
||||
) {}
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.protectProcedure
|
||||
.input(PostCreateArgsSchema)
|
||||
|
@ -97,12 +97,14 @@ export class PostRouter {
|
|||
return await this.postService.findManyWithCursor(input, staff, ip);
|
||||
}),
|
||||
findManyWithPagination: this.trpc.procedure
|
||||
.input(z.object({
|
||||
page: z.number(),
|
||||
pageSize: z.number().optional(),
|
||||
where: PostWhereInputSchema.optional(),
|
||||
select: PostSelectSchema.optional()
|
||||
})) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.input(
|
||||
z.object({
|
||||
page: z.number(),
|
||||
pageSize: z.number().optional(),
|
||||
where: PostWhereInputSchema.optional(),
|
||||
select: PostSelectSchema.optional(),
|
||||
}),
|
||||
) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input }) => {
|
||||
return await this.postService.findManyWithPagination(input);
|
||||
}),
|
||||
|
|
|
@ -33,6 +33,7 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
|
|||
args: Prisma.PostCreateArgs,
|
||||
params: { staff?: UserProfile; tx?: Prisma.PostDelegate },
|
||||
) {
|
||||
console.log('params?.staff?.id', params?.staff?.id);
|
||||
args.data.authorId = params?.staff?.id;
|
||||
args.data.updatedAt = new Date();
|
||||
// args.data.resources
|
||||
|
|
|
@ -85,6 +85,7 @@ export function getClientIp(req: any): string {
|
|||
return ip || '';
|
||||
}
|
||||
export async function updatePostState(id: string) {
|
||||
console.log('updateState');
|
||||
const post = await db.post.findUnique({
|
||||
where: {
|
||||
id: id,
|
||||
|
|
|
@ -5,13 +5,13 @@ import { TusService } from './tus.service';
|
|||
import { ResourceModule } from '@server/models/resource/resource.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
BullModule.registerQueue({
|
||||
name: 'file-queue', // 确保这个名称与 service 中注入的队列名称一致
|
||||
}),
|
||||
ResourceModule
|
||||
],
|
||||
controllers: [UploadController],
|
||||
providers: [TusService],
|
||||
imports: [
|
||||
BullModule.registerQueue({
|
||||
name: 'file-queue', // 确保这个名称与 service 中注入的队列名称一致
|
||||
}),
|
||||
ResourceModule,
|
||||
],
|
||||
controllers: [UploadController],
|
||||
providers: [TusService],
|
||||
})
|
||||
export class UploadModule { }
|
||||
export class UploadModule {}
|
||||
|
|
|
@ -29,7 +29,7 @@ export function SendCard({ staff, termId }: SendCardProps) {
|
|||
{staff.meta?.photoUrl ? (
|
||||
<img
|
||||
src={staff.meta.photoUrl}
|
||||
alt={staff.showname}
|
||||
alt={staff?.showname}
|
||||
className="w-full h-full object-cover hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
) : (
|
||||
|
@ -61,7 +61,7 @@ export function SendCard({ staff, termId }: SendCardProps) {
|
|||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-2xl font-semibold text-gray-900">
|
||||
{staff.showname}
|
||||
{staff?.showname}
|
||||
</h3>
|
||||
<Badge status="success" />
|
||||
</div>
|
||||
|
|
|
@ -1,7 +1,196 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { UploadOutlined } from "@ant-design/icons";
|
||||
import { Form, Upload, message } from "antd";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import {
|
||||
UploadOutlined,
|
||||
CheckCircleOutlined,
|
||||
DeleteOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Upload, message, Progress, Button } from "antd";
|
||||
import type { UploadFile } from "antd";
|
||||
import { useTusUpload } from "@web/src/hooks/useTusUpload";
|
||||
|
||||
export const TusUploader = ({ value = [], onChange }) => {
|
||||
return <Upload.Dragger></Upload.Dragger>;
|
||||
export interface TusUploaderProps {
|
||||
value?: string[];
|
||||
onChange?: (value: string[]) => void;
|
||||
}
|
||||
|
||||
interface UploadingFile {
|
||||
name: string;
|
||||
progress: number;
|
||||
status: "uploading" | "done" | "error";
|
||||
fileId?: string;
|
||||
}
|
||||
|
||||
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 [uploadResults, setUploadResults] = useState<string[]>(value || []);
|
||||
|
||||
const handleRemoveFile = useCallback(
|
||||
(fileId: string) => {
|
||||
setCompletedFiles((prev) =>
|
||||
prev.filter((f) => f.fileId !== fileId)
|
||||
);
|
||||
const newResults = uploadResults.filter(id => id !== fileId);
|
||||
setUploadResults(newResults);
|
||||
onChange?.(newResults);
|
||||
},
|
||||
[uploadResults, onChange]
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
async (fileList: UploadFile | UploadFile[]) => {
|
||||
const files = Array.isArray(fileList) ? fileList : [fileList];
|
||||
console.log("files", files);
|
||||
// 验证文件对象
|
||||
if (!files.every((f) => f instanceof File)) {
|
||||
message.error("Invalid file format");
|
||||
return false;
|
||||
}
|
||||
|
||||
const newFiles: UploadingFile[] = files.map((f) => ({
|
||||
name: f.name,
|
||||
progress: 0,
|
||||
status: "uploading" as const,
|
||||
}));
|
||||
setUploadingFiles((prev) => [...prev, ...newFiles]);
|
||||
const newUploadResults: string[] = [];
|
||||
|
||||
try {
|
||||
for (const [index, f] of files.entries()) {
|
||||
if (!f) {
|
||||
throw new Error(`File ${f.name} is invalid`);
|
||||
}
|
||||
|
||||
const fileId = await new Promise<string>(
|
||||
(resolve, reject) => {
|
||||
handleFileUpload(
|
||||
f as File,
|
||||
(result) => {
|
||||
console.log("Upload success:", result);
|
||||
const completedFile = {
|
||||
name: f.name,
|
||||
progress: 1,
|
||||
status: "done" as const,
|
||||
fileId: result.fileId,
|
||||
};
|
||||
setCompletedFiles((prev) => [
|
||||
...prev,
|
||||
completedFile,
|
||||
]);
|
||||
setUploadingFiles((prev) =>
|
||||
prev.filter((_, i) => i !== index)
|
||||
);
|
||||
resolve(result.fileId);
|
||||
},
|
||||
(error) => {
|
||||
console.error("Upload error:", error);
|
||||
reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
newUploadResults.push(fileId);
|
||||
}
|
||||
|
||||
// Update with all uploaded files
|
||||
const newValue = Array.from(new Set([...uploadResults, ...newUploadResults]));
|
||||
setUploadResults(newValue);
|
||||
onChange?.(newValue);
|
||||
message.success(`${files.length} files uploaded successfully`);
|
||||
} catch (error) {
|
||||
console.error("Upload error details:", error);
|
||||
message.error(
|
||||
`Upload failed: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||
);
|
||||
setUploadingFiles((prev) =>
|
||||
prev.map((f) => ({ ...f, status: "error" }))
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[uploadResults, onChange, handleFileUpload]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Upload.Dragger
|
||||
name="files"
|
||||
multiple
|
||||
showUploadList={false}
|
||||
beforeUpload={handleChange}
|
||||
style={{
|
||||
border: "2px dashed #1677ff",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: "#f0f8ff",
|
||||
}}>
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -63,7 +63,7 @@ const AdminHeader: React.FC<AdminHeaderProps> = ({
|
|||
const localState = {
|
||||
user: {
|
||||
id: user.id,
|
||||
showname: user.showname || user.username,
|
||||
showname: user?.showname || user.username,
|
||||
deptName: user.department?.name,
|
||||
sessionId,
|
||||
},
|
||||
|
|
|
@ -1,14 +1,22 @@
|
|||
import { EyeOutlined, LikeOutlined, LikeFilled, UserOutlined, BankOutlined, CalendarOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import { Button, Typography, Space, Tooltip } from 'antd';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useState } from 'react';
|
||||
import { getBadgeStyle } from '@web/src/app/main/letter/list/utils';
|
||||
import { PostDto } from '@nice/common';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
EyeOutlined,
|
||||
LikeOutlined,
|
||||
LikeFilled,
|
||||
UserOutlined,
|
||||
BankOutlined,
|
||||
CalendarOutlined,
|
||||
FileTextOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Button, Typography, Space, Tooltip } from "antd";
|
||||
import toast from "react-hot-toast";
|
||||
import { useState } from "react";
|
||||
import { getBadgeStyle } from "@web/src/app/main/letter/list/utils";
|
||||
import { PostDto } from "@nice/common";
|
||||
import dayjs from "dayjs";
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
interface LetterCardProps {
|
||||
letter: PostDto;
|
||||
letter: PostDto;
|
||||
}
|
||||
|
||||
export function LetterCard({ letter }: LetterCardProps) {
|
||||
|
@ -45,32 +53,35 @@ export function LetterCard({ letter }: LetterCardProps) {
|
|||
className="text-primary transition-all duration-300 relative
|
||||
before:absolute before:bottom-0 before:left-0 before:w-0 before:h-[2px] before:bg-primary-600
|
||||
group-hover:before:w-full before:transition-all before:duration-300
|
||||
group-hover:text-primary-600 group-hover:scale-105 group-hover:drop-shadow-md"
|
||||
>
|
||||
{letter.title}
|
||||
</a>
|
||||
</Title>
|
||||
group-hover:text-primary-600 group-hover:scale-105 group-hover:drop-shadow-md">
|
||||
{letter.title}
|
||||
</a>
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Meta Info */}
|
||||
<div className="flex justify-between items-center text-sm text-secondary">
|
||||
<Space size="middle">
|
||||
<Space>
|
||||
<UserOutlined className="text-secondary-400" />
|
||||
<Text strong>{letter.author.showname}</Text>
|
||||
</Space>
|
||||
<Text type="secondary">|</Text>
|
||||
<Space>
|
||||
<BankOutlined className="text-secondary-400" />
|
||||
<Text>{letter.author.department.name}</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
<Space>
|
||||
<CalendarOutlined className="text-secondary-400" />
|
||||
<Text type="secondary">{dayjs(letter.createdAt).format('YYYY-MM-DD')}</Text>
|
||||
</Space>
|
||||
</div>
|
||||
{/* Meta Info */}
|
||||
<div className="flex justify-between items-center text-sm text-secondary">
|
||||
<Space size="middle">
|
||||
<Space>
|
||||
<UserOutlined className="text-secondary-400" />
|
||||
<Text strong>
|
||||
{letter.author?.showname ||
|
||||
letter?.author?.username}
|
||||
</Text>
|
||||
</Space>
|
||||
<Text type="secondary">|</Text>
|
||||
<Space>
|
||||
<BankOutlined className="text-secondary-400" />
|
||||
<Text>{letter.author?.department?.name}</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
<Space>
|
||||
<CalendarOutlined className="text-secondary-400" />
|
||||
<Text type="secondary">
|
||||
{dayjs(letter.createdAt).format("YYYY-MM-DD")}
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Content Preview */}
|
||||
{letter.content && (
|
||||
|
@ -84,12 +95,12 @@ export function LetterCard({ letter }: LetterCardProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Badges & Interactions */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Space size="small" wrap className="flex-1">
|
||||
<Badge type="category" value={'11'} />
|
||||
<Badge type="status" value={'22'} />
|
||||
</Space>
|
||||
{/* Badges & Interactions */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Space size="small" wrap className="flex-1">
|
||||
<Badge type="category" value={"11"} />
|
||||
<Badge type="status" value={"22"} />
|
||||
</Space>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1 text-gray-500">
|
||||
|
@ -140,7 +151,6 @@ export function Badge({
|
|||
transition-all duration-200 ease-in-out transform hover:scale-105
|
||||
${className}
|
||||
`}>
|
||||
|
||||
{value?.toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
|
|
|
@ -9,6 +9,7 @@ import { PostDetailContext } from "./context/PostDetailContext";
|
|||
import { LikeFilled, LikeOutlined } from "@ant-design/icons";
|
||||
import PostLikeButton from "./PostHeader/PostLikeButton";
|
||||
import { CustomAvatar } from "@web/src/components/presentation/CustomAvatar";
|
||||
import PostResources from "./PostResources";
|
||||
|
||||
export default function PostCommentCard({
|
||||
post,
|
||||
|
@ -19,40 +20,6 @@ export default function PostCommentCard({
|
|||
index: number;
|
||||
isReceiverComment: boolean;
|
||||
}) {
|
||||
const { user } = useContext(PostDetailContext);
|
||||
const { like, unLike } = useVisitor();
|
||||
const [liked, setLiked] = useState(post?.liked || false);
|
||||
const [likeCount, setLikeCount] = useState(post?.likes || 0);
|
||||
|
||||
async function likeThisPost() {
|
||||
if (!liked) {
|
||||
try {
|
||||
setLikeCount((prev) => prev + 1);
|
||||
setLiked(true);
|
||||
like.mutateAsync({
|
||||
data: {
|
||||
visitorId: user?.id || null,
|
||||
postId: post.id,
|
||||
type: VisitType.LIKE,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to like post:", error);
|
||||
setLikeCount((prev) => prev - 1);
|
||||
setLiked(false);
|
||||
}
|
||||
} else {
|
||||
setLikeCount((prev) => prev - 1);
|
||||
setLiked(false);
|
||||
unLike.mutateAsync({
|
||||
where: {
|
||||
visitorId: user?.id || null,
|
||||
postId: post.id,
|
||||
type: VisitType.LIKE,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return (
|
||||
<motion.div
|
||||
className="bg-white rounded-lg shadow-sm border border-slate-200 p-4"
|
||||
|
@ -67,24 +34,31 @@ export default function PostCommentCard({
|
|||
}></CustomAvatar>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-1 justify-between">
|
||||
<div className="flex flex-1 justify-between ">
|
||||
<div className="flex space-x-2" style={{ height: 40 }}>
|
||||
<span className="font-medium text-slate-900">
|
||||
<span className="flex font-medium text-slate-900">
|
||||
{post.author?.showname || "匿名用户"}
|
||||
</span>
|
||||
<span className="text-sm text-slate-500">
|
||||
<span className="flex text-sm text-slate-500">
|
||||
{dayjs(post?.createdAt).format(
|
||||
"YYYY-MM-DD HH:mm"
|
||||
)}
|
||||
</span>
|
||||
{isReceiverComment && (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800">
|
||||
官方回答
|
||||
</span>
|
||||
<div className=" ">
|
||||
<span className=" px-2 text-sm rounded-full bg-blue-100 text-blue-800">
|
||||
官方回答
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 添加有帮助按钮 */}
|
||||
<PostLikeButton post={post}></PostLikeButton>
|
||||
<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
|
||||
|
@ -94,6 +68,7 @@ export default function PostCommentCard({
|
|||
}}
|
||||
dangerouslySetInnerHTML={{ __html: post.content || "" }}
|
||||
/>
|
||||
<PostResources post={post}></PostResources>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
|
@ -14,6 +14,7 @@ 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();
|
||||
|
@ -26,8 +27,14 @@ export default function PostCommentEditor() {
|
|||
await create.mutateAsync({
|
||||
data: {
|
||||
type: PostType.POST_COMMENT,
|
||||
|
||||
parentId: post?.id,
|
||||
content: content,
|
||||
resources: {
|
||||
connect: fileIds.filter(Boolean).map((id) => ({
|
||||
fileId: id,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
toast.success("发布成功!");
|
||||
|
@ -83,7 +90,10 @@ export default function PostCommentEditor() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<TusUploader></TusUploader>
|
||||
<TusUploader
|
||||
onChange={(value) => {
|
||||
setFileIds(value);
|
||||
}}></TusUploader>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<motion.div
|
||||
|
|
|
@ -33,7 +33,7 @@ export default function PostCommentList() {
|
|||
},
|
||||
select: postDetailSelect,
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
take: 3,
|
||||
take: 5,
|
||||
}),
|
||||
[post, receiverIds]
|
||||
);
|
||||
|
@ -43,11 +43,14 @@ export default function PostCommentList() {
|
|||
where: {
|
||||
parentId: post?.id,
|
||||
type: PostType.POST_COMMENT,
|
||||
authorId: { notIn: receiverIds },
|
||||
OR: [
|
||||
{ authorId: null }, // 允许 authorId 为 null
|
||||
{ authorId: { notIn: receiverIds } }, // 排除 receiverIds 中的 authorId
|
||||
],
|
||||
},
|
||||
select: postDetailSelect,
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
take: 3,
|
||||
take: 5,
|
||||
}),
|
||||
[post, receiverIds]
|
||||
);
|
||||
|
@ -147,6 +150,12 @@ export default function PostCommentList() {
|
|||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center py-12 text-slate-500">
|
||||
暂无回复,来发表第一条回复吧
|
||||
<Button
|
||||
onClick={() => {
|
||||
console.log(receiverIds);
|
||||
}}>
|
||||
123
|
||||
</Button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,166 +0,0 @@
|
|||
import { useContext } from "react";
|
||||
import { PostDetailContext } from "./context/PostDetailContext";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
CalendarIcon,
|
||||
UserCircleIcon,
|
||||
LockClosedIcon,
|
||||
LockOpenIcon,
|
||||
StarIcon,
|
||||
ClockIcon,
|
||||
EyeIcon,
|
||||
ChatBubbleLeftIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Button, Typography, Space, Tooltip } from "antd";
|
||||
import { useVisitor } from "@nice/client";
|
||||
import { PostState, VisitType } from "@nice/common";
|
||||
import {
|
||||
CalendarOutlined,
|
||||
ClockCircleOutlined,
|
||||
CommentOutlined,
|
||||
EyeOutlined,
|
||||
FileTextOutlined,
|
||||
FolderOutlined,
|
||||
LikeFilled,
|
||||
LikeOutlined,
|
||||
LockOutlined,
|
||||
UnlockOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import dayjs from "dayjs";
|
||||
import { TitleSection } from "./PostHeader/TitleSection";
|
||||
import {
|
||||
AuthorBadge,
|
||||
DateBadge,
|
||||
TermBadge,
|
||||
UpdatedBadge,
|
||||
VisibilityBadge,
|
||||
} from "./PostHeader/InfoBadge";
|
||||
import { StatsSection } from "./PostHeader/StatsSection";
|
||||
import { PostBadge } from "./badge/PostBadge";
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
export default function PostHeader() {
|
||||
const { post, user } = useContext(PostDetailContext);
|
||||
const { like, unLike } = useVisitor();
|
||||
|
||||
function likeThisPost() {
|
||||
if (!post?.liked) {
|
||||
post.likes += 1;
|
||||
post.liked = true;
|
||||
like.mutateAsync({
|
||||
data: {
|
||||
visitorId: user?.id || null,
|
||||
postId: post.id,
|
||||
type: VisitType.LIKE,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
post.likes -= 1;
|
||||
post.liked = false;
|
||||
unLike.mutateAsync({
|
||||
where: {
|
||||
visitorId: user?.id || null,
|
||||
postId: post.id,
|
||||
type: VisitType.LIKE,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="relative bg-gradient-to-br from-primary-250 via-primary-150 to--primary-350 rounded-lg p-6 shadow-lg border border-[#97A9C4]/30">
|
||||
{/* Corner Decorations */}
|
||||
<div className="absolute top-0 left-0 w-5 h-5 border-t-4 border-l-4 border-primary rounded-tl-lg" />
|
||||
<div className="absolute bottom-0 right-0 w-5 h-5 border-b-4 border-r-4 border-primary rounded-br-lg" />
|
||||
|
||||
{/* Title Section */}
|
||||
|
||||
<TitleSection></TitleSection>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 收件人信息行 */}
|
||||
<Space>
|
||||
<UserOutlined className="text-secondary-400" />
|
||||
<span className="text-secondary-400">收件人:</span>
|
||||
<Text strong>
|
||||
{post?.receivers?.map((receiver) => receiver?.showname)}
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
{/* First Row - Basic Info */}
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{/* Author Info Badge */}
|
||||
<Space>
|
||||
<UserOutlined className="text-secondary-400" />
|
||||
<span className="text-secondary-400">发件人:</span>
|
||||
<Text strong>
|
||||
{post?.author?.showname || "匿名用户"}
|
||||
</Text>
|
||||
</Space>
|
||||
<Text type="secondary">|</Text>
|
||||
{/* Date Info Badge */}
|
||||
<Space>
|
||||
<CalendarOutlined className="text-secondary-400" />
|
||||
|
||||
<Text>
|
||||
创建于:
|
||||
{dayjs(post?.createdAt).format("YYYY-MM-DD")}
|
||||
</Text>
|
||||
</Space>
|
||||
<Text type="secondary">|</Text>
|
||||
{/* Last Updated Badge */}
|
||||
<Space>
|
||||
<ClockCircleOutlined className="text-secondary-400" />
|
||||
<Text>
|
||||
更新于:
|
||||
{dayjs(post?.updatedAt).format("YYYY-MM-DD")}
|
||||
</Text>
|
||||
</Space>
|
||||
<Text type="secondary">|</Text>
|
||||
{/* Visibility Status Badge */}
|
||||
<Space>
|
||||
{post?.isPublic ? (
|
||||
<UnlockOutlined className="text-secondary-400" />
|
||||
) : (
|
||||
<LockOutlined className="text-secondary-400" />
|
||||
)}
|
||||
<Text>{post?.isPublic ? "公开" : "私信"}</Text>
|
||||
</Space>
|
||||
</div>
|
||||
{/* Second Row - Term and Tags */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{/* Tags Badges */}
|
||||
{post?.meta?.tags &&
|
||||
post.meta.tags.length > 0 &&
|
||||
post.meta.tags.map((tag, index) => (
|
||||
<Space key={index}>
|
||||
<PostBadge
|
||||
type="tag"
|
||||
value={`#${tag}`}></PostBadge>
|
||||
</Space>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Section */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="mt-6 text-secondary-700">
|
||||
<div
|
||||
className="ql-editor p-0 space-y-4 leading-relaxed duration-300"
|
||||
dangerouslySetInnerHTML={{ __html: post?.content || "" }}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Stats Section */}
|
||||
<StatsSection></StatsSection>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import { useContext } 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);
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
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="ql-editor p-0 space-y-1 leading-relaxed duration-300"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: post?.content || "",
|
||||
}}
|
||||
/>
|
||||
<PostResources post={post}></PostResources>
|
||||
{/* <div>{post.resources?.map((resource) => {})}</div> */}
|
||||
</motion.div>
|
||||
|
||||
{/* Stats Section */}
|
||||
<StatsSection></StatsSection>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,104 @@
|
|||
import { useContext } from "react";
|
||||
import { PostDetailContext } from "../context/PostDetailContext";
|
||||
import { Space, Typography } from "antd";
|
||||
import { PostBadge } from "../badge/PostBadge";
|
||||
import {
|
||||
CalendarOutlined,
|
||||
ClockCircleOutlined,
|
||||
LockOutlined,
|
||||
UnlockOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import dayjs from "dayjs";
|
||||
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">
|
||||
<div className="flex flex-col space-y-6">
|
||||
{/* 主标题 */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-wider flex items-center gap-2">
|
||||
{post?.title}
|
||||
|
||||
<PostBadge type="category" value={post?.term?.name} />
|
||||
<PostBadge type="state" value={post?.state} />
|
||||
</h1>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{/* 收件人信息行 */}
|
||||
<Space>
|
||||
<UserOutlined className="text-white" />
|
||||
<span className="text-white">收件人:</span>
|
||||
|
||||
{post?.receivers?.map((receiver, index) => (
|
||||
<Text
|
||||
strong
|
||||
className="text-white"
|
||||
key={`${index}`}>
|
||||
{receiver?.showname}
|
||||
</Text>
|
||||
))}
|
||||
</Space>
|
||||
|
||||
{/* First Row - Basic Info */}
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{/* Author Info Badge */}
|
||||
<Space>
|
||||
<UserOutlined className="text-white" />
|
||||
<span className="text-white">发件人:</span>
|
||||
<Text className="text-white" strong>
|
||||
{post?.author?.showname || "匿名用户"}
|
||||
</Text>
|
||||
</Space>
|
||||
<Text type="secondary">|</Text>
|
||||
{/* Date Info Badge */}
|
||||
<Space>
|
||||
<CalendarOutlined className="text-white" />
|
||||
|
||||
<Text className="text-white">
|
||||
创建于:
|
||||
{dayjs(post?.createdAt).format("YYYY-MM-DD")}
|
||||
</Text>
|
||||
</Space>
|
||||
<Text type="secondary">|</Text>
|
||||
{/* Last Updated Badge */}
|
||||
<Space>
|
||||
<ClockCircleOutlined className="text-white" />
|
||||
<Text className="text-white">
|
||||
更新于:
|
||||
{dayjs(post?.updatedAt).format("YYYY-MM-DD")}
|
||||
</Text>
|
||||
</Space>
|
||||
<Text type="secondary">|</Text>
|
||||
{/* Visibility Status Badge */}
|
||||
<Space>
|
||||
{post?.isPublic ? (
|
||||
<UnlockOutlined className="text-white" />
|
||||
) : (
|
||||
<LockOutlined className="text-white" />
|
||||
)}
|
||||
<Text className="text-white">
|
||||
{post?.isPublic ? "公开" : "私信"}
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
{/* Second Row - Term and Tags */}
|
||||
{post?.meta?.tags?.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{/* Tags Badges */}
|
||||
{post.meta.tags.length > 0 &&
|
||||
post.meta.tags.map((tag, index) => (
|
||||
<Space key={index}>
|
||||
<PostBadge
|
||||
type="tag"
|
||||
value={`#${tag}`}></PostBadge>
|
||||
</Space>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import { useContext } from "react";
|
||||
import { PostDetailContext } from "../context/PostDetailContext";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { StatsSection } from "./StatsSection";
|
||||
|
||||
import PostResources from "../PostResources";
|
||||
import Header from "./Header";
|
||||
import Content from "./Content";
|
||||
|
||||
export default function PostHeader() {
|
||||
const { post, user } = useContext(PostDetailContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header></Header>
|
||||
<Content></Content>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,48 +0,0 @@
|
|||
import { motion } from "framer-motion";
|
||||
import { Space, Tag } from "antd";
|
||||
import { PostState } from "@nice/common";
|
||||
import {
|
||||
ClockIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Badge } from "@web/src/app/main/letter/list/LetterCard";
|
||||
import { useContext } from "react";
|
||||
import { PostDetailContext } from "../context/PostDetailContext";
|
||||
import { PostBadge } from "../badge/PostBadge";
|
||||
|
||||
interface TitleSectionProps {
|
||||
title: string;
|
||||
state: PostState;
|
||||
}
|
||||
|
||||
const stateLabels = {
|
||||
[PostState.PENDING]: "待处理",
|
||||
[PostState.PROCESSING]: "处理中",
|
||||
[PostState.RESOLVED]: "已完成",
|
||||
};
|
||||
|
||||
export function TitleSection() {
|
||||
const { post, user } = useContext(PostDetailContext);
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="relative mb-6 flex items-center gap-4">
|
||||
{/* Decorative Line */}
|
||||
{/* <div className="absolute -left-2 top-1/2 -translate-y-1/2 w-1 h-8 bg-primary" /> */}
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-xl font-bold text-primary tracking-wider uppercase">
|
||||
{post?.title}
|
||||
</h1>
|
||||
|
||||
<Space size="small" wrap className="flex-1">
|
||||
<PostBadge type="category" value={post?.term?.name} />
|
||||
<PostBadge type="state" value={post?.state} />
|
||||
</Space>
|
||||
{/* </motion.div> */}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
import React, { useContext, useMemo } from "react";
|
||||
import { Image, Button } from "antd";
|
||||
import { DownloadOutlined } from "@ant-design/icons";
|
||||
import { PostDetailContext } from "./context/PostDetailContext";
|
||||
import { env } from "@web/src/env";
|
||||
import dayjs from "dayjs";
|
||||
import { PostDto } from "packages/common/dist";
|
||||
|
||||
export default function PostResources({ post }: { post: PostDto }) {
|
||||
const { user } = useContext(PostDetailContext);
|
||||
const resources = useMemo(() => {
|
||||
return post?.resources?.map((resource) => ({
|
||||
url: `${env.SERVER_IP}/uploads/${resource.url}`,
|
||||
title: resource.title,
|
||||
}));
|
||||
}, [post]);
|
||||
|
||||
const isImage = (url: string) => {
|
||||
return /\.(png|jpg|jpeg|gif|webp)$/i.test(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{resources?.map((resource) => (
|
||||
<div
|
||||
key={resource.url}
|
||||
className="flex items-center gap-4 mt-2 rounded-lg">
|
||||
{isImage(resource.url) ? (
|
||||
<>
|
||||
<Image
|
||||
src={resource.url}
|
||||
alt={resource.title}
|
||||
className="rounded-lg"
|
||||
width={"100%"}
|
||||
height={"auto"}
|
||||
style={{ objectFit: "cover" }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
href={resource.url}
|
||||
download
|
||||
className="bg-blue-600 hover:bg-blue-700">
|
||||
{resource.title || "下载"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,7 +1,8 @@
|
|||
import { motion } from "framer-motion";
|
||||
import { useContext, useEffect } from "react";
|
||||
import { PostDetailContext } from "../context/PostDetailContext";
|
||||
import PostHeader from "../PostHeader";
|
||||
import PostHeader from "../PostHeader/PostHeader";
|
||||
import WriteHeader from "../PostHeader/Header";
|
||||
import PostCommentEditor from "../PostCommentEditor";
|
||||
import PostCommentList from "../PostCommentList";
|
||||
import { useVisitor } from "@nice/client";
|
||||
|
|
|
@ -9,6 +9,8 @@ export interface LetterFormData {
|
|||
title: string;
|
||||
content: string;
|
||||
resources?: string[];
|
||||
receivers?: string[];
|
||||
terms?: string[];
|
||||
isPublic?: boolean;
|
||||
signature?: string;
|
||||
meta: {
|
||||
|
@ -43,24 +45,38 @@ export function LetterFormProvider({
|
|||
const onSubmit = async (data: LetterFormData) => {
|
||||
try {
|
||||
console.log("data", data);
|
||||
const receivers = data?.receivers;
|
||||
const terms = data?.terms;
|
||||
delete data.receivers;
|
||||
delete data.terms;
|
||||
const result = await create.mutateAsync({
|
||||
data: {
|
||||
...data,
|
||||
type: PostType.POST,
|
||||
termId: termId,
|
||||
receivers: {
|
||||
connect: [receiverId].filter(Boolean).map((id) => ({
|
||||
|
||||
terms: {
|
||||
connect: (terms || [])?.filter(Boolean).map((id) => ({
|
||||
id,
|
||||
})),
|
||||
},
|
||||
state: PostState.PENDING,
|
||||
isPublic: data?.isPublic,
|
||||
...data,
|
||||
resources: data.resources?.length
|
||||
? {
|
||||
connect: data.resources.map((id) => ({
|
||||
receivers: {
|
||||
connect: (receivers || [])
|
||||
?.filter(Boolean)
|
||||
.map((id) => ({
|
||||
id,
|
||||
})),
|
||||
}
|
||||
},
|
||||
state: PostState.PENDING,
|
||||
isPublic: data?.isPublic,
|
||||
|
||||
resources: data.resources?.length
|
||||
? {
|
||||
connect: (
|
||||
data.resources?.filter(Boolean) || []
|
||||
).map((fileId) => ({
|
||||
fileId,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -2,6 +2,8 @@ import { Form, Input, Button, Checkbox, Select } from "antd";
|
|||
import { useLetterEditor } from "../context/LetterEditorContext";
|
||||
import { SendOutlined } from "@ant-design/icons";
|
||||
import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor";
|
||||
import { PostBadge } from "../../detail/badge/PostBadge";
|
||||
import { TusUploader } from "@web/src/components/common/uploader/TusUploader";
|
||||
import StaffSelect from "../../../staff/staff-select";
|
||||
import TermSelect from "../../../term/term-select";
|
||||
|
||||
|
@ -13,30 +15,30 @@ export function LetterBasicForm() {
|
|||
return (
|
||||
<div className=" p-6 ">
|
||||
<Form
|
||||
|
||||
size="large"
|
||||
form={form}
|
||||
onFinish={handleFinish}
|
||||
initialValues={{ meta: { tags: [] }, receiverId, termId, isPublic: true }}
|
||||
>
|
||||
initialValues={{
|
||||
meta: { tags: [] },
|
||||
receiverId,
|
||||
termId,
|
||||
isPublic: true,
|
||||
}}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Form.Item label='收件人' name={'receiverId'}>
|
||||
<Form.Item label="收件人" name={"receivers"}>
|
||||
<StaffSelect multiple placeholder="选择收信人员" />
|
||||
</Form.Item>
|
||||
<Form.Item label='分类' name={'termId'}>
|
||||
<Form.Item label="分类" name={"terms"}>
|
||||
<TermSelect placeholder="选择信件分类" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item
|
||||
name="title"
|
||||
rules={[{ required: true, message: "请输入信件标题" }]}
|
||||
>
|
||||
rules={[{ required: true, message: "请输入信件标题" }]}>
|
||||
<Input
|
||||
maxLength={20}
|
||||
showCount
|
||||
placeholder="请输入信件标题"
|
||||
|
||||
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
|
@ -46,7 +48,9 @@ export function LetterBasicForm() {
|
|||
mode="tags"
|
||||
placeholder="输入标签后按回车添加"
|
||||
value={form.getFieldValue(["meta", "tags"]) || []}
|
||||
onChange={(value) => form.setFieldValue(["meta", "tags"], value)}
|
||||
onChange={(value) =>
|
||||
form.setFieldValue(["meta", "tags"], value)
|
||||
}
|
||||
tokenSeparators={[",", " "]}
|
||||
className="w-full"
|
||||
dropdownStyle={{ display: "none" }}
|
||||
|
@ -55,8 +59,7 @@ export function LetterBasicForm() {
|
|||
{label}
|
||||
<span
|
||||
className="ml-2 cursor-pointer hover:text-primary-700"
|
||||
onClick={onClose}
|
||||
>
|
||||
onClick={onClose}>
|
||||
×
|
||||
</span>
|
||||
</div>
|
||||
|
@ -68,26 +71,32 @@ export function LetterBasicForm() {
|
|||
<Form.Item
|
||||
name="content"
|
||||
rules={[{ required: true, message: "请输入内容" }]}
|
||||
required={false}
|
||||
>
|
||||
required={false}>
|
||||
<div className="rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||
<QuillEditor
|
||||
maxLength={10000}
|
||||
placeholder="请输入内容"
|
||||
minRows={16}
|
||||
onChange={(content) => form.setFieldValue("content", content)}
|
||||
|
||||
onChange={(content) =>
|
||||
form.setFieldValue("content", content)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item name="resources" required={false}>
|
||||
<div className="rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||
<TusUploader
|
||||
onChange={(resources) =>
|
||||
form.setFieldValue("resources", resources)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</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"
|
||||
|
||||
|
||||
>
|
||||
<Checkbox className="text-gray-600 hover:text-gray-900 transition-colors text-sm">
|
||||
是否公开
|
||||
|
@ -98,8 +107,7 @@ export function LetterBasicForm() {
|
|||
onClick={() => form.submit()}
|
||||
size="large"
|
||||
icon={<SendOutlined />}
|
||||
className="w-full sm:w-40"
|
||||
>
|
||||
className="w-full sm:w-40">
|
||||
发送信件
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
@ -26,10 +26,19 @@ export function useTusUpload() {
|
|||
onSuccess: (result: UploadResult) => void,
|
||||
onError: (error: Error) => void
|
||||
) => {
|
||||
if (!file || !file.name || !file.type) {
|
||||
const error = new Error('Invalid file provided');
|
||||
setUploadError(error.message);
|
||||
onError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setProgress(0);
|
||||
setUploadError(null);
|
||||
const upload = new tus.Upload(file, {
|
||||
|
||||
try {
|
||||
const upload = new tus.Upload(file, {
|
||||
endpoint: "http://localhost:3000/upload",
|
||||
retryDelays: [0, 1000, 3000, 5000],
|
||||
metadata: {
|
||||
|
@ -73,7 +82,14 @@ export function useTusUpload() {
|
|||
},
|
||||
});
|
||||
upload.start();
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error("Upload failed");
|
||||
setIsUploading(false);
|
||||
setUploadError(err.message);
|
||||
onError(err);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
progress,
|
||||
isUploading,
|
||||
|
|
|
@ -71,11 +71,11 @@ server {
|
|||
|
||||
# 文件访问认证
|
||||
# 通过内部认证服务验证
|
||||
auth_request /auth-file;
|
||||
# auth_request /auth-file;
|
||||
# 存储认证状态和用户信息
|
||||
auth_request_set $auth_status $upstream_status;
|
||||
auth_request_set $auth_user_id $upstream_http_x_user_id;
|
||||
auth_request_set $auth_resource_type $upstream_http_x_resource_type;
|
||||
# auth_request_set $auth_status $upstream_status;
|
||||
# auth_request_set $auth_user_id $upstream_http_x_user_id;
|
||||
# auth_request_set $auth_resource_type $upstream_http_x_resource_type;
|
||||
# 不缓存
|
||||
expires 0;
|
||||
# 私有缓存,禁止转换
|
||||
|
|
|
@ -27,7 +27,8 @@ model Taxonomy {
|
|||
model Term {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
posts Post[]
|
||||
// posts Post[]
|
||||
posts Post[] @relation("post_term")
|
||||
taxonomy Taxonomy? @relation(fields: [taxonomyId], references: [id])
|
||||
taxonomyId String? @map("taxonomy_id")
|
||||
order Float? @map("order")
|
||||
|
@ -193,8 +194,10 @@ model Post {
|
|||
content String? // 帖子内容,可为空
|
||||
|
||||
domainId String? @map("domain_id")
|
||||
term Term? @relation(fields: [termId], references: [id])
|
||||
termId String? @map("term_id")
|
||||
// term Term? @relation(fields: [termId], references: [id])
|
||||
// termId String? @map("term_id")
|
||||
// 添加多对多关系
|
||||
terms Term[] @relation("post_term")
|
||||
// 日期时间类型字段
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @map("updated_at")
|
||||
|
|
|
@ -12,16 +12,10 @@ export const postDetailSelect: Prisma.PostSelect = {
|
|||
resources: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
termId: true,
|
||||
term: {
|
||||
|
||||
terms: {
|
||||
include: {
|
||||
taxonomy: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
|
||||
},
|
||||
},
|
||||
authorId: true,
|
||||
|
|
Loading…
Reference in New Issue