This commit is contained in:
Rao 2025-03-27 13:26:13 +08:00
parent 23daab3b3b
commit 1fc1aa368c
13 changed files with 175 additions and 144 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

@ -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 (
@ -63,7 +70,7 @@ export default function MindEditor({ id }: { id?: string }) {
const { handleFileUpload } = useTusUpload(); const { handleFileUpload } = useTusUpload();
const [form] = Form.useForm(); const [form] = Form.useForm();
const handleIcon = () => { const handleIcon = () => {
const hyperLinkElement =document.querySelectorAll(".hyper-link"); const hyperLinkElement = document.querySelectorAll(".hyper-link");
console.log("hyperLinkElement", hyperLinkElement); console.log("hyperLinkElement", hyperLinkElement);
hyperLinkElement.forEach((item) => { hyperLinkElement.forEach((item) => {
const hyperLinkDom = createRoot(item); const hyperLinkDom = createRoot(item);
@ -133,7 +140,7 @@ export default function MindEditor({ id }: { id?: string }) {
containerRef.current.hidden = true; containerRef.current.hidden = true;
//挂载实例 //挂载实例
setInstance(mind); setInstance(mind);
}, [canEdit]); }, [canEdit]);
useEffect(() => { useEffect(() => {
handleIcon() handleIcon()
@ -212,6 +219,12 @@ export default function MindEditor({ id }: { id?: string }) {
`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`;
}, []); }, []);
@ -252,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

@ -41,7 +41,7 @@ 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 {/* <ReactPlayer
url={lecture?.meta?.videoUrl} url={lecture?.meta?.videoUrl}
controls={true} controls={true}
width="100%" width="100%"
@ -49,8 +49,8 @@ export const CourseDetailDisplayArea: React.FC = () => {
onError={(error) => { onError={(error) => {
console.log(error); console.log(error);
}} }}
/> /> */}
{/* <VideoPlayer src={lecture?.meta?.videoUrl} /> */} <VideoPlayer src={lecture?.meta?.videoUrl} />
</div> </div>
</motion.div> </motion.div>
</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

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

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