This commit is contained in:
ditiqi 2025-03-27 20:47:44 +08:00
commit 17633bcbd3
23 changed files with 314 additions and 204 deletions

View File

@ -1,5 +1,5 @@
# 基础镜像 # 基础镜像
FROM node:18.17-alpine as base FROM node:18-alpine as base
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \ RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
echo "https://mirrors.aliyun.com/alpine/v3.18/community" >> /etc/apk/repositories echo "https://mirrors.aliyun.com/alpine/v3.18/community" >> /etc/apk/repositories

View File

@ -125,5 +125,14 @@ export class PostRouter {
const { staff } = ctx; const { staff } = ctx;
return await this.postService.updateOrderByIds(input.ids); return await this.postService.updateOrderByIds(input.ids);
}), }),
softDeletePostDescendant:this.trpc.protectProcedure
.input(
z.object({
ancestorId:z.string()
})
)
.mutation(async ({ input })=>{
return await this.postService.softDeletePostDescendant(input)
})
}); });
} }

View File

@ -101,6 +101,7 @@ export class PostService extends BaseTreeService<Prisma.PostDelegate> {
}, },
params: { staff?: UserProfile; tx?: Prisma.TransactionClient }, params: { staff?: UserProfile; tx?: Prisma.TransactionClient },
) { ) {
const { courseDetail } = args; const { courseDetail } = args;
// If no transaction is provided, create a new one // If no transaction is provided, create a new one
if (!params.tx) { if (!params.tx) {
@ -295,40 +296,33 @@ export class PostService extends BaseTreeService<Prisma.PostDelegate> {
staff?.id && { staff?.id && {
authorId: staff.id, authorId: staff.id,
}, },
// staff?.id && {
// watchableStaffs: {
// some: {
// id: staff.id,
// },
// },
// },
// deptId && {
// watchableDepts: {
// some: {
// id: {
// in: parentDeptIds,
// },
// },
// },
// },
// {
// AND: [
// {
// watchableStaffs: {
// none: {}, // 匹配 watchableStaffs 为空
// },
// },
// {
// watchableDepts: {
// none: {}, // 匹配 watchableDepts 为空
// },
// },
// ],
// },
].filter(Boolean); ].filter(Boolean);
if (orCondition?.length > 0) return orCondition; if (orCondition?.length > 0) return orCondition;
return undefined; return undefined;
} }
async softDeletePostDescendant(args:{ancestorId?:string}){
const { ancestorId } = args
const descendantIds = []
await db.postAncestry.findMany({
where:{
ancestorId,
},
select:{
descendantId:true
}
}).then(res=>{
res.forEach(item=>{
descendantIds.push(item.descendantId)
})
})
console.log(descendantIds)
const result = super.softDeleteByIds([...descendantIds,ancestorId])
EventBus.emit('dataChanged', {
type: ObjectType.POST,
operation: CrudOperation.DELETED,
data: result,
});
return result
}
} }

View File

@ -64,6 +64,7 @@
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-hot-toast": "^2.4.1", "react-hot-toast": "^2.4.1",
"react-player": "^2.16.0",
"react-resizable": "^3.0.5", "react-resizable": "^3.0.5",
"react-router-dom": "^6.24.1", "react-router-dom": "^6.24.1",
"superjson": "^2.2.1", "superjson": "^2.2.1",

View File

@ -6,7 +6,7 @@ export default function PathEditorPage() {
const { id } = useParams(); const { id } = useParams();
return ( return (
<PostDetailProvider editId={id}> <PostDetailProvider editId={id}>
<MindEditor id={id}></MindEditor>; <MindEditor id={id}></MindEditor>
</PostDetailProvider> </PostDetailProvider>
); );
} }

View File

@ -25,6 +25,8 @@ import JoinButton from "../../models/course/detail/CourseOperationBtns/JoinButto
import { CourseDetailContext } from "../../models/course/detail/PostDetailContext"; import { CourseDetailContext } from "../../models/course/detail/PostDetailContext";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { useQueryClient } from "@tanstack/react-query";
import { getQueryKey } from "@trpc/react-query";
export default function MindEditor({ id }: { id?: string }) { export default function MindEditor({ id }: { id?: string }) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const { const {
@ -36,6 +38,7 @@ export default function MindEditor({ id }: { id?: string }) {
const [instance, setInstance] = useState<MindElixirInstance | null>(null); const [instance, setInstance] = useState<MindElixirInstance | null>(null);
const { isAuthenticated, user, hasSomePermissions } = useAuth(); const { isAuthenticated, user, hasSomePermissions } = useAuth();
const { read } = useVisitor(); const { read } = useVisitor();
const queryClient = useQueryClient();
// const { data: post, isLoading }: { data: PathDto; isLoading: boolean } = // const { data: post, isLoading }: { data: PathDto; isLoading: boolean } =
// api.post.findFirst.useQuery( // api.post.findFirst.useQuery(
// { // {
@ -46,7 +49,11 @@ export default function MindEditor({ id }: { id?: string }) {
// }, // },
// { enabled: Boolean(id) } // { enabled: Boolean(id) }
// ); // );
const softDeletePostDescendant = api.post.softDeletePostDescendant.useMutation({
onSuccess:()=>{
queryClient.invalidateQueries({ queryKey: getQueryKey(api.post) });
}
})
const canEdit: boolean = useMemo(() => { const canEdit: boolean = useMemo(() => {
const isAuth = isAuthenticated && user?.id === post?.author?.id; const isAuth = isAuthenticated && user?.id === post?.author?.id;
return ( return (
@ -62,16 +69,16 @@ export default function MindEditor({ id }: { id?: string }) {
}); });
const { handleFileUpload } = useTusUpload(); const { handleFileUpload } = useTusUpload();
const [form] = Form.useForm(); const [form] = Form.useForm();
const CustomLinkIconPlugin = (mind) => { const handleIcon = () => {
mind.bus.addListener("operation", async () => { const hyperLinkElement = document.querySelectorAll(".hyper-link");
const hyperLinkElement = console.log("hyperLinkElement", hyperLinkElement);
await document.querySelectorAll(".hyper-link"); hyperLinkElement.forEach((item) => {
console.log("hyperLinkElement", hyperLinkElement); const hyperLinkDom = createRoot(item);
hyperLinkElement.forEach((item) => { hyperLinkDom.render(<LinkOutlined />);
const hyperLinkDom = createRoot(item);
hyperLinkDom.render(<LinkOutlined />);
});
}); });
}
const CustomLinkIconPlugin = (mind) => {
mind.bus.addListener("operation", handleIcon)
}; };
useEffect(() => { useEffect(() => {
if (post?.id && id) { if (post?.id && id) {
@ -133,7 +140,11 @@ export default function MindEditor({ id }: { id?: string }) {
containerRef.current.hidden = true; containerRef.current.hidden = true;
//挂载实例 //挂载实例
setInstance(mind); setInstance(mind);
}, [canEdit]); }, [canEdit]);
useEffect(() => {
handleIcon()
});
useEffect(() => { useEffect(() => {
if ((!id || post) && instance) { if ((!id || post) && instance) {
containerRef.current.hidden = false; containerRef.current.hidden = false;
@ -204,10 +215,16 @@ export default function MindEditor({ id }: { id?: string }) {
} }
console.log(result); console.log(result);
}, },
(error) => {}, (error) => { },
`mind-thumb-${new Date().toString()}` `mind-thumb-${new Date().toString()}`
); );
}; };
const handleDelete = async () => {
await softDeletePostDescendant.mutateAsync({
ancestorId: id,
});
navigate("/path");
}
useEffect(() => { useEffect(() => {
containerRef.current.style.height = `${Math.floor(window.innerHeight - 271)}px`; containerRef.current.style.height = `${Math.floor(window.innerHeight - 271)}px`;
}, []); }, []);
@ -248,14 +265,28 @@ export default function MindEditor({ id }: { id?: string }) {
</div> </div>
<div> <div>
{canEdit && ( {canEdit && (
<Button <>
ghost {
type="primary" id && (
icon={<SaveOutlined></SaveOutlined>} <Button
onSubmit={(e) => e.preventDefault()} danger
onClick={handleSave}> icon={<SaveOutlined></SaveOutlined>}
{id ? "更新" : "保存"} onSubmit={(e) => e.preventDefault()}
</Button> onClick={handleDelete}>
</Button>
)
}
<Button
className="ml-4"
ghost
type="primary"
icon={<SaveOutlined></SaveOutlined>}
onSubmit={(e) => e.preventDefault()}
onClick={handleSave}>
{id ? "更新" : "保存"}
</Button>
</>
)} )}
</div> </div>
</div> </div>

View File

@ -12,7 +12,7 @@ import PostSelect from "../../models/post/PostSelect/PostSelect";
import { Lecture, PostType } from "@nice/common"; import { Lecture, PostType } from "@nice/common";
import { xmindColorPresets } from "./constant"; import { xmindColorPresets } from "./constant";
import { api } from "@nice/client"; import { api } from "@nice/client";
import { env } from "@web/src/env"; import { useAuth } from "@web/src/providers/auth-provider";
interface NodeMenuProps { interface NodeMenuProps {
mind: MindElixirInstance; mind: MindElixirInstance;
@ -20,12 +20,13 @@ interface NodeMenuProps {
//管理节点样式状态 //管理节点样式状态
const NodeMenu: React.FC<NodeMenuProps> = ({ mind }) => { const NodeMenu: React.FC<NodeMenuProps> = ({ mind }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [selectedFontColor, setSelectedFontColor] = useState<string>(""); const [selectedFontColor, setSelectedFontColor] = useState<string>("");
const [selectedBgColor, setSelectedBgColor] = useState<string>(""); const [selectedBgColor, setSelectedBgColor] = useState<string>("");
const [selectedSize, setSelectedSize] = useState<string>(""); const [selectedSize, setSelectedSize] = useState<string>("");
const [isBold, setIsBold] = useState(false); const [isBold, setIsBold] = useState(false);
const { user} = useAuth();
const [urlMode, setUrlMode] = useState<"URL" | "POSTURL">("POSTURL"); const [urlMode, setUrlMode] = useState<"URL" | "POSTURL">("POSTURL");
const [url, setUrl] = useState<string>(""); const [url, setUrl] = useState<string>("");
const [postId, setPostId] = useState<string>(""); const [postId, setPostId] = useState<string>("");
@ -238,13 +239,15 @@ const NodeMenu: React.FC<NodeMenuProps> = ({ mind }) => {
{urlMode === "POSTURL" ? ( {urlMode === "POSTURL" ? (
<PostSelect <PostSelect
onChange={(value) => { onChange={(value) => {
if (typeof value === "string") { if (typeof value === "string" ) {
setPostId(value); setPostId(value);
} }
}} }}
params={{ params={{
where: { where: {
type: PostType.LECTURE, type: PostType.LECTURE,
deletedAt: null,
authorId: user?.id,
}, },
}} }}
/> />

View File

@ -7,8 +7,10 @@ import { formatFileSize, getCompressedImageUrl } from "@nice/utils";
export default function ResourcesShower({ export default function ResourcesShower({
resources = [], resources = [],
isShowImage
}: { }: {
resources: ResourceDto[]; resources: ResourceDto[];
isShowImage?: boolean;
}) { }) {
const { resources: dealedResources } = useMemo(() => { const { resources: dealedResources } = useMemo(() => {
if (!resources) return { resources: [] }; if (!resources) return { resources: [] };
@ -36,7 +38,7 @@ export default function ResourcesShower({
const fileResources = dealedResources.filter((res) => !res.isImage); const fileResources = dealedResources.filter((res) => !res.isImage);
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{imageResources.length > 0 && ( {imageResources.length > 0 && isShowImage && (
<Row gutter={[16, 16]} className="mb-6"> <Row gutter={[16, 16]} className="mb-6">
<Image.PreviewGroup> <Image.PreviewGroup>
{imageResources.map((resource) => ( {imageResources.map((resource) => (

View File

@ -1,4 +1,4 @@
import { useCallback, useState } from "react"; import { ReactNode, useCallback, useState } from "react";
import { import {
UploadOutlined, UploadOutlined,
CheckCircleOutlined, CheckCircleOutlined,
@ -12,6 +12,9 @@ export interface TusUploaderProps {
onChange?: (value: string[]) => void; onChange?: (value: string[]) => void;
multiple?: boolean; multiple?: boolean;
allowTypes?: string[]; allowTypes?: string[];
style?:string
icon?:ReactNode,
description?:string
} }
interface UploadingFile { interface UploadingFile {
@ -27,6 +30,9 @@ export const TusUploader = ({
onChange, onChange,
multiple = true, multiple = true,
allowTypes = undefined, allowTypes = undefined,
style="",
icon = <UploadOutlined />,
description = "点击或拖拽文件到此区域进行上传",
}: TusUploaderProps) => { }: TusUploaderProps) => {
const { handleFileUpload, uploadProgress } = useTusUpload(); const { handleFileUpload, uploadProgress } = useTusUpload();
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([]); const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([]);
@ -137,7 +143,7 @@ export const TusUploader = ({
); );
return ( return (
<div className="space-y-1"> <div className={`space-y-1 ${style}`}>
<Upload.Dragger <Upload.Dragger
accept={allowTypes?.join(",")} accept={allowTypes?.join(",")}
name="files" name="files"
@ -145,18 +151,18 @@ export const TusUploader = ({
showUploadList={false} showUploadList={false}
beforeUpload={handleBeforeUpload}> beforeUpload={handleBeforeUpload}>
<p className="ant-upload-drag-icon"> <p className="ant-upload-drag-icon">
<UploadOutlined /> {icon}
</p> </p>
<p className="ant-upload-text"> <p className="ant-upload-text">
{description}
</p> </p>
<p className="ant-upload-hint"> <p className="ant-upload-hint">
{multiple ? "支持单个或批量上传文件" : "仅支持上传单个文件"} {multiple ? "支持单个或批量上传文件" : "仅支持上传单个文件"}
{allowTypes && ( {/* {allowTypes && (
<span className="block text-xs text-gray-500"> <span className="block text-xs text-gray-500">
: {allowTypes.join(", ")} : {allowTypes.join(", ")}
</span> </span>
)} )} */}
</p> </p>
<div className="px-2 py-0 rounded mt-1"> <div className="px-2 py-0 rounded mt-1">

View File

@ -10,11 +10,11 @@ import { Skeleton } from "antd";
import ResourcesShower from "@web/src/components/common/uploader/ResourceShower"; import ResourcesShower from "@web/src/components/common/uploader/ResourceShower";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import CourseDetailTitle from "./CourseDetailTitle"; import CourseDetailTitle from "./CourseDetailTitle";
import ReactPlayer from "react-player";
export const CourseDetailDisplayArea: React.FC = () => { export const CourseDetailDisplayArea: React.FC = () => {
// 创建滚动动画效果 // 创建滚动动画效果
const { const {
isLoading, isLoading,
canEdit, canEdit,
lecture, lecture,
@ -41,6 +41,15 @@ export const CourseDetailDisplayArea: React.FC = () => {
}} }}
className="w-full bg-black rounded-lg "> className="w-full bg-black rounded-lg ">
<div className=" w-full cursor-pointer"> <div className=" w-full cursor-pointer">
{/* <ReactPlayer
url={lecture?.meta?.videoUrl}
controls={true}
width="100%"
height="100%"
onError={(error) => {
console.log(error);
}}
/> */}
<VideoPlayer src={lecture?.meta?.videoUrl} /> <VideoPlayer src={lecture?.meta?.videoUrl} />
</div> </div>
</motion.div> </motion.div>
@ -48,18 +57,22 @@ export const CourseDetailDisplayArea: React.FC = () => {
)} )}
{!lectureIsLoading && {!lectureIsLoading &&
selectedLectureId && selectedLectureId &&
lecture?.meta?.type === LectureType.ARTICLE && ( (
<div className="flex justify-center flex-col items-center gap-2 w-full my-2 "> <div className="flex justify-center flex-col items-center gap-2 w-full my-2 ">
<div className="w-full rounded-lg "> <div className="w-full rounded-lg ">
<CollapsibleContent {lecture?.meta?.type === LectureType.ARTICLE && (
content={lecture?.content || ""} <CollapsibleContent
maxHeight={500} // Optional, defaults to 150 content={lecture?.content || ""}
/> maxHeight={500} // Optional, defaults to 150
/>
)}
<div className="px-6"> <div className="px-6">
<ResourcesShower <ResourcesShower
resources={ resources={
lecture?.resources lecture?.resources
}></ResourcesShower> }
isShowImage = {lecture?.meta?.type === LectureType.ARTICLE}
></ResourcesShower>
</div> </div>
</div> </div>
</div> </div>

View File

@ -23,15 +23,9 @@ export default function CourseDetailTitle() {
{!selectedLectureId ? course?.title : lecture?.title} {!selectedLectureId ? course?.title : lecture?.title}
</div> </div>
<div className="text-gray-600 flex w-full justify-start items-center gap-5"> <div className="text-gray-600 flex w-full justify-start items-center gap-5">
{course?.author?.showname && (
<div>
:
{course?.author?.showname}
</div>
)}
{course?.depts && course?.depts?.length > 0 && ( {course?.depts && course?.depts?.length > 0 && (
<div> <div>
:
{course?.depts?.map((dept) => dept.name)} {course?.depts?.map((dept) => dept.name)}
</div> </div>
)} )}

View File

@ -1,97 +1,40 @@
import { useAuth } from "@web/src/providers/auth-provider"; import { useContext, useEffect, useState } from "react";
import { useContext, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { CourseDetailContext } from "../PostDetailContext"; import { CourseDetailContext } from "../PostDetailContext";
import { useStaff } from "@nice/client"; import { api } from "@nice/client";
import { import {
CheckCircleOutlined, DeleteTwoTone,
CloseCircleOutlined,
EditTwoTone, EditTwoTone,
LoginOutlined, ExclamationCircleFilled,
} from "@ant-design/icons"; } from "@ant-design/icons";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import JoinButton from "./JoinButton"; import JoinButton from "./JoinButton";
import { Modal } from "antd";
import { useQueryClient } from "@tanstack/react-query";
import { getQueryKey } from "@trpc/react-query";
export default function CourseOperationBtns() { export default function CourseOperationBtns() {
// const { isAuthenticated, user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { post, canEdit, userIsLearning, setUserIsLearning } = const { post, canEdit } = useContext(CourseDetailContext);
useContext(CourseDetailContext);
// const { update } = useStaff();
// const [isHovered, setIsHovered] = useState(false);
// const toggleLearning = async () => {
// if (!userIsLearning) {
// await update.mutateAsync({
// where: { id: user?.id },
// data: {
// learningPosts: {
// connect: { id: course.id },
// },
// },
// });
// setUserIsLearning(true);
// toast.success("加入学习成功");
// } else {
// await update.mutateAsync({
// where: { id: user?.id },
// data: {
// learningPosts: {
// disconnect: {
// id: course.id,
// },
// },
// },
// });
// toast.success("退出学习成功");
// setUserIsLearning(false);
// }
// };
return ( return (
<> <>
{/* {isAuthenticated && (
<div
onClick={toggleLearning}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={`flex px-1 py-0.5 gap-1 hover:cursor-pointer transition-all ${
userIsLearning
? isHovered
? "text-red-500 border-red-500 rounded-md "
: "text-green-500 "
: "text-primary "
}`}>
{userIsLearning ? (
isHovered ? (
<CloseCircleOutlined />
) : (
<CheckCircleOutlined />
)
) : (
<LoginOutlined />
)}
<span>
{userIsLearning
? isHovered
? "退出学习"
: "正在学习"
: "加入学习"}
</span>
</div>
)} */}
<JoinButton></JoinButton> <JoinButton></JoinButton>
{canEdit && ( {canEdit && (
<div <>
className="flex gap-1 px-1 py-0.5 text-primary hover:cursor-pointer" <div
onClick={() => { className="flex gap-1 px-1 py-0.5 text-primary hover:cursor-pointer"
const url = post?.id onClick={() => {
? `/course/${post?.id}/editor` const url = post?.id
: "/course/editor"; ? `/course/${post?.id}/editor`
navigate(url); : "/course/editor";
}}> navigate(url);
<EditTwoTone></EditTwoTone> }}>
{"编辑课程"} <EditTwoTone></EditTwoTone>
</div> {"编辑课程"}
</div>
</>
)} )}
</> </>
); );

View File

@ -13,6 +13,8 @@ import { api, usePost } from "@nice/client";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { z } from "zod"; import { z } from "zod";
import { useAuth } from "@web/src/providers/auth-provider"; import { useAuth } from "@web/src/providers/auth-provider";
import { getQueryKey } from "@trpc/react-query";
import { useQueryClient } from "@tanstack/react-query";
export type CourseFormData = { export type CourseFormData = {
title: string; title: string;
@ -26,6 +28,7 @@ export type CourseFormData = {
interface CourseEditorContextType { interface CourseEditorContextType {
onSubmit: (values: CourseFormData) => Promise<void>; onSubmit: (values: CourseFormData) => Promise<void>;
handleDeleteCourse: () => Promise<void>;
editId?: string; editId?: string;
course?: CourseDto; course?: CourseDto;
taxonomies?: Taxonomy[]; // 根据实际类型调整 taxonomies?: Taxonomy[]; // 根据实际类型调整
@ -45,6 +48,7 @@ export function CourseFormProvider({
}: CourseFormProviderProps) { }: CourseFormProviderProps) {
const [form] = Form.useForm<CourseFormData>(); const [form] = Form.useForm<CourseFormData>();
const { create, update, createCourse } = usePost(); const { create, update, createCourse } = usePost();
const queryClient = useQueryClient();
const { user } = useAuth(); const { user } = useAuth();
const { data: course }: { data: CourseDto } = api.post.findFirst.useQuery( const { data: course }: { data: CourseDto } = api.post.findFirst.useQuery(
{ {
@ -60,6 +64,11 @@ export function CourseFormProvider({
} = api.taxonomy.getAll.useQuery({ } = api.taxonomy.getAll.useQuery({
type: ObjectType.COURSE, type: ObjectType.COURSE,
}); });
const softDeletePostDescendant = api.post.softDeletePostDescendant.useMutation({
onSuccess:()=>{
queryClient.invalidateQueries({ queryKey: getQueryKey(api.post) });
}
})
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
@ -92,7 +101,15 @@ export function CourseFormProvider({
form.setFieldsValue(formData); form.setFieldsValue(formData);
} }
}, [course, form]); }, [course, form]);
const handleDeleteCourse = async () => {
if(editId){
await softDeletePostDescendant.mutateAsync({
ancestorId: editId,
});
navigate("/courses");
}
}
const onSubmit = async (values: any) => { const onSubmit = async (values: any) => {
const sections = values?.sections || []; const sections = values?.sections || [];
const deptIds = values?.deptIds || []; const deptIds = values?.deptIds || [];
@ -172,6 +189,7 @@ export function CourseFormProvider({
course, course,
taxonomies, taxonomies,
form, form,
handleDeleteCourse
}}> }}>
<Form <Form
// requiredMark="optional" // requiredMark="optional"

View File

@ -25,8 +25,11 @@ import { CourseSectionEmpty } from "./CourseSectionEmpty";
import { SortableSection } from "./SortableSection"; import { SortableSection } from "./SortableSection";
import { LectureList } from "./LectureList"; import { LectureList } from "./LectureList";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useQueryClient } from "@tanstack/react-query";
import { getQueryKey } from "@trpc/react-query";
const CourseContentForm: React.FC = () => { const CourseContentForm: React.FC = () => {
const queryClient = useQueryClient();
const { editId } = useCourseEditor(); const { editId } = useCourseEditor();
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor), useSensor(PointerSensor),
@ -71,7 +74,11 @@ const CourseContentForm: React.FC = () => {
ids: newItems.map((item) => item.id), ids: newItems.map((item) => item.id),
}); });
}; };
const softDeletePostDescendant = api.post.softDeletePostDescendant.useMutation({
onSuccess:()=>{
queryClient.invalidateQueries({ queryKey: getQueryKey(api.post) });
}
})
return ( return (
<div className="max-w-4xl mx-auto p-6"> <div className="max-w-4xl mx-auto p-6">
<CourseContentFormHeader /> <CourseContentFormHeader />
@ -93,8 +100,8 @@ const CourseContentForm: React.FC = () => {
field={section} field={section}
remove={async () => { remove={async () => {
if (section?.id) { if (section?.id) {
await softDeleteByIds.mutateAsync({ await softDeletePostDescendant.mutateAsync({
ids: [section.id], ancestorId: section.id,
}); });
} }
setItems(sections); setItems(sections);

View File

@ -3,6 +3,8 @@ import {
CaretRightOutlined, CaretRightOutlined,
SaveOutlined, SaveOutlined,
CaretDownOutlined, CaretDownOutlined,
PaperClipOutlined,
PlaySquareOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { Form, Button, Input, Select, Space } from "antd"; import { Form, Button, Input, Select, Space } from "antd";
import React, { useState } from "react"; import React, { useState } from "react";
@ -86,12 +88,12 @@ export const SortableLecture: React.FC<SortableLectureProps> = ({
resources: resources:
[videoUrlId, ...fileIds].filter(Boolean)?.length > 0 [videoUrlId, ...fileIds].filter(Boolean)?.length > 0
? { ? {
connect: [videoUrlId, ...fileIds] connect: [videoUrlId, ...fileIds]
.filter(Boolean) .filter(Boolean)
.map((fileId) => ({ .map((fileId) => ({
fileId, fileId,
})), })),
} }
: undefined, : undefined,
content: values?.content, content: values?.content,
}, },
@ -115,12 +117,12 @@ export const SortableLecture: React.FC<SortableLectureProps> = ({
resources: resources:
[videoUrlId, ...fileIds].filter(Boolean)?.length > 0 [videoUrlId, ...fileIds].filter(Boolean)?.length > 0
? { ? {
set: [videoUrlId, ...fileIds] set: [videoUrlId, ...fileIds]
.filter(Boolean) .filter(Boolean)
.map((fileId) => ({ .map((fileId) => ({
fileId, fileId,
})), })),
} }
: undefined, : undefined,
content: values?.content, content: values?.content,
}, },
@ -175,22 +177,42 @@ export const SortableLecture: React.FC<SortableLectureProps> = ({
/> />
</Form.Item> </Form.Item>
</div> </div>
<div className="mt-4 flex flex-1 "> <div className="mt-4 flex flex-2 flex-row gap-x-5 ">
{lectureType === LectureType.VIDEO ? ( {lectureType === LectureType.VIDEO ? (
<Form.Item <>
name={["meta", "videoIds"]} <div className="mb-0 flex-1">
className="mb-0 flex-1" {/* <span className="inline-block w-full h-7 my-1 rounded-lg bg-slate-100 text-center leading-7">添加视频</span> */}
rules={[ <Form.Item
{ name={["meta", "videoIds"]}
required: true, rules={[
message: "请传入视频", {
}, required: true,
]}> message: "请传入视频",
<TusUploader },
allowTypes={videoMimeTypes} ]}>
multiple={false} <TusUploader
/> allowTypes={videoMimeTypes}
</Form.Item> multiple={false}
style={"h-64"}
icon={<PlaySquareOutlined />}
description="点击或拖拽视频到此区域进行上传"
/>
</Form.Item>
</div>
<div className="mb-0 flex-1">
{/* <span className="inline-block w-full h-7 my-1 rounded-lg bg-slate-100 text-center leading-7">添加附件</span> */}
<Form.Item
name={["meta", "fileIds"]}>
<TusUploader
style={"h-64"}
multiple={true}
icon={<PaperClipOutlined />}
description="点击或拖拽附件到此区域进行上传"
/>
</Form.Item>
</div>
</>
) : ( ) : (
<div> <div>
<Form.Item <Form.Item
@ -204,7 +226,7 @@ export const SortableLecture: React.FC<SortableLectureProps> = ({
]}> ]}>
<QuillEditor <QuillEditor
style={{ style={{
width:"700px", width: "700px",
}} }}
></QuillEditor> ></QuillEditor>
</Form.Item> </Form.Item>

View File

@ -1,10 +1,11 @@
import { ArrowLeftOutlined, ClockCircleOutlined } from "@ant-design/icons"; import { ArrowLeftOutlined, ClockCircleOutlined, ExclamationCircleFilled } from "@ant-design/icons";
import { Button, Tag, Typography } from "antd"; import { Button, Modal, Tag, Typography } from "antd";
import { useEffect } from "react"; import { useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { CourseStatus, CourseStatusLabel } from "@nice/common"; import { CourseStatus, CourseStatusLabel } from "@nice/common";
import { useCourseEditor } from "../context/CourseEditorContext"; import { useCourseEditor } from "../context/CourseEditorContext";
import { useAuth } from "@web/src/providers/auth-provider"; import { useAuth } from "@web/src/providers/auth-provider";
import toast from "react-hot-toast";
const { Title } = Typography; const { Title } = Typography;
@ -18,8 +19,8 @@ const courseStatusVariant: Record<CourseStatus, string> = {
export default function CourseEditorHeader() { export default function CourseEditorHeader() {
const navigate = useNavigate(); const navigate = useNavigate();
const { user, hasSomePermissions } = useAuth(); const { user, hasSomePermissions } = useAuth();
const { confirm } = Modal;
const { onSubmit, course, form } = useCourseEditor(); const { onSubmit, course, form, handleDeleteCourse, editId } = useCourseEditor();
const handleSave = () => { const handleSave = () => {
try { try {
@ -30,7 +31,26 @@ export default function CourseEditorHeader() {
console.log(err); console.log(err);
} }
}; };
const showDeleteConfirm = () => {
confirm({
title: '确定删除该课程吗',
icon: <ExclamationCircleFilled />,
content: '',
okText: '删除',
okType: 'danger',
cancelText: '取消',
async onOk() {
console.log('OK');
console.log(editId)
await handleDeleteCourse()
toast.success('课程已删除')
navigate("/courses");
},
onCancel() {
console.log('Cancel');
},
});
};
return ( return (
<header className="fixed top-0 left-0 right-0 h-16 bg-white border-b border-gray-200 z-10"> <header className="fixed top-0 left-0 right-0 h-16 bg-white border-b border-gray-200 z-10">
<div className="h-full flex items-center justify-between px-3 md:px-4"> <div className="h-full flex items-center justify-between px-3 md:px-4">
@ -70,16 +90,27 @@ export default function CourseEditorHeader() {
)} */} )} */}
</div> </div>
</div> </div>
<Button <div>
type="primary" {editId &&
// size="small" <Button
onClick={handleSave} danger
onClick={showDeleteConfirm}
>
</Button>
}
<Button
className="ml-4"
type="primary"
// size="small"
onClick={handleSave}
// disabled={form // disabled={form
// .getFieldsError() // .getFieldsError()
// .some(({ errors }) => errors.length)} // .some(({ errors }) => errors.length)}
> >
</Button> </Button>
</div>
</div> </div>
</header> </header>
); );

View File

@ -44,7 +44,8 @@ export default function PostList({
const posts = useMemo(() => { const posts = useMemo(() => {
if (data && !isLoading) { if (data && !isLoading) {
return data?.items; console.log(data?.items)
return data?.items.filter(item=>item.deletedAt === null);
} }
return []; return [];
}, [data, isLoading]); }, [data, isLoading]);

View File

@ -13,6 +13,7 @@ export default function PostSelect({
placeholder = "请选择课时", placeholder = "请选择课时",
params = { where: {}, select: {} }, params = { where: {}, select: {} },
className, className,
createdById,
}: { }: {
value?: string | string[]; value?: string | string[];
onChange?: (value: string | string[]) => void; onChange?: (value: string | string[]) => void;
@ -22,6 +23,7 @@ export default function PostSelect({
select?: Prisma.PostSelect<DefaultArgs>; select?: Prisma.PostSelect<DefaultArgs>;
}; };
className?: string; className?: string;
createdById?: string;
}) { }) {
const [searchValue, setSearch] = useState(""); const [searchValue, setSearch] = useState("");
const searchCondition: Prisma.PostWhereInput = useMemo(() => { const searchCondition: Prisma.PostWhereInput = useMemo(() => {

View File

@ -128,7 +128,7 @@ export const routes: CustomRouteObject[] = [
path: "course", path: "course",
children: [ children: [
{ {
path: ":id?/editor", path: ":id?/editor",
element: ( element: (
<WithAuth> <WithAuth>
<CourseEditorLayout></CourseEditorLayout> <CourseEditorLayout></CourseEditorLayout>

BIN
apps/web/web-dist.zip Normal file

Binary file not shown.

View File

@ -15,7 +15,7 @@ done
if [ -f "/usr/share/nginx/html/index.html" ]; then if [ -f "/usr/share/nginx/html/index.html" ]; then
# Use envsubst to replace environment variable placeholders # Use envsubst to replace environment variable placeholders
echo "Processing /usr/share/nginx/html/index.html" echo "Processing /usr/share/nginx/html/index.html"
envsubst < /usr/share/nginx/html/index.temp > /usr/share/nginx/html/index.html.tmp envsubst < /usr/share/nginx/html/index.template > /usr/share/nginx/html/index.html.tmp
mv /usr/share/nginx/html/index.html.tmp /usr/share/nginx/html/index.html mv /usr/share/nginx/html/index.html.tmp /usr/share/nginx/html/index.html
echo "Processed content:" echo "Processed content:"
cat /usr/share/nginx/html/index.html cat /usr/share/nginx/html/index.html

View File

@ -100,6 +100,7 @@ export const courseDetailSelect: Prisma.PostSelect = {
// isFeatured: true, // isFeatured: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
deletedAt: true,
// 关联表选择 // 关联表选择
terms: { terms: {
select: { select: {

View File

@ -386,6 +386,9 @@ importers:
react-hot-toast: react-hot-toast:
specifier: ^2.4.1 specifier: ^2.4.1
version: 2.5.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) version: 2.5.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react-player:
specifier: ^2.16.0
version: 2.16.0(react@18.2.0)
react-resizable: react-resizable:
specifier: ^3.0.5 specifier: ^3.0.5
version: 3.0.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0) version: 3.0.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@ -5603,6 +5606,9 @@ packages:
enquirer: enquirer:
optional: true optional: true
load-script@1.0.0:
resolution: {integrity: sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==}
load-tsconfig@0.2.5: load-tsconfig@0.2.5:
resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@ -5818,6 +5824,9 @@ packages:
resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==}
engines: {node: '>= 4.0.0'} engines: {node: '>= 4.0.0'}
memoize-one@5.2.1:
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
meow@8.1.2: meow@8.1.2:
resolution: {integrity: sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q==} resolution: {integrity: sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -6645,6 +6654,9 @@ packages:
react: '>= 16.3.0' react: '>= 16.3.0'
react-dom: '>= 16.3.0' react-dom: '>= 16.3.0'
react-fast-compare@3.2.2:
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
react-hook-form@7.54.2: react-hook-form@7.54.2:
resolution: {integrity: sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==} resolution: {integrity: sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@ -6664,6 +6676,11 @@ packages:
react-is@18.3.1: react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
react-player@2.16.0:
resolution: {integrity: sha512-mAIPHfioD7yxO0GNYVFD1303QFtI3lyyQZLY229UEAp/a10cSW+hPcakg0Keq8uWJxT2OiT/4Gt+Lc9bD6bJmQ==}
peerDependencies:
react: '>=16.6.0'
react-refresh@0.14.2: react-refresh@0.14.2:
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -13723,6 +13740,8 @@ snapshots:
rfdc: 1.4.1 rfdc: 1.4.1
wrap-ansi: 8.1.0 wrap-ansi: 8.1.0
load-script@1.0.0: {}
load-tsconfig@0.2.5: {} load-tsconfig@0.2.5: {}
loader-runner@4.3.0: {} loader-runner@4.3.0: {}
@ -13899,6 +13918,8 @@ snapshots:
dependencies: dependencies:
fs-monkey: 1.0.6 fs-monkey: 1.0.6
memoize-one@5.2.1: {}
meow@8.1.2: meow@8.1.2:
dependencies: dependencies:
'@types/minimist': 1.2.5 '@types/minimist': 1.2.5
@ -14774,6 +14795,8 @@ snapshots:
react: 18.2.0 react: 18.2.0
react-dom: 18.2.0(react@18.2.0) react-dom: 18.2.0(react@18.2.0)
react-fast-compare@3.2.2: {}
react-hook-form@7.54.2(react@18.2.0): react-hook-form@7.54.2(react@18.2.0):
dependencies: dependencies:
react: 18.2.0 react: 18.2.0
@ -14789,6 +14812,15 @@ snapshots:
react-is@18.3.1: {} react-is@18.3.1: {}
react-player@2.16.0(react@18.2.0):
dependencies:
deepmerge: 4.3.1
load-script: 1.0.0
memoize-one: 5.2.1
prop-types: 15.8.1
react: 18.2.0
react-fast-compare: 3.2.2
react-refresh@0.14.2: {} react-refresh@0.14.2: {}
react-resizable@3.0.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0): react-resizable@3.0.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0):