add
This commit is contained in:
parent
455c82911d
commit
db2e3a044b
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,171 +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>
|
|
||||||
|
|
||||||
{post?.receivers?.map((receiver, index) => (
|
|
||||||
<Text strong 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-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 */}
|
|
||||||
{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>
|
|
||||||
|
|
||||||
{/* 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";
|
||||||
|
|
|
@ -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;
|
||||||
# 私有缓存,禁止转换
|
# 私有缓存,禁止转换
|
||||||
|
|
Loading…
Reference in New Issue