add
This commit is contained in:
parent
c6a40da7c2
commit
93c2151af2
|
@ -15,7 +15,7 @@ import {
|
||||||
import { MessageService } from '../message/message.service';
|
import { MessageService } from '../message/message.service';
|
||||||
import { BaseService } from '../base/base.service';
|
import { BaseService } from '../base/base.service';
|
||||||
import { DepartmentService } from '../department/department.service';
|
import { DepartmentService } from '../department/department.service';
|
||||||
import { setCourseInfo, setPostRelation } from './utils';
|
import { setPostInfo, setPostRelation } from './utils';
|
||||||
import EventBus, { CrudOperation } from '@server/utils/event-bus';
|
import EventBus, { CrudOperation } from '@server/utils/event-bus';
|
||||||
import { BaseTreeService } from '../base/base.tree.service';
|
import { BaseTreeService } from '../base/base.tree.service';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
@ -155,7 +155,7 @@ export class PostService extends BaseTreeService<Prisma.PostDelegate> {
|
||||||
if (result) {
|
if (result) {
|
||||||
await setPostRelation({ data: result, staff });
|
await setPostRelation({ data: result, staff });
|
||||||
await this.setPerms(result, staff);
|
await this.setPerms(result, staff);
|
||||||
await setCourseInfo({ data: result });
|
await setPostInfo({ data: result });
|
||||||
}
|
}
|
||||||
// console.log(result);
|
// console.log(result);
|
||||||
return result;
|
return result;
|
||||||
|
|
|
@ -126,7 +126,7 @@ export async function updateCourseEnrollmentStats(courseId: string) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setCourseInfo({ data }: { data: Post }) {
|
export async function setPostInfo({ data }: { data: Post }) {
|
||||||
// await db.term
|
// await db.term
|
||||||
if (data?.type === PostType.COURSE) {
|
if (data?.type === PostType.COURSE) {
|
||||||
const ancestries = await db.postAncestry.findMany({
|
const ancestries = await db.postAncestry.findMany({
|
||||||
|
@ -169,20 +169,36 @@ export async function setCourseInfo({ data }: { data: Post }) {
|
||||||
) as any as Lecture[];
|
) as any as Lecture[];
|
||||||
});
|
});
|
||||||
|
|
||||||
const students = await db.staff.findMany({
|
Object.assign(data, { sections, lectureCount });
|
||||||
|
}
|
||||||
|
if (data?.type === PostType.LECTURE || data?.type === PostType.SECTION) {
|
||||||
|
const ancestry = await db.postAncestry.findFirst({
|
||||||
where: {
|
where: {
|
||||||
learningPosts: {
|
descendantId: data?.id,
|
||||||
some: {
|
ancestor: {
|
||||||
id: data.id,
|
type: PostType.COURSE,
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
ancestor: { select: { id: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const courseId = ancestry.ancestor.id;
|
||||||
const studentIds = (students || []).map((student) => student?.id);
|
Object.assign(data, { courseId });
|
||||||
Object.assign(data, { sections, lectureCount, studentIds });
|
|
||||||
}
|
}
|
||||||
|
const students = await db.staff.findMany({
|
||||||
|
where: {
|
||||||
|
learningPosts: {
|
||||||
|
some: {
|
||||||
|
id: data.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const studentIds = (students || []).map((student) => student?.id);
|
||||||
|
Object.assign(data, { studentIds });
|
||||||
}
|
}
|
||||||
|
|
|
@ -81,7 +81,7 @@ export function MainProvider({ children }: MainProviderProps) {
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
: {};
|
: {};
|
||||||
}, [searchValue, debouncedValue]);
|
}, [debouncedValue]);
|
||||||
return (
|
return (
|
||||||
<MainContext.Provider
|
<MainContext.Provider
|
||||||
value={{
|
value={{
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { useMainContext } from "../../layout/MainProvider";
|
||||||
import { PostType } from "@nice/common";
|
import { PostType } from "@nice/common";
|
||||||
import PathCard from "@web/src/components/models/post/SubPost/PathCard";
|
import PathCard from "@web/src/components/models/post/SubPost/PathCard";
|
||||||
|
|
||||||
export default function MyLearningListContainer() {
|
export default function MyDutyPathContainer() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { searchCondition, termsCondition } = useMainContext();
|
const { searchCondition, termsCondition } = useMainContext();
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
import MindEditor from "@web/src/components/common/editor/MindEditor";
|
import MindEditor from "@web/src/components/common/editor/MindEditor";
|
||||||
|
import { PostDetailProvider } from "@web/src/components/models/course/detail/PostDetailContext";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
export default function PathEditorPage() {
|
export default function PathEditorPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
return (
|
||||||
return <div className="">
|
<PostDetailProvider editId={id}>
|
||||||
<MindEditor id={id}></MindEditor>
|
<MindEditor id={id}></MindEditor>;
|
||||||
</div>
|
</PostDetailProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,12 +3,15 @@ import BasePostLayout from "../layout/BasePost/BasePostLayout";
|
||||||
import { useMainContext } from "../layout/MainProvider";
|
import { useMainContext } from "../layout/MainProvider";
|
||||||
import PathListContainer from "./components/PathListContainer";
|
import PathListContainer from "./components/PathListContainer";
|
||||||
import { PostType } from "@nice/common";
|
import { PostType } from "@nice/common";
|
||||||
|
import { PostDetailProvider } from "@web/src/components/models/course/detail/PostDetailContext";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
export default function PathPage() {
|
export default function PathPage() {
|
||||||
const { setSearchMode } = useMainContext();
|
const { setSearchMode } = useMainContext();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSearchMode(PostType.PATH);
|
setSearchMode(PostType.PATH);
|
||||||
}, [setSearchMode]);
|
}, [setSearchMode]);
|
||||||
|
const { id } = useParams();
|
||||||
return (
|
return (
|
||||||
<BasePostLayout>
|
<BasePostLayout>
|
||||||
<PathListContainer></PathListContainer>
|
<PathListContainer></PathListContainer>
|
||||||
|
|
|
@ -12,7 +12,7 @@ import {
|
||||||
} from "@nice/common";
|
} from "@nice/common";
|
||||||
import TermSelect from "../../models/term/term-select";
|
import TermSelect from "../../models/term/term-select";
|
||||||
import DepartmentSelect from "../../models/department/department-select";
|
import DepartmentSelect from "../../models/department/department-select";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { MindElixirInstance } from "mind-elixir";
|
import { MindElixirInstance } from "mind-elixir";
|
||||||
import MindElixir from "mind-elixir";
|
import MindElixir from "mind-elixir";
|
||||||
|
@ -21,21 +21,29 @@ import { useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "@web/src/providers/auth-provider";
|
import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
import { MIND_OPTIONS } from "./constant";
|
import { MIND_OPTIONS } from "./constant";
|
||||||
import { SaveOutlined } from "@ant-design/icons";
|
import { SaveOutlined } from "@ant-design/icons";
|
||||||
|
import JoinButton from "../../models/course/detail/CourseOperationBtns/JoinButton";
|
||||||
|
import { CourseDetailContext } from "../../models/course/detail/PostDetailContext";
|
||||||
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 {
|
||||||
|
post,
|
||||||
|
isLoading,
|
||||||
|
// userIsLearning,
|
||||||
|
// setUserIsLearning,
|
||||||
|
} = useContext(CourseDetailContext);
|
||||||
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 { data: post, isLoading }: { data: PathDto; isLoading: boolean } =
|
// const { data: post, isLoading }: { data: PathDto; isLoading: boolean } =
|
||||||
api.post.findFirst.useQuery(
|
// api.post.findFirst.useQuery(
|
||||||
{
|
// {
|
||||||
where: {
|
// where: {
|
||||||
id,
|
// id,
|
||||||
},
|
// },
|
||||||
select: postDetailSelect,
|
// select: postDetailSelect,
|
||||||
},
|
// },
|
||||||
{ enabled: Boolean(id) }
|
// { enabled: Boolean(id) }
|
||||||
);
|
// );
|
||||||
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 !!id || isAuth || hasSomePermissions(RolePerms.MANAGE_ANY_POST);
|
return !!id || isAuth || hasSomePermissions(RolePerms.MANAGE_ANY_POST);
|
||||||
|
@ -97,8 +105,8 @@ export default function MindEditor({ id }: { id?: string }) {
|
||||||
if ((!id || post) && instance) {
|
if ((!id || post) && instance) {
|
||||||
containerRef.current.hidden = false;
|
containerRef.current.hidden = false;
|
||||||
instance.toCenter();
|
instance.toCenter();
|
||||||
if (post?.meta?.nodeData) {
|
if ((post as any as PathDto)?.meta?.nodeData) {
|
||||||
instance.refresh(post?.meta);
|
instance.refresh((post as any as PathDto)?.meta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [id, post, instance]);
|
}, [id, post, instance]);
|
||||||
|
@ -201,17 +209,20 @@ export default function MindEditor({ id }: { id?: string }) {
|
||||||
multiple
|
multiple
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<JoinButton></JoinButton>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{canEdit && (
|
||||||
|
<Button
|
||||||
|
ghost
|
||||||
|
type="primary"
|
||||||
|
icon={<SaveOutlined></SaveOutlined>}
|
||||||
|
onSubmit={(e) => e.preventDefault()}
|
||||||
|
onClick={handleSave}>
|
||||||
|
{id ? "更新" : "保存"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{canEdit && (
|
|
||||||
<Button
|
|
||||||
ghost
|
|
||||||
type="primary"
|
|
||||||
icon={<SaveOutlined></SaveOutlined>}
|
|
||||||
onSubmit={(e) => e.preventDefault()}
|
|
||||||
onClick={handleSave}>
|
|
||||||
{id ? "更新" : "保存"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -1,190 +1,249 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import { Input, Button, ColorPicker, Select } from 'antd';
|
import { Input, Button, ColorPicker, Select } from "antd";
|
||||||
import {
|
import {
|
||||||
FontSizeOutlined,
|
FontSizeOutlined,
|
||||||
BoldOutlined,
|
BoldOutlined,
|
||||||
LinkOutlined,
|
LinkOutlined,
|
||||||
} from '@ant-design/icons';
|
GlobalOutlined,
|
||||||
import type { MindElixirInstance, NodeObj } from 'mind-elixir';
|
SwapOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
const xmindColorPresets = [
|
import type { MindElixirInstance, NodeObj } from "mind-elixir";
|
||||||
// 经典16色
|
import PostSelect from "../../models/post/PostSelect/PostSelect";
|
||||||
'#FFFFFF', '#F5F5F5', // 白色系
|
import { Lecture, PostType } from "@nice/common";
|
||||||
'#2196F3', '#1976D2', // 蓝色系
|
import { xmindColorPresets } from "./constant";
|
||||||
'#4CAF50', '#388E3C', // 绿色系
|
import { api } from "@nice/client";
|
||||||
'#FF9800', '#F57C00', // 橙色系
|
import { env } from "@web/src/env";
|
||||||
'#F44336', '#D32F2F', // 红色系
|
|
||||||
'#9C27B0', '#7B1FA2', // 紫色系
|
|
||||||
'#424242', '#757575', // 灰色系
|
|
||||||
'#FFEB3B', '#FBC02D' // 黄色系
|
|
||||||
];
|
|
||||||
|
|
||||||
interface NodeMenuProps {
|
interface NodeMenuProps {
|
||||||
mind: MindElixirInstance;
|
mind: MindElixirInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 [url, setUrl] = useState<string>('');
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const [urlMode, setUrlMode] = useState<"URL" | "POSTURL">("POSTURL");
|
||||||
const handleSelectNode = (nodeObj: NodeObj) => {
|
const [url, setUrl] = useState<string>("");
|
||||||
setIsOpen(true);
|
const [postId, setPostId] = useState<string>("");
|
||||||
const style = nodeObj.style || {};
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
setSelectedFontColor(style.color || '');
|
const { data: lecture, isLoading }: { data: Lecture; isLoading: boolean } =
|
||||||
setSelectedBgColor(style.background || '');
|
api.post.findFirst.useQuery(
|
||||||
setSelectedSize(style.fontSize || '24');
|
{
|
||||||
setIsBold(style.fontWeight === 'bold');
|
where: { id: postId },
|
||||||
setUrl(nodeObj.hyperLink || '');
|
},
|
||||||
};
|
{ enabled: !!postId }
|
||||||
const handleUnselectNode = () => {
|
);
|
||||||
setIsOpen(false);
|
useEffect(() => {
|
||||||
};
|
if (urlMode === "POSTURL")
|
||||||
mind.bus.addListener('selectNode', handleSelectNode);
|
setUrl(`/course/${lecture?.courseId}/detail/${lecture?.id}`);
|
||||||
mind.bus.addListener('unselectNode', handleUnselectNode);
|
|
||||||
}, [mind]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
mind.reshapeNode(mind.currentNode, {
|
||||||
if (containerRef.current && mind.container) {
|
hyperLink: `/course/${lecture?.courseId}/detail/${lecture?.id}`,
|
||||||
mind.container.appendChild(containerRef.current);
|
});
|
||||||
}
|
}, [postId, lecture, isLoading, urlMode]);
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSelectNode = (nodeObj: NodeObj) => {
|
||||||
|
setIsOpen(true);
|
||||||
|
const style = nodeObj.style || {};
|
||||||
|
setSelectedFontColor(style.color || "");
|
||||||
|
setSelectedBgColor(style.background || "");
|
||||||
|
setSelectedSize(style.fontSize || "24");
|
||||||
|
setIsBold(style.fontWeight === "bold");
|
||||||
|
setUrl(nodeObj.hyperLink || "");
|
||||||
|
};
|
||||||
|
const handleUnselectNode = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
mind.bus.addListener("selectNode", handleSelectNode);
|
||||||
|
mind.bus.addListener("unselectNode", handleUnselectNode);
|
||||||
|
}, [mind]);
|
||||||
|
|
||||||
}, [mind.container]);
|
useEffect(() => {
|
||||||
|
if (containerRef.current && mind.container) {
|
||||||
|
mind.container.appendChild(containerRef.current);
|
||||||
|
}
|
||||||
|
}, [mind.container]);
|
||||||
|
|
||||||
const handleColorChange = (type: "font" | "background", color: string) => {
|
const handleColorChange = (type: "font" | "background", color: string) => {
|
||||||
if (type === 'font') {
|
if (type === "font") {
|
||||||
setSelectedFontColor(color);
|
setSelectedFontColor(color);
|
||||||
} else {
|
} else {
|
||||||
setSelectedBgColor(color);
|
setSelectedBgColor(color);
|
||||||
}
|
}
|
||||||
const patch = { style: {} as any };
|
const patch = { style: {} as any };
|
||||||
if (type === 'font') {
|
if (type === "font") {
|
||||||
patch.style.color = color;
|
patch.style.color = color;
|
||||||
} else {
|
} else {
|
||||||
patch.style.background = color;
|
patch.style.background = color;
|
||||||
}
|
}
|
||||||
mind.reshapeNode(mind.currentNode, patch);
|
mind.reshapeNode(mind.currentNode, patch);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSizeChange = (size: string) => {
|
const handleSizeChange = (size: string) => {
|
||||||
setSelectedSize(size);
|
setSelectedSize(size);
|
||||||
mind.reshapeNode(mind.currentNode, { style: { fontSize: size } });
|
mind.reshapeNode(mind.currentNode, { style: { fontSize: size } });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBoldToggle = () => {
|
const handleBoldToggle = () => {
|
||||||
const fontWeight = isBold ? '' : 'bold';
|
const fontWeight = isBold ? "" : "bold";
|
||||||
setIsBold(!isBold);
|
setIsBold(!isBold);
|
||||||
mind.reshapeNode(mind.currentNode, { style: { fontWeight } });
|
mind.reshapeNode(mind.currentNode, { style: { fontWeight } });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
setUrl(value);
|
setUrl(value);
|
||||||
mind.reshapeNode(mind.currentNode, { hyperLink: value });
|
mind.reshapeNode(mind.currentNode, {
|
||||||
};
|
hyperLink: value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`node-menu-container absolute right-2 top-2 rounded-lg bg-slate-200 shadow-xl ring-2 ring-white transition-all duration-300 ${isOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4 pointer-events-none'
|
className={`node-menu-container absolute right-2 top-2 rounded-lg bg-slate-200 shadow-xl ring-2 ring-white transition-all duration-300 ${
|
||||||
}`}
|
isOpen
|
||||||
ref={containerRef}
|
? "opacity-100 translate-y-0"
|
||||||
>
|
: "opacity-0 translate-y-4 pointer-events-none"
|
||||||
<div className="p-5 space-y-6">
|
}`}
|
||||||
{/* Font Size Selector */}
|
ref={containerRef}>
|
||||||
<div className="space-y-2">
|
<div className="p-5 space-y-6">
|
||||||
<h3 className="text-sm font-medium text-gray-600">文字样式</h3>
|
{/* Font Size Selector */}
|
||||||
<div className="flex gap-3 items-center justify-between">
|
<div className="space-y-2">
|
||||||
<Select
|
<h3 className="text-sm font-medium text-gray-600">
|
||||||
value={selectedSize}
|
文字样式
|
||||||
onChange={handleSizeChange}
|
</h3>
|
||||||
prefix={<FontSizeOutlined className='mr-2' />}
|
<div className="flex gap-3 items-center justify-between">
|
||||||
className="w-1/2"
|
<Select
|
||||||
options={[
|
value={selectedSize}
|
||||||
{ value: '12', label: '12' },
|
onChange={handleSizeChange}
|
||||||
{ value: '14', label: '14' },
|
prefix={<FontSizeOutlined className="mr-2" />}
|
||||||
{ value: '16', label: '16' },
|
className="w-1/2"
|
||||||
{ value: '18', label: '18' },
|
options={[
|
||||||
{ value: '20', label: '20' },
|
{ value: "12", label: "12" },
|
||||||
{ value: '24', label: '24' },
|
{ value: "14", label: "14" },
|
||||||
{ value: '28', label: '28' },
|
{ value: "16", label: "16" },
|
||||||
{ value: '32', label: '32' }
|
{ value: "18", label: "18" },
|
||||||
]}
|
{ value: "20", label: "20" },
|
||||||
/>
|
{ value: "24", label: "24" },
|
||||||
<Button
|
{ value: "28", label: "28" },
|
||||||
type={isBold ? "primary" : "default"}
|
{ value: "32", label: "32" },
|
||||||
onClick={handleBoldToggle}
|
]}
|
||||||
className='w-1/2'
|
/>
|
||||||
icon={<BoldOutlined />}
|
<Button
|
||||||
>
|
type={isBold ? "primary" : "default"}
|
||||||
|
onClick={handleBoldToggle}
|
||||||
|
className="w-1/2"
|
||||||
|
icon={<BoldOutlined />}>
|
||||||
|
加粗
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
加粗
|
{/* Color Picker */}
|
||||||
</Button>
|
<div className="space-y-3">
|
||||||
</div>
|
<h3 className="text-sm font-medium text-gray-600">
|
||||||
</div>
|
颜色设置
|
||||||
|
</h3>
|
||||||
|
|
||||||
{/* Color Picker */}
|
{/* Font Color Picker */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
<h3 className="text-sm font-medium text-gray-600">颜色设置</h3>
|
<h4 className="text-xs font-medium text-gray-500">
|
||||||
|
文字颜色
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-8 gap-2 p-2 bg-gray-50 rounded-lg">
|
||||||
|
{xmindColorPresets.map((color) => (
|
||||||
|
<div
|
||||||
|
key={`font-${color}`}
|
||||||
|
className={`w-6 h-6 rounded-full cursor-pointer hover:scale-105 transition-transform outline outline-2 outline-offset-1 ${
|
||||||
|
selectedFontColor === color
|
||||||
|
? "outline-blue-500"
|
||||||
|
: "outline-transparent"
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
onClick={() => {
|
||||||
|
handleColorChange("font", color);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Font Color Picker */}
|
{/* Background Color Picker */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="text-xs font-medium text-gray-500">文字颜色</h4>
|
<h4 className="text-xs font-medium text-gray-500">
|
||||||
<div className="grid grid-cols-8 gap-2 p-2 bg-gray-50 rounded-lg">
|
背景颜色
|
||||||
{xmindColorPresets.map((color) => (
|
</h4>
|
||||||
<div
|
<div className="grid grid-cols-8 gap-2 p-2 bg-gray-50 rounded-lg">
|
||||||
key={`font-${color}`}
|
{xmindColorPresets.map((color) => (
|
||||||
className={`w-6 h-6 rounded-full cursor-pointer hover:scale-105 transition-transform outline outline-2 outline-offset-1 ${selectedFontColor === color ? 'outline-blue-500' : 'outline-transparent'
|
<div
|
||||||
}`}
|
key={`bg-${color}`}
|
||||||
style={{ backgroundColor: color }}
|
className={`w-6 h-6 rounded-full cursor-pointer hover:scale-105 transition-transform outline outline-2 outline-offset-1 ${
|
||||||
onClick={() => {
|
selectedBgColor === color
|
||||||
|
? "outline-blue-500"
|
||||||
|
: "outline-transparent"
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
onClick={() => {
|
||||||
|
handleColorChange("background", color);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
handleColorChange('font', color);
|
<div className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||||
}}
|
{urlMode === "URL" ? "关联链接" : "关联课时"}
|
||||||
/>
|
<Button
|
||||||
))}
|
type="text"
|
||||||
</div>
|
className=" hover:bg-gray-400 active:bg-gray-300 rounded-md text-gray-600 border transition-colors"
|
||||||
</div>
|
size="small"
|
||||||
|
icon={<SwapOutlined />}
|
||||||
|
onClick={() =>
|
||||||
|
setUrlMode((prev) =>
|
||||||
|
prev === "POSTURL" ? "URL" : "POSTURL"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Background Color Picker */}
|
<div className="space-y-1">
|
||||||
<div className="space-y-2">
|
{urlMode === "POSTURL" ? (
|
||||||
<h4 className="text-xs font-medium text-gray-500">背景颜色</h4>
|
<PostSelect
|
||||||
<div className="grid grid-cols-8 gap-2 p-2 bg-gray-50 rounded-lg">
|
onChange={(value) => {
|
||||||
{xmindColorPresets.map((color) => (
|
if (typeof value === "string") {
|
||||||
<div
|
setPostId(value);
|
||||||
key={`bg-${color}`}
|
}
|
||||||
className={`w-6 h-6 rounded-full cursor-pointer hover:scale-105 transition-transform outline outline-2 outline-offset-1 ${selectedBgColor === color ? 'outline-blue-500' : 'outline-transparent'
|
}}
|
||||||
}`}
|
params={{
|
||||||
style={{ backgroundColor: color }}
|
where: {
|
||||||
onClick={() => {
|
type: PostType.LECTURE,
|
||||||
handleColorChange('background', color);
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
) : (
|
||||||
</div>
|
<Input
|
||||||
</div>
|
placeholder="例如:https://example.com"
|
||||||
</div>
|
value={url}
|
||||||
|
onChange={handleUrlChange}
|
||||||
|
addonBefore={<LinkOutlined />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<h3 className="text-sm font-medium text-gray-600">关联链接</h3>
|
{urlMode === "URL" &&
|
||||||
{/* URL Input */}
|
url &&
|
||||||
<div className="space-y-1">
|
!/^(https?:\/\/\S+|\/|\.\/|\.\.\/)?\S+$/.test(url) && (
|
||||||
<Input
|
<p className="text-xs text-red-500">
|
||||||
placeholder="例如:https://example.com"
|
请输入有效的URL地址
|
||||||
value={url}
|
</p>
|
||||||
onChange={handleUrlChange}
|
)}
|
||||||
addonBefore={<LinkOutlined />}
|
</div>
|
||||||
/>
|
</div>
|
||||||
{url && !/^https?:\/\/\S+$/.test(url) && (
|
</div>
|
||||||
<p className="text-xs text-red-500">请输入有效的URL地址</p>
|
);
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default NodeMenu;
|
export default NodeMenu;
|
||||||
|
|
|
@ -32,3 +32,23 @@ export const MIND_OPTIONS = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const xmindColorPresets = [
|
||||||
|
// 经典16色
|
||||||
|
"#FFFFFF",
|
||||||
|
"#F5F5F5", // 白色系
|
||||||
|
"#2196F3",
|
||||||
|
"#1976D2", // 蓝色系
|
||||||
|
"#4CAF50",
|
||||||
|
"#388E3C", // 绿色系
|
||||||
|
"#FF9800",
|
||||||
|
"#F57C00", // 橙色系
|
||||||
|
"#F44336",
|
||||||
|
"#D32F2F", // 红色系
|
||||||
|
"#9C27B0",
|
||||||
|
"#7B1FA2", // 紫色系
|
||||||
|
"#424242",
|
||||||
|
"#757575", // 灰色系
|
||||||
|
"#FFEB3B",
|
||||||
|
"#FBC02D", // 黄色系
|
||||||
|
];
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { CourseDetailProvider } from "./CourseDetailContext";
|
import { PostDetailProvider } from "./PostDetailContext";
|
||||||
import CourseDetailLayout from "./CourseDetailLayout";
|
import CourseDetailLayout from "./CourseDetailLayout";
|
||||||
|
|
||||||
export default function CourseDetail({
|
export default function CourseDetail({
|
||||||
|
@ -8,12 +8,11 @@ export default function CourseDetail({
|
||||||
id?: string;
|
id?: string;
|
||||||
lectureId?: string;
|
lectureId?: string;
|
||||||
}) {
|
}) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CourseDetailProvider editId={id}>
|
<PostDetailProvider editId={id}>
|
||||||
<CourseDetailLayout></CourseDetailLayout>
|
<CourseDetailLayout></CourseDetailLayout>
|
||||||
</CourseDetailProvider>
|
</PostDetailProvider>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Course, TaxonomySlug } from "@nice/common";
|
import { Course, CourseDto, TaxonomySlug } from "@nice/common";
|
||||||
import React, { useContext, useEffect, useMemo } from "react";
|
import React, { useContext, useEffect, useMemo } from "react";
|
||||||
import { Image, Typography, Skeleton, Tag } from "antd"; // 引入 antd 组件
|
import { Image, Typography, Skeleton, Tag } from "antd"; // 引入 antd 组件
|
||||||
import { CourseDetailContext } from "./CourseDetailContext";
|
import { CourseDetailContext } from "./PostDetailContext";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useStaff } from "@nice/client";
|
import { useStaff } from "@nice/client";
|
||||||
import { useAuth } from "@web/src/providers/auth-provider";
|
import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
|
@ -10,7 +10,7 @@ import { PictureOutlined } from "@ant-design/icons";
|
||||||
|
|
||||||
export const CourseDetailDescription: React.FC = () => {
|
export const CourseDetailDescription: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
course,
|
post,
|
||||||
canEdit,
|
canEdit,
|
||||||
isLoading,
|
isLoading,
|
||||||
selectedLectureId,
|
selectedLectureId,
|
||||||
|
@ -22,14 +22,14 @@ export const CourseDetailDescription: React.FC = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { update } = useStaff();
|
const { update } = useStaff();
|
||||||
const firstLectureId = useMemo(() => {
|
const firstLectureId = useMemo(() => {
|
||||||
return course?.sections?.[0]?.lectures?.[0]?.id;
|
return (post as CourseDto)?.sections?.[0]?.lectures?.[0]?.id;
|
||||||
}, [course]);
|
}, [post]);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
return (
|
return (
|
||||||
// <div className="w-full bg-white shadow-md rounded-lg border border-gray-200 p-5 my-4">
|
// <div className="w-full bg-white shadow-md rounded-lg border border-gray-200 p-5 my-4">
|
||||||
<div className="w-full px-5 my-2">
|
<div className="w-full px-5 my-2">
|
||||||
{isLoading || !course ? (
|
{isLoading || !post ? (
|
||||||
<Skeleton active paragraph={{ rows: 4 }} />
|
<Skeleton active paragraph={{ rows: 4 }} />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
@ -39,7 +39,7 @@ export const CourseDetailDescription: React.FC = () => {
|
||||||
<div
|
<div
|
||||||
className="w-full rounded-xl aspect-video bg-cover bg-center z-0"
|
className="w-full rounded-xl aspect-video bg-cover bg-center z-0"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${course?.meta?.thumbnail || "/placeholder.webp"})`,
|
backgroundImage: `url(${post?.meta?.thumbnail || "/placeholder.webp"})`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
@ -52,7 +52,7 @@ export const CourseDetailDescription: React.FC = () => {
|
||||||
data: {
|
data: {
|
||||||
learningPosts: {
|
learningPosts: {
|
||||||
connect: {
|
connect: {
|
||||||
id: course.id,
|
id: post.id,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -69,8 +69,8 @@ export const CourseDetailDescription: React.FC = () => {
|
||||||
<div className="text-lg font-bold">{"课程简介:"}</div>
|
<div className="text-lg font-bold">{"课程简介:"}</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex gap-2 flex-wrap items-center float-start">
|
<div className="flex gap-2 flex-wrap items-center float-start">
|
||||||
{course?.subTitle && <div>{course?.subTitle}</div>}
|
{post?.subTitle && <div>{post?.subTitle}</div>}
|
||||||
<TermInfo terms={course.terms}></TermInfo>
|
<TermInfo terms={post.terms}></TermInfo>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Paragraph
|
<Paragraph
|
||||||
|
@ -81,7 +81,7 @@ export const CourseDetailDescription: React.FC = () => {
|
||||||
symbol: "展开",
|
symbol: "展开",
|
||||||
onExpand: () => console.log("展开"),
|
onExpand: () => console.log("展开"),
|
||||||
}}>
|
}}>
|
||||||
{course?.content}
|
{post?.content}
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -4,18 +4,17 @@ import React, { useContext, useRef, useState } from "react";
|
||||||
import { VideoPlayer } from "@web/src/components/presentation/video-player/VideoPlayer";
|
import { VideoPlayer } from "@web/src/components/presentation/video-player/VideoPlayer";
|
||||||
import { CourseDetailDescription } from "./CourseDetailDescription";
|
import { CourseDetailDescription } from "./CourseDetailDescription";
|
||||||
import { Course, LectureType, PostType } from "@nice/common";
|
import { Course, LectureType, PostType } from "@nice/common";
|
||||||
import { CourseDetailContext } from "./CourseDetailContext";
|
import { CourseDetailContext } from "./PostDetailContext";
|
||||||
import CollapsibleContent from "@web/src/components/common/container/CollapsibleContent";
|
import CollapsibleContent from "@web/src/components/common/container/CollapsibleContent";
|
||||||
import { Skeleton } from "antd";
|
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";
|
||||||
|
|
||||||
|
|
||||||
export const CourseDetailDisplayArea: React.FC = () => {
|
export const CourseDetailDisplayArea: React.FC = () => {
|
||||||
// 创建滚动动画效果
|
// 创建滚动动画效果
|
||||||
const {
|
const {
|
||||||
course,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
canEdit,
|
canEdit,
|
||||||
lecture,
|
lecture,
|
||||||
|
|
|
@ -1,97 +0,0 @@
|
||||||
import { useContext, useState } from "react";
|
|
||||||
import { Input, Layout, Avatar, Button, Dropdown } from "antd";
|
|
||||||
import {
|
|
||||||
EditFilled,
|
|
||||||
HomeOutlined,
|
|
||||||
SearchOutlined,
|
|
||||||
UserOutlined,
|
|
||||||
} from "@ant-design/icons";
|
|
||||||
import { useAuth } from "@web/src/providers/auth-provider";
|
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
|
||||||
import { UserMenu } from "@web/src/app/main/layout/UserMenu/UserMenu";
|
|
||||||
import { CourseDetailContext } from "../CourseDetailContext";
|
|
||||||
import { usePost, useStaff } from "@nice/client";
|
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import { NavigationMenu } from "@web/src/app/main/layout/NavigationMenu";
|
|
||||||
|
|
||||||
const { Header } = Layout;
|
|
||||||
|
|
||||||
export function CourseDetailHeader() {
|
|
||||||
const { id } = useParams();
|
|
||||||
const { isAuthenticated, user, hasSomePermissions, hasEveryPermissions } =
|
|
||||||
useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { course, canEdit, userIsLearning } = useContext(CourseDetailContext);
|
|
||||||
const { update } = useStaff();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Header className="select-none flex items-center justify-center bg-white shadow-md border-b border-gray-100 fixed w-full z-30">
|
|
||||||
<div className="w-full max-w-screen-3xl px-4 md:px-6 flex items-center justify-between h-full">
|
|
||||||
<div className="flex items-center space-x-8">
|
|
||||||
<div
|
|
||||||
onClick={() => navigate("/")}
|
|
||||||
className="text-2xl font-bold bg-gradient-to-r from-primary-600 via-primary-500 to-primary-400 bg-clip-text text-transparent hover:scale-105 transition-transform cursor-pointer">
|
|
||||||
烽火慕课
|
|
||||||
</div>
|
|
||||||
<NavigationMenu />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-6">
|
|
||||||
{isAuthenticated && (
|
|
||||||
<Button
|
|
||||||
onClick={async () => {
|
|
||||||
if (!userIsLearning) {
|
|
||||||
await update.mutateAsync({
|
|
||||||
where: { id: user?.id },
|
|
||||||
data: {
|
|
||||||
learningPosts: {
|
|
||||||
connect: { id: course.id },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await update.mutateAsync({
|
|
||||||
where: { id: user?.id },
|
|
||||||
data: {
|
|
||||||
learningPosts: {
|
|
||||||
disconnect: {
|
|
||||||
id: course.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="flex items-center space-x-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-600 hover:to-blue-700 border-none shadow-md hover:shadow-lg transition-all"
|
|
||||||
icon={<EditFilled />}>
|
|
||||||
{userIsLearning ? "退出学习" : "加入学习"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{canEdit && (
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
const url = id
|
|
||||||
? `/course/${id}/editor`
|
|
||||||
: "/course/editor";
|
|
||||||
navigate(url);
|
|
||||||
}}
|
|
||||||
className="flex items-center space-x-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-600 hover:to-blue-700 border-none shadow-md hover:shadow-lg transition-all"
|
|
||||||
icon={<EditFilled />}>
|
|
||||||
{"编辑课程"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{isAuthenticated ? (
|
|
||||||
<UserMenu />
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
onClick={() => navigate("/login")}
|
|
||||||
className="flex items-center space-x-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-600 hover:to-blue-700 border-none shadow-md hover:shadow-lg transition-all"
|
|
||||||
icon={<UserOutlined />}>
|
|
||||||
登录
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Header>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,14 +1,14 @@
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { useContext, useState } from "react";
|
import { useContext, useState } from "react";
|
||||||
import { CourseDetailContext } from "./CourseDetailContext";
|
import { CourseDetailContext } from "./PostDetailContext";
|
||||||
|
|
||||||
import CourseDetailDisplayArea from "./CourseDetailDisplayArea";
|
import CourseDetailDisplayArea from "./CourseDetailDisplayArea";
|
||||||
import { CourseSyllabus } from "./CourseSyllabus/CourseSyllabus";
|
import { CourseSyllabus } from "./CourseSyllabus/CourseSyllabus";
|
||||||
import { CourseDetailHeader } from "./CourseDetailHeader/CourseDetailHeader";
|
import { CourseDto } from "packages/common/dist";
|
||||||
|
|
||||||
export default function CourseDetailLayout() {
|
export default function CourseDetailLayout() {
|
||||||
const {
|
const {
|
||||||
course,
|
post,
|
||||||
|
|
||||||
setSelectedLectureId,
|
setSelectedLectureId,
|
||||||
} = useContext(CourseDetailContext);
|
} = useContext(CourseDetailContext);
|
||||||
|
@ -19,7 +19,6 @@ export default function CourseDetailLayout() {
|
||||||
const [isSyllabusOpen, setIsSyllabusOpen] = useState(true);
|
const [isSyllabusOpen, setIsSyllabusOpen] = useState(true);
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|
||||||
<div className="pt-12 px-32">
|
<div className="pt-12 px-32">
|
||||||
{" "}
|
{" "}
|
||||||
{/* 添加这个包装 div */}
|
{/* 添加这个包装 div */}
|
||||||
|
@ -36,7 +35,7 @@ export default function CourseDetailLayout() {
|
||||||
</motion.div>
|
</motion.div>
|
||||||
{/* 课程大纲侧边栏 */}
|
{/* 课程大纲侧边栏 */}
|
||||||
<CourseSyllabus
|
<CourseSyllabus
|
||||||
sections={course?.sections || []}
|
sections={(post as CourseDto)?.sections || []}
|
||||||
onLectureClick={handleLectureClick}
|
onLectureClick={handleLectureClick}
|
||||||
isOpen={isSyllabusOpen}
|
isOpen={isSyllabusOpen}
|
||||||
onToggle={() => setIsSyllabusOpen(!isSyllabusOpen)}
|
onToggle={() => setIsSyllabusOpen(!isSyllabusOpen)}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { useContext } from "react";
|
import { useContext } from "react";
|
||||||
import { CourseDetailContext } from "./CourseDetailContext";
|
import { CourseDetailContext } from "./PostDetailContext";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
BookOutlined,
|
BookOutlined,
|
||||||
|
@ -9,11 +9,14 @@ import {
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import CourseOperationBtns from "./JoinLearingButton";
|
import CourseOperationBtns from "./CourseOperationBtns/CourseOperationBtns";
|
||||||
|
|
||||||
export default function CourseDetailTitle() {
|
export default function CourseDetailTitle() {
|
||||||
const { course, lecture, selectedLectureId } =
|
const {
|
||||||
useContext(CourseDetailContext);
|
post: course,
|
||||||
|
lecture,
|
||||||
|
selectedLectureId,
|
||||||
|
} = useContext(CourseDetailContext);
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center flex-col items-center gap-2 w-full my-2 px-6">
|
<div className="flex justify-center flex-col items-center gap-2 w-full my-2 px-6">
|
||||||
<div className="flex justify-start w-full text-2xl font-bold">
|
<div className="flex justify-start w-full text-2xl font-bold">
|
||||||
|
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
|
import { useContext, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { CourseDetailContext } from "../PostDetailContext";
|
||||||
|
import { useStaff } from "@nice/client";
|
||||||
|
import {
|
||||||
|
CheckCircleOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
EditTwoTone,
|
||||||
|
LoginOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import JoinButton from "./JoinButton";
|
||||||
|
|
||||||
|
export default function CourseOperationBtns() {
|
||||||
|
// const { isAuthenticated, user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { post, canEdit, userIsLearning, setUserIsLearning } =
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
{/* {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>
|
||||||
|
{canEdit && (
|
||||||
|
<div
|
||||||
|
className="flex gap-1 px-1 py-0.5 text-primary hover:cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
const url = post?.id
|
||||||
|
? `/course/${post?.id}/editor`
|
||||||
|
: "/course/editor";
|
||||||
|
navigate(url);
|
||||||
|
}}>
|
||||||
|
<EditTwoTone></EditTwoTone>
|
||||||
|
{"编辑课程"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,54 +1,47 @@
|
||||||
import { useAuth } from "@web/src/providers/auth-provider";
|
import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
import { useContext, useState } from "react";
|
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
|
||||||
import { CourseDetailContext } from "./CourseDetailContext";
|
|
||||||
import { useStaff } from "@nice/client";
|
import { useStaff } from "@nice/client";
|
||||||
|
import { useContext, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { CourseDetailContext } from "../PostDetailContext";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
import {
|
import {
|
||||||
CheckCircleFilled,
|
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
CloseCircleFilled,
|
|
||||||
CloseCircleOutlined,
|
CloseCircleOutlined,
|
||||||
EditFilled,
|
|
||||||
EditTwoTone,
|
|
||||||
LoginOutlined,
|
LoginOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import toast from "react-hot-toast";
|
|
||||||
|
|
||||||
export default function CourseOperationBtns() {
|
export default function JoinButton() {
|
||||||
const { id } = useParams();
|
const { isAuthenticated, user } = useAuth();
|
||||||
const { isAuthenticated, user, hasSomePermissions, hasEveryPermissions } =
|
|
||||||
useAuth();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { course, canEdit, userIsLearning, setUserIsLearning} = useContext(CourseDetailContext);
|
const { post, canEdit, userIsLearning, setUserIsLearning } =
|
||||||
|
useContext(CourseDetailContext);
|
||||||
const { update } = useStaff();
|
const { update } = useStaff();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
const toggleLearning = async () => {
|
const toggleLearning = async () => {
|
||||||
if (!userIsLearning) {
|
if (!userIsLearning) {
|
||||||
await update.mutateAsync({
|
await update.mutateAsync({
|
||||||
where: { id: user?.id },
|
where: { id: user?.id },
|
||||||
data: {
|
data: {
|
||||||
learningPosts: {
|
learningPosts: {
|
||||||
connect: { id: course.id },
|
connect: { id: post.id },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
setUserIsLearning(true)
|
setUserIsLearning(true);
|
||||||
toast.success("加入学习成功");
|
toast.success("加入学习成功");
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
await update.mutateAsync({
|
await update.mutateAsync({
|
||||||
where: { id: user?.id },
|
where: { id: user?.id },
|
||||||
data: {
|
data: {
|
||||||
learningPosts: {
|
learningPosts: {
|
||||||
disconnect: {
|
disconnect: {
|
||||||
id: course.id,
|
id: post.id,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
toast.success("退出学习成功");
|
toast.success("退出学习成功");
|
||||||
setUserIsLearning(false)
|
setUserIsLearning(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
|
@ -83,19 +76,6 @@ export default function CourseOperationBtns() {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{canEdit && (
|
|
||||||
<div
|
|
||||||
className="flex gap-1 px-1 py-0.5 text-primary hover:cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
const url = course?.id
|
|
||||||
? `/course/${course?.id}/editor`
|
|
||||||
: "/course/editor";
|
|
||||||
navigate(url);
|
|
||||||
}}>
|
|
||||||
<EditTwoTone></EditTwoTone>
|
|
||||||
{"编辑课程"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -3,16 +3,16 @@ import { CoursePreviewMsg } from "@web/src/app/main/course/preview/type.ts";
|
||||||
import { Button, Tabs, Image, Skeleton } from "antd";
|
import { Button, Tabs, Image, Skeleton } from "antd";
|
||||||
import type { TabsProps } from "antd";
|
import type { TabsProps } from "antd";
|
||||||
import { PlayCircleOutlined } from "@ant-design/icons";
|
import { PlayCircleOutlined } from "@ant-design/icons";
|
||||||
import { CourseDetailContext } from "../CourseDetailContext";
|
import { CourseDetailContext } from "../PostDetailContext";
|
||||||
export function CoursePreview() {
|
export function CoursePreview() {
|
||||||
const { course, isLoading, lecture, lectureIsLoading, selectedLectureId } =
|
const { post, isLoading, lecture, lectureIsLoading, selectedLectureId } =
|
||||||
useContext(CourseDetailContext);
|
useContext(CourseDetailContext);
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen max-w-7xl mx-auto px-6 lg:px-8">
|
<div className="min-h-screen max-w-7xl mx-auto px-6 lg:px-8">
|
||||||
<div className="overflow-auto flex justify-around align-items-center w-full mx-auto my-8">
|
<div className="overflow-auto flex justify-around align-items-center w-full mx-auto my-8">
|
||||||
<div className="relative w-[600px] h-[340px] m-4 overflow-hidden flex justify-center items-center">
|
<div className="relative w-[600px] h-[340px] m-4 overflow-hidden flex justify-center items-center">
|
||||||
<Image
|
<Image
|
||||||
src={isLoading ? "error" : course?.meta?.thumbnail}
|
src={isLoading ? "error" : post?.meta?.thumbnail}
|
||||||
alt="example"
|
alt="example"
|
||||||
preview={false}
|
preview={false}
|
||||||
className="w-full h-full object-cover z-0"
|
className="w-full h-full object-cover z-0"
|
||||||
|
@ -28,13 +28,13 @@ export function CoursePreview() {
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="text-3xl font-bold my-3 ">
|
<span className="text-3xl font-bold my-3 ">
|
||||||
{course.title}
|
{post.title}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xl font-semibold my-3 text-gray-700">
|
<span className="text-xl font-semibold my-3 text-gray-700">
|
||||||
{course.subTitle}
|
{post.subTitle}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-lg font-light my-3 text-gray-500 text-clip">
|
<span className="text-lg font-light my-3 text-gray-500 text-clip">
|
||||||
{course.content}
|
{post.content}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { SectionDto, TaxonomySlug } from "@nice/common";
|
||||||
import { SyllabusHeader } from "./SyllabusHeader";
|
import { SyllabusHeader } from "./SyllabusHeader";
|
||||||
import { SectionItem } from "./SectionItem";
|
import { SectionItem } from "./SectionItem";
|
||||||
import { CollapsedButton } from "./CollapsedButton";
|
import { CollapsedButton } from "./CollapsedButton";
|
||||||
import { CourseDetailContext } from "../CourseDetailContext";
|
import { CourseDetailContext } from "../PostDetailContext";
|
||||||
import { api } from "@nice/client";
|
import { api } from "@nice/client";
|
||||||
|
|
||||||
interface CourseSyllabusProps {
|
interface CourseSyllabusProps {
|
||||||
|
|
|
@ -4,6 +4,7 @@ import {
|
||||||
CourseDto,
|
CourseDto,
|
||||||
Lecture,
|
Lecture,
|
||||||
lectureDetailSelect,
|
lectureDetailSelect,
|
||||||
|
PostDto,
|
||||||
RolePerms,
|
RolePerms,
|
||||||
VisitType,
|
VisitType,
|
||||||
} from "@nice/common";
|
} from "@nice/common";
|
||||||
|
@ -19,7 +20,7 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
interface CourseDetailContextType {
|
interface CourseDetailContextType {
|
||||||
editId?: string; // 添加 editId
|
editId?: string; // 添加 editId
|
||||||
course?: CourseDto;
|
post?: PostDto;
|
||||||
lecture?: Lecture;
|
lecture?: Lecture;
|
||||||
selectedLectureId?: string | undefined;
|
selectedLectureId?: string | undefined;
|
||||||
setSelectedLectureId?: React.Dispatch<React.SetStateAction<string>>;
|
setSelectedLectureId?: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
@ -29,7 +30,7 @@ interface CourseDetailContextType {
|
||||||
setIsHeaderVisible: (visible: boolean) => void; // 新增
|
setIsHeaderVisible: (visible: boolean) => void; // 新增
|
||||||
canEdit?: boolean;
|
canEdit?: boolean;
|
||||||
userIsLearning?: boolean;
|
userIsLearning?: boolean;
|
||||||
setUserIsLearning:(learning: boolean) => void;
|
setUserIsLearning: (learning: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CourseFormProviderProps {
|
interface CourseFormProviderProps {
|
||||||
|
@ -39,7 +40,7 @@ interface CourseFormProviderProps {
|
||||||
|
|
||||||
export const CourseDetailContext =
|
export const CourseDetailContext =
|
||||||
createContext<CourseDetailContextType | null>(null);
|
createContext<CourseDetailContextType | null>(null);
|
||||||
export function CourseDetailProvider({
|
export function PostDetailProvider({
|
||||||
children,
|
children,
|
||||||
editId,
|
editId,
|
||||||
}: CourseFormProviderProps) {
|
}: CourseFormProviderProps) {
|
||||||
|
@ -48,28 +49,24 @@ export function CourseDetailProvider({
|
||||||
const { user, hasSomePermissions, isAuthenticated } = useAuth();
|
const { user, hasSomePermissions, isAuthenticated } = useAuth();
|
||||||
const { lectureId } = useParams();
|
const { lectureId } = useParams();
|
||||||
|
|
||||||
const { data: course, isLoading }: { data: CourseDto; isLoading: boolean } =
|
const { data: post, isLoading }: { data: PostDto; isLoading: boolean } = (
|
||||||
(api.post as any).findFirst.useQuery(
|
api.post as any
|
||||||
{
|
).findFirst.useQuery(
|
||||||
where: { id: editId },
|
{
|
||||||
select: courseDetailSelect,
|
where: { id: editId },
|
||||||
},
|
select: courseDetailSelect,
|
||||||
{ enabled: Boolean(editId) }
|
},
|
||||||
);
|
{ enabled: Boolean(editId) }
|
||||||
|
);
|
||||||
// const userIsLearning = useMemo(() => {
|
|
||||||
// return (course?.studentIds || []).includes(user?.id);
|
|
||||||
// }, [user, course, isLoading]);
|
|
||||||
const [userIsLearning, setUserIsLearning] = useState(false);
|
const [userIsLearning, setUserIsLearning] = useState(false);
|
||||||
useEffect(()=>{
|
useEffect(() => {
|
||||||
console.log(course?.studentIds,user?.id)
|
setUserIsLearning((post?.studentIds || []).includes(user?.id));
|
||||||
setUserIsLearning((course?.studentIds || []).includes(user?.id));
|
}, [user, post, isLoading]);
|
||||||
},[user, course, isLoading])
|
|
||||||
const canEdit = useMemo(() => {
|
const canEdit = useMemo(() => {
|
||||||
const isAuthor = isAuthenticated && user?.id === course?.authorId;
|
const isAuthor = isAuthenticated && user?.id === post?.authorId;
|
||||||
const isRoot = hasSomePermissions(RolePerms?.MANAGE_ANY_POST);
|
const isRoot = hasSomePermissions(RolePerms?.MANAGE_ANY_POST);
|
||||||
return isAuthor || isRoot;
|
return isAuthor || isRoot;
|
||||||
}, [user, course]);
|
}, [user, post]);
|
||||||
|
|
||||||
const [selectedLectureId, setSelectedLectureId] = useState<
|
const [selectedLectureId, setSelectedLectureId] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
|
@ -86,8 +83,6 @@ export function CourseDetailProvider({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lectureId) {
|
if (lectureId) {
|
||||||
console.log(123);
|
|
||||||
console.log(lectureId);
|
|
||||||
read.mutateAsync({
|
read.mutateAsync({
|
||||||
data: {
|
data: {
|
||||||
visitorId: user?.id || null,
|
visitorId: user?.id || null,
|
||||||
|
@ -96,8 +91,6 @@ export function CourseDetailProvider({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.log(321);
|
|
||||||
console.log(editId);
|
|
||||||
read.mutateAsync({
|
read.mutateAsync({
|
||||||
data: {
|
data: {
|
||||||
visitorId: user?.id || null,
|
visitorId: user?.id || null,
|
||||||
|
@ -117,7 +110,7 @@ export function CourseDetailProvider({
|
||||||
<CourseDetailContext.Provider
|
<CourseDetailContext.Provider
|
||||||
value={{
|
value={{
|
||||||
editId,
|
editId,
|
||||||
course,
|
post,
|
||||||
lecture,
|
lecture,
|
||||||
selectedLectureId,
|
selectedLectureId,
|
||||||
setSelectedLectureId,
|
setSelectedLectureId,
|
||||||
|
@ -127,7 +120,7 @@ export function CourseDetailProvider({
|
||||||
setIsHeaderVisible,
|
setIsHeaderVisible,
|
||||||
canEdit,
|
canEdit,
|
||||||
userIsLearning,
|
userIsLearning,
|
||||||
setUserIsLearning
|
setUserIsLearning,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</CourseDetailContext.Provider>
|
</CourseDetailContext.Provider>
|
|
@ -1,15 +1,110 @@
|
||||||
import { api } from "@nice/client";
|
import { api } from "@nice/client";
|
||||||
import { Select } from "antd";
|
import { Button, Select } from "antd";
|
||||||
import { useState } from "react";
|
import {
|
||||||
|
Lecture,
|
||||||
|
lectureDetailSelect,
|
||||||
|
postDetailSelect,
|
||||||
|
postUnDetailSelect,
|
||||||
|
Prisma,
|
||||||
|
} from "@nice/common";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import PostSelectOption from "./PostSelectOption";
|
||||||
|
import { DefaultArgs } from "@prisma/client/runtime/library";
|
||||||
|
import { safeOR } from "@nice/utils";
|
||||||
|
|
||||||
export default function PostSelect() {
|
export default function PostSelect({
|
||||||
api.post.findMany.useQuery({});
|
value,
|
||||||
const [search, setSearch] = useState("");
|
onChange,
|
||||||
|
placeholder = "请选择课时",
|
||||||
|
params = { where: {}, select: {} },
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
value?: string | string[];
|
||||||
|
onChange?: (value: string | string[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
params?: {
|
||||||
|
where?: Prisma.PostWhereInput;
|
||||||
|
select?: Prisma.PostSelect<DefaultArgs>;
|
||||||
|
};
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const [searchValue, setSearch] = useState("");
|
||||||
|
const searchCondition: Prisma.PostWhereInput = useMemo(() => {
|
||||||
|
const containTextCondition: Prisma.StringNullableFilter = {
|
||||||
|
contains: searchValue,
|
||||||
|
mode: "insensitive" as Prisma.QueryMode, // 使用类型断言
|
||||||
|
};
|
||||||
|
return searchValue
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ title: containTextCondition },
|
||||||
|
|
||||||
|
{ content: containTextCondition },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
}, [searchValue]);
|
||||||
|
// 核心条件生成逻辑
|
||||||
|
const idCondition: Prisma.PostWhereInput = useMemo(() => {
|
||||||
|
if (value === undefined) return {}; // 无值时返回空对象
|
||||||
|
// 字符串类型增强判断
|
||||||
|
if (typeof value === "string") {
|
||||||
|
// 如果明确需要支持逗号分隔字符串
|
||||||
|
|
||||||
|
return { id: value };
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length > 0 ? { id: { in: value } } : {}; // 空数组不注入条件
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}, [value]);
|
||||||
|
const {
|
||||||
|
data: lectures,
|
||||||
|
isLoading,
|
||||||
|
}: { data: Lecture[]; isLoading: boolean } = api.post.findMany.useQuery({
|
||||||
|
where: safeOR([
|
||||||
|
{ ...idCondition },
|
||||||
|
|
||||||
|
{ ...searchCondition, ...(params?.where || {}) },
|
||||||
|
]),
|
||||||
|
select: { ...postDetailSelect, ...(params?.select || {}) },
|
||||||
|
take: 15,
|
||||||
|
});
|
||||||
|
const options = useMemo(() => {
|
||||||
|
return (lectures || []).map((lecture, index) => {
|
||||||
|
return {
|
||||||
|
value: lecture.id,
|
||||||
|
label: <PostSelectOption post={lecture}></PostSelectOption>,
|
||||||
|
tag: lecture?.title,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [lectures, isLoading]);
|
||||||
|
const tagRender = (props) => {
|
||||||
|
// 根据 value 找到对应的 option
|
||||||
|
const option = options.find((opt) => opt.value === props.value);
|
||||||
|
// 使用自定义的展示内容(这里假设你的 option 中有 customDisplay 字段)
|
||||||
|
return <span style={{ marginRight: 3 }}>{option?.tag}</span>;
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<div
|
||||||
|
style={{
|
||||||
|
width: 200,
|
||||||
|
}}>
|
||||||
<Select
|
<Select
|
||||||
options={[{ value: "id1", label: <></> }]}
|
showSearch
|
||||||
|
value={value}
|
||||||
|
dropdownStyle={{
|
||||||
|
minWidth: 200, // 设置合适的最小宽度
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={onChange}
|
||||||
|
filterOption={false}
|
||||||
|
loading={isLoading}
|
||||||
|
className={`flex-1 w-full ${className}`}
|
||||||
|
options={options}
|
||||||
|
tagRender={tagRender}
|
||||||
|
optionLabelProp="tag" // 新增这个属性 ✅
|
||||||
onSearch={(inputValue) => setSearch(inputValue)}></Select>
|
onSearch={(inputValue) => setSearch(inputValue)}></Select>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { Lecture, LessonTypeLabel } from "@nice/common";
|
||||||
|
|
||||||
|
// 修改 PostSelectOption 组件
|
||||||
|
export default function PostSelectOption({ post }: { post: Lecture }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{" "}
|
||||||
|
{/* 添加 min-w-0 */}
|
||||||
|
<img
|
||||||
|
src={post?.meta?.thumbnail || "/placeholder.webp"}
|
||||||
|
className="w-8 h-8 object-cover rounded flex-shrink-0" // 添加 flex-shrink-0
|
||||||
|
alt="课程封面"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col min-w-0 flex-1">
|
||||||
|
{" "}
|
||||||
|
{/* 修改这里 */}
|
||||||
|
{post?.meta?.type && (
|
||||||
|
<span className="text-sm text-gray-500 truncate">
|
||||||
|
{LessonTypeLabel[post?.meta?.type]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="font-medium truncate">{post?.title}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,46 @@
|
||||||
|
type PrismaCondition = Record<string, any>;
|
||||||
|
type SafeOROptions = {
|
||||||
|
/**
|
||||||
|
* 当所有条件为空时的处理方式
|
||||||
|
* @default 'return-undefined' 返回 undefined (等效查询所有)
|
||||||
|
* 'throw-error' 抛出错误
|
||||||
|
* 'return-empty' 返回空对象
|
||||||
|
*/
|
||||||
|
emptyBehavior?: "return-undefined" | "throw-error" | "return-empty";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全合并多个查询条件为 OR 关系
|
||||||
|
* @param conditions 多个查询条件
|
||||||
|
* @param options 配置选项
|
||||||
|
* @returns 安全的 Prisma WHERE 条件
|
||||||
|
*/
|
||||||
|
const safeOR = (
|
||||||
|
conditions: PrismaCondition[],
|
||||||
|
options?: SafeOROptions
|
||||||
|
): PrismaCondition | undefined => {
|
||||||
|
const { emptyBehavior = "return-undefined" } = options || {};
|
||||||
|
|
||||||
|
// 过滤空条件和无效值
|
||||||
|
const validConditions = conditions.filter(
|
||||||
|
(cond) => cond && Object.keys(cond).length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理全空情况
|
||||||
|
if (validConditions.length === 0) {
|
||||||
|
switch (emptyBehavior) {
|
||||||
|
case "throw-error":
|
||||||
|
throw new Error("No valid conditions provided to OR query");
|
||||||
|
case "return-empty":
|
||||||
|
return {};
|
||||||
|
case "return-undefined":
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优化单条件查询
|
||||||
|
return validConditions.length === 1
|
||||||
|
? validConditions[0]
|
||||||
|
: { OR: validConditions };
|
||||||
|
};
|
|
@ -4,6 +4,7 @@ export const env: {
|
||||||
VERSION: string;
|
VERSION: string;
|
||||||
FILE_PORT: string;
|
FILE_PORT: string;
|
||||||
SERVER_PORT: string;
|
SERVER_PORT: string;
|
||||||
|
WEB_PORT: string;
|
||||||
} = {
|
} = {
|
||||||
APP_NAME: import.meta.env.PROD
|
APP_NAME: import.meta.env.PROD
|
||||||
? (window as any).env.VITE_APP_APP_NAME
|
? (window as any).env.VITE_APP_APP_NAME
|
||||||
|
@ -14,6 +15,9 @@ export const env: {
|
||||||
FILE_PORT: import.meta.env.PROD
|
FILE_PORT: import.meta.env.PROD
|
||||||
? (window as any).env.VITE_APP_FILE_PORT
|
? (window as any).env.VITE_APP_FILE_PORT
|
||||||
: import.meta.env.VITE_APP_FILE_PORT,
|
: import.meta.env.VITE_APP_FILE_PORT,
|
||||||
|
WEB_PORT: import.meta.env.PROD
|
||||||
|
? (window as any).env.VITE_APP_WEB_PORT
|
||||||
|
: import.meta.env.VITE_APP_WEB_PORT,
|
||||||
SERVER_PORT: import.meta.env.PROD
|
SERVER_PORT: import.meta.env.PROD
|
||||||
? (window as any).env.VITE_APP_SERVER_PORT
|
? (window as any).env.VITE_APP_SERVER_PORT
|
||||||
: import.meta.env.VITE_APP_SERVER_PORT,
|
: import.meta.env.VITE_APP_SERVER_PORT,
|
||||||
|
|
|
@ -45,7 +45,6 @@ export type PostDto = Post & {
|
||||||
watchableStaffs: Staff[];
|
watchableStaffs: Staff[];
|
||||||
terms: TermDto[];
|
terms: TermDto[];
|
||||||
depts: DepartmentDto[];
|
depts: DepartmentDto[];
|
||||||
|
|
||||||
studentIds?: string[];
|
studentIds?: string[];
|
||||||
};
|
};
|
||||||
export type PostMeta = {
|
export type PostMeta = {
|
||||||
|
@ -64,6 +63,7 @@ export type LectureMeta = PostMeta & {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Lecture = Post & {
|
export type Lecture = Post & {
|
||||||
|
courseId?: string;
|
||||||
resources?: ResourceDto[];
|
resources?: ResourceDto[];
|
||||||
meta?: LectureMeta;
|
meta?: LectureMeta;
|
||||||
};
|
};
|
||||||
|
@ -72,6 +72,7 @@ export type SectionMeta = PostMeta & {
|
||||||
objectives?: string[];
|
objectives?: string[];
|
||||||
};
|
};
|
||||||
export type Section = Post & {
|
export type Section = Post & {
|
||||||
|
courseId?: string;
|
||||||
meta?: SectionMeta;
|
meta?: SectionMeta;
|
||||||
};
|
};
|
||||||
export type SectionDto = Section & {
|
export type SectionDto = Section & {
|
||||||
|
|
|
@ -39,3 +39,4 @@ export const getCompressedImageUrl = (originalUrl: string): string => {
|
||||||
return `${cleanUrl.slice(0, lastSlashIndex)}/compressed/${cleanUrl.slice(lastSlashIndex + 1).replace(/\.[^.]+$/, ".webp")}`;
|
return `${cleanUrl.slice(0, lastSlashIndex)}/compressed/${cleanUrl.slice(lastSlashIndex + 1).replace(/\.[^.]+$/, ".webp")}`;
|
||||||
};
|
};
|
||||||
export * from "./type-utils";
|
export * from "./type-utils";
|
||||||
|
export * from "./safePrismaQuery";
|
||||||
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
type PrismaCondition = Record<string, any>;
|
||||||
|
type SafeOROptions = {
|
||||||
|
/**
|
||||||
|
* 当所有条件为空时的处理方式
|
||||||
|
* @default 'return-undefined' 返回 undefined (等效查询所有)
|
||||||
|
* 'throw-error' 抛出错误
|
||||||
|
* 'return-empty' 返回空对象
|
||||||
|
*/
|
||||||
|
emptyBehavior?: "return-undefined" | "throw-error" | "return-empty";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全合并多个查询条件为 OR 关系
|
||||||
|
* @param conditions 多个查询条件
|
||||||
|
* @param options 配置选项
|
||||||
|
* @returns 安全的 Prisma WHERE 条件
|
||||||
|
*/
|
||||||
|
export const safeOR = (
|
||||||
|
conditions: PrismaCondition[],
|
||||||
|
options?: SafeOROptions
|
||||||
|
): PrismaCondition | undefined => {
|
||||||
|
const { emptyBehavior = "return-undefined" } = options || {};
|
||||||
|
|
||||||
|
// 过滤空条件和无效值
|
||||||
|
const validConditions = conditions.filter(
|
||||||
|
(cond) => cond && Object.keys(cond).length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理全空情况
|
||||||
|
if (validConditions.length === 0) {
|
||||||
|
switch (emptyBehavior) {
|
||||||
|
case "throw-error":
|
||||||
|
throw new Error("No valid conditions provided to OR query");
|
||||||
|
case "return-empty":
|
||||||
|
return {};
|
||||||
|
case "return-undefined":
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优化单条件查询
|
||||||
|
return validConditions.length === 1
|
||||||
|
? validConditions[0]
|
||||||
|
: { OR: validConditions };
|
||||||
|
};
|
Loading…
Reference in New Issue