This commit is contained in:
Li1304553726 2025-05-08 11:44:48 +08:00
parent 1c3af15978
commit 89102d7f8c
30 changed files with 2033 additions and 195 deletions

View File

@ -23,13 +23,24 @@ 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 });
}),
createMany: this.trpc.protectProcedure
.input(z.array(ResourceCreateManyInputSchema))
.mutation(async ({ ctx, input }) => {
@ -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;
@ -78,20 +91,24 @@ export class ResourceRouter {
const { staff } = ctx;
return await this.resourceService.findManyWithCursor(input);
}),
count: this.trpc.procedure
count: this.trpc.procedure
.input(
z.object({
where: z.object({
AND: z.object({
title: z.object({
not: z.null()
}),
description: z.object({
not: z.null()
})
}).optional(),
deletedAt: z.date().nullable().optional(),
}).optional(),
where: z
.object({
AND: z
.object({
title: z.object({
not: z.null(),
}),
description: z.object({
not: z.null(),
}),
})
.optional(),
deletedAt: z.date().nullable().optional(),
})
.optional(),
}),
)
.query(async ({ input }) => {

View File

@ -1,8 +0,0 @@
export function ExampleContent() {
return (
<div className="min-h-[400px]">
<h2 className="text-xl font-bold mb-4"></h2>
{/* 这里放视频列表内容 */}
</div>
);
}

View File

@ -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;

View File

@ -1,88 +1,85 @@
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';
VideoCameraOutlined,
FileTextOutlined,
CustomerServiceOutlined,
ReadOutlined,
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',
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 />
},
{
key: 'science',
label: (
<span className="flex items-center gap-2 text-gray-700 hover:text-primary transition-colors">
< ReadOutlined className="text-lg" />
<span></span>
</span>
),
children: <ScienceContent />
},
{
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 />
},
{
key: 'video',
label: (
<span className="flex items-center gap-2 text-gray-700 hover:text-primary transition-colors">
< FileTextOutlined className="text-lg" />
<span></span>
</span>
),
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 />
},
];
return (
<div className="w-full from bg-white rounded-lg shadow-md">
<Tabs
defaultActiveKey="publicity"
items={items}
className="psychology-tabs"
tabBarStyle={{
margin: 0,
padding: '12px 16px 0',
borderBottom: '1px solid #f0f0f0'
}}
tabBarGutter={200}
/>
</div>
);
const items = [
{
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 />,
},
{
key: "science",
label: (
<span className="flex items-center gap-2 text-gray-700 hover:text-primary transition-colors">
<ReadOutlined className="text-lg" />
<span></span>
</span>
),
children: <ScienceContent />,
},
{
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 />,
},
{
key: "video",
label: (
<span className="flex items-center gap-2 text-gray-700 hover:text-primary transition-colors">
<FileTextOutlined className="text-lg" />
<span></span>
</span>
),
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 />
// },
];
return (
<div className="w-full from bg-white rounded-lg shadow-md">
<Tabs
defaultActiveKey="publicity"
items={items}
className="psychology-tabs"
tabBarStyle={{
margin: 0,
padding: "12px 16px 0",
borderBottom: "1px solid #f0f0f0",
}}
tabBarGutter={300}
/>
</div>
);
}
export default PsychologyNav;
export default PsychologyNav;

View File

@ -1,8 +0,0 @@
export function PublicityContent() {
return (
<div className="min-h-[400px]">
<h2 className="text-xl font-bold mb-4"></h2>
{/* 这里放课件列表内容 */}
</div>
);
}

View File

@ -1,8 +0,0 @@
export function ScienceContent() {
return (
<div className="min-h-[400px]">
<h2 className="text-xl font-bold mb-4"></h2>
{/* 这里放视频列表内容 */}
</div>
);
}

View File

@ -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>excelppt等多种格式文件</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,70 +347,80 @@ 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>
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{fileResources.map((resource) => (
<div
key={resource.url}
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>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{resource.title || "未命名文件"}
{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.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>
{resource.description && (
<div className="text-xs text-gray-500 mt-1">
: {resource.description}
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{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 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="flex gap-2">
<Button
type="text"
size="small"
onClick={() => window.open(resource.url)}
>
</Button>
{isDomainAdmin && (
<div className="flex gap-2">
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={(e) => {
e.stopPropagation();
handleDelete(resource.id);
}}
/>
)}
onClick={() => window.open(resource.url)}
>
</Button>
{isDomainAdmin && (
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={(e) => {
e.stopPropagation();
handleDelete(resource.id);
}}
/>
)}
</div>
</div>
</div>
))}
</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>

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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;
};

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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;
};

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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;
};

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -143,7 +143,7 @@ export function StatsSection() {
</Button>
<Modal
centered
title="选择分发医师"
title="选择分发人员"
visible={isResending}
onOk={handleResend}
onCancel={handleCancelResend}>

View File

@ -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 {

View File

@ -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",

View File

@ -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",

View File

@ -240,6 +240,7 @@ export interface PostMeta {
ip?: string;
tags?: string[];
ownCode?: string;
coverImageUrl?: string;
}
export type RowModelResult = {
rowData: any[];