+
文件列表
共 {filePagination.total} 个文件
-
- {fileResources.map((resource) => (
-
-
- {getFileIcon(resource.url)}
-
-
-
- {resource.title || "未命名文件"}
+ {fileResources.length > 0 && (
+
+ {fileResources.map((resource) => (
+
+
+ {getFileIcon(resource.url)}
- {resource.description && (
-
- 描述: {resource.description}
+
+
+ {resource.title}
+
+ {resource.description && (
+
+ 描述: {resource.description}
+
+ )}
+
+
+ {dayjs(resource.createdAt).format("YYYY-MM-DD")}
+
+
+ {resource.meta?.size &&
+ formatFileSize(resource.meta.size)}
+
- )}
-
-
- {dayjs(resource.createdAt).format("YYYY-MM-DD")}
-
-
- {resource.meta?.size &&
- formatFileSize(resource.meta.size)}
-
-
-
-
- {isDomainAdmin && (
+
}
- onClick={(e) => {
- e.stopPropagation();
- handleDelete(resource.id);
- }}
- />
- )}
+ onClick={() => window.open(resource.url)}
+ >
+ 下载
+
+ {isDomainAdmin && (
+ }
+ onClick={(e) => {
+ e.stopPropagation();
+ handleDelete(resource.id);
+ }}
+ />
+ )}
+
-
- ))}
-
+ ))}
+
+ )}
+
+ {fileResources.length === 0 && searchTerm && (
+
+ 未找到匹配 "{searchTerm}" 的文件。
+
+ )}
{/* 文件分页 */}
{filePagination.total > pageSize && (
@@ -396,6 +435,10 @@ export function VideoContent() {
)}
+ ) : (
+
+ {searchTerm ? `未找到匹配"${searchTerm}"的文件` : "暂无文件资源"}
+
)}
diff --git a/apps/web/src/app/main/help/example/ExampleBasicForm.tsx b/apps/web/src/app/main/help/example/ExampleBasicForm.tsx
new file mode 100644
index 0000000..eafea81
--- /dev/null
+++ b/apps/web/src/app/main/help/example/ExampleBasicForm.tsx
@@ -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(
+
+ 表单校验失败:
+ {errorMessages.map((msg, i) => (
+ · {msg}
+ ))}
+
,
+ {
+ duration: 5000,
+ position: "top-center",
+ }
+ );
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
封面图片:
+
{
+ 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}
+ />
+
+
+
+
+
+
+
+ form.setFieldValue("content", content)}
+ />
+
+
+
+
+
+
+ {
+ form.setFieldValue("resources", resources);
+ }}
+ />
+
+
+
+
+
+
+ }
+ style={{ width: 150 }}
+ >
+ 发布
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/app/main/help/example/ExampleCard.tsx b/apps/web/src/app/main/help/example/ExampleCard.tsx
new file mode 100644
index 0000000..22a8a1f
--- /dev/null
+++ b/apps/web/src/app/main/help/example/ExampleCard.tsx
@@ -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 (
+
{
+ 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"
+ >
+
+ {/* 左侧图片区域 */}
+
+ {coverImageUrl ? (
+

{
+ console.error("图片加载失败:", coverImageUrl);
+ // 显示占位图标
+ e.currentTarget.style.display = "none";
+ e.currentTarget.parentElement!.innerHTML =
+ '
';
+ }}
+ />
+ ) : (
+
+
+
+ )}
+
+
+ {/* 右侧内容区域 */}
+
+
+
+ {/* Badges & Interactions */}
+
+
+ {example.terms &&
+ example.terms.map((term) => (
+
+ ))}
+
+
+
+
+
}
+ size="small"
+ >
+
浏览量
+ {example.views || 0}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/main/help/example/ExampleContent.tsx b/apps/web/src/app/main/help/example/ExampleContent.tsx
new file mode 100755
index 0000000..f034dcd
--- /dev/null
+++ b/apps/web/src/app/main/help/example/ExampleContent.tsx
@@ -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 (
+
+
+ setSearchTerm(e.target.value)}
+ style={{ width: 300 }}
+ />
+ {isDomainAdmin && (
+ }
+ 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",
+ }}
+ >
+ 发布
+
+ )}
+
+
+ {loading || isLoading ? (
+
加载中...
+ ) : exampleList.length > 0 ? (
+ <>
+
+ {exampleList.map((example) => (
+
+ ))}
+
+
+ {/* 分页组件 */}
+
+ >
+ ) : (
+
+ {searchTerm ? `未找到标题包含"${searchTerm}"的案例` : "暂无案例分析"}
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/app/main/help/example/ExampleEditorContext.tsx b/apps/web/src/app/main/help/example/ExampleEditorContext.tsx
new file mode 100644
index 0000000..a85bfe0
--- /dev/null
+++ b/apps/web/src/app/main/help/example/ExampleEditorContext.tsx
@@ -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
;
+ termId?: string;
+ form: FormInstance;
+}
+
+interface ExampleFormProviderProps {
+ children: ReactNode;
+ termId?: string;
+}
+
+const ExampleEditorContext = createContext(null);
+
+export function ExampleFormProvider({ children, termId }: ExampleFormProviderProps) {
+ const { create } = usePost();
+ const navigate = useNavigate();
+ const [form] = Form.useForm();
+
+ 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 (
+
+ {children}
+
+ );
+}
+
+export const useExampleEditor = () => {
+ const context = useContext(ExampleEditorContext);
+ if (!context) {
+ throw new Error("useExampleEditor must be used within ExampleFormProvider");
+ }
+ return context;
+};
\ No newline at end of file
diff --git a/apps/web/src/app/main/help/example/ExampleHeader.tsx b/apps/web/src/app/main/help/example/ExampleHeader.tsx
new file mode 100644
index 0000000..75b820d
--- /dev/null
+++ b/apps/web/src/app/main/help/example/ExampleHeader.tsx
@@ -0,0 +1,61 @@
+export default function ExampleHeader() {
+ return (
+
+ );
+ }
\ No newline at end of file
diff --git a/apps/web/src/app/main/help/example/page.tsx b/apps/web/src/app/main/help/example/page.tsx
new file mode 100644
index 0000000..9c2ea76
--- /dev/null
+++ b/apps/web/src/app/main/help/example/page.tsx
@@ -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 (
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/app/main/help/news/NewsBasicForm.tsx b/apps/web/src/app/main/help/news/NewsBasicForm.tsx
new file mode 100644
index 0000000..bd832be
--- /dev/null
+++ b/apps/web/src/app/main/help/news/NewsBasicForm.tsx
@@ -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(
+
+ 表单校验失败:
+ {errorMessages.map((msg, i) => (
+ · {msg}
+ ))}
+
,
+ {
+ duration: 5000,
+ position: "top-center",
+ }
+ );
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
封面图片:
+
{
+ 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}
+ />
+
+
+
+
+
+
+
+ form.setFieldValue("content", content)}
+ />
+
+
+
+
+
+
+ {
+ form.setFieldValue("resources", resources);
+ }}
+ />
+
+
+
+
+
+
+ }
+ style={{ width: 150 }}
+ >
+ 发布
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/main/help/news/NewsCard.tsx b/apps/web/src/app/main/help/news/NewsCard.tsx
new file mode 100644
index 0000000..1a3205f
--- /dev/null
+++ b/apps/web/src/app/main/help/news/NewsCard.tsx
@@ -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 (
+ {
+ 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"
+ >
+
+ {/* 左侧图片区域 */}
+
+ {coverImageUrl ? (
+

{
+ console.error("图片加载失败:", coverImageUrl);
+ // 显示占位图标
+ e.currentTarget.style.display = "none";
+ e.currentTarget.parentElement!.innerHTML =
+ '
';
+ }}
+ />
+ ) : (
+
+
+
+ )}
+
+
+ {/* 右侧内容区域 */}
+
+
+
+ {/* Badges & Interactions */}
+
+
+ {news.terms &&
+ news.terms.map((term) => (
+
+ ))}
+
+
+
+
+
}
+ size="small"
+ >
+
浏览量
+ {news.views || 0}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/main/help/news/NewsEditorContext.tsx b/apps/web/src/app/main/help/news/NewsEditorContext.tsx
new file mode 100644
index 0000000..09e4335
--- /dev/null
+++ b/apps/web/src/app/main/help/news/NewsEditorContext.tsx
@@ -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;
+ termId?: string;
+ form: FormInstance;
+}
+
+interface NewsFormProviderProps {
+ children: ReactNode;
+ termId?: string;
+}
+
+const NewsEditorContext = createContext(null);
+
+export function NewsFormProvider({ children, termId }: NewsFormProviderProps) {
+ const { create } = usePost();
+ const navigate = useNavigate();
+ const [form] = Form.useForm();
+
+ 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 (
+
+ {children}
+
+ );
+}
+
+export const useNewsEditor = () => {
+ const context = useContext(NewsEditorContext);
+ if (!context) {
+ throw new Error("useNewsEditor must be used within NewsFormProvider");
+ }
+ return context;
+};
diff --git a/apps/web/src/app/main/help/news/NewsHeader.tsx b/apps/web/src/app/main/help/news/NewsHeader.tsx
new file mode 100644
index 0000000..987c862
--- /dev/null
+++ b/apps/web/src/app/main/help/news/NewsHeader.tsx
@@ -0,0 +1,61 @@
+export default function NewsHeader() {
+ return (
+
+ );
+ }
\ No newline at end of file
diff --git a/apps/web/src/app/main/help/news/PublicityContent.tsx b/apps/web/src/app/main/help/news/PublicityContent.tsx
new file mode 100755
index 0000000..abd30d6
--- /dev/null
+++ b/apps/web/src/app/main/help/news/PublicityContent.tsx
@@ -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 (
+
+
+ setSearchTerm(e.target.value)}
+ style={{ width: 300 }}
+ />
+ {isDomainAdmin && (
+ }
+ 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",
+ }}
+ >
+ 发布
+
+ )}
+
+
+ {loading || isLoading ? (
+
加载中...
+ ) : newsList.length > 0 ? (
+ <>
+
+ {newsList.map((news) => (
+
+ ))}
+
+
+ {/* 分页组件 */}
+
+ >
+ ) : (
+
+ {searchTerm ? `未找到标题包含"${searchTerm}"的新闻` : "暂无新闻报道"}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/app/main/help/news/page.tsx b/apps/web/src/app/main/help/news/page.tsx
new file mode 100644
index 0000000..2da78d9
--- /dev/null
+++ b/apps/web/src/app/main/help/news/page.tsx
@@ -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 (
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/app/main/help/science/ScienceBasicForm.tsx b/apps/web/src/app/main/help/science/ScienceBasicForm.tsx
new file mode 100644
index 0000000..2650e3f
--- /dev/null
+++ b/apps/web/src/app/main/help/science/ScienceBasicForm.tsx
@@ -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(
+
+ 表单校验失败:
+ {errorMessages.map((msg, i) => (
+ · {msg}
+ ))}
+
,
+ {
+ duration: 5000,
+ position: "top-center",
+ }
+ );
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
封面图片:
+
{
+ 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}
+ />
+
+
+
+
+
+
+
+ form.setFieldValue("content", content)}
+ />
+
+
+
+
+
+
+ {
+ form.setFieldValue("resources", resources);
+ }}
+ />
+
+
+
+
+
+
+ }
+ style={{ width: 150 }}
+ >
+ 发布
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/app/main/help/science/ScienceCard.tsx b/apps/web/src/app/main/help/science/ScienceCard.tsx
new file mode 100644
index 0000000..7356358
--- /dev/null
+++ b/apps/web/src/app/main/help/science/ScienceCard.tsx
@@ -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 (
+ {
+ 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"
+ >
+
+ {/* 左侧图片区域 */}
+
+ {coverImageUrl ? (
+

{
+ console.error("图片加载失败:", coverImageUrl);
+ // 显示占位图标
+ e.currentTarget.style.display = "none";
+ e.currentTarget.parentElement!.innerHTML =
+ '
';
+ }}
+ />
+ ) : (
+
+
+
+ )}
+
+
+ {/* 右侧内容区域 */}
+
+
+
+ {/* 内容预览 */}
+ {/* {science.content && (
+
+ {science.content.replace(/<[^>]*>/g, "")}
+
+ )} */}
+
+ {/* Badges & Interactions */}
+
+
+ {science.terms &&
+ science.terms.map((term) => (
+
+ ))}
+
+ {science.meta?.tags &&
+ science.meta.tags.length > 0 &&
+ science.meta.tags.map((tag, index) => (
+
+ ))}
+
+
+
}
+ size="small"
+ >
+
浏览量
+ {science.views || 0}
+
+
+ {/* {science.author && (
+
+
+ {science.author.showname || science.author.username}
+
+
+ )} */}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/main/help/science/ScienceContent.tsx b/apps/web/src/app/main/help/science/ScienceContent.tsx
new file mode 100755
index 0000000..7ba3c3d
--- /dev/null
+++ b/apps/web/src/app/main/help/science/ScienceContent.tsx
@@ -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 (
+
+
+ setSearchTerm(e.target.value)}
+ style={{ width: 300 }}
+ />
+ {isDomainAdmin && (
+ }
+ 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",
+ }}
+ >
+ 发布
+
+ )}
+
+
+ {loading || isLoading ? (
+
加载中...
+ ) : scienceList.length > 0 ? (
+ <>
+
+ {scienceList.map((science) => (
+
+ ))}
+
+
+ {/* 分页组件 */}
+
+ >
+ ) : (
+
+ {searchTerm ? `未找到标题包含"${searchTerm}"的科普内容` : "暂无科普内容"}
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/app/main/help/science/ScienceEditorContext.tsx b/apps/web/src/app/main/help/science/ScienceEditorContext.tsx
new file mode 100644
index 0000000..ab3b8b2
--- /dev/null
+++ b/apps/web/src/app/main/help/science/ScienceEditorContext.tsx
@@ -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;
+ termId?: string;
+ form: FormInstance;
+}
+
+interface ScienceFormProviderProps {
+ children: ReactNode;
+ termId?: string;
+}
+
+const ScienceEditorContext = createContext(null);
+
+export function ScienceFormProvider({ children, termId }: ScienceFormProviderProps) {
+ const { create } = usePost();
+ const navigate = useNavigate();
+ const [form] = Form.useForm();
+
+ 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 (
+
+ {children}
+
+ );
+}
+
+export const useScienceEditor = () => {
+ const context = useContext(ScienceEditorContext);
+ if (!context) {
+ throw new Error("useScienceEditor must be used within ScienceFormProvider");
+ }
+ return context;
+};
\ No newline at end of file
diff --git a/apps/web/src/app/main/help/science/ScienceHeader.tsx b/apps/web/src/app/main/help/science/ScienceHeader.tsx
new file mode 100644
index 0000000..e4a39bd
--- /dev/null
+++ b/apps/web/src/app/main/help/science/ScienceHeader.tsx
@@ -0,0 +1,61 @@
+export default function ScienceHeader() {
+ return (
+
+ );
+ }
\ No newline at end of file
diff --git a/apps/web/src/app/main/help/science/page.tsx b/apps/web/src/app/main/help/science/page.tsx
new file mode 100644
index 0000000..3ae3b4c
--- /dev/null
+++ b/apps/web/src/app/main/help/science/page.tsx
@@ -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 (
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/components/models/post/detail/PostHeader/StatsSection.tsx b/apps/web/src/components/models/post/detail/PostHeader/StatsSection.tsx
index faa368a..c40073e 100755
--- a/apps/web/src/components/models/post/detail/PostHeader/StatsSection.tsx
+++ b/apps/web/src/components/models/post/detail/PostHeader/StatsSection.tsx
@@ -143,7 +143,7 @@ export function StatsSection() {
diff --git a/apps/web/src/components/models/post/editor/form/LetterBasicForm.tsx b/apps/web/src/components/models/post/editor/form/LetterBasicForm.tsx
index 63c70b7..7bfcbd7 100755
--- a/apps/web/src/components/models/post/editor/form/LetterBasicForm.tsx
+++ b/apps/web/src/components/models/post/editor/form/LetterBasicForm.tsx
@@ -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 {
diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx
index e3b6737..53988c8 100755
--- a/apps/web/src/routes/index.tsx
+++ b/apps/web/src/routes/index.tsx
@@ -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: ,
+
+ },
+ {
+ path: "news", // 新添加的路由
+ element: ,
+ },
+ {
+ path: "science", // 新添加的路由
+ element: ,
+ },
+ {
+ path: "example", // 新添加的路由
+ element: ,
},
{
path: "submission-success",
diff --git a/packages/common/src/enum.ts b/packages/common/src/enum.ts
index 4d66418..fc54227 100755
--- a/packages/common/src/enum.ts
+++ b/packages/common/src/enum.ts
@@ -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",
diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts
index ca23407..5717ce6 100755
--- a/packages/common/src/types.ts
+++ b/packages/common/src/types.ts
@@ -240,6 +240,7 @@ export interface PostMeta {
ip?: string;
tags?: string[];
ownCode?: string;
+ coverImageUrl?: string;
}
export type RowModelResult = {
rowData: any[];