add
This commit is contained in:
parent
1c3af15978
commit
89102d7f8c
|
@ -23,13 +23,24 @@ export class ResourceRouter {
|
||||||
) {}
|
) {}
|
||||||
router = this.trpc.router({
|
router = this.trpc.router({
|
||||||
create: this.trpc.protectProcedure
|
create: this.trpc.protectProcedure
|
||||||
.input(ResourceCreateArgsSchema)
|
.input(
|
||||||
|
z.object({
|
||||||
|
data: z.object({
|
||||||
|
fileId: z.string(),
|
||||||
|
isPublic: z.boolean().optional(),
|
||||||
|
title: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
type: z.string().optional(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { staff } = ctx;
|
const { staff } = ctx;
|
||||||
|
console.log('资源创建数据:', input.data);
|
||||||
return await this.resourceService.create(input, { staff });
|
return await this.resourceService.create(input, { staff });
|
||||||
}),
|
}),
|
||||||
|
|
||||||
createMany: this.trpc.protectProcedure
|
createMany: this.trpc.protectProcedure
|
||||||
.input(z.array(ResourceCreateManyInputSchema))
|
.input(z.array(ResourceCreateManyInputSchema))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
@ -48,9 +59,11 @@ export class ResourceRouter {
|
||||||
return await this.resourceService.findFirst(input);
|
return await this.resourceService.findFirst(input);
|
||||||
}),
|
}),
|
||||||
softDeleteByIds: this.trpc.protectProcedure
|
softDeleteByIds: this.trpc.protectProcedure
|
||||||
.input(z.object({
|
.input(
|
||||||
ids: z.array(z.string())
|
z.object({
|
||||||
}))
|
ids: z.array(z.string()),
|
||||||
|
}),
|
||||||
|
)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const result = await this.resourceService.softDeleteByIds(input.ids);
|
const result = await this.resourceService.softDeleteByIds(input.ids);
|
||||||
return result;
|
return result;
|
||||||
|
@ -78,20 +91,24 @@ export class ResourceRouter {
|
||||||
const { staff } = ctx;
|
const { staff } = ctx;
|
||||||
return await this.resourceService.findManyWithCursor(input);
|
return await this.resourceService.findManyWithCursor(input);
|
||||||
}),
|
}),
|
||||||
count: this.trpc.procedure
|
count: this.trpc.procedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
where: z.object({
|
where: z
|
||||||
AND: z.object({
|
.object({
|
||||||
title: z.object({
|
AND: z
|
||||||
not: z.null()
|
.object({
|
||||||
}),
|
title: z.object({
|
||||||
description: z.object({
|
not: z.null(),
|
||||||
not: z.null()
|
}),
|
||||||
})
|
description: z.object({
|
||||||
}).optional(),
|
not: z.null(),
|
||||||
deletedAt: z.date().nullable().optional(),
|
}),
|
||||||
}).optional(),
|
})
|
||||||
|
.optional(),
|
||||||
|
deletedAt: z.date().nullable().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
export function ExampleContent() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-[400px]">
|
|
||||||
<h2 className="text-xl font-bold mb-4">案例分析</h2>
|
|
||||||
{/* 这里放视频列表内容 */}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -18,7 +18,7 @@ import dayjs from "dayjs";
|
||||||
import { env } from "@web/src/env";
|
import { env } from "@web/src/env";
|
||||||
import { getFileIcon } from "@web/src/components/models/post/detail/utils";
|
import { getFileIcon } from "@web/src/components/models/post/detail/utils";
|
||||||
import { formatFileSize, getCompressedImageUrl } from "@nice/utils";
|
import { formatFileSize, getCompressedImageUrl } from "@nice/utils";
|
||||||
import { ResourceDto, RoleName } from "packages/common/dist";
|
import { ResourceDto, RoleName, ResourceType } from "@nice/common";
|
||||||
import { useAuth } from "@web/src/providers/auth-provider";
|
import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
const { TabPane } = Tabs;
|
const { TabPane } = Tabs;
|
||||||
|
|
||||||
|
|
|
@ -1,88 +1,85 @@
|
||||||
import { Tabs } from 'antd';
|
import { Tabs } from "antd";
|
||||||
import {
|
import {
|
||||||
VideoCameraOutlined,
|
VideoCameraOutlined,
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
CustomerServiceOutlined,
|
CustomerServiceOutlined,
|
||||||
ReadOutlined,
|
ReadOutlined,
|
||||||
BookOutlined
|
BookOutlined,
|
||||||
} from '@ant-design/icons';
|
} from "@ant-design/icons";
|
||||||
import { VideoContent } from './VideoContent';
|
import { VideoContent } from "./VideoContent";
|
||||||
import { MusicContent } from './MusicContent';
|
import { MusicContent } from "./MusicContent";
|
||||||
import { ScienceContent } from './ScienceContent';
|
import { ScienceContent } from "./science/ScienceContent";
|
||||||
import { PublicityContent } from './PublicityContent';
|
import { PublicityContent } from "./news/PublicityContent";
|
||||||
import { ExampleContent } from './ExampleContent';
|
import { ExampleContent } from "./example/ExampleContent";
|
||||||
import './pt.css';
|
import "./pt.css";
|
||||||
|
|
||||||
export function PsychologyNav() {
|
export function PsychologyNav() {
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
key: 'publicity',
|
key: "publicity",
|
||||||
label: (
|
label: (
|
||||||
<span className="flex items-center gap-2 text-gray-700 hover:text-primary transition-colors">
|
<span className="flex items-center gap-2 text-gray-700 hover:text-primary transition-colors">
|
||||||
<BookOutlined className="text-lg" />
|
<BookOutlined className="text-lg" />
|
||||||
<span>宣传报道</span>
|
<span>宣传报道</span>
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
children: <PublicityContent />
|
children: <PublicityContent />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'science',
|
key: "science",
|
||||||
label: (
|
label: (
|
||||||
<span className="flex items-center gap-2 text-gray-700 hover:text-primary transition-colors">
|
<span className="flex items-center gap-2 text-gray-700 hover:text-primary transition-colors">
|
||||||
< ReadOutlined className="text-lg" />
|
<ReadOutlined className="text-lg" />
|
||||||
<span>常识科普</span>
|
<span>常识科普</span>
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
children: <ScienceContent />
|
children: <ScienceContent />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'example',
|
key: "example",
|
||||||
label: (
|
label: (
|
||||||
<span className="flex items-center gap-2 text-gray-700 hover:text-primary transition-colors">
|
<span className="flex items-center gap-2 text-gray-700 hover:text-primary transition-colors">
|
||||||
<VideoCameraOutlined className="text-lg" />
|
<VideoCameraOutlined className="text-lg" />
|
||||||
<span>案例分析</span>
|
<span>案例分析</span>
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
children: <ExampleContent />
|
children: <ExampleContent />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'video',
|
key: "video",
|
||||||
label: (
|
label: (
|
||||||
<span className="flex items-center gap-2 text-gray-700 hover:text-primary transition-colors">
|
<span className="flex items-center gap-2 text-gray-700 hover:text-primary transition-colors">
|
||||||
< FileTextOutlined className="text-lg" />
|
<FileTextOutlined className="text-lg" />
|
||||||
<span>心理课件</span>
|
<span>文件共享</span>
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
children: <VideoContent />
|
children: <VideoContent />,
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
key: 'music',
|
// key: 'music',
|
||||||
label: (
|
// label: (
|
||||||
<span className="flex items-center gap-2 text-gray-700 hover:text-primary transition-colors">
|
// <span className="flex items-center gap-2 text-gray-700 hover:text-primary transition-colors">
|
||||||
<CustomerServiceOutlined className="text-lg" />
|
// <CustomerServiceOutlined className="text-lg" />
|
||||||
<span>音视频</span>
|
// <span>音视频</span>
|
||||||
</span>
|
// </span>
|
||||||
),
|
// ),
|
||||||
children: <MusicContent />
|
// children: <MusicContent />
|
||||||
},
|
// },
|
||||||
|
];
|
||||||
|
return (
|
||||||
];
|
<div className="w-full from bg-white rounded-lg shadow-md">
|
||||||
return (
|
<Tabs
|
||||||
<div className="w-full from bg-white rounded-lg shadow-md">
|
defaultActiveKey="publicity"
|
||||||
<Tabs
|
items={items}
|
||||||
defaultActiveKey="publicity"
|
className="psychology-tabs"
|
||||||
items={items}
|
tabBarStyle={{
|
||||||
className="psychology-tabs"
|
margin: 0,
|
||||||
tabBarStyle={{
|
padding: "12px 16px 0",
|
||||||
margin: 0,
|
borderBottom: "1px solid #f0f0f0",
|
||||||
padding: '12px 16px 0',
|
}}
|
||||||
borderBottom: '1px solid #f0f0f0'
|
tabBarGutter={300}
|
||||||
}}
|
/>
|
||||||
tabBarGutter={200}
|
</div>
|
||||||
/>
|
);
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
export default PsychologyNav;
|
export default PsychologyNav;
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
export function PublicityContent() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-[400px]">
|
|
||||||
<h2 className="text-xl font-bold mb-4">宣传报道</h2>
|
|
||||||
{/* 这里放课件列表内容 */}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,8 +0,0 @@
|
||||||
export function ScienceContent() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-[400px]">
|
|
||||||
<h2 className="text-xl font-bold mb-4">科普</h2>
|
|
||||||
{/* 这里放视频列表内容 */}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -18,7 +18,7 @@ import dayjs from "dayjs";
|
||||||
import { env } from "@web/src/env";
|
import { env } from "@web/src/env";
|
||||||
import { getFileIcon } from "@web/src/components/models/post/detail/utils";
|
import { getFileIcon } from "@web/src/components/models/post/detail/utils";
|
||||||
import { formatFileSize, getCompressedImageUrl } from "@nice/utils";
|
import { formatFileSize, getCompressedImageUrl } from "@nice/utils";
|
||||||
import { ResourceDto, RoleName } from "packages/common/dist";
|
import { ResourceDto, RoleName, ResourceType } from "@nice/common";
|
||||||
import { useAuth } from "@web/src/providers/auth-provider";
|
import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
const { TabPane } = Tabs;
|
const { TabPane } = Tabs;
|
||||||
|
|
||||||
|
@ -26,6 +26,7 @@ export function VideoContent() {
|
||||||
const { isAuthenticated, user, hasSomePermissions } = useAuth();
|
const { isAuthenticated, user, hasSomePermissions } = useAuth();
|
||||||
const [fileIds, setFileIds] = useState<string[]>([]);
|
const [fileIds, setFileIds] = useState<string[]>([]);
|
||||||
const [uploaderKey, setUploaderKey] = useState<number>(0);
|
const [uploaderKey, setUploaderKey] = useState<number>(0);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
// 分页状态
|
// 分页状态
|
||||||
const [imagePage, setImagePage] = useState(1);
|
const [imagePage, setImagePage] = useState(1);
|
||||||
|
@ -41,7 +42,8 @@ export function VideoContent() {
|
||||||
const {
|
const {
|
||||||
data: resources,
|
data: resources,
|
||||||
refetch,
|
refetch,
|
||||||
}: { data: ResourceDto[]; refetch: () => void } =
|
isLoading,
|
||||||
|
}: { data: ResourceDto[]; refetch: () => void; isLoading: boolean } =
|
||||||
api.resource.findMany.useQuery({
|
api.resource.findMany.useQuery({
|
||||||
where: {
|
where: {
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
|
@ -55,13 +57,14 @@ export function VideoContent() {
|
||||||
// 处理资源数据
|
// 处理资源数据
|
||||||
const { imageResources, fileResources, imagePagination, filePagination } =
|
const { imageResources, fileResources, imagePagination, filePagination } =
|
||||||
useMemo(() => {
|
useMemo(() => {
|
||||||
if (!resources)
|
if (!resources) {
|
||||||
return {
|
return {
|
||||||
imageResources: [],
|
imageResources: [],
|
||||||
fileResources: [],
|
fileResources: [],
|
||||||
imagePagination: { total: 0, data: [] },
|
imagePagination: { total: 0, data: [] },
|
||||||
filePagination: { total: 0, data: [] },
|
filePagination: { total: 0, data: [] },
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const isImage = (url: string) => /\.(png|jpg|jpeg|gif|webp)$/i.test(url);
|
const isImage = (url: string) => /\.(png|jpg|jpeg|gif|webp)$/i.test(url);
|
||||||
|
|
||||||
|
@ -70,17 +73,34 @@ export function VideoContent() {
|
||||||
if (!resource?.url) return null;
|
if (!resource?.url) return null;
|
||||||
const original = `http://${env.SERVER_IP}:${env.UPLOAD_PORT}/uploads/${resource.url}`;
|
const original = `http://${env.SERVER_IP}:${env.UPLOAD_PORT}/uploads/${resource.url}`;
|
||||||
const isImg = isImage(resource.url);
|
const isImg = isImage(resource.url);
|
||||||
|
|
||||||
|
// 确保 title 存在,优先使用 resource.title,然后是 resource.meta.filename
|
||||||
|
const displayTitle =
|
||||||
|
resource.title || resource.meta?.filename || "未命名文件";
|
||||||
|
// 用于搜索的名称,确保从 meta.filename 获取(如果存在)
|
||||||
|
const searchableFilename = resource.meta?.filename || "";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...resource,
|
...resource,
|
||||||
url: isImg ? getCompressedImageUrl(original) : original,
|
url: isImg ? getCompressedImageUrl(original) : original,
|
||||||
originalUrl: original,
|
originalUrl: original,
|
||||||
isImage: isImg,
|
isImage: isImg,
|
||||||
|
title: displayTitle, // 用于显示
|
||||||
|
searchableFilename: searchableFilename, // 用于搜索
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
|
// 根据搜索词筛选文件资源 (基于 searchableFilename)
|
||||||
|
const filteredFileResources = processedResources.filter(
|
||||||
|
(res) =>
|
||||||
|
!res.isImage &&
|
||||||
|
res.searchableFilename
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
const allImageResources = processedResources.filter((res) => res.isImage);
|
const allImageResources = processedResources.filter((res) => res.isImage);
|
||||||
const allFileResources = processedResources.filter((res) => !res.isImage);
|
|
||||||
|
|
||||||
// 分页处理
|
// 分页处理
|
||||||
const imageStart = (imagePage - 1) * pageSize;
|
const imageStart = (imagePage - 1) * pageSize;
|
||||||
|
@ -91,17 +111,20 @@ export function VideoContent() {
|
||||||
imageStart,
|
imageStart,
|
||||||
imageStart + pageSize
|
imageStart + pageSize
|
||||||
),
|
),
|
||||||
fileResources: allFileResources.slice(fileStart, fileStart + pageSize),
|
fileResources: filteredFileResources.slice(
|
||||||
|
fileStart,
|
||||||
|
fileStart + pageSize
|
||||||
|
),
|
||||||
imagePagination: {
|
imagePagination: {
|
||||||
total: allImageResources.length,
|
total: allImageResources.length,
|
||||||
data: allImageResources,
|
data: allImageResources,
|
||||||
},
|
},
|
||||||
filePagination: {
|
filePagination: {
|
||||||
total: allFileResources.length,
|
total: filteredFileResources.length,
|
||||||
data: allFileResources,
|
data: filteredFileResources,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [resources, imagePage, filePage]);
|
}, [resources, imagePage, filePage, searchTerm]); // searchTerm 作为依赖项
|
||||||
|
|
||||||
const createMutation = api.resource.create.useMutation({});
|
const createMutation = api.resource.create.useMutation({});
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
@ -161,7 +184,7 @@ export function VideoContent() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isDomainAdmin) {
|
if (!isDomainAdmin) {
|
||||||
message.error("只有域管理员才能删除文件");
|
message.error("只有管理员才能删除文件");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -196,11 +219,17 @@ export function VideoContent() {
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<header className="rounded-t-xl bg-gradient-to-r from-primary to-primary-400 text-white p-6 relative mb-4">
|
<header className="rounded-t-xl bg-gradient-to-r from-primary to-primary-400 text-white p-6 relative mb-4">
|
||||||
<div className="flex flex-col space-b-1">
|
<div className="flex flex-col space-b-1">
|
||||||
<h1>资源上传</h1>
|
{/* <h1>资源上传</h1> */}
|
||||||
<p>支持视频、图片、文档、PPT等多种格式文件</p>
|
<p>支持视频、excel、文档、ppt等多种格式文件</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<Input.Search
|
||||||
|
placeholder="搜索文件名"
|
||||||
|
allowClear
|
||||||
|
onSearch={(value) => setSearchTerm(value)}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
style={{ width: 300 }}
|
||||||
|
/>
|
||||||
{!isAuthenticated && (
|
{!isAuthenticated && (
|
||||||
<Alert
|
<Alert
|
||||||
message="请先登录"
|
message="请先登录"
|
||||||
|
@ -258,16 +287,16 @@ export function VideoContent() {
|
||||||
{/* 文件展示区域 */}
|
{/* 文件展示区域 */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 图片资源展示 */}
|
{/* 图片资源展示 */}
|
||||||
{imagePagination?.total > 0 && (
|
{/* {imagePagination?.total > 0 && (
|
||||||
<div className="rounded-xl border p-4 bg-white">
|
<div className="rounded-xl border p-4 bg-white">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h3 className="text-lg font-medium">图片列表</h3>
|
<h3 className="text-lg font-medium">图片列表</h3>
|
||||||
<span className="text-gray-500 text-sm">
|
<span className="text-gray-500 text-sm">
|
||||||
共 {imagePagination.total} 张图片
|
共 {imagePagination.total} 张图片
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div> */}
|
||||||
|
|
||||||
<Row gutter={[16, 16]}>
|
{/* <Row gutter={[16, 16]}>
|
||||||
<Image.PreviewGroup>
|
<Image.PreviewGroup>
|
||||||
{imageResources.map((resource) => (
|
{imageResources.map((resource) => (
|
||||||
<Col key={resource.url} xs={12} sm={8} md={6} lg={4}>
|
<Col key={resource.url} xs={12} sm={8} md={6} lg={4}>
|
||||||
|
@ -303,10 +332,10 @@ export function VideoContent() {
|
||||||
</Col>
|
</Col>
|
||||||
))}
|
))}
|
||||||
</Image.PreviewGroup>
|
</Image.PreviewGroup>
|
||||||
</Row>
|
</Row> */}
|
||||||
|
|
||||||
{/* 图片分页 */}
|
{/* 图片分页 */}
|
||||||
{imagePagination.total > pageSize && (
|
{/* {imagePagination.total > pageSize && (
|
||||||
<div className="flex justify-center mt-6">
|
<div className="flex justify-center mt-6">
|
||||||
<Pagination
|
<Pagination
|
||||||
current={imagePage}
|
current={imagePage}
|
||||||
|
@ -318,70 +347,80 @@ export function VideoContent() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)} */}
|
||||||
|
|
||||||
{/* 其他文件资源展示 */}
|
{/* 其他文件资源展示 */}
|
||||||
{filePagination?.total > 0 && (
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8">加载中...</div>
|
||||||
|
) : filePagination?.total > 0 ? (
|
||||||
<div className="rounded-xl border p-4 bg-white">
|
<div className="rounded-xl border p-4 bg-white">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex flex-col sm:flex-row justify-between items-center mb-4 gap-4">
|
||||||
<h3 className="text-lg font-medium">文件列表</h3>
|
<h3 className="text-lg font-medium">文件列表</h3>
|
||||||
<span className="text-gray-500 text-sm">
|
<span className="text-gray-500 text-sm">
|
||||||
共 {filePagination.total} 个文件
|
共 {filePagination.total} 个文件
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
{fileResources.length > 0 && (
|
||||||
{fileResources.map((resource) => (
|
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<div
|
{fileResources.map((resource) => (
|
||||||
key={resource.url}
|
<div
|
||||||
className="flex items-center p-3 rounded-lg border hover:shadow-md transition-shadow"
|
key={resource.id}
|
||||||
>
|
className="flex items-center p-3 rounded-lg border hover:shadow-md transition-shadow"
|
||||||
<div className="text-primary-600 text-2xl mr-3">
|
>
|
||||||
{getFileIcon(resource.url)}
|
<div className="text-primary-600 text-2xl mr-3">
|
||||||
</div>
|
{getFileIcon(resource.url)}
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="font-medium truncate">
|
|
||||||
{resource.title || "未命名文件"}
|
|
||||||
</div>
|
</div>
|
||||||
{resource.description && (
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
<div className="font-medium truncate">
|
||||||
描述: {resource.description}
|
{resource.title}
|
||||||
|
</div>
|
||||||
|
{resource.description && (
|
||||||
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
|
描述: {resource.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-gray-500 flex items-center gap-2">
|
||||||
|
<span>
|
||||||
|
{dayjs(resource.createdAt).format("YYYY-MM-DD")}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{resource.meta?.size &&
|
||||||
|
formatFileSize(resource.meta.size)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<div className="text-xs text-gray-500 flex items-center gap-2">
|
|
||||||
<span>
|
|
||||||
{dayjs(resource.createdAt).format("YYYY-MM-DD")}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{resource.meta?.size &&
|
|
||||||
formatFileSize(resource.meta.size)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex gap-2">
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
onClick={() => window.open(resource.url)}
|
|
||||||
>
|
|
||||||
下载
|
|
||||||
</Button>
|
|
||||||
{isDomainAdmin && (
|
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
size="small"
|
size="small"
|
||||||
danger
|
onClick={() => window.open(resource.url)}
|
||||||
icon={<DeleteOutlined />}
|
>
|
||||||
onClick={(e) => {
|
下载
|
||||||
e.stopPropagation();
|
</Button>
|
||||||
handleDelete(resource.id);
|
{isDomainAdmin && (
|
||||||
}}
|
<Button
|
||||||
/>
|
type="text"
|
||||||
)}
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete(resource.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{fileResources.length === 0 && searchTerm && (
|
||||||
|
<div className="text-center py-4 text-gray-500">
|
||||||
|
未找到匹配 "{searchTerm}" 的文件。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 文件分页 */}
|
{/* 文件分页 */}
|
||||||
{filePagination.total > pageSize && (
|
{filePagination.total > pageSize && (
|
||||||
|
@ -396,6 +435,10 @@ export function VideoContent() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
{searchTerm ? `未找到匹配"${searchTerm}"的文件` : "暂无文件资源"}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { Form, Input, Button, Tabs } from "antd";
|
||||||
|
import { SendOutlined } from "@ant-design/icons";
|
||||||
|
import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor";
|
||||||
|
import { TusUploader } from "@web/src/components/common/uploader/TusUploader";
|
||||||
|
import TermSelect from "@web/src/components/models/term/term-select";
|
||||||
|
import TabPane from "antd/es/tabs/TabPane";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useExampleEditor } from "./ExampleEditorContext";
|
||||||
|
import AvatarUploader from "@web/src/components/common/uploader/AvatarUploader";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
export function ExampleBasicForm() {
|
||||||
|
const { onSubmit, form } = useExampleEditor();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const handleFinish = async (values: any) => {
|
||||||
|
await onSubmit(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await form.validateFields();
|
||||||
|
form.submit();
|
||||||
|
navigate("/example");
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessages = (error as any).errorFields
|
||||||
|
.map((field) => field.errors[0])
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
toast.error(
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<b>表单校验失败:</b>
|
||||||
|
{errorMessages.map((msg, i) => (
|
||||||
|
<span key={i}>· {msg}</span>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
{
|
||||||
|
duration: 5000,
|
||||||
|
position: "top-center",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<Form
|
||||||
|
size="large"
|
||||||
|
form={form}
|
||||||
|
onFinish={handleFinish}
|
||||||
|
initialValues={{
|
||||||
|
meta: {
|
||||||
|
tags: [],
|
||||||
|
coverImageUrl: "",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="title"
|
||||||
|
label="标题"
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
rules={[{ required: true, message: "请输入标题" }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
maxLength={50}
|
||||||
|
showCount
|
||||||
|
placeholder="请输入案例标题"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name={["meta", "coverImageUrl"]}
|
||||||
|
required={false}
|
||||||
|
className="mb-10"
|
||||||
|
style={{ marginBottom: "20px" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="mr-4 font-medium" style={{ width: '80px' }}>封面图片:</div>
|
||||||
|
<AvatarUploader
|
||||||
|
onChange={(coverUrl) => {
|
||||||
|
const meta = form.getFieldValue("meta") || {};
|
||||||
|
form.setFieldValue("meta", {
|
||||||
|
...meta,
|
||||||
|
coverImageUrl: coverUrl,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="点击上传"
|
||||||
|
style={{ width: "200px", height: "112px", borderRadius: "6px" }}
|
||||||
|
successText="封面上传成功"
|
||||||
|
value={form.getFieldValue("meta")?.coverImageUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Tabs defaultActiveKey="1">
|
||||||
|
<TabPane tab="正文" key="1">
|
||||||
|
<Form.Item
|
||||||
|
name="content"
|
||||||
|
rules={[{ required: true, message: "请输入正文内容" }]}
|
||||||
|
required={false}
|
||||||
|
>
|
||||||
|
<div className="rounded-xl border border-white hover:ring-1 ring-white transition-all duration-300 ease-in-out bg-slate-100">
|
||||||
|
<QuillEditor
|
||||||
|
maxLength={20000}
|
||||||
|
placeholder="请输入案例内容"
|
||||||
|
minRows={10}
|
||||||
|
onChange={(content) => form.setFieldValue("content", content)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
</TabPane>
|
||||||
|
<TabPane tab="附件" key="2">
|
||||||
|
<div className="rounded-xl border border-white hover:ring-1 ring-white transition-all duration-300 ease-in-out bg-slate-100">
|
||||||
|
<Form.Item name="resources" required={false}>
|
||||||
|
<TusUploader
|
||||||
|
onChange={async (resources) => {
|
||||||
|
form.setFieldValue("resources", resources);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</TabPane>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-4">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
size="large"
|
||||||
|
icon={<SendOutlined />}
|
||||||
|
style={{ width: 150 }}
|
||||||
|
>
|
||||||
|
发布
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,132 @@
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
EyeOutlined,
|
||||||
|
LikeOutlined,
|
||||||
|
LikeFilled,
|
||||||
|
FileTextOutlined,
|
||||||
|
MailOutlined,
|
||||||
|
ReadOutlined,
|
||||||
|
FileImageOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { Button, Typography, Space, Tooltip } from "antd";
|
||||||
|
import { PostDto, PostStateLabels, ResourceDto } from "@nice/common";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import PostLikeButton from "@web/src/components/models/post/detail/PostHeader/PostLikeButton";
|
||||||
|
import { LetterBadge } from "@web/src/components/models/post/LetterBadge";
|
||||||
|
import PostHateButton from "@web/src/components/models/post/detail/PostHeader/PostHateButton";
|
||||||
|
import { env } from "@web/src/env";
|
||||||
|
import { getCompressedImageUrl } from "@nice/utils";
|
||||||
|
const { Title, Paragraph, Text } = Typography;
|
||||||
|
interface ExampleCardProps {
|
||||||
|
example: PostDto;
|
||||||
|
}
|
||||||
|
interface PostMeta {
|
||||||
|
coverImageUrl?: string;
|
||||||
|
}
|
||||||
|
export function ExampleCard({ example }: ExampleCardProps) {
|
||||||
|
const [debugInfo, setDebugInfo] = useState("");
|
||||||
|
// 获取封面图片URL
|
||||||
|
const coverImageUrl = useMemo(() => {
|
||||||
|
// 首先检查meta中是否有封面URL
|
||||||
|
if (example.meta?.coverImageUrl) {
|
||||||
|
return example.meta.coverImageUrl;
|
||||||
|
}
|
||||||
|
// 如果meta中没有封面URL,回退到从resources中查找图片
|
||||||
|
if (!example.resources || example.resources.length === 0) return null;
|
||||||
|
|
||||||
|
// 查找第一个图片资源
|
||||||
|
const imageResource = example.resources.find(
|
||||||
|
(resource) =>
|
||||||
|
resource.url && /\.(png|jpg|jpeg|gif|webp)$/i.test(resource.url)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!imageResource || !imageResource.url) return null;
|
||||||
|
|
||||||
|
// 构建原始URL
|
||||||
|
const original = `http://${env.SERVER_IP}:${env.UPLOAD_PORT}/uploads/${imageResource.url}`;
|
||||||
|
|
||||||
|
// 返回压缩后的URL
|
||||||
|
return getCompressedImageUrl(original);
|
||||||
|
}, [example.resources, example.meta?.coverImageUrl]);
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("coverImageUrl", coverImageUrl);
|
||||||
|
}, [coverImageUrl]);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
window.open(`/${example.id}/detail`);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer p-3 bg-slate-100/80 rounded-xl hover:ring-blue-400 hover:ring-1 transition-all
|
||||||
|
duration-300 ease-in-out hover:-translate-y-0.5
|
||||||
|
active:scale-[0.98] border border-white
|
||||||
|
group relative overflow-hidden h-full"
|
||||||
|
>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* 左侧图片区域 */}
|
||||||
|
<div className="w-32 h-24 flex-shrink-0 overflow-hidden rounded-lg border border-gray-200 bg-gray-50">
|
||||||
|
{coverImageUrl ? (
|
||||||
|
<img
|
||||||
|
src={coverImageUrl}
|
||||||
|
alt={example.title || "案例图片"}
|
||||||
|
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||||
|
onError={(e) => {
|
||||||
|
console.error("图片加载失败:", coverImageUrl);
|
||||||
|
// 显示占位图标
|
||||||
|
e.currentTarget.style.display = "none";
|
||||||
|
e.currentTarget.parentElement!.innerHTML =
|
||||||
|
'<div class="w-full h-full bg-orange-50 flex items-center justify-center text-blue-500"><span style="font-size: 32px;" class="anticon"><svg viewBox="64 64 896 896" focusable="false" data-icon="file-image" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M553.1 509.1l-77.8 99.2-41.1-52.4a8 8 0 00-12.6 0l-99.8 127.2a7.98 7.98 0 006.3 12.9H696c6.7 0 10.4-7.7 6.3-12.9l-136.5-174a8.1 8.1 0 00-12.7 0zM360 442a40 40 0 1080 0 40 40 0 10-80 0zm494.6-153.4L639.4 73.4c-6-6-14.1-9.4-22.6-9.4H192c-17.7 0-32 14.3-32 32v832c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V311.3c0-8.5-3.4-16.7-9.4-22.7zM790.2 326H602V137.8L790.2 326zm1.8 562H232V136h302v216a42 42 0 0042 42h216v494z"></path></svg></span></div>';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-orange-50 flex items-center justify-center text-blue-500">
|
||||||
|
<FileImageOutlined style={{ fontSize: "32px" }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧内容区域 */}
|
||||||
|
<div className="flex flex-col flex-grow gap-2">
|
||||||
|
<div className="text-xl text-blue-600 font-bold flex items-center gap-2">
|
||||||
|
<ReadOutlined className="flex-shrink-0" />
|
||||||
|
<div className="truncate">{example.title}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badges & Interactions */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{example.terms &&
|
||||||
|
example.terms.map((term) => (
|
||||||
|
<LetterBadge
|
||||||
|
key={term.name}
|
||||||
|
type="category"
|
||||||
|
value={term.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<LetterBadge
|
||||||
|
type="date"
|
||||||
|
value={dayjs(example.createdAt).format("YYYY-MM-DD")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
shape="round"
|
||||||
|
style={{
|
||||||
|
color: "#4b5563",
|
||||||
|
}}
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<span className="mr-1">浏览量</span>
|
||||||
|
{example.views || 0}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<PostLikeButton post={example as any}></PostLikeButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { Button, Input, Pagination } from "antd";
|
||||||
|
import { PlusOutlined } from "@ant-design/icons";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { api } from "@nice/client";
|
||||||
|
import { PostType } from "@nice/common";
|
||||||
|
import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
|
import { ExampleCard } from "./ExampleCard";
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
|
||||||
|
export function ExampleContent() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, hasSomePermissions, isAuthenticated } = useAuth();
|
||||||
|
const [exampleList, setExampleList] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const pageSize = 6; // 每页显示5条案例
|
||||||
|
const isDomainAdmin = useMemo(() => {
|
||||||
|
return hasSomePermissions("MANAGE_DOM_STAFF", "MANAGE_ANY_STAFF");
|
||||||
|
}, [hasSomePermissions]);
|
||||||
|
const { data, isLoading } = api.post.findManyWithPagination.useQuery(
|
||||||
|
{
|
||||||
|
page: currentPage,
|
||||||
|
pageSize: pageSize,
|
||||||
|
where: {
|
||||||
|
type: PostType.EXAMPLE,
|
||||||
|
isPublic: true,
|
||||||
|
deletedAt: null,
|
||||||
|
title: searchTerm
|
||||||
|
? { contains: searchTerm, mode: "insensitive" }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
updatedAt: "desc",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.items) {
|
||||||
|
setExampleList(data.items);
|
||||||
|
setLoading(false);
|
||||||
|
} else if (!isLoading) {
|
||||||
|
setExampleList([]);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [data, isLoading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchTerm) {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}
|
||||||
|
}, [searchTerm]);
|
||||||
|
|
||||||
|
const handleAddExample = () => {
|
||||||
|
navigate("/example");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = (page) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (value: string) => {
|
||||||
|
setSearchTerm(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[400px]">
|
||||||
|
<div className="flex justify-between items-center mt-2 mx-2 mb-4">
|
||||||
|
<Input.Search
|
||||||
|
placeholder="搜索标题"
|
||||||
|
allowClear
|
||||||
|
onSearch={handleSearch}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
style={{ width: 300 }}
|
||||||
|
/>
|
||||||
|
{isDomainAdmin && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={handleAddExample}
|
||||||
|
size="middle"
|
||||||
|
className="shadow-md hover:scale-105 transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
background: "#1677ff",
|
||||||
|
borderRadius: "6px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
发布
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading || isLoading ? (
|
||||||
|
<div className="text-center py-8">加载中...</div>
|
||||||
|
) : exampleList.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-4 mx-2">
|
||||||
|
{exampleList.map((example) => (
|
||||||
|
<ExampleCard key={example.id} example={example} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分页组件 */}
|
||||||
|
<div className="flex justify-center my-6">
|
||||||
|
<Pagination
|
||||||
|
current={currentPage}
|
||||||
|
total={data?.totalCount || 0}
|
||||||
|
pageSize={pageSize}
|
||||||
|
onChange={handlePageChange}
|
||||||
|
showSizeChanger={false}
|
||||||
|
showQuickJumper
|
||||||
|
size="default"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
{searchTerm ? `未找到标题包含"${searchTerm}"的案例` : "暂无案例分析"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { createContext, useContext, ReactNode } from "react";
|
||||||
|
import { Form, FormInstance } from "antd";
|
||||||
|
import { api, usePost } from "@nice/client";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { PostState, PostType } from "@nice/common";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
export interface ExampleFormData {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
resources?: string[];
|
||||||
|
term?: string;
|
||||||
|
meta: {
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExampleEditorContextType {
|
||||||
|
onSubmit: (values: ExampleFormData) => Promise<void>;
|
||||||
|
termId?: string;
|
||||||
|
form: FormInstance<ExampleFormData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExampleFormProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
termId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExampleEditorContext = createContext<ExampleEditorContextType | null>(null);
|
||||||
|
|
||||||
|
export function ExampleFormProvider({ children, termId }: ExampleFormProviderProps) {
|
||||||
|
const { create } = usePost();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [form] = Form.useForm<ExampleFormData>();
|
||||||
|
|
||||||
|
const onSubmit = async (data: ExampleFormData) => {
|
||||||
|
try {
|
||||||
|
const term = data?.term;
|
||||||
|
delete data.term;
|
||||||
|
|
||||||
|
console.log("即将提交的资源IDs:", data.resources);
|
||||||
|
|
||||||
|
const result = await create.mutateAsync({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
type: PostType.EXAMPLE,
|
||||||
|
terms: term
|
||||||
|
? {
|
||||||
|
connect: {
|
||||||
|
id: term,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
state: PostState.RESOLVED, // 案例直接设为已发布状态
|
||||||
|
isPublic: true, // 案例永远是公开的
|
||||||
|
resources: data.resources?.length
|
||||||
|
? {
|
||||||
|
connect: (data.resources?.filter(Boolean) || []).map(
|
||||||
|
(fileId) => ({
|
||||||
|
fileId,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(`发布成功!`, {
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
|
||||||
|
navigate("/help", {
|
||||||
|
state: {
|
||||||
|
successMessage: "发布成功",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
form.resetFields();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error submitting form:", error);
|
||||||
|
toast.error("操作失败,请重试!");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ExampleEditorContext.Provider
|
||||||
|
value={{
|
||||||
|
onSubmit,
|
||||||
|
termId,
|
||||||
|
form,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ExampleEditorContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useExampleEditor = () => {
|
||||||
|
const context = useContext(ExampleEditorContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useExampleEditor must be used within ExampleFormProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
|
@ -0,0 +1,61 @@
|
||||||
|
export default function ExampleHeader() {
|
||||||
|
return (
|
||||||
|
<header className="rounded-t-xl bg-gradient-to-r from-blue-600 to-blue-500 text-white p-6">
|
||||||
|
<div className="flex flex-col space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-wider">
|
||||||
|
案例发布
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-6 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>发布案例</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>分享经验</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M8 16l2.879-2.879m0 0a3 3 0 104.243-4.242 3 3 0 00-4.243 4.242zM21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>案例分析</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { ExampleFormProvider } from "./ExampleEditorContext";
|
||||||
|
import { ExampleBasicForm } from "./ExampleBasicForm";
|
||||||
|
import ExampleHeader from "./ExampleHeader";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function ExampleEditorPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const termId = searchParams.get("termId");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shadow-elegant border-2 border-white rounded-xl bg-slate-200">
|
||||||
|
<ExampleHeader />
|
||||||
|
<ExampleFormProvider termId={termId}>
|
||||||
|
<ExampleBasicForm />
|
||||||
|
</ExampleFormProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,146 @@
|
||||||
|
import { Form, Input, Button, Tabs } from "antd";
|
||||||
|
import { SendOutlined } from "@ant-design/icons";
|
||||||
|
import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor";
|
||||||
|
import { TusUploader } from "@web/src/components/common/uploader/TusUploader";
|
||||||
|
import TermSelect from "@web/src/components/models/term/term-select";
|
||||||
|
import TabPane from "antd/es/tabs/TabPane";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useNewsEditor } from "./NewsEditorContext";
|
||||||
|
import AvatarUploader from "@web/src/components/common/uploader/AvatarUploader";
|
||||||
|
|
||||||
|
export function NewsBasicForm() {
|
||||||
|
const { onSubmit, form } = useNewsEditor();
|
||||||
|
const handleFinish = async (values: any) => {
|
||||||
|
await onSubmit(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await form.validateFields();
|
||||||
|
form.submit();
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessages = (error as any).errorFields
|
||||||
|
.map((field) => field.errors[0])
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
toast.error(
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<b>表单校验失败:</b>
|
||||||
|
{errorMessages.map((msg, i) => (
|
||||||
|
<span key={i}>· {msg}</span>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
{
|
||||||
|
duration: 5000,
|
||||||
|
position: "top-center",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<Form
|
||||||
|
size="large"
|
||||||
|
form={form}
|
||||||
|
onFinish={handleFinish}
|
||||||
|
initialValues={{
|
||||||
|
meta: {
|
||||||
|
tags: [],
|
||||||
|
coverImageUrl: "",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Form.Item
|
||||||
|
label="分类"
|
||||||
|
name={"term"}
|
||||||
|
rules={[{ required: true, message: "请选择分类" }]}
|
||||||
|
>
|
||||||
|
<TermSelect placeholder="选择分类" />
|
||||||
|
</Form.Item>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="title"
|
||||||
|
label="标题"
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
rules={[{ required: true, message: "请输入标题" }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
maxLength={50}
|
||||||
|
showCount
|
||||||
|
placeholder="请输入新闻标题"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name={["meta", "coverImageUrl"]}
|
||||||
|
required={false}
|
||||||
|
className="mb-10"
|
||||||
|
style={{ marginBottom: "20px" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="mr-4 font-medium" style={{ width: '80px' }}>封面图片:</div>
|
||||||
|
<AvatarUploader
|
||||||
|
onChange={(coverUrl) => {
|
||||||
|
const meta = form.getFieldValue("meta") || {};
|
||||||
|
form.setFieldValue("meta", {
|
||||||
|
...meta,
|
||||||
|
coverImageUrl: coverUrl,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="点击上传"
|
||||||
|
style={{ width: "200px", height: "112px", borderRadius: "6px" }}
|
||||||
|
successText="封面上传成功"
|
||||||
|
value={form.getFieldValue("meta")?.coverImageUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Tabs defaultActiveKey="1">
|
||||||
|
<TabPane tab="正文" key="1">
|
||||||
|
<Form.Item
|
||||||
|
name="content"
|
||||||
|
rules={[{ required: true, message: "请输入正文内容" }]}
|
||||||
|
required={false}
|
||||||
|
>
|
||||||
|
<div className="rounded-xl border border-white hover:ring-1 ring-white transition-all duration-300 ease-in-out bg-slate-100">
|
||||||
|
<QuillEditor
|
||||||
|
maxLength={20000}
|
||||||
|
placeholder="请输入新闻内容"
|
||||||
|
minRows={10}
|
||||||
|
onChange={(content) => form.setFieldValue("content", content)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
</TabPane>
|
||||||
|
<TabPane tab="附件" key="2">
|
||||||
|
<div className="rounded-xl border border-white hover:ring-1 ring-white transition-all duration-300 ease-in-out bg-slate-100">
|
||||||
|
<Form.Item name="resources" required={false}>
|
||||||
|
<TusUploader
|
||||||
|
onChange={async (resources) => {
|
||||||
|
form.setFieldValue("resources", resources);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</TabPane>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-4">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
size="large"
|
||||||
|
icon={<SendOutlined />}
|
||||||
|
style={{ width: 150 }}
|
||||||
|
>
|
||||||
|
发布
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,132 @@
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
EyeOutlined,
|
||||||
|
LikeOutlined,
|
||||||
|
LikeFilled,
|
||||||
|
FileTextOutlined,
|
||||||
|
MailOutlined,
|
||||||
|
NotificationOutlined,
|
||||||
|
FileImageOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { Button, Typography, Space, Tooltip } from "antd";
|
||||||
|
import { PostDto, PostStateLabels, ResourceDto } from "@nice/common";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import PostLikeButton from "@web/src/components/models/post/detail/PostHeader/PostLikeButton";
|
||||||
|
import { LetterBadge } from "@web/src/components/models/post/LetterBadge";
|
||||||
|
import PostHateButton from "@web/src/components/models/post/detail/PostHeader/PostHateButton";
|
||||||
|
import { env } from "@web/src/env";
|
||||||
|
import { getCompressedImageUrl } from "@nice/utils";
|
||||||
|
const { Title, Paragraph, Text } = Typography;
|
||||||
|
interface NewsCardProps {
|
||||||
|
news: PostDto;
|
||||||
|
}
|
||||||
|
interface PostMeta {
|
||||||
|
coverImageUrl?: string;
|
||||||
|
}
|
||||||
|
export function NewsCard({ news }: NewsCardProps) {
|
||||||
|
const [debugInfo, setDebugInfo] = useState("");
|
||||||
|
// 获取封面图片URL
|
||||||
|
const coverImageUrl = useMemo(() => {
|
||||||
|
// 首先检查meta中是否有封面URL
|
||||||
|
if (news.meta?.coverImageUrl) {
|
||||||
|
return news.meta.coverImageUrl;
|
||||||
|
}
|
||||||
|
// 如果meta中没有封面URL,回退到从resources中查找图片
|
||||||
|
if (!news.resources || news.resources.length === 0) return null;
|
||||||
|
|
||||||
|
// 查找第一个图片资源
|
||||||
|
const imageResource = news.resources.find(
|
||||||
|
(resource) =>
|
||||||
|
resource.url && /\.(png|jpg|jpeg|gif|webp)$/i.test(resource.url)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!imageResource || !imageResource.url) return null;
|
||||||
|
|
||||||
|
// 构建原始URL
|
||||||
|
const original = `http://${env.SERVER_IP}:${env.UPLOAD_PORT}/uploads/${imageResource.url}`;
|
||||||
|
|
||||||
|
// 返回压缩后的URL
|
||||||
|
return getCompressedImageUrl(original);
|
||||||
|
}, [news.resources, news.meta?.coverImageUrl]);
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("coverImageUrl", coverImageUrl);
|
||||||
|
}, [coverImageUrl]);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
window.open(`/${news.id}/detail`);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer p-3 bg-slate-100/80 rounded-xl hover:ring-blue-400 hover:ring-1 transition-all
|
||||||
|
duration-300 ease-in-out hover:-translate-y-0.5
|
||||||
|
active:scale-[0.98] border border-white
|
||||||
|
group relative overflow-hidden h-full"
|
||||||
|
>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* 左侧图片区域 */}
|
||||||
|
<div className="w-32 h-24 flex-shrink-0 overflow-hidden rounded-lg border border-gray-200 bg-gray-50">
|
||||||
|
{coverImageUrl ? (
|
||||||
|
<img
|
||||||
|
src={coverImageUrl}
|
||||||
|
alt={news.title || "新闻图片"}
|
||||||
|
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||||
|
onError={(e) => {
|
||||||
|
console.error("图片加载失败:", coverImageUrl);
|
||||||
|
// 显示占位图标
|
||||||
|
e.currentTarget.style.display = "none";
|
||||||
|
e.currentTarget.parentElement!.innerHTML =
|
||||||
|
'<div class="w-full h-full bg-blue-50 flex items-center justify-center text-blue-500"><span style="font-size: 32px;" class="anticon"><svg viewBox="64 64 896 896" focusable="false" data-icon="file-image" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M553.1 509.1l-77.8 99.2-41.1-52.4a8 8 0 00-12.6 0l-99.8 127.2a7.98 7.98 0 006.3 12.9H696c6.7 0 10.4-7.7 6.3-12.9l-136.5-174a8.1 8.1 0 00-12.7 0zM360 442a40 40 0 1080 0 40 40 0 10-80 0zm494.6-153.4L639.4 73.4c-6-6-14.1-9.4-22.6-9.4H192c-17.7 0-32 14.3-32 32v832c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V311.3c0-8.5-3.4-16.7-9.4-22.7zM790.2 326H602V137.8L790.2 326zm1.8 562H232V136h302v216a42 42 0 0042 42h216v494z"></path></svg></span></div>';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-blue-50 flex items-center justify-center text-blue-500">
|
||||||
|
<FileImageOutlined style={{ fontSize: "32px" }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧内容区域 */}
|
||||||
|
<div className="flex flex-col flex-grow gap-2">
|
||||||
|
<div className="text-xl text-blue-600 font-bold flex items-center gap-2">
|
||||||
|
<NotificationOutlined className="flex-shrink-0" />
|
||||||
|
<div className="truncate">{news.title}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badges & Interactions */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{news.terms &&
|
||||||
|
news.terms.map((term) => (
|
||||||
|
<LetterBadge
|
||||||
|
key={term.name}
|
||||||
|
type="category"
|
||||||
|
value={term.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<LetterBadge
|
||||||
|
type="date"
|
||||||
|
value={dayjs(news.createdAt).format("YYYY-MM-DD")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
shape="round"
|
||||||
|
style={{
|
||||||
|
color: "#4b5563",
|
||||||
|
}}
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<span className="mr-1">浏览量</span>
|
||||||
|
{news.views || 0}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<PostLikeButton post={news as any}></PostLikeButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { createContext, useContext, ReactNode } from "react";
|
||||||
|
import { Form, FormInstance } from "antd";
|
||||||
|
import { api, usePost } from "@nice/client";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { PostState, PostType } from "@nice/common";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
export interface NewsFormData {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
resources?: string[];
|
||||||
|
term?: string;
|
||||||
|
meta: {
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NewsEditorContextType {
|
||||||
|
onSubmit: (values: NewsFormData) => Promise<void>;
|
||||||
|
termId?: string;
|
||||||
|
form: FormInstance<NewsFormData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NewsFormProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
termId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NewsEditorContext = createContext<NewsEditorContextType | null>(null);
|
||||||
|
|
||||||
|
export function NewsFormProvider({ children, termId }: NewsFormProviderProps) {
|
||||||
|
const { create } = usePost();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [form] = Form.useForm<NewsFormData>();
|
||||||
|
|
||||||
|
const onSubmit = async (data: NewsFormData) => {
|
||||||
|
try {
|
||||||
|
const term = data?.term;
|
||||||
|
delete data.term;
|
||||||
|
|
||||||
|
console.log("即将提交的资源IDs:", data.resources);
|
||||||
|
|
||||||
|
const result = await create.mutateAsync({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
type: PostType.NEW,
|
||||||
|
terms: term
|
||||||
|
? {
|
||||||
|
connect: {
|
||||||
|
id: term,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
state: PostState.RESOLVED, // 新闻直接设为已发布状态
|
||||||
|
isPublic: true, // 新闻永远是公开的
|
||||||
|
resources: data.resources?.length
|
||||||
|
? {
|
||||||
|
connect: (data.resources?.filter(Boolean) || []).map(
|
||||||
|
(fileId) => ({
|
||||||
|
fileId,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(`发布成功!`, {
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
|
||||||
|
navigate("/help", {
|
||||||
|
state: {
|
||||||
|
successMessage: "发布成功",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
form.resetFields();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error submitting form:", error);
|
||||||
|
toast.error("操作失败,请重试!");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NewsEditorContext.Provider
|
||||||
|
value={{
|
||||||
|
onSubmit,
|
||||||
|
termId,
|
||||||
|
form,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</NewsEditorContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useNewsEditor = () => {
|
||||||
|
const context = useContext(NewsEditorContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useNewsEditor must be used within NewsFormProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
|
@ -0,0 +1,61 @@
|
||||||
|
export default function NewsHeader() {
|
||||||
|
return (
|
||||||
|
<header className="rounded-t-xl bg-gradient-to-r from-blue-600 to-blue-500 text-white p-6">
|
||||||
|
<div className="flex flex-col space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-wider">
|
||||||
|
新闻发布
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-6 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>发布资讯</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>分享活动</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M8 16l2.879-2.879m0 0a3 3 0 104.243-4.242 3 3 0 00-4.243 4.242zM21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>信息共享</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { Button, Input, Pagination } from "antd";
|
||||||
|
import { PlusOutlined } from "@ant-design/icons";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { api } from "@nice/client";
|
||||||
|
import { PostType } from "@nice/common";
|
||||||
|
import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
|
import { NewsCard } from "./NewsCard";
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
|
||||||
|
export function PublicityContent() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, hasSomePermissions, isAuthenticated } = useAuth();
|
||||||
|
const [newsList, setNewsList] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const pageSize = 6; // 每页显示5条新闻
|
||||||
|
const isDomainAdmin = useMemo(() => {
|
||||||
|
return hasSomePermissions("MANAGE_DOM_STAFF", "MANAGE_ANY_STAFF");
|
||||||
|
}, [hasSomePermissions]);
|
||||||
|
const { data, isLoading } = api.post.findManyWithPagination.useQuery(
|
||||||
|
{
|
||||||
|
page: currentPage,
|
||||||
|
pageSize: pageSize,
|
||||||
|
where: {
|
||||||
|
type: PostType.NEW,
|
||||||
|
isPublic: true,
|
||||||
|
deletedAt: null,
|
||||||
|
title: searchTerm
|
||||||
|
? { contains: searchTerm, mode: "insensitive" }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
updatedAt: "desc",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.items) {
|
||||||
|
setNewsList(data.items);
|
||||||
|
setLoading(false);
|
||||||
|
} else if (!isLoading) {
|
||||||
|
setNewsList([]);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [data, isLoading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchTerm) {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}
|
||||||
|
}, [searchTerm]);
|
||||||
|
|
||||||
|
const handleAddNews = () => {
|
||||||
|
navigate("/news");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = (page) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (value: string) => {
|
||||||
|
setSearchTerm(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[400px]">
|
||||||
|
<div className="flex justify-between items-center mt-2 mx-2 mb-4">
|
||||||
|
<Input.Search
|
||||||
|
placeholder="搜索标题"
|
||||||
|
allowClear
|
||||||
|
onSearch={handleSearch}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
style={{ width: 300 }}
|
||||||
|
/>
|
||||||
|
{isDomainAdmin && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={handleAddNews}
|
||||||
|
size="middle"
|
||||||
|
className="shadow-md hover:scale-105 transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
background: "#1677ff",
|
||||||
|
borderRadius: "6px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
发布
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading || isLoading ? (
|
||||||
|
<div className="text-center py-8">加载中...</div>
|
||||||
|
) : newsList.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-4 mx-2">
|
||||||
|
{newsList.map((news) => (
|
||||||
|
<NewsCard key={news.id} news={news} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分页组件 */}
|
||||||
|
<div className="flex justify-center my-6">
|
||||||
|
<Pagination
|
||||||
|
current={currentPage}
|
||||||
|
total={data?.totalCount || 0}
|
||||||
|
pageSize={pageSize}
|
||||||
|
onChange={handlePageChange}
|
||||||
|
showSizeChanger={false}
|
||||||
|
showQuickJumper
|
||||||
|
size="default"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
{searchTerm ? `未找到标题包含"${searchTerm}"的新闻` : "暂无新闻报道"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { NewsFormProvider } from "./NewsEditorContext";
|
||||||
|
import { NewsBasicForm } from "./NewsBasicForm";
|
||||||
|
import NewsHeader from "./NewsHeader";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function NewsEditorPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const termId = searchParams.get("termId");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shadow-elegant border-2 border-white rounded-xl bg-slate-200">
|
||||||
|
<NewsHeader />
|
||||||
|
<NewsFormProvider termId={termId}>
|
||||||
|
<NewsBasicForm />
|
||||||
|
</NewsFormProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { Form, Input, Button, Tabs } from "antd";
|
||||||
|
import { SendOutlined } from "@ant-design/icons";
|
||||||
|
import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor";
|
||||||
|
import { TusUploader } from "@web/src/components/common/uploader/TusUploader";
|
||||||
|
import TermSelect from "@web/src/components/models/term/term-select";
|
||||||
|
import TabPane from "antd/es/tabs/TabPane";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useScienceEditor } from "./ScienceEditorContext";
|
||||||
|
import AvatarUploader from "@web/src/components/common/uploader/AvatarUploader";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
export function ScienceBasicForm() {
|
||||||
|
const { onSubmit, form } = useScienceEditor();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const handleFinish = async (values: any) => {
|
||||||
|
await onSubmit(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await form.validateFields();
|
||||||
|
form.submit();
|
||||||
|
navigate("/help/science");
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessages = (error as any).errorFields
|
||||||
|
.map((field) => field.errors[0])
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
toast.error(
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<b>表单校验失败:</b>
|
||||||
|
{errorMessages.map((msg, i) => (
|
||||||
|
<span key={i}>· {msg}</span>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
{
|
||||||
|
duration: 5000,
|
||||||
|
position: "top-center",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<Form
|
||||||
|
size="large"
|
||||||
|
form={form}
|
||||||
|
onFinish={handleFinish}
|
||||||
|
initialValues={{
|
||||||
|
meta: {
|
||||||
|
tags: [],
|
||||||
|
coverImageUrl: "",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="title"
|
||||||
|
label="标题"
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
rules={[{ required: true, message: "请输入标题" }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
maxLength={50}
|
||||||
|
showCount
|
||||||
|
placeholder="请输入科普标题"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name={["meta", "coverImageUrl"]}
|
||||||
|
required={false}
|
||||||
|
className="mb-10"
|
||||||
|
style={{ marginBottom: "20px" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="mr-4 font-medium" style={{ width: '80px' }}>封面图片:</div>
|
||||||
|
<AvatarUploader
|
||||||
|
onChange={(coverUrl) => {
|
||||||
|
const meta = form.getFieldValue("meta") || {};
|
||||||
|
form.setFieldValue("meta", {
|
||||||
|
...meta,
|
||||||
|
coverImageUrl: coverUrl,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="点击上传"
|
||||||
|
style={{ width: "200px", height: "112px", borderRadius: "6px" }}
|
||||||
|
successText="封面上传成功"
|
||||||
|
value={form.getFieldValue("meta")?.coverImageUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Tabs defaultActiveKey="1">
|
||||||
|
<TabPane tab="正文" key="1">
|
||||||
|
<Form.Item
|
||||||
|
name="content"
|
||||||
|
rules={[{ required: true, message: "请输入正文内容" }]}
|
||||||
|
required={false}
|
||||||
|
>
|
||||||
|
<div className="rounded-xl border border-white hover:ring-1 ring-white transition-all duration-300 ease-in-out bg-slate-100">
|
||||||
|
<QuillEditor
|
||||||
|
maxLength={20000}
|
||||||
|
placeholder="请输入科普内容"
|
||||||
|
minRows={10}
|
||||||
|
onChange={(content) => form.setFieldValue("content", content)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
</TabPane>
|
||||||
|
<TabPane tab="附件" key="2">
|
||||||
|
<div className="rounded-xl border border-white hover:ring-1 ring-white transition-all duration-300 ease-in-out bg-slate-100">
|
||||||
|
<Form.Item name="resources" required={false}>
|
||||||
|
<TusUploader
|
||||||
|
onChange={async (resources) => {
|
||||||
|
form.setFieldValue("resources", resources);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</TabPane>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-4">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
size="large"
|
||||||
|
icon={<SendOutlined />}
|
||||||
|
style={{ width: 150 }}
|
||||||
|
>
|
||||||
|
发布
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,152 @@
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
EyeOutlined,
|
||||||
|
LikeOutlined,
|
||||||
|
LikeFilled,
|
||||||
|
FileTextOutlined,
|
||||||
|
BulbOutlined,
|
||||||
|
FileImageOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { Button, Typography, Space, Tooltip } from "antd";
|
||||||
|
import { PostDto, PostStateLabels, ResourceDto, PostMeta } from "@nice/common";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import PostLikeButton from "@web/src/components/models/post/detail/PostHeader/PostLikeButton";
|
||||||
|
import { LetterBadge } from "@web/src/components/models/post/LetterBadge";
|
||||||
|
import PostHateButton from "@web/src/components/models/post/detail/PostHeader/PostHateButton";
|
||||||
|
import { env } from "@web/src/env";
|
||||||
|
import { getCompressedImageUrl } from "@nice/utils";
|
||||||
|
const { Title, Paragraph, Text } = Typography;
|
||||||
|
|
||||||
|
interface ScienceCardProps {
|
||||||
|
science: PostDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScienceCard({ science }: ScienceCardProps) {
|
||||||
|
const [debugInfo, setDebugInfo] = useState("");
|
||||||
|
|
||||||
|
// 获取封面图片URL
|
||||||
|
const coverImageUrl = useMemo(() => {
|
||||||
|
// 首先检查meta中是否有封面URL
|
||||||
|
if (science.meta?.coverImageUrl) {
|
||||||
|
return science.meta.coverImageUrl;
|
||||||
|
}
|
||||||
|
// 如果meta中没有封面URL,回退到从resources中查找图片
|
||||||
|
if (!science.resources || science.resources.length === 0) return null;
|
||||||
|
|
||||||
|
// 查找第一个图片资源
|
||||||
|
const imageResource = science.resources.find(
|
||||||
|
(resource) =>
|
||||||
|
resource.url && /\.(png|jpg|jpeg|gif|webp)$/i.test(resource.url)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!imageResource || !imageResource.url) return null;
|
||||||
|
|
||||||
|
// 构建原始URL
|
||||||
|
const original = `http://${env.SERVER_IP}:${env.UPLOAD_PORT}/uploads/${imageResource.url}`;
|
||||||
|
|
||||||
|
// 返回压缩后的URL
|
||||||
|
return getCompressedImageUrl(original);
|
||||||
|
}, [science.resources, science.meta?.coverImageUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("coverImageUrl", coverImageUrl);
|
||||||
|
}, [coverImageUrl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
window.open(`/${science.id}/detail`);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer p-3 bg-slate-100/80 rounded-xl hover:ring-blue-400 hover:ring-1 transition-all
|
||||||
|
duration-300 ease-in-out hover:-translate-y-0.5
|
||||||
|
active:scale-[0.98] border border-white
|
||||||
|
group relative overflow-hidden h-full"
|
||||||
|
>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* 左侧图片区域 */}
|
||||||
|
<div className="w-32 h-24 flex-shrink-0 overflow-hidden rounded-lg border border-gray-200 bg-gray-50 relative">
|
||||||
|
{coverImageUrl ? (
|
||||||
|
<img
|
||||||
|
src={coverImageUrl}
|
||||||
|
alt={science.title || "科普图片"}
|
||||||
|
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||||
|
onError={(e) => {
|
||||||
|
console.error("图片加载失败:", coverImageUrl);
|
||||||
|
// 显示占位图标
|
||||||
|
e.currentTarget.style.display = "none";
|
||||||
|
e.currentTarget.parentElement!.innerHTML =
|
||||||
|
'<div class="w-full h-full bg-green-50 flex items-center justify-center text-blue-500"><span style="font-size: 32px;" class="anticon"><svg viewBox="64 64 896 896" focusable="false" data-icon="file-image" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M553.1 509.1l-77.8 99.2-41.1-52.4a8 8 0 00-12.6 0l-99.8 127.2a7.98 7.98 0 006.3 12.9H696c6.7 0 10.4-7.7 6.3-12.9l-136.5-174a8.1 8.1 0 00-12.7 0zM360 442a40 40 0 1080 0 40 40 0 10-80 0zm494.6-153.4L639.4 73.4c-6-6-14.1-9.4-22.6-9.4H192c-17.7 0-32 14.3-32 32v832c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V311.3c0-8.5-3.4-16.7-9.4-22.7zM790.2 326H602V137.8L790.2 326zm1.8 562H232V136h302v216a42 42 0 0042 42h216v494z"></path></svg></span></div>';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-blue-50 flex items-center justify-center text-blue-500">
|
||||||
|
<FileImageOutlined style={{ fontSize: "32px" }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧内容区域 */}
|
||||||
|
<div className="flex flex-col flex-grow gap-2">
|
||||||
|
<div className="text-xl text-blue-600 font-bold flex items-center gap-2">
|
||||||
|
<BulbOutlined className="flex-shrink-0" />
|
||||||
|
<div className="truncate">{science.title}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容预览 */}
|
||||||
|
{/* {science.content && (
|
||||||
|
<div className="text-gray-600 text-sm line-clamp-1">
|
||||||
|
{science.content.replace(/<[^>]*>/g, "")}
|
||||||
|
</div>
|
||||||
|
)} */}
|
||||||
|
|
||||||
|
{/* Badges & Interactions */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{science.terms &&
|
||||||
|
science.terms.map((term) => (
|
||||||
|
<LetterBadge
|
||||||
|
key={term.id || term.name}
|
||||||
|
type="category"
|
||||||
|
value={term.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<LetterBadge
|
||||||
|
type="date"
|
||||||
|
value={dayjs(science.createdAt).format("YYYY-MM-DD")}
|
||||||
|
/>
|
||||||
|
{science.meta?.tags &&
|
||||||
|
science.meta.tags.length > 0 &&
|
||||||
|
science.meta.tags.map((tag, index) => (
|
||||||
|
<LetterBadge key={`tag-${index}`} type="tag" value={tag} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
shape="round"
|
||||||
|
style={{
|
||||||
|
color: "#4b5563",
|
||||||
|
}}
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<span className="mr-1">浏览量</span>
|
||||||
|
{science.views || 0}
|
||||||
|
</Button>
|
||||||
|
<PostLikeButton post={science}></PostLikeButton>
|
||||||
|
{/* {science.author && (
|
||||||
|
<Tooltip
|
||||||
|
title={`作者:${science.author.showname || science.author.username}`}
|
||||||
|
>
|
||||||
|
<div className="text-xs text-gray-500 ml-2 max-w-24 truncate">
|
||||||
|
{science.author.showname || science.author.username}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)} */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { Button, Input, Pagination } from "antd";
|
||||||
|
import { PlusOutlined } from "@ant-design/icons";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { api } from "@nice/client";
|
||||||
|
import { PostType } from "@nice/common";
|
||||||
|
import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
|
import { ScienceCard } from "./ScienceCard";
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
|
||||||
|
export function ScienceContent() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, hasSomePermissions, isAuthenticated } = useAuth();
|
||||||
|
const [scienceList, setScienceList] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const pageSize = 6; // 每页显示5条科普
|
||||||
|
const isDomainAdmin = useMemo(() => {
|
||||||
|
return hasSomePermissions("MANAGE_DOM_STAFF", "MANAGE_ANY_STAFF");
|
||||||
|
}, [hasSomePermissions]);
|
||||||
|
|
||||||
|
const { data, isLoading } = api.post.findManyWithPagination.useQuery(
|
||||||
|
{
|
||||||
|
page: currentPage,
|
||||||
|
pageSize: pageSize,
|
||||||
|
where: {
|
||||||
|
type: PostType.SCIENCE,
|
||||||
|
isPublic: true,
|
||||||
|
deletedAt: null,
|
||||||
|
title: searchTerm
|
||||||
|
? { contains: searchTerm, mode: "insensitive" }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
updatedAt: "desc",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.items) {
|
||||||
|
setScienceList(data.items);
|
||||||
|
setLoading(false);
|
||||||
|
} else if (!isLoading) {
|
||||||
|
setScienceList([]);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [data, isLoading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchTerm) {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}
|
||||||
|
}, [searchTerm]);
|
||||||
|
|
||||||
|
const handleAddScience = () => {
|
||||||
|
navigate("/science");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = (page) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (value: string) => {
|
||||||
|
setSearchTerm(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[400px]">
|
||||||
|
<div className="flex justify-between items-center mt-2 mx-2 mb-4">
|
||||||
|
<Input.Search
|
||||||
|
placeholder="搜索标题"
|
||||||
|
allowClear
|
||||||
|
onSearch={handleSearch}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
style={{ width: 300 }}
|
||||||
|
/>
|
||||||
|
{isDomainAdmin && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={handleAddScience}
|
||||||
|
size="middle"
|
||||||
|
className="shadow-md hover:scale-105 transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
background: "#1677ff",
|
||||||
|
borderRadius: "6px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
发布
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading || isLoading ? (
|
||||||
|
<div className="text-center py-8">加载中...</div>
|
||||||
|
) : scienceList.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-4 mx-2">
|
||||||
|
{scienceList.map((science) => (
|
||||||
|
<ScienceCard key={science.id} science={science} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分页组件 */}
|
||||||
|
<div className="flex justify-center my-6">
|
||||||
|
<Pagination
|
||||||
|
current={currentPage}
|
||||||
|
total={data?.totalCount || 0}
|
||||||
|
pageSize={pageSize}
|
||||||
|
onChange={handlePageChange}
|
||||||
|
showSizeChanger={false}
|
||||||
|
showQuickJumper
|
||||||
|
size="default"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
{searchTerm ? `未找到标题包含"${searchTerm}"的科普内容` : "暂无科普内容"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { createContext, useContext, ReactNode } from "react";
|
||||||
|
import { Form, FormInstance } from "antd";
|
||||||
|
import { api, usePost } from "@nice/client";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { PostState, PostType } from "@nice/common";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
export interface ScienceFormData {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
resources?: string[];
|
||||||
|
term?: string;
|
||||||
|
meta: {
|
||||||
|
tags: string[];
|
||||||
|
videoUrl?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScienceEditorContextType {
|
||||||
|
onSubmit: (values: ScienceFormData) => Promise<void>;
|
||||||
|
termId?: string;
|
||||||
|
form: FormInstance<ScienceFormData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScienceFormProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
termId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ScienceEditorContext = createContext<ScienceEditorContextType | null>(null);
|
||||||
|
|
||||||
|
export function ScienceFormProvider({ children, termId }: ScienceFormProviderProps) {
|
||||||
|
const { create } = usePost();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [form] = Form.useForm<ScienceFormData>();
|
||||||
|
|
||||||
|
const onSubmit = async (data: ScienceFormData) => {
|
||||||
|
try {
|
||||||
|
const term = data?.term;
|
||||||
|
delete data.term;
|
||||||
|
|
||||||
|
console.log("即将提交的资源IDs:", data.resources);
|
||||||
|
|
||||||
|
const result = await create.mutateAsync({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
type: PostType.SCIENCE,
|
||||||
|
terms: term
|
||||||
|
? {
|
||||||
|
connect: {
|
||||||
|
id: term,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
state: PostState.RESOLVED,
|
||||||
|
isPublic: true,
|
||||||
|
resources: data.resources?.length
|
||||||
|
? {
|
||||||
|
connect: (data.resources?.filter(Boolean) || []).map(
|
||||||
|
(fileId) => ({
|
||||||
|
fileId,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(`发布成功!`, {
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
|
||||||
|
navigate("/help", {
|
||||||
|
state: {
|
||||||
|
successMessage: "发布成功",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
form.resetFields();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error submitting form:", error);
|
||||||
|
toast.error("操作失败,请重试!");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScienceEditorContext.Provider
|
||||||
|
value={{
|
||||||
|
onSubmit,
|
||||||
|
termId,
|
||||||
|
form,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScienceEditorContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useScienceEditor = () => {
|
||||||
|
const context = useContext(ScienceEditorContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useScienceEditor must be used within ScienceFormProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
|
@ -0,0 +1,61 @@
|
||||||
|
export default function ScienceHeader() {
|
||||||
|
return (
|
||||||
|
<header className="rounded-t-xl bg-gradient-to-r from-blue-600 to-blue-500 text-white p-6">
|
||||||
|
<div className="flex flex-col space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-wider">
|
||||||
|
科普发布
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-6 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>发布科普</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>学习常识</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M8 16l2.879-2.879m0 0a3 3 0 104.243-4.242 3 3 0 00-4.243 4.242zM21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>知识分享</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { ScienceFormProvider } from "./ScienceEditorContext";
|
||||||
|
import { ScienceBasicForm } from "./ScienceBasicForm";
|
||||||
|
import ScienceHeader from "./ScienceHeader";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function ScienceEditorPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const termId = searchParams.get("termId");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shadow-elegant border-2 border-white rounded-xl bg-slate-200">
|
||||||
|
<ScienceHeader />
|
||||||
|
<ScienceFormProvider termId={termId}>
|
||||||
|
<ScienceBasicForm />
|
||||||
|
</ScienceFormProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -143,7 +143,7 @@ export function StatsSection() {
|
||||||
</Button>
|
</Button>
|
||||||
<Modal
|
<Modal
|
||||||
centered
|
centered
|
||||||
title="选择分发医师"
|
title="选择分发人员"
|
||||||
visible={isResending}
|
visible={isResending}
|
||||||
onOk={handleResend}
|
onOk={handleResend}
|
||||||
onCancel={handleCancelResend}>
|
onCancel={handleCancelResend}>
|
||||||
|
|
|
@ -20,7 +20,7 @@ export function LetterBasicForm() {
|
||||||
|
|
||||||
const { data: enabledStaffIds, isLoading: roleMapIsLoading } =
|
const { data: enabledStaffIds, isLoading: roleMapIsLoading } =
|
||||||
api.rolemap.getStaffIdsByRoleNames.useQuery({
|
api.rolemap.getStaffIdsByRoleNames.useQuery({
|
||||||
roleNames: [RoleName.Leader, RoleName.Organization, RoleName.RootAdmin],
|
roleNames: [RoleName.Leader, RoleName.Organization, RoleName.DomainAdmin],
|
||||||
});
|
});
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -14,6 +14,10 @@ import InboxPage from "../app/main/letter/inbox/page";
|
||||||
import OutboxPage from "../app/main/letter/outbox/page";
|
import OutboxPage from "../app/main/letter/outbox/page";
|
||||||
import IndexPage from "../app/main/letter/index/page";
|
import IndexPage from "../app/main/letter/index/page";
|
||||||
import SubmissionSuccess from "../app/SubmissionSuccess";
|
import SubmissionSuccess from "../app/SubmissionSuccess";
|
||||||
|
import NewsEditorPage from "../app/main/help/news/page";
|
||||||
|
import ScienceEditorPage from "../app/main/help/science/page";
|
||||||
|
import ExampleEditorPage from "../app/main/help/example/page";
|
||||||
|
import { NewsCard } from "../app/main/help/news/NewsCard";
|
||||||
export const routes: CustomRouteObject[] = [
|
export const routes: CustomRouteObject[] = [
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
|
@ -64,6 +68,19 @@ export const routes: CustomRouteObject[] = [
|
||||||
{
|
{
|
||||||
path: "help",
|
path: "help",
|
||||||
element: <HelpPage></HelpPage>,
|
element: <HelpPage></HelpPage>,
|
||||||
|
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "news", // 新添加的路由
|
||||||
|
element: <NewsEditorPage></NewsEditorPage>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "science", // 新添加的路由
|
||||||
|
element: <ScienceEditorPage></ScienceEditorPage>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "example", // 新添加的路由
|
||||||
|
element: <ExampleEditorPage></ExampleEditorPage>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "submission-success",
|
path: "submission-success",
|
||||||
|
|
|
@ -5,6 +5,9 @@ export enum PostType {
|
||||||
POST = "post",
|
POST = "post",
|
||||||
POST_COMMENT = "post_comment",
|
POST_COMMENT = "post_comment",
|
||||||
COURSE_REVIEW = "course_review",
|
COURSE_REVIEW = "course_review",
|
||||||
|
NEW = "new",
|
||||||
|
SCIENCE = "science",
|
||||||
|
EXAMPLE = "example",
|
||||||
}
|
}
|
||||||
export enum TaxonomySlug {
|
export enum TaxonomySlug {
|
||||||
CATEGORY = "category",
|
CATEGORY = "category",
|
||||||
|
|
|
@ -240,6 +240,7 @@ export interface PostMeta {
|
||||||
ip?: string;
|
ip?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
ownCode?: string;
|
ownCode?: string;
|
||||||
|
coverImageUrl?: string;
|
||||||
}
|
}
|
||||||
export type RowModelResult = {
|
export type RowModelResult = {
|
||||||
rowData: any[];
|
rowData: any[];
|
||||||
|
|
Loading…
Reference in New Issue