01261248
This commit is contained in:
parent
99d39ffe5b
commit
f166a447b4
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -1,7 +0,0 @@
|
||||||
export const isContentEmpty = (html: string) => {
|
|
||||||
// 创建一个临时 div 来解析 HTML 内容
|
|
||||||
const temp = document.createElement("div");
|
|
||||||
temp.innerHTML = html;
|
|
||||||
// 获取纯文本内容并检查是否为空
|
|
||||||
return !temp.textContent?.trim();
|
|
||||||
};
|
|
|
@ -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" />;
|
||||||
|
}
|
||||||
|
};
|
Loading…
Reference in New Issue