This commit is contained in:
longdayi 2025-01-26 12:48:10 +08:00
parent 99d39ffe5b
commit f166a447b4
17 changed files with 313 additions and 290 deletions

View File

@ -14,7 +14,7 @@ export class ImageProcessor extends BaseProcessor {
const { url } = resource; const { url } = resource;
const filepath = getUploadFilePath(url); const filepath = getUploadFilePath(url);
const originMeta = resource.metadata as unknown as FileMetadata; const originMeta = resource.metadata as unknown as FileMetadata;
if (!originMeta.mimeType?.startsWith('image/')) { if (!originMeta.filetype?.startsWith('image/')) {
this.logger.log(`Skipping non-image resource: ${resource.id}`); this.logger.log(`Skipping non-image resource: ${resource.id}`);
return resource; return resource;
} }

View File

@ -19,7 +19,7 @@ export class VideoProcessor extends BaseProcessor {
); );
const originMeta = resource.metadata as unknown as FileMetadata; const originMeta = resource.metadata as unknown as FileMetadata;
if (!originMeta.mimeType?.startsWith('video/')) { if (!originMeta.filetype?.startsWith('video/')) {
this.logger.log(`Skipping non-video resource: ${resource.id}`); this.logger.log(`Skipping non-video resource: ${resource.id}`);
return resource; return resource;
} }

View File

@ -11,7 +11,7 @@ export interface ProcessResult {
export interface BaseMetadata { export interface BaseMetadata {
size: number size: number
mimeType: string filetype: string
filename: string filename: string
extension: string extension: string
modifiedAt: Date modifiedAt: Date

View File

@ -1,24 +0,0 @@
import { InjectQueue } from '@nestjs/bullmq';
import { Injectable } from '@nestjs/common';
import EventBus from '@server/utils/event-bus';
import { Queue } from 'bullmq';
import { ObjectType } from '@nice/common';
import { QueueJobType } from '../types';
@Injectable()
export class PostProcessService {
constructor(@InjectQueue('general') private generalQueue: Queue) {}
private generateJobId(type: ObjectType, data: any): string {
// 根据类型和相关ID生成唯一的job标识
switch (type) {
case ObjectType.ENROLLMENT:
return `stats_${type}_${data.courseId}`;
case ObjectType.LECTURE:
return `stats_${type}_${data.courseId}_${data.sectionId}`;
case ObjectType.POST:
return `stats_${type}_${data.courseId}`;
default:
return `stats_${type}_${Date.now()}`;
}
}
}

View File

@ -8,36 +8,7 @@ import { updatePostViewCount } from '../models/post/utils';
const logger = new Logger('QueueWorker'); const logger = new Logger('QueueWorker');
export default async function processJob(job: Job<any, any, QueueJobType>) { export default async function processJob(job: Job<any, any, QueueJobType>) {
try { try {
if (job.name === QueueJobType.UPDATE_STATS) {
const { sectionId, courseId, type } = job.data;
// 处理 section 统计
// if (sectionId) {
// await updateSectionLectureStats(sectionId);
// logger.debug(`Updated section stats for sectionId: ${sectionId}`);
// }
// // 如果没有 courseId提前返回
// if (!courseId) {
// return;
// }
// 处理 course 相关统计
switch (type) {
// case ObjectType.LECTURE:
// await updateCourseLectureStats(courseId);
// break;
// case ObjectType.ENROLLMENT:
// await updateCourseEnrollmentStats(courseId);
// break;
// case ObjectType.POST:
// await updateCourseReviewStats(courseId);
// break;
default:
logger.warn(`Unknown update stats type: ${type}`);
}
logger.debug(
`Updated course stats for courseId: ${courseId}, type: ${type}`,
);
}
if (job.name === QueueJobType.UPDATE_POST_VISIT_COUNT) { if (job.name === QueueJobType.UPDATE_POST_VISIT_COUNT) {
await updatePostViewCount(job.data.id, job.data.type); await updatePostViewCount(job.data.id, job.data.type);
} }

View File

@ -23,7 +23,7 @@ export class TusService implements OnModuleInit {
constructor( constructor(
private readonly resourceService: ResourceService, private readonly resourceService: ResourceService,
@InjectQueue('file-queue') private fileQueue: Queue, @InjectQueue('file-queue') private fileQueue: Queue,
) {} ) { }
onModuleInit() { onModuleInit() {
this.initializeTusServer(); this.initializeTusServer();
this.setupTusEventHandlers(); this.setupTusEventHandlers();
@ -68,7 +68,7 @@ export class TusService implements OnModuleInit {
) { ) {
try { try {
const fileId = this.getFileId(upload.id); const fileId = this.getFileId(upload.id);
const filename = upload.metadata.filename;
await this.resourceService.create({ await this.resourceService.create({
data: { data: {
title: getFilenameWithoutExt(upload.metadata.filename), title: getFilenameWithoutExt(upload.metadata.filename),

View File

@ -48,10 +48,10 @@ const AuthPage: React.FC = () => {
transition={{ delay: 0.2, duration: 0.5 }} transition={{ delay: 0.2, duration: 0.5 }}
> >
<div className="text-4xl text-white mb-4 font-serif"> <div className="text-4xl text-white mb-4 font-serif">
</div> </div>
<Paragraph className="text-lg mb-8 text-blue-100 leading-relaxed text-justify"> <Paragraph className="text-lg mb-8 text-blue-100 leading-relaxed text-justify">
</Paragraph> </Paragraph>
{showLogin && ( {showLogin && (
<Button <Button

View File

@ -11,139 +11,136 @@ import { SearchOutlined } from "@ant-design/icons";
import WriteHeader from "./WriteHeader"; import WriteHeader from "./WriteHeader";
export default function WriteLetterPage() { export default function WriteLetterPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const termId = searchParams.get("termId"); const termId = searchParams.get("termId");
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [selectedDept, setSelectedDept] = useState<string>(); const [selectedDept, setSelectedDept] = useState<string>();
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const pageSize = 10; const pageSize = 10;
const { getTerm } = useTerm(); const { getTerm } = useTerm();
const { data, isLoading, error } = const { data, isLoading, error } =
api.staff.findManyWithPagination.useQuery({ api.staff.findManyWithPagination.useQuery({
page: currentPage, page: currentPage,
pageSize, pageSize,
where: { where: {
deptId: selectedDept, deptId: selectedDept,
OR: [ OR: [
{ {
showname: { showname: {
contains: searchQuery, contains: searchQuery,
}, },
}, },
{ {
username: { username: {
contains: searchQuery, contains: searchQuery,
}, },
}, },
], ],
}, },
orderBy: { orderBy: {
order: "desc", order: "desc",
}, }
// orderBy:{ });
// } const resetPage = useCallback(() => {
}); setCurrentPage(1);
}, []);
const resetPage = useCallback(() => { // Reset page when search or department changes
setCurrentPage(1); useEffect(() => {
}, []); resetPage();
}, [searchQuery, selectedDept, resetPage]);
// Reset page when search or department changes return (
useEffect(() => { <div className="min-h-screen shadow-elegant border-2 border-white rounded-xl bg-slate-200">
resetPage(); <WriteHeader term={getTerm(termId)} />
}, [searchQuery, selectedDept, resetPage]); <div className="container mx-auto px-4 py-8">
<div className="mb-8 space-y-4">
{/* Search and Filter Section */}
<div className="flex flex-col md:flex-row gap-4 items-center">
<DepartmentSelect
variant="filled"
size="large"
value={selectedDept}
onChange={setSelectedDept as any}
className="w-1/2"
/>
<Input
variant="filled"
className={"w-1/2"}
prefix={
<SearchOutlined className="text-gray-400" />
}
placeholder="搜索领导姓名或职级..."
onChange={debounce(
(e) => setSearchQuery(e.target.value),
300
)}
size="large"
/>
</div>
return ( {error && (
<div className="min-h-screen shadow-elegant border-2 border-white rounded-xl bg-slate-200"> <Alert
<WriteHeader term={getTerm(termId)} /> message="加载失败"
<div className="container mx-auto px-4 py-8"> description="获取数据时出现错误,请刷新页面重试。"
<div className="mb-8 space-y-4"> type="error"
{/* Search and Filter Section */} showIcon
<div className="flex flex-col md:flex-row gap-4 items-center"> />
<DepartmentSelect )}
variant="filled" </div>
size="large"
value={selectedDept}
onChange={setSelectedDept as any}
className="w-1/2"
/>
<Input
variant="filled"
className={"w-1/2"}
prefix={
<SearchOutlined className="text-gray-400" />
}
placeholder="搜索领导姓名或职级..."
onChange={debounce(
(e) => setSearchQuery(e.target.value),
300
)}
size="large"
/>
</div>
{error && ( <AnimatePresence>
<Alert {isLoading ? (
message="加载失败" <div className="flex justify-center items-center py-12">
description="获取数据时出现错误,请刷新页面重试。" <Spin size="large" tip="加载中..." />
type="error" </div>
showIcon ) : data?.items.length > 0 ? (
/> <motion.div
)} className="grid grid-cols-1 gap-6"
</div> initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}>
{data?.items.map((item: any) => (
<SendCard
key={item.id}
staff={item}
termId={termId || undefined}
/>
))}
</motion.div>
) : (
<motion.div
className="text-center py-12"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}>
<Empty
description="没有找到匹配的收信人"
className="py-12"
/>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence> {/* Pagination */}
{isLoading ? ( {data?.items.length > 0 && (
<div className="flex justify-center items-center py-12"> <div className="flex justify-center mt-8">
<Spin size="large" tip="加载中..." /> <Pagination
</div> current={currentPage}
) : data?.items.length > 0 ? ( total={data?.totalPages || 0}
<motion.div pageSize={pageSize}
className="grid grid-cols-1 gap-6" onChange={(page) => {
initial={{ opacity: 0 }} setCurrentPage(page);
animate={{ opacity: 1 }} window.scrollTo(0, 0);
exit={{ opacity: 0 }}> }}
{data?.items.map((item: any) => ( showSizeChanger={false}
<SendCard showTotal={(total) => `${total} 条记录`}
key={item.id} />
staff={item} </div>
termId={termId || undefined} )}
/> </div>
))} </div>
</motion.div> );
) : (
<motion.div
className="text-center py-12"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}>
<Empty
description="没有找到匹配的收信人"
className="py-12"
/>
</motion.div>
)}
</AnimatePresence>
{/* Pagination */}
{data?.items.length > 0 && (
<div className="flex justify-center mt-8">
<Pagination
current={currentPage}
total={data?.totalPages || 0}
pageSize={pageSize}
onChange={(page) => {
setCurrentPage(page);
window.scrollTo(0, 0);
}}
showSizeChanger={false}
showTotal={(total) => `${total} 条记录`}
/>
</div>
)}
</div>
</div>
);
} }

View File

@ -12,7 +12,7 @@ export function Footer() {
</h3> </h3>
<p className="text-gray-400 text-xs italic"> <p className="text-gray-400 text-xs italic">
</p> </p>
</div> </div>

View File

@ -7,24 +7,24 @@ import { UserOutlined } from "@ant-design/icons";
import { UserMenu } from "../element/usermenu"; import { UserMenu } from "../element/usermenu";
import SineWavesCanvas from "../../animation/sine-wave"; import SineWavesCanvas from "../../animation/sine-wave";
interface HeaderProps { interface HeaderProps {
onSearch?: (query: string) => void; onSearch?: (query: string) => void;
} }
export const Header = memo(function Header({ onSearch }: HeaderProps) { export const Header = memo(function Header({ onSearch }: HeaderProps) {
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
return ( return (
<header className="sticky top-0 z-50 bg-gradient-to-br from-primary-500 to-primary-800 text-white shadow-lg"> <header className="sticky top-0 z-50 bg-gradient-to-br from-primary-500 to-primary-800 text-white shadow-lg">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="py-3"> <div className="py-3">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className=" text-xl font-bold"></div> <div className=" text-xl font-bold"></div>
<div className="flex-grow max-w-2xl"> <div className="flex-grow max-w-2xl">
<SearchBar onSearch={onSearch} /> <SearchBar onSearch={onSearch} />
</div> </div>
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
{!isAuthenticated ? ( {!isAuthenticated ? (
<Link <Link
to="/auth" to="/auth"
className="group flex items-center gap-2 rounded-lg className="group flex items-center gap-2 rounded-lg
bg-[#00539B]/90 px-5 py-2.5 font-medium bg-[#00539B]/90 px-5 py-2.5 font-medium
shadow-lg transition-all duration-300 shadow-lg transition-all duration-300
hover:-translate-y-0.5 hover:bg-[#0063B8] hover:-translate-y-0.5 hover:bg-[#0063B8]
@ -32,21 +32,21 @@ hover:shadow-xl hover:shadow-[#00539B]/30
focus:outline-none focus:ring-2 focus:outline-none focus:ring-2
focus:ring-[#8EADD4] focus:ring-offset-2 focus:ring-[#8EADD4] focus:ring-offset-2
focus:ring-offset-[#13294B]" focus:ring-offset-[#13294B]"
aria-label="Login"> aria-label="Login">
<UserOutlined <UserOutlined
className="h-5 w-5 transition-transform className="h-5 w-5 transition-transform
group-hover:scale-110 group-hover:rotate-12"></UserOutlined> group-hover:scale-110 group-hover:rotate-12"></UserOutlined>
<span></span> <span></span>
</Link> </Link>
) : ( ) : (
<UserMenu /> <UserMenu />
)} )}
</div> </div>
</div> </div>
</div> </div>
<Navigation /> <Navigation />
</div> </div>
</header> </header>
); );
}); });

View File

@ -62,7 +62,7 @@ export default function PostCommentCard({
</div> </div>
<div <div
className="ql-editor text-slate-800 mt-1" className="ql-editor text-slate-800 mt-1 text-base mb-2"
style={{ style={{
padding: 0, padding: 0,
}} }}

View File

@ -141,7 +141,7 @@ export default function PostCommentList() {
} }
return ( return (
<div className=" space-y-2 p-6"> <div className=" flex flex-col gap-4 p-6">
<AnimatePresence mode="popLayout"> <AnimatePresence mode="popLayout">
{items.map((comment, index) => ( {items.map((comment, index) => (
<motion.div <motion.div

View File

@ -20,7 +20,7 @@ export default function Content() {
return ( return (
<div className="p-6 text-base " > <div className="p-6 text-base " >
<div className="text-secondary-700 bg-slate-100 border border-white hover:ring-1 ring-white transition-all duration-300 ease-in-out rounded-xl p-6 "> <div className="text-secondary-700 flex flex-col gap-4 bg-slate-100 border border-white hover:ring-1 ring-white transition-all duration-300 ease-in-out rounded-xl p-6 ">
{/* 包装整个内容区域的容器 */} {/* 包装整个内容区域的容器 */}
<div <div
ref={contentWrapperRef} ref={contentWrapperRef}
@ -52,6 +52,8 @@ export default function Content() {
{isExpanded ? "收起" : "展开"} {isExpanded ? "收起" : "展开"}
</button> </button>
)} )}
{/* PostResources 组件 */}
<PostResources post={post} />
</div> </div>
<StatsSection /> <StatsSection />
</div> </div>

View File

@ -16,9 +16,8 @@ export function StatsSection() {
return ( return (
<div <div
className="mt-6 flex flex-wrap gap-4 justify-between items-center"> className="mt-6 flex flex-wrap gap-4 justify-end items-center">
{/* PostResources 组件 */}
<PostResources post={post} />
<div className=" flex gap-2"> <div className=" flex gap-2">
<Button title="浏览量" type="default" shape="round" icon={<EyeOutlined />}> <Button title="浏览量" type="default" shape="round" icon={<EyeOutlined />}>
<span className="mr-1"></span>{post?.views} <span className="mr-1"></span>{post?.views}

View File

@ -1,70 +1,116 @@
import React, { useContext, useMemo } from "react"; import React, { useMemo } from "react";
import { Image, Button } from "antd"; import { Image, Button, Row, Col } from "antd";
import { DownloadOutlined, PaperClipOutlined } from "@ant-design/icons"; import { DownloadOutlined } from "@ant-design/icons";
import { PostDetailContext } from "./context/PostDetailContext";
import { env } from "@web/src/env";
import { PostDto } from "@nice/common"; import { PostDto } from "@nice/common";
import { env } from "@web/src/env";
import { getFileIcon } from "./utils";
export default function PostResources({ post }: { post: PostDto }) { export default function PostResources({ post }: { post: PostDto }) {
const resources = useMemo(() => { const { resources } = useMemo(() => {
if (!post?.resources) return []; if (!post?.resources) return { resources: [] };
const isImage = (url: string) => { const isImage = (url: string) => /\.(png|jpg|jpeg|gif|webp)$/i.test(url);
return /\.(png|jpg|jpeg|gif|webp)$/i.test(url);
};
return post.resources const sortedResources = post.resources
.map((resource) => ({ .map((resource) => ({
url: `${env.SERVER_IP}/uploads/${resource.url}`, ...resource,
title: resource.title, url: `http://${env.SERVER_IP}/uploads/${resource.url}`,
isImage: isImage(resource.url), isImage: isImage(resource.url),
})) }))
.sort((a, b) => { .sort((a, b) => (a.isImage === b.isImage ? 0 : a.isImage ? -1 : 1));
// 图片排在前面,非图片排在后面
if (a.isImage && !b.isImage) return -1; return { resources: sortedResources };
if (!a.isImage && b.isImage) return 1;
return 0;
});
}, [post]); }, [post]);
const imageResources = resources.filter((res) => res.isImage);
const fileResources = resources.filter((res) => !res.isImage);
return ( return (
<div className="flex flex-col "> <div className="space-y-6">
<div className="flex flex-col "> {imageResources.length > 0 && (
{resources <Row gutter={[16, 16]} className="mb-6">
?.filter((resource) => resource.isImage) <Image.PreviewGroup>
.map((resource) => ( {imageResources.map((resource) => (
<div key={resource.url} className="mt-0.5"> <Col
<Image key={resource.url}
src={resource.url} xs={12}
alt={resource.title} sm={8}
className="rounded-lg" md={6}
width={"100%"} lg={6}
height={"auto"} xl={4}
style={{ objectFit: "cover" }} className="relative"
/> >
</div> <div className="relative aspect-square rounded-lg overflow-hidden shadow-md hover:shadow-lg transition-shadow duration-300 bg-gray-100">
))} <div className="w-full h-full">
</div> <Image
<div className="flex flex-wrap gap-4"> src={resource.url}
{resources alt={resource.title}
?.filter((resource) => !resource.isImage) preview={{
.map((resource) => ( mask: (
<Button <div className="flex items-center justify-center text-white">
key={resource.url}
type="text" </div>
icon={ ),
<PaperClipOutlined className="text-gray-500" /> }}
} style={{
href={resource.url} position: "absolute",
download inset: 0,
className="flex items-center justify-start p-2 hover:bg-gray-100 transition-colors duration-200"> width: "100%",
<span className="mr-2 text-gray-600 truncate max-w-[150px]"> height: "100%",
{resource.title || "附件"} objectFit: "cover",
</span> }}
<DownloadOutlined className="text-blue-600" /> rootClassName="w-full h-full"
</Button> />
))} </div>
</div> {resource.title && (
<div className="absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-black/60 to-transparent text-white text-sm truncate">
{resource.title}
</div>
)}
</div>
</Col>
))}
</Image.PreviewGroup>
</Row>
)}
{fileResources.length > 0 && (
<div className="rounded-xl p-4 bg-gray-200">
<div className="space-y-2">
{fileResources.map((resource) => (
<div
key={resource.url}
className="flex items-center justify-between p-3 hover:bg-gray-50 rounded-md transition-colors duration-200"
>
<div className="flex items-center space-x-3 min-w-0">
<span className="text-xl">{getFileIcon(resource.url)}</span>
<div className="min-w-0">
<p className="text-gray-800 truncate">
{resource.title || "未命名文件"}
</p>
<div className="flex items-center space-x-2">
<span className="text-xs text-gray-500">
{resource.url.split(".").pop()?.toUpperCase()}
</span>
<span className="text-xs text-gray-400">
{resource.metadata.size &&
`${(resource.metadata.size / 1024 / 1024).toFixed(1)}MB`}
</span>
</div>
</div>
</div>
<Button
icon={<DownloadOutlined />}
href={resource.url}
download
>
</Button>
</div>
))}
</div>
</div>
)}
</div> </div>
); );
} }

View File

@ -1,7 +0,0 @@
export const isContentEmpty = (html: string) => {
// 创建一个临时 div 来解析 HTML 内容
const temp = document.createElement("div");
temp.innerHTML = html;
// 获取纯文本内容并检查是否为空
return !temp.textContent?.trim();
};

View File

@ -0,0 +1,39 @@
import { FilePdfOutlined, FileWordOutlined, FileExcelOutlined, FilePptOutlined, FileTextOutlined, FileZipOutlined, FileImageOutlined, FileUnknownOutlined } from "@ant-design/icons";
export const isContentEmpty = (html: string) => {
// 创建一个临时 div 来解析 HTML 内容
const temp = document.createElement("div");
temp.innerHTML = html;
// 获取纯文本内容并检查是否为空
return !temp.textContent?.trim();
};
export const getFileIcon = (filename: string) => {
const extension = filename.split(".").pop()?.toLowerCase();
switch (extension) {
case "pdf":
return <FilePdfOutlined className="text-red-500" />;
case "doc":
case "docx":
return <FileWordOutlined className="text-blue-500" />;
case "xls":
case "xlsx":
return <FileExcelOutlined className="text-green-600" />;
case "ppt":
case "pptx":
return <FilePptOutlined className="text-orange-500" />;
case "txt":
return <FileTextOutlined className="text-gray-600" />;
case "zip":
case "rar":
case "7z":
return <FileZipOutlined className="text-purple-500" />;
case "png":
case "jpg":
case "jpeg":
case "gif":
case "webp":
return <FileImageOutlined className="text-pink-400" />;
default:
return <FileUnknownOutlined className="text-gray-500" />;
}
};