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: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true, // 全局可用
|
isGlobal: true, // 全局可用
|
||||||
envFilePath: '.env'
|
envFilePath: '.env',
|
||||||
}),
|
}),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
global: true,
|
global: true,
|
||||||
secret: env.JWT_SECRET
|
secret: env.JWT_SECRET,
|
||||||
}),
|
}),
|
||||||
WebSocketModule,
|
WebSocketModule,
|
||||||
TrpcModule,
|
TrpcModule,
|
||||||
|
@ -42,11 +42,13 @@ import { UploadModule } from './upload/upload.module';
|
||||||
MinioModule,
|
MinioModule,
|
||||||
CollaborationModule,
|
CollaborationModule,
|
||||||
RealTimeModule,
|
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 并允许所有来源
|
// 启用 CORS 并允许所有来源
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: "*",
|
origin: '*',
|
||||||
});
|
});
|
||||||
const wsService = app.get(WebSocketService);
|
const wsService = app.get(WebSocketService);
|
||||||
await wsService.initialize(app.getHttpServer());
|
await wsService.initialize(app.getHttpServer());
|
||||||
|
@ -18,6 +18,5 @@ async function bootstrap() {
|
||||||
const port = process.env.SERVER_PORT || 3000;
|
const port = process.env.SERVER_PORT || 3000;
|
||||||
|
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
|
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|
|
@ -17,7 +17,7 @@ export class PostRouter {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly trpc: TrpcService,
|
private readonly trpc: TrpcService,
|
||||||
private readonly postService: PostService,
|
private readonly postService: PostService,
|
||||||
) { }
|
) {}
|
||||||
router = this.trpc.router({
|
router = this.trpc.router({
|
||||||
create: this.trpc.protectProcedure
|
create: this.trpc.protectProcedure
|
||||||
.input(PostCreateArgsSchema)
|
.input(PostCreateArgsSchema)
|
||||||
|
@ -97,12 +97,14 @@ export class PostRouter {
|
||||||
return await this.postService.findManyWithCursor(input, staff, ip);
|
return await this.postService.findManyWithCursor(input, staff, ip);
|
||||||
}),
|
}),
|
||||||
findManyWithPagination: this.trpc.procedure
|
findManyWithPagination: this.trpc.procedure
|
||||||
.input(z.object({
|
.input(
|
||||||
page: z.number(),
|
z.object({
|
||||||
pageSize: z.number().optional(),
|
page: z.number(),
|
||||||
where: PostWhereInputSchema.optional(),
|
pageSize: z.number().optional(),
|
||||||
select: PostSelectSchema.optional()
|
where: PostWhereInputSchema.optional(),
|
||||||
})) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
select: PostSelectSchema.optional(),
|
||||||
|
}),
|
||||||
|
) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
return await this.postService.findManyWithPagination(input);
|
return await this.postService.findManyWithPagination(input);
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -33,6 +33,7 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
|
||||||
args: Prisma.PostCreateArgs,
|
args: Prisma.PostCreateArgs,
|
||||||
params: { staff?: UserProfile; tx?: Prisma.PostDelegate },
|
params: { staff?: UserProfile; tx?: Prisma.PostDelegate },
|
||||||
) {
|
) {
|
||||||
|
console.log('params?.staff?.id', params?.staff?.id);
|
||||||
args.data.authorId = params?.staff?.id;
|
args.data.authorId = params?.staff?.id;
|
||||||
args.data.updatedAt = new Date();
|
args.data.updatedAt = new Date();
|
||||||
// args.data.resources
|
// args.data.resources
|
||||||
|
|
|
@ -85,6 +85,7 @@ export function getClientIp(req: any): string {
|
||||||
return ip || '';
|
return ip || '';
|
||||||
}
|
}
|
||||||
export async function updatePostState(id: string) {
|
export async function updatePostState(id: string) {
|
||||||
|
console.log('updateState');
|
||||||
const post = await db.post.findUnique({
|
const post = await db.post.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: id,
|
id: id,
|
||||||
|
|
|
@ -5,13 +5,13 @@ import { TusService } from './tus.service';
|
||||||
import { ResourceModule } from '@server/models/resource/resource.module';
|
import { ResourceModule } from '@server/models/resource/resource.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
BullModule.registerQueue({
|
BullModule.registerQueue({
|
||||||
name: 'file-queue', // 确保这个名称与 service 中注入的队列名称一致
|
name: 'file-queue', // 确保这个名称与 service 中注入的队列名称一致
|
||||||
}),
|
}),
|
||||||
ResourceModule
|
ResourceModule,
|
||||||
],
|
],
|
||||||
controllers: [UploadController],
|
controllers: [UploadController],
|
||||||
providers: [TusService],
|
providers: [TusService],
|
||||||
})
|
})
|
||||||
export class UploadModule { }
|
export class UploadModule {}
|
||||||
|
|
|
@ -29,7 +29,7 @@ export function SendCard({ staff, termId }: SendCardProps) {
|
||||||
{staff.meta?.photoUrl ? (
|
{staff.meta?.photoUrl ? (
|
||||||
<img
|
<img
|
||||||
src={staff.meta.photoUrl}
|
src={staff.meta.photoUrl}
|
||||||
alt={staff.showname}
|
alt={staff?.showname}
|
||||||
className="w-full h-full object-cover hover:scale-105 transition-transform duration-300"
|
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>
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<h3 className="text-2xl font-semibold text-gray-900">
|
<h3 className="text-2xl font-semibold text-gray-900">
|
||||||
{staff.showname}
|
{staff?.showname}
|
||||||
</h3>
|
</h3>
|
||||||
<Badge status="success" />
|
<Badge status="success" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,7 +1,196 @@
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useCallback, useState } from "react";
|
||||||
import { UploadOutlined } from "@ant-design/icons";
|
import {
|
||||||
import { Form, Upload, message } from "antd";
|
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 }) => {
|
export interface TusUploaderProps {
|
||||||
return <Upload.Dragger></Upload.Dragger>;
|
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 = {
|
const localState = {
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
showname: user.showname || user.username,
|
showname: user?.showname || user.username,
|
||||||
deptName: user.department?.name,
|
deptName: user.department?.name,
|
||||||
sessionId,
|
sessionId,
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,14 +1,22 @@
|
||||||
import { EyeOutlined, LikeOutlined, LikeFilled, UserOutlined, BankOutlined, CalendarOutlined, FileTextOutlined } from '@ant-design/icons';
|
import {
|
||||||
import { Button, Typography, Space, Tooltip } from 'antd';
|
EyeOutlined,
|
||||||
import toast from 'react-hot-toast';
|
LikeOutlined,
|
||||||
import { useState } from 'react';
|
LikeFilled,
|
||||||
import { getBadgeStyle } from '@web/src/app/main/letter/list/utils';
|
UserOutlined,
|
||||||
import { PostDto } from '@nice/common';
|
BankOutlined,
|
||||||
import dayjs from 'dayjs';
|
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;
|
const { Title, Paragraph, Text } = Typography;
|
||||||
|
|
||||||
interface LetterCardProps {
|
interface LetterCardProps {
|
||||||
letter: PostDto;
|
letter: PostDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LetterCard({ letter }: LetterCardProps) {
|
export function LetterCard({ letter }: LetterCardProps) {
|
||||||
|
@ -45,32 +53,35 @@ export function LetterCard({ letter }: LetterCardProps) {
|
||||||
className="text-primary transition-all duration-300 relative
|
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
|
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:before:w-full before:transition-all before:duration-300
|
||||||
group-hover:text-primary-600 group-hover:scale-105 group-hover:drop-shadow-md"
|
group-hover:text-primary-600 group-hover:scale-105 group-hover:drop-shadow-md">
|
||||||
>
|
{letter.title}
|
||||||
{letter.title}
|
</a>
|
||||||
</a>
|
</Title>
|
||||||
</Title>
|
</div>
|
||||||
|
|
||||||
</div>
|
{/* Meta Info */}
|
||||||
|
<div className="flex justify-between items-center text-sm text-secondary">
|
||||||
{/* Meta Info */}
|
<Space size="middle">
|
||||||
<div className="flex justify-between items-center text-sm text-secondary">
|
<Space>
|
||||||
<Space size="middle">
|
<UserOutlined className="text-secondary-400" />
|
||||||
<Space>
|
<Text strong>
|
||||||
<UserOutlined className="text-secondary-400" />
|
{letter.author?.showname ||
|
||||||
<Text strong>{letter.author.showname}</Text>
|
letter?.author?.username}
|
||||||
</Space>
|
</Text>
|
||||||
<Text type="secondary">|</Text>
|
</Space>
|
||||||
<Space>
|
<Text type="secondary">|</Text>
|
||||||
<BankOutlined className="text-secondary-400" />
|
<Space>
|
||||||
<Text>{letter.author.department.name}</Text>
|
<BankOutlined className="text-secondary-400" />
|
||||||
</Space>
|
<Text>{letter.author?.department?.name}</Text>
|
||||||
</Space>
|
</Space>
|
||||||
<Space>
|
</Space>
|
||||||
<CalendarOutlined className="text-secondary-400" />
|
<Space>
|
||||||
<Text type="secondary">{dayjs(letter.createdAt).format('YYYY-MM-DD')}</Text>
|
<CalendarOutlined className="text-secondary-400" />
|
||||||
</Space>
|
<Text type="secondary">
|
||||||
</div>
|
{dayjs(letter.createdAt).format("YYYY-MM-DD")}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Content Preview */}
|
{/* Content Preview */}
|
||||||
{letter.content && (
|
{letter.content && (
|
||||||
|
@ -84,12 +95,12 @@ export function LetterCard({ letter }: LetterCardProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Badges & Interactions */}
|
{/* Badges & Interactions */}
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<Space size="small" wrap className="flex-1">
|
<Space size="small" wrap className="flex-1">
|
||||||
<Badge type="category" value={'11'} />
|
<Badge type="category" value={"11"} />
|
||||||
<Badge type="status" value={'22'} />
|
<Badge type="status" value={"22"} />
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-1 text-gray-500">
|
<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
|
transition-all duration-200 ease-in-out transform hover:scale-105
|
||||||
${className}
|
${className}
|
||||||
`}>
|
`}>
|
||||||
|
|
||||||
{value?.toUpperCase()}
|
{value?.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { PostDetailContext } from "./context/PostDetailContext";
|
||||||
import { LikeFilled, LikeOutlined } from "@ant-design/icons";
|
import { LikeFilled, LikeOutlined } from "@ant-design/icons";
|
||||||
import PostLikeButton from "./PostHeader/PostLikeButton";
|
import PostLikeButton from "./PostHeader/PostLikeButton";
|
||||||
import { CustomAvatar } from "@web/src/components/presentation/CustomAvatar";
|
import { CustomAvatar } from "@web/src/components/presentation/CustomAvatar";
|
||||||
|
import PostResources from "./PostResources";
|
||||||
|
|
||||||
export default function PostCommentCard({
|
export default function PostCommentCard({
|
||||||
post,
|
post,
|
||||||
|
@ -19,40 +20,6 @@ export default function PostCommentCard({
|
||||||
index: number;
|
index: number;
|
||||||
isReceiverComment: boolean;
|
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 (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="bg-white rounded-lg shadow-sm border border-slate-200 p-4"
|
className="bg-white rounded-lg shadow-sm border border-slate-200 p-4"
|
||||||
|
@ -67,24 +34,31 @@ export default function PostCommentCard({
|
||||||
}></CustomAvatar>
|
}></CustomAvatar>
|
||||||
</div>
|
</div>
|
||||||
<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 space-x-2" style={{ height: 40 }}>
|
<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 || "匿名用户"}
|
{post.author?.showname || "匿名用户"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-slate-500">
|
<span className="flex text-sm text-slate-500">
|
||||||
{dayjs(post?.createdAt).format(
|
{dayjs(post?.createdAt).format(
|
||||||
"YYYY-MM-DD HH:mm"
|
"YYYY-MM-DD HH:mm"
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{isReceiverComment && (
|
{isReceiverComment && (
|
||||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800">
|
<div className=" ">
|
||||||
官方回答
|
<span className=" px-2 text-sm rounded-full bg-blue-100 text-blue-800">
|
||||||
</span>
|
官方回答
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
@ -94,6 +68,7 @@ export default function PostCommentCard({
|
||||||
}}
|
}}
|
||||||
dangerouslySetInnerHTML={{ __html: post.content || "" }}
|
dangerouslySetInnerHTML={{ __html: post.content || "" }}
|
||||||
/>
|
/>
|
||||||
|
<PostResources post={post}></PostResources>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
|
@ -14,6 +14,7 @@ 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 [isPreview, setIsPreview] = useState(false);
|
||||||
|
const [fileIds, setFileIds] = useState<string[]>([]);
|
||||||
const { create } = usePost();
|
const { create } = usePost();
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
@ -26,8 +27,14 @@ export default function PostCommentEditor() {
|
||||||
await create.mutateAsync({
|
await create.mutateAsync({
|
||||||
data: {
|
data: {
|
||||||
type: PostType.POST_COMMENT,
|
type: PostType.POST_COMMENT,
|
||||||
|
|
||||||
parentId: post?.id,
|
parentId: post?.id,
|
||||||
content: content,
|
content: content,
|
||||||
|
resources: {
|
||||||
|
connect: fileIds.filter(Boolean).map((id) => ({
|
||||||
|
fileId: id,
|
||||||
|
})),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
toast.success("发布成功!");
|
toast.success("发布成功!");
|
||||||
|
@ -83,7 +90,10 @@ export default function PostCommentEditor() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TusUploader></TusUploader>
|
<TusUploader
|
||||||
|
onChange={(value) => {
|
||||||
|
setFileIds(value);
|
||||||
|
}}></TusUploader>
|
||||||
|
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end">
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
|
@ -33,7 +33,7 @@ export default function PostCommentList() {
|
||||||
},
|
},
|
||||||
select: postDetailSelect,
|
select: postDetailSelect,
|
||||||
orderBy: [{ createdAt: "desc" }],
|
orderBy: [{ createdAt: "desc" }],
|
||||||
take: 3,
|
take: 5,
|
||||||
}),
|
}),
|
||||||
[post, receiverIds]
|
[post, receiverIds]
|
||||||
);
|
);
|
||||||
|
@ -43,11 +43,14 @@ export default function PostCommentList() {
|
||||||
where: {
|
where: {
|
||||||
parentId: post?.id,
|
parentId: post?.id,
|
||||||
type: PostType.POST_COMMENT,
|
type: PostType.POST_COMMENT,
|
||||||
authorId: { notIn: receiverIds },
|
OR: [
|
||||||
|
{ authorId: null }, // 允许 authorId 为 null
|
||||||
|
{ authorId: { notIn: receiverIds } }, // 排除 receiverIds 中的 authorId
|
||||||
|
],
|
||||||
},
|
},
|
||||||
select: postDetailSelect,
|
select: postDetailSelect,
|
||||||
orderBy: [{ createdAt: "desc" }],
|
orderBy: [{ createdAt: "desc" }],
|
||||||
take: 3,
|
take: 5,
|
||||||
}),
|
}),
|
||||||
[post, receiverIds]
|
[post, receiverIds]
|
||||||
);
|
);
|
||||||
|
@ -147,6 +150,12 @@ export default function PostCommentList() {
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="text-center py-12 text-slate-500">
|
className="text-center py-12 text-slate-500">
|
||||||
暂无回复,来发表第一条回复吧
|
暂无回复,来发表第一条回复吧
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
console.log(receiverIds);
|
||||||
|
}}>
|
||||||
|
123
|
||||||
|
</Button>
|
||||||
</motion.div>
|
</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 { motion } from "framer-motion";
|
||||||
import { useContext, useEffect } from "react";
|
import { useContext, useEffect } from "react";
|
||||||
import { PostDetailContext } from "../context/PostDetailContext";
|
import { PostDetailContext } from "../context/PostDetailContext";
|
||||||
import PostHeader from "../PostHeader";
|
import PostHeader from "../PostHeader/PostHeader";
|
||||||
|
import WriteHeader from "../PostHeader/Header";
|
||||||
import PostCommentEditor from "../PostCommentEditor";
|
import PostCommentEditor from "../PostCommentEditor";
|
||||||
import PostCommentList from "../PostCommentList";
|
import PostCommentList from "../PostCommentList";
|
||||||
import { useVisitor } from "@nice/client";
|
import { useVisitor } from "@nice/client";
|
||||||
|
|
|
@ -9,6 +9,8 @@ export interface LetterFormData {
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
resources?: string[];
|
resources?: string[];
|
||||||
|
receivers?: string[];
|
||||||
|
terms?: string[];
|
||||||
isPublic?: boolean;
|
isPublic?: boolean;
|
||||||
signature?: string;
|
signature?: string;
|
||||||
meta: {
|
meta: {
|
||||||
|
@ -43,24 +45,38 @@ export function LetterFormProvider({
|
||||||
const onSubmit = async (data: LetterFormData) => {
|
const onSubmit = async (data: LetterFormData) => {
|
||||||
try {
|
try {
|
||||||
console.log("data", data);
|
console.log("data", data);
|
||||||
|
const receivers = data?.receivers;
|
||||||
|
const terms = data?.terms;
|
||||||
|
delete data.receivers;
|
||||||
|
delete data.terms;
|
||||||
const result = await create.mutateAsync({
|
const result = await create.mutateAsync({
|
||||||
data: {
|
data: {
|
||||||
|
...data,
|
||||||
type: PostType.POST,
|
type: PostType.POST,
|
||||||
termId: termId,
|
|
||||||
receivers: {
|
terms: {
|
||||||
connect: [receiverId].filter(Boolean).map((id) => ({
|
connect: (terms || [])?.filter(Boolean).map((id) => ({
|
||||||
id,
|
id,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
state: PostState.PENDING,
|
receivers: {
|
||||||
isPublic: data?.isPublic,
|
connect: (receivers || [])
|
||||||
...data,
|
?.filter(Boolean)
|
||||||
resources: data.resources?.length
|
.map((id) => ({
|
||||||
? {
|
|
||||||
connect: data.resources.map((id) => ({
|
|
||||||
id,
|
id,
|
||||||
})),
|
})),
|
||||||
}
|
},
|
||||||
|
state: PostState.PENDING,
|
||||||
|
isPublic: data?.isPublic,
|
||||||
|
|
||||||
|
resources: data.resources?.length
|
||||||
|
? {
|
||||||
|
connect: (
|
||||||
|
data.resources?.filter(Boolean) || []
|
||||||
|
).map((fileId) => ({
|
||||||
|
fileId,
|
||||||
|
})),
|
||||||
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -2,6 +2,8 @@ import { Form, Input, Button, Checkbox, Select } from "antd";
|
||||||
import { useLetterEditor } from "../context/LetterEditorContext";
|
import { useLetterEditor } from "../context/LetterEditorContext";
|
||||||
import { SendOutlined } from "@ant-design/icons";
|
import { SendOutlined } from "@ant-design/icons";
|
||||||
import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor";
|
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 StaffSelect from "../../../staff/staff-select";
|
||||||
import TermSelect from "../../../term/term-select";
|
import TermSelect from "../../../term/term-select";
|
||||||
|
|
||||||
|
@ -13,30 +15,30 @@ export function LetterBasicForm() {
|
||||||
return (
|
return (
|
||||||
<div className=" p-6 ">
|
<div className=" p-6 ">
|
||||||
<Form
|
<Form
|
||||||
|
|
||||||
size="large"
|
size="large"
|
||||||
form={form}
|
form={form}
|
||||||
onFinish={handleFinish}
|
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">
|
<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="选择收信人员" />
|
<StaffSelect multiple placeholder="选择收信人员" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label='分类' name={'termId'}>
|
<Form.Item label="分类" name={"terms"}>
|
||||||
<TermSelect placeholder="选择信件分类" />
|
<TermSelect placeholder="选择信件分类" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="title"
|
name="title"
|
||||||
rules={[{ required: true, message: "请输入信件标题" }]}
|
rules={[{ required: true, message: "请输入信件标题" }]}>
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
maxLength={20}
|
maxLength={20}
|
||||||
showCount
|
showCount
|
||||||
placeholder="请输入信件标题"
|
placeholder="请输入信件标题"
|
||||||
|
|
||||||
|
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
@ -46,7 +48,9 @@ export function LetterBasicForm() {
|
||||||
mode="tags"
|
mode="tags"
|
||||||
placeholder="输入标签后按回车添加"
|
placeholder="输入标签后按回车添加"
|
||||||
value={form.getFieldValue(["meta", "tags"]) || []}
|
value={form.getFieldValue(["meta", "tags"]) || []}
|
||||||
onChange={(value) => form.setFieldValue(["meta", "tags"], value)}
|
onChange={(value) =>
|
||||||
|
form.setFieldValue(["meta", "tags"], value)
|
||||||
|
}
|
||||||
tokenSeparators={[",", " "]}
|
tokenSeparators={[",", " "]}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
dropdownStyle={{ display: "none" }}
|
dropdownStyle={{ display: "none" }}
|
||||||
|
@ -55,8 +59,7 @@ export function LetterBasicForm() {
|
||||||
{label}
|
{label}
|
||||||
<span
|
<span
|
||||||
className="ml-2 cursor-pointer hover:text-primary-700"
|
className="ml-2 cursor-pointer hover:text-primary-700"
|
||||||
onClick={onClose}
|
onClick={onClose}>
|
||||||
>
|
|
||||||
×
|
×
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -68,26 +71,32 @@ export function LetterBasicForm() {
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="content"
|
name="content"
|
||||||
rules={[{ required: true, message: "请输入内容" }]}
|
rules={[{ required: true, message: "请输入内容" }]}
|
||||||
required={false}
|
required={false}>
|
||||||
>
|
|
||||||
<div className="rounded-lg border border-gray-200 bg-white shadow-sm">
|
<div className="rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||||
<QuillEditor
|
<QuillEditor
|
||||||
maxLength={10000}
|
maxLength={10000}
|
||||||
placeholder="请输入内容"
|
placeholder="请输入内容"
|
||||||
minRows={16}
|
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>
|
</div>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{/* Footer Actions */}
|
{/* Footer Actions */}
|
||||||
<div className="flex flex-col-reverse sm:flex-row items-center justify-between gap-4 mt-6">
|
<div className="flex flex-col-reverse sm:flex-row items-center justify-between gap-4 mt-6">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="isPublic"
|
name="isPublic"
|
||||||
valuePropName="checked"
|
valuePropName="checked"
|
||||||
|
|
||||||
|
|
||||||
>
|
>
|
||||||
<Checkbox className="text-gray-600 hover:text-gray-900 transition-colors text-sm">
|
<Checkbox className="text-gray-600 hover:text-gray-900 transition-colors text-sm">
|
||||||
是否公开
|
是否公开
|
||||||
|
@ -98,8 +107,7 @@ export function LetterBasicForm() {
|
||||||
onClick={() => form.submit()}
|
onClick={() => form.submit()}
|
||||||
size="large"
|
size="large"
|
||||||
icon={<SendOutlined />}
|
icon={<SendOutlined />}
|
||||||
className="w-full sm:w-40"
|
className="w-full sm:w-40">
|
||||||
>
|
|
||||||
发送信件
|
发送信件
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -26,10 +26,19 @@ export function useTusUpload() {
|
||||||
onSuccess: (result: UploadResult) => void,
|
onSuccess: (result: UploadResult) => void,
|
||||||
onError: (error: Error) => 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);
|
setIsUploading(true);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
setUploadError(null);
|
setUploadError(null);
|
||||||
const upload = new tus.Upload(file, {
|
|
||||||
|
try {
|
||||||
|
const upload = new tus.Upload(file, {
|
||||||
endpoint: "http://localhost:3000/upload",
|
endpoint: "http://localhost:3000/upload",
|
||||||
retryDelays: [0, 1000, 3000, 5000],
|
retryDelays: [0, 1000, 3000, 5000],
|
||||||
metadata: {
|
metadata: {
|
||||||
|
@ -73,7 +82,14 @@ export function useTusUpload() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
upload.start();
|
upload.start();
|
||||||
|
} catch (error) {
|
||||||
|
const err = error instanceof Error ? error : new Error("Upload failed");
|
||||||
|
setIsUploading(false);
|
||||||
|
setUploadError(err.message);
|
||||||
|
onError(err);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
progress,
|
progress,
|
||||||
isUploading,
|
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_status $upstream_status;
|
||||||
auth_request_set $auth_user_id $upstream_http_x_user_id;
|
# 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_resource_type $upstream_http_x_resource_type;
|
||||||
# 不缓存
|
# 不缓存
|
||||||
expires 0;
|
expires 0;
|
||||||
# 私有缓存,禁止转换
|
# 私有缓存,禁止转换
|
||||||
|
|
|
@ -27,7 +27,8 @@ model Taxonomy {
|
||||||
model Term {
|
model Term {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
posts Post[]
|
// posts Post[]
|
||||||
|
posts Post[] @relation("post_term")
|
||||||
taxonomy Taxonomy? @relation(fields: [taxonomyId], references: [id])
|
taxonomy Taxonomy? @relation(fields: [taxonomyId], references: [id])
|
||||||
taxonomyId String? @map("taxonomy_id")
|
taxonomyId String? @map("taxonomy_id")
|
||||||
order Float? @map("order")
|
order Float? @map("order")
|
||||||
|
@ -193,8 +194,10 @@ model Post {
|
||||||
content String? // 帖子内容,可为空
|
content String? // 帖子内容,可为空
|
||||||
|
|
||||||
domainId String? @map("domain_id")
|
domainId String? @map("domain_id")
|
||||||
term Term? @relation(fields: [termId], references: [id])
|
// term Term? @relation(fields: [termId], references: [id])
|
||||||
termId String? @map("term_id")
|
// termId String? @map("term_id")
|
||||||
|
// 添加多对多关系
|
||||||
|
terms Term[] @relation("post_term")
|
||||||
// 日期时间类型字段
|
// 日期时间类型字段
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @map("updated_at")
|
updatedAt DateTime @map("updated_at")
|
||||||
|
|
|
@ -12,16 +12,10 @@ export const postDetailSelect: Prisma.PostSelect = {
|
||||||
resources: true,
|
resources: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
termId: true,
|
|
||||||
term: {
|
terms: {
|
||||||
include: {
|
include: {
|
||||||
taxonomy: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
slug: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
authorId: true,
|
authorId: true,
|
||||||
|
|
Loading…
Reference in New Issue