add
This commit is contained in:
parent
1c3af15978
commit
89102d7f8c
|
@ -23,10 +23,21 @@ export class ResourceRouter {
|
|||
) {}
|
||||
router = this.trpc.router({
|
||||
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 }) => {
|
||||
const { staff } = ctx;
|
||||
console.log('资源创建数据:', input.data);
|
||||
return await this.resourceService.create(input, { staff });
|
||||
}),
|
||||
|
||||
|
@ -48,9 +59,11 @@ export class ResourceRouter {
|
|||
return await this.resourceService.findFirst(input);
|
||||
}),
|
||||
softDeleteByIds: this.trpc.protectProcedure
|
||||
.input(z.object({
|
||||
ids: z.array(z.string())
|
||||
}))
|
||||
.input(
|
||||
z.object({
|
||||
ids: z.array(z.string()),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const result = await this.resourceService.softDeleteByIds(input.ids);
|
||||
return result;
|
||||
|
@ -81,17 +94,21 @@ export class ResourceRouter {
|
|||
count: this.trpc.procedure
|
||||
.input(
|
||||
z.object({
|
||||
where: z.object({
|
||||
AND: z.object({
|
||||
where: z
|
||||
.object({
|
||||
AND: z
|
||||
.object({
|
||||
title: z.object({
|
||||
not: z.null()
|
||||
not: z.null(),
|
||||
}),
|
||||
description: z.object({
|
||||
not: z.null()
|
||||
not: z.null(),
|
||||
}),
|
||||
})
|
||||
}).optional(),
|
||||
.optional(),
|
||||
deletedAt: z.date().nullable().optional(),
|
||||
}).optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.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 { getFileIcon } from "@web/src/components/models/post/detail/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";
|
||||
const { TabPane } = Tabs;
|
||||
|
||||
|
|
|
@ -1,72 +1,70 @@
|
|||
import { Tabs } from 'antd';
|
||||
import { Tabs } from "antd";
|
||||
import {
|
||||
VideoCameraOutlined,
|
||||
FileTextOutlined,
|
||||
CustomerServiceOutlined,
|
||||
ReadOutlined,
|
||||
BookOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { VideoContent } from './VideoContent';
|
||||
import { MusicContent } from './MusicContent';
|
||||
import { ScienceContent } from './ScienceContent';
|
||||
import { PublicityContent } from './PublicityContent';
|
||||
import { ExampleContent } from './ExampleContent';
|
||||
import './pt.css';
|
||||
BookOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { VideoContent } from "./VideoContent";
|
||||
import { MusicContent } from "./MusicContent";
|
||||
import { ScienceContent } from "./science/ScienceContent";
|
||||
import { PublicityContent } from "./news/PublicityContent";
|
||||
import { ExampleContent } from "./example/ExampleContent";
|
||||
import "./pt.css";
|
||||
|
||||
export function PsychologyNav() {
|
||||
const items = [
|
||||
{
|
||||
key: 'publicity',
|
||||
key: "publicity",
|
||||
label: (
|
||||
<span className="flex items-center gap-2 text-gray-700 hover:text-primary transition-colors">
|
||||
<BookOutlined className="text-lg" />
|
||||
<span>宣传报道</span>
|
||||
</span>
|
||||
),
|
||||
children: <PublicityContent />
|
||||
children: <PublicityContent />,
|
||||
},
|
||||
{
|
||||
key: 'science',
|
||||
key: "science",
|
||||
label: (
|
||||
<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>
|
||||
),
|
||||
children: <ScienceContent />
|
||||
children: <ScienceContent />,
|
||||
},
|
||||
{
|
||||
key: 'example',
|
||||
key: "example",
|
||||
label: (
|
||||
<span className="flex items-center gap-2 text-gray-700 hover:text-primary transition-colors">
|
||||
<VideoCameraOutlined className="text-lg" />
|
||||
<span>案例分析</span>
|
||||
</span>
|
||||
),
|
||||
children: <ExampleContent />
|
||||
children: <ExampleContent />,
|
||||
},
|
||||
{
|
||||
key: 'video',
|
||||
key: "video",
|
||||
label: (
|
||||
<span className="flex items-center gap-2 text-gray-700 hover:text-primary transition-colors">
|
||||
< FileTextOutlined className="text-lg" />
|
||||
<span>心理课件</span>
|
||||
<FileTextOutlined className="text-lg" />
|
||||
<span>文件共享</span>
|
||||
</span>
|
||||
),
|
||||
children: <VideoContent />
|
||||
children: <VideoContent />,
|
||||
},
|
||||
{
|
||||
key: 'music',
|
||||
label: (
|
||||
<span className="flex items-center gap-2 text-gray-700 hover:text-primary transition-colors">
|
||||
<CustomerServiceOutlined className="text-lg" />
|
||||
<span>音视频</span>
|
||||
</span>
|
||||
),
|
||||
children: <MusicContent />
|
||||
},
|
||||
|
||||
|
||||
// {
|
||||
// key: 'music',
|
||||
// label: (
|
||||
// <span className="flex items-center gap-2 text-gray-700 hover:text-primary transition-colors">
|
||||
// <CustomerServiceOutlined className="text-lg" />
|
||||
// <span>音视频</span>
|
||||
// </span>
|
||||
// ),
|
||||
// children: <MusicContent />
|
||||
// },
|
||||
];
|
||||
return (
|
||||
<div className="w-full from bg-white rounded-lg shadow-md">
|
||||
|
@ -76,13 +74,12 @@ export function PsychologyNav() {
|
|||
className="psychology-tabs"
|
||||
tabBarStyle={{
|
||||
margin: 0,
|
||||
padding: '12px 16px 0',
|
||||
borderBottom: '1px solid #f0f0f0'
|
||||
padding: "12px 16px 0",
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
}}
|
||||
tabBarGutter={200}
|
||||
tabBarGutter={300}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
}
|
||||
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 { getFileIcon } from "@web/src/components/models/post/detail/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";
|
||||
const { TabPane } = Tabs;
|
||||
|
||||
|
@ -26,6 +26,7 @@ export function VideoContent() {
|
|||
const { isAuthenticated, user, hasSomePermissions } = useAuth();
|
||||
const [fileIds, setFileIds] = useState<string[]>([]);
|
||||
const [uploaderKey, setUploaderKey] = useState<number>(0);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// 分页状态
|
||||
const [imagePage, setImagePage] = useState(1);
|
||||
|
@ -41,7 +42,8 @@ export function VideoContent() {
|
|||
const {
|
||||
data: resources,
|
||||
refetch,
|
||||
}: { data: ResourceDto[]; refetch: () => void } =
|
||||
isLoading,
|
||||
}: { data: ResourceDto[]; refetch: () => void; isLoading: boolean } =
|
||||
api.resource.findMany.useQuery({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
|
@ -55,13 +57,14 @@ export function VideoContent() {
|
|||
// 处理资源数据
|
||||
const { imageResources, fileResources, imagePagination, filePagination } =
|
||||
useMemo(() => {
|
||||
if (!resources)
|
||||
if (!resources) {
|
||||
return {
|
||||
imageResources: [],
|
||||
fileResources: [],
|
||||
imagePagination: { total: 0, data: [] },
|
||||
filePagination: { total: 0, data: [] },
|
||||
};
|
||||
}
|
||||
|
||||
const isImage = (url: string) => /\.(png|jpg|jpeg|gif|webp)$/i.test(url);
|
||||
|
||||
|
@ -70,17 +73,34 @@ export function VideoContent() {
|
|||
if (!resource?.url) return null;
|
||||
const original = `http://${env.SERVER_IP}:${env.UPLOAD_PORT}/uploads/${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 {
|
||||
...resource,
|
||||
url: isImg ? getCompressedImageUrl(original) : original,
|
||||
originalUrl: original,
|
||||
isImage: isImg,
|
||||
title: displayTitle, // 用于显示
|
||||
searchableFilename: searchableFilename, // 用于搜索
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
// 根据搜索词筛选文件资源 (基于 searchableFilename)
|
||||
const filteredFileResources = processedResources.filter(
|
||||
(res) =>
|
||||
!res.isImage &&
|
||||
res.searchableFilename
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const allImageResources = processedResources.filter((res) => res.isImage);
|
||||
const allFileResources = processedResources.filter((res) => !res.isImage);
|
||||
|
||||
// 分页处理
|
||||
const imageStart = (imagePage - 1) * pageSize;
|
||||
|
@ -91,17 +111,20 @@ export function VideoContent() {
|
|||
imageStart,
|
||||
imageStart + pageSize
|
||||
),
|
||||
fileResources: allFileResources.slice(fileStart, fileStart + pageSize),
|
||||
fileResources: filteredFileResources.slice(
|
||||
fileStart,
|
||||
fileStart + pageSize
|
||||
),
|
||||
imagePagination: {
|
||||
total: allImageResources.length,
|
||||
data: allImageResources,
|
||||
},
|
||||
filePagination: {
|
||||
total: allFileResources.length,
|
||||
data: allFileResources,
|
||||
total: filteredFileResources.length,
|
||||
data: filteredFileResources,
|
||||
},
|
||||
};
|
||||
}, [resources, imagePage, filePage]);
|
||||
}, [resources, imagePage, filePage, searchTerm]); // searchTerm 作为依赖项
|
||||
|
||||
const createMutation = api.resource.create.useMutation({});
|
||||
const handleSubmit = async () => {
|
||||
|
@ -161,7 +184,7 @@ export function VideoContent() {
|
|||
}
|
||||
|
||||
if (!isDomainAdmin) {
|
||||
message.error("只有域管理员才能删除文件");
|
||||
message.error("只有管理员才能删除文件");
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -196,11 +219,17 @@ export function VideoContent() {
|
|||
<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">
|
||||
<div className="flex flex-col space-b-1">
|
||||
<h1>资源上传</h1>
|
||||
<p>支持视频、图片、文档、PPT等多种格式文件</p>
|
||||
{/* <h1>资源上传</h1> */}
|
||||
<p>支持视频、excel、文档、ppt等多种格式文件</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Input.Search
|
||||
placeholder="搜索文件名"
|
||||
allowClear
|
||||
onSearch={(value) => setSearchTerm(value)}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
style={{ width: 300 }}
|
||||
/>
|
||||
{!isAuthenticated && (
|
||||
<Alert
|
||||
message="请先登录"
|
||||
|
@ -258,16 +287,16 @@ export function VideoContent() {
|
|||
{/* 文件展示区域 */}
|
||||
<div className="space-y-6">
|
||||
{/* 图片资源展示 */}
|
||||
{imagePagination?.total > 0 && (
|
||||
{/* {imagePagination?.total > 0 && (
|
||||
<div className="rounded-xl border p-4 bg-white">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium">图片列表</h3>
|
||||
<span className="text-gray-500 text-sm">
|
||||
共 {imagePagination.total} 张图片
|
||||
</span>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
{/* <Row gutter={[16, 16]}>
|
||||
<Image.PreviewGroup>
|
||||
{imageResources.map((resource) => (
|
||||
<Col key={resource.url} xs={12} sm={8} md={6} lg={4}>
|
||||
|
@ -303,10 +332,10 @@ export function VideoContent() {
|
|||
</Col>
|
||||
))}
|
||||
</Image.PreviewGroup>
|
||||
</Row>
|
||||
</Row> */}
|
||||
|
||||
{/* 图片分页 */}
|
||||
{imagePagination.total > pageSize && (
|
||||
{/* {imagePagination.total > pageSize && (
|
||||
<div className="flex justify-center mt-6">
|
||||
<Pagination
|
||||
current={imagePage}
|
||||
|
@ -318,22 +347,25 @@ export function VideoContent() {
|
|||
</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="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>
|
||||
<span className="text-gray-500 text-sm">
|
||||
共 {filePagination.total} 个文件
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{fileResources.length > 0 && (
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{fileResources.map((resource) => (
|
||||
<div
|
||||
key={resource.url}
|
||||
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">
|
||||
|
@ -341,7 +373,7 @@ export function VideoContent() {
|
|||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
{resource.title || "未命名文件"}
|
||||
{resource.title}
|
||||
</div>
|
||||
{resource.description && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
|
@ -382,6 +414,13 @@ export function VideoContent() {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileResources.length === 0 && searchTerm && (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
未找到匹配 "{searchTerm}" 的文件。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文件分页 */}
|
||||
{filePagination.total > pageSize && (
|
||||
|
@ -396,6 +435,10 @@ export function VideoContent() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{searchTerm ? `未找到匹配"${searchTerm}"的文件` : "暂无文件资源"}
|
||||
</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>
|
||||
<Modal
|
||||
centered
|
||||
title="选择分发医师"
|
||||
title="选择分发人员"
|
||||
visible={isResending}
|
||||
onOk={handleResend}
|
||||
onCancel={handleCancelResend}>
|
||||
|
|
|
@ -20,7 +20,7 @@ export function LetterBasicForm() {
|
|||
|
||||
const { data: enabledStaffIds, isLoading: roleMapIsLoading } =
|
||||
api.rolemap.getStaffIdsByRoleNames.useQuery({
|
||||
roleNames: [RoleName.Leader, RoleName.Organization, RoleName.RootAdmin],
|
||||
roleNames: [RoleName.Leader, RoleName.Organization, RoleName.DomainAdmin],
|
||||
});
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
|
|
|
@ -14,6 +14,10 @@ import InboxPage from "../app/main/letter/inbox/page";
|
|||
import OutboxPage from "../app/main/letter/outbox/page";
|
||||
import IndexPage from "../app/main/letter/index/page";
|
||||
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[] = [
|
||||
{
|
||||
path: "/",
|
||||
|
@ -64,6 +68,19 @@ export const routes: CustomRouteObject[] = [
|
|||
{
|
||||
path: "help",
|
||||
element: <HelpPage></HelpPage>,
|
||||
|
||||
},
|
||||
{
|
||||
path: "news", // 新添加的路由
|
||||
element: <NewsEditorPage></NewsEditorPage>,
|
||||
},
|
||||
{
|
||||
path: "science", // 新添加的路由
|
||||
element: <ScienceEditorPage></ScienceEditorPage>,
|
||||
},
|
||||
{
|
||||
path: "example", // 新添加的路由
|
||||
element: <ExampleEditorPage></ExampleEditorPage>,
|
||||
},
|
||||
{
|
||||
path: "submission-success",
|
||||
|
|
|
@ -5,6 +5,9 @@ export enum PostType {
|
|||
POST = "post",
|
||||
POST_COMMENT = "post_comment",
|
||||
COURSE_REVIEW = "course_review",
|
||||
NEW = "new",
|
||||
SCIENCE = "science",
|
||||
EXAMPLE = "example",
|
||||
}
|
||||
export enum TaxonomySlug {
|
||||
CATEGORY = "category",
|
||||
|
|
|
@ -240,6 +240,7 @@ export interface PostMeta {
|
|||
ip?: string;
|
||||
tags?: string[];
|
||||
ownCode?: string;
|
||||
coverImageUrl?: string;
|
||||
}
|
||||
export type RowModelResult = {
|
||||
rowData: any[];
|
||||
|
|
Loading…
Reference in New Issue