add
This commit is contained in:
parent
c6a40da7c2
commit
93c2151af2
|
@ -15,7 +15,7 @@ import {
|
|||
import { MessageService } from '../message/message.service';
|
||||
import { BaseService } from '../base/base.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 { BaseTreeService } from '../base/base.tree.service';
|
||||
import { z } from 'zod';
|
||||
|
@ -155,7 +155,7 @@ export class PostService extends BaseTreeService<Prisma.PostDelegate> {
|
|||
if (result) {
|
||||
await setPostRelation({ data: result, staff });
|
||||
await this.setPerms(result, staff);
|
||||
await setCourseInfo({ data: result });
|
||||
await setPostInfo({ data: result });
|
||||
}
|
||||
// console.log(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
|
||||
if (data?.type === PostType.COURSE) {
|
||||
const ancestries = await db.postAncestry.findMany({
|
||||
|
@ -169,6 +169,23 @@ export async function setCourseInfo({ data }: { data: Post }) {
|
|||
) as any as Lecture[];
|
||||
});
|
||||
|
||||
Object.assign(data, { sections, lectureCount });
|
||||
}
|
||||
if (data?.type === PostType.LECTURE || data?.type === PostType.SECTION) {
|
||||
const ancestry = await db.postAncestry.findFirst({
|
||||
where: {
|
||||
descendantId: data?.id,
|
||||
ancestor: {
|
||||
type: PostType.COURSE,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
ancestor: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
const courseId = ancestry.ancestor.id;
|
||||
Object.assign(data, { courseId });
|
||||
}
|
||||
const students = await db.staff.findMany({
|
||||
where: {
|
||||
learningPosts: {
|
||||
|
@ -183,6 +200,5 @@ export async function setCourseInfo({ data }: { data: Post }) {
|
|||
});
|
||||
|
||||
const studentIds = (students || []).map((student) => student?.id);
|
||||
Object.assign(data, { sections, lectureCount, studentIds });
|
||||
}
|
||||
Object.assign(data, { studentIds });
|
||||
}
|
||||
|
|
|
@ -81,7 +81,7 @@ export function MainProvider({ children }: MainProviderProps) {
|
|||
],
|
||||
}
|
||||
: {};
|
||||
}, [searchValue, debouncedValue]);
|
||||
}, [debouncedValue]);
|
||||
return (
|
||||
<MainContext.Provider
|
||||
value={{
|
||||
|
|
|
@ -4,7 +4,7 @@ import { useMainContext } from "../../layout/MainProvider";
|
|||
import { PostType } from "@nice/common";
|
||||
import PathCard from "@web/src/components/models/post/SubPost/PathCard";
|
||||
|
||||
export default function MyLearningListContainer() {
|
||||
export default function MyDutyPathContainer() {
|
||||
const { user } = useAuth();
|
||||
const { searchCondition, termsCondition } = useMainContext();
|
||||
return (
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
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";
|
||||
|
||||
export default function PathEditorPage() {
|
||||
const { id } = useParams();
|
||||
|
||||
return <div className="">
|
||||
<MindEditor id={id}></MindEditor>
|
||||
</div>
|
||||
return (
|
||||
<PostDetailProvider editId={id}>
|
||||
<MindEditor id={id}></MindEditor>;
|
||||
</PostDetailProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -3,12 +3,15 @@ import BasePostLayout from "../layout/BasePost/BasePostLayout";
|
|||
import { useMainContext } from "../layout/MainProvider";
|
||||
import PathListContainer from "./components/PathListContainer";
|
||||
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() {
|
||||
const { setSearchMode } = useMainContext();
|
||||
useEffect(() => {
|
||||
setSearchMode(PostType.PATH);
|
||||
}, [setSearchMode]);
|
||||
const { id } = useParams();
|
||||
return (
|
||||
<BasePostLayout>
|
||||
<PathListContainer></PathListContainer>
|
||||
|
|
|
@ -12,7 +12,7 @@ import {
|
|||
} from "@nice/common";
|
||||
import TermSelect from "../../models/term/term-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 { MindElixirInstance } 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 { MIND_OPTIONS } from "./constant";
|
||||
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 }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
post,
|
||||
isLoading,
|
||||
// userIsLearning,
|
||||
// setUserIsLearning,
|
||||
} = useContext(CourseDetailContext);
|
||||
const [instance, setInstance] = useState<MindElixirInstance | null>(null);
|
||||
const { isAuthenticated, user, hasSomePermissions } = useAuth();
|
||||
const { read } = useVisitor();
|
||||
const { data: post, isLoading }: { data: PathDto; isLoading: boolean } =
|
||||
api.post.findFirst.useQuery(
|
||||
{
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: postDetailSelect,
|
||||
},
|
||||
{ enabled: Boolean(id) }
|
||||
);
|
||||
// const { data: post, isLoading }: { data: PathDto; isLoading: boolean } =
|
||||
// api.post.findFirst.useQuery(
|
||||
// {
|
||||
// where: {
|
||||
// id,
|
||||
// },
|
||||
// select: postDetailSelect,
|
||||
// },
|
||||
// { enabled: Boolean(id) }
|
||||
// );
|
||||
const canEdit: boolean = useMemo(() => {
|
||||
const isAuth = isAuthenticated && user?.id === post?.author?.id;
|
||||
return !!id || isAuth || hasSomePermissions(RolePerms.MANAGE_ANY_POST);
|
||||
|
@ -97,8 +105,8 @@ export default function MindEditor({ id }: { id?: string }) {
|
|||
if ((!id || post) && instance) {
|
||||
containerRef.current.hidden = false;
|
||||
instance.toCenter();
|
||||
if (post?.meta?.nodeData) {
|
||||
instance.refresh(post?.meta);
|
||||
if ((post as any as PathDto)?.meta?.nodeData) {
|
||||
instance.refresh((post as any as PathDto)?.meta);
|
||||
}
|
||||
}
|
||||
}, [id, post, instance]);
|
||||
|
@ -201,7 +209,9 @@ export default function MindEditor({ id }: { id?: string }) {
|
|||
multiple
|
||||
/>
|
||||
</Form.Item>
|
||||
<JoinButton></JoinButton>
|
||||
</div>
|
||||
<div>
|
||||
{canEdit && (
|
||||
<Button
|
||||
ghost
|
||||
|
@ -213,6 +223,7 @@ export default function MindEditor({ id }: { id?: string }) {
|
|||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
<div
|
||||
|
|
|
@ -1,23 +1,18 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Input, Button, ColorPicker, Select } from 'antd';
|
||||
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { Input, Button, ColorPicker, Select } from "antd";
|
||||
import {
|
||||
FontSizeOutlined,
|
||||
BoldOutlined,
|
||||
LinkOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { MindElixirInstance, NodeObj } from 'mind-elixir';
|
||||
|
||||
const xmindColorPresets = [
|
||||
// 经典16色
|
||||
'#FFFFFF', '#F5F5F5', // 白色系
|
||||
'#2196F3', '#1976D2', // 蓝色系
|
||||
'#4CAF50', '#388E3C', // 绿色系
|
||||
'#FF9800', '#F57C00', // 橙色系
|
||||
'#F44336', '#D32F2F', // 红色系
|
||||
'#9C27B0', '#7B1FA2', // 紫色系
|
||||
'#424242', '#757575', // 灰色系
|
||||
'#FFEB3B', '#FBC02D' // 黄色系
|
||||
];
|
||||
GlobalOutlined,
|
||||
SwapOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { MindElixirInstance, NodeObj } from "mind-elixir";
|
||||
import PostSelect from "../../models/post/PostSelect/PostSelect";
|
||||
import { Lecture, PostType } from "@nice/common";
|
||||
import { xmindColorPresets } from "./constant";
|
||||
import { api } from "@nice/client";
|
||||
import { env } from "@web/src/env";
|
||||
|
||||
interface NodeMenuProps {
|
||||
mind: MindElixirInstance;
|
||||
|
@ -25,45 +20,61 @@ interface NodeMenuProps {
|
|||
|
||||
const NodeMenu: React.FC<NodeMenuProps> = ({ mind }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedFontColor, setSelectedFontColor] = useState<string>('');
|
||||
const [selectedBgColor, setSelectedBgColor] = useState<string>('');
|
||||
const [selectedSize, setSelectedSize] = useState<string>('');
|
||||
const [selectedFontColor, setSelectedFontColor] = useState<string>("");
|
||||
const [selectedBgColor, setSelectedBgColor] = useState<string>("");
|
||||
const [selectedSize, setSelectedSize] = useState<string>("");
|
||||
const [isBold, setIsBold] = useState(false);
|
||||
const [url, setUrl] = useState<string>('');
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [urlMode, setUrlMode] = useState<"URL" | "POSTURL">("POSTURL");
|
||||
const [url, setUrl] = useState<string>("");
|
||||
const [postId, setPostId] = useState<string>("");
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const { data: lecture, isLoading }: { data: Lecture; isLoading: boolean } =
|
||||
api.post.findFirst.useQuery(
|
||||
{
|
||||
where: { id: postId },
|
||||
},
|
||||
{ enabled: !!postId }
|
||||
);
|
||||
useEffect(() => {
|
||||
if (urlMode === "POSTURL")
|
||||
setUrl(`/course/${lecture?.courseId}/detail/${lecture?.id}`);
|
||||
|
||||
mind.reshapeNode(mind.currentNode, {
|
||||
hyperLink: `/course/${lecture?.courseId}/detail/${lecture?.id}`,
|
||||
});
|
||||
}, [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 || '');
|
||||
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.bus.addListener("selectNode", handleSelectNode);
|
||||
mind.bus.addListener("unselectNode", handleUnselectNode);
|
||||
}, [mind]);
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current && mind.container) {
|
||||
mind.container.appendChild(containerRef.current);
|
||||
}
|
||||
|
||||
}, [mind.container]);
|
||||
|
||||
const handleColorChange = (type: "font" | "background", color: string) => {
|
||||
if (type === 'font') {
|
||||
if (type === "font") {
|
||||
setSelectedFontColor(color);
|
||||
} else {
|
||||
setSelectedBgColor(color);
|
||||
}
|
||||
const patch = { style: {} as any };
|
||||
if (type === 'font') {
|
||||
if (type === "font") {
|
||||
patch.style.color = color;
|
||||
} else {
|
||||
patch.style.background = color;
|
||||
|
@ -77,7 +88,7 @@ const NodeMenu: React.FC<NodeMenuProps> = ({ mind }) => {
|
|||
};
|
||||
|
||||
const handleBoldToggle = () => {
|
||||
const fontWeight = isBold ? '' : 'bold';
|
||||
const fontWeight = isBold ? "" : "bold";
|
||||
setIsBold(!isBold);
|
||||
mind.reshapeNode(mind.currentNode, { style: { fontWeight } });
|
||||
};
|
||||
|
@ -85,43 +96,47 @@ const NodeMenu: React.FC<NodeMenuProps> = ({ mind }) => {
|
|||
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setUrl(value);
|
||||
mind.reshapeNode(mind.currentNode, { hyperLink: value });
|
||||
mind.reshapeNode(mind.currentNode, {
|
||||
hyperLink: value,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<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
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 translate-y-4 pointer-events-none"
|
||||
}`}
|
||||
ref={containerRef}
|
||||
>
|
||||
ref={containerRef}>
|
||||
<div className="p-5 space-y-6">
|
||||
{/* Font Size Selector */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-gray-600">文字样式</h3>
|
||||
<h3 className="text-sm font-medium text-gray-600">
|
||||
文字样式
|
||||
</h3>
|
||||
<div className="flex gap-3 items-center justify-between">
|
||||
<Select
|
||||
value={selectedSize}
|
||||
onChange={handleSizeChange}
|
||||
prefix={<FontSizeOutlined className='mr-2' />}
|
||||
prefix={<FontSizeOutlined className="mr-2" />}
|
||||
className="w-1/2"
|
||||
options={[
|
||||
{ value: '12', label: '12' },
|
||||
{ value: '14', label: '14' },
|
||||
{ value: '16', label: '16' },
|
||||
{ value: '18', label: '18' },
|
||||
{ value: '20', label: '20' },
|
||||
{ value: '24', label: '24' },
|
||||
{ value: '28', label: '28' },
|
||||
{ value: '32', label: '32' }
|
||||
{ value: "12", label: "12" },
|
||||
{ value: "14", label: "14" },
|
||||
{ value: "16", label: "16" },
|
||||
{ value: "18", label: "18" },
|
||||
{ value: "20", label: "20" },
|
||||
{ value: "24", label: "24" },
|
||||
{ value: "28", label: "28" },
|
||||
{ value: "32", label: "32" },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
type={isBold ? "primary" : "default"}
|
||||
onClick={handleBoldToggle}
|
||||
className='w-1/2'
|
||||
icon={<BoldOutlined />}
|
||||
>
|
||||
|
||||
className="w-1/2"
|
||||
icon={<BoldOutlined />}>
|
||||
加粗
|
||||
</Button>
|
||||
</div>
|
||||
|
@ -129,21 +144,27 @@ const NodeMenu: React.FC<NodeMenuProps> = ({ mind }) => {
|
|||
|
||||
{/* Color Picker */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-gray-600">颜色设置</h3>
|
||||
<h3 className="text-sm font-medium text-gray-600">
|
||||
颜色设置
|
||||
</h3>
|
||||
|
||||
{/* Font Color Picker */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium text-gray-500">文字颜色</h4>
|
||||
<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'
|
||||
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);
|
||||
handleColorChange("font", color);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
@ -152,16 +173,21 @@ const NodeMenu: React.FC<NodeMenuProps> = ({ mind }) => {
|
|||
|
||||
{/* Background Color Picker */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium text-gray-500">背景颜色</h4>
|
||||
<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={`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'
|
||||
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"
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => {
|
||||
handleColorChange('background', color);
|
||||
handleColorChange("background", color);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
@ -169,17 +195,50 @@ const NodeMenu: React.FC<NodeMenuProps> = ({ mind }) => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-gray-600">关联链接</h3>
|
||||
{/* URL Input */}
|
||||
<div className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||
{urlMode === "URL" ? "关联链接" : "关联课时"}
|
||||
<Button
|
||||
type="text"
|
||||
className=" hover:bg-gray-400 active:bg-gray-300 rounded-md text-gray-600 border transition-colors"
|
||||
size="small"
|
||||
icon={<SwapOutlined />}
|
||||
onClick={() =>
|
||||
setUrlMode((prev) =>
|
||||
prev === "POSTURL" ? "URL" : "POSTURL"
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{urlMode === "POSTURL" ? (
|
||||
<PostSelect
|
||||
onChange={(value) => {
|
||||
if (typeof value === "string") {
|
||||
setPostId(value);
|
||||
}
|
||||
}}
|
||||
params={{
|
||||
where: {
|
||||
type: PostType.LECTURE,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
placeholder="例如:https://example.com"
|
||||
value={url}
|
||||
onChange={handleUrlChange}
|
||||
addonBefore={<LinkOutlined />}
|
||||
/>
|
||||
{url && !/^https?:\/\/\S+$/.test(url) && (
|
||||
<p className="text-xs text-red-500">请输入有效的URL地址</p>
|
||||
)}
|
||||
|
||||
{urlMode === "URL" &&
|
||||
url &&
|
||||
!/^(https?:\/\/\S+|\/|\.\/|\.\.\/)?\S+$/.test(url) && (
|
||||
<p className="text-xs text-red-500">
|
||||
请输入有效的URL地址
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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";
|
||||
|
||||
export default function CourseDetail({
|
||||
|
@ -8,12 +8,11 @@ export default function CourseDetail({
|
|||
id?: string;
|
||||
lectureId?: string;
|
||||
}) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<CourseDetailProvider editId={id}>
|
||||
<PostDetailProvider editId={id}>
|
||||
<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 { Image, Typography, Skeleton, Tag } from "antd"; // 引入 antd 组件
|
||||
import { CourseDetailContext } from "./CourseDetailContext";
|
||||
import { CourseDetailContext } from "./PostDetailContext";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useStaff } from "@nice/client";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
|
@ -10,7 +10,7 @@ import { PictureOutlined } from "@ant-design/icons";
|
|||
|
||||
export const CourseDetailDescription: React.FC = () => {
|
||||
const {
|
||||
course,
|
||||
post,
|
||||
canEdit,
|
||||
isLoading,
|
||||
selectedLectureId,
|
||||
|
@ -22,14 +22,14 @@ export const CourseDetailDescription: React.FC = () => {
|
|||
const { user } = useAuth();
|
||||
const { update } = useStaff();
|
||||
const firstLectureId = useMemo(() => {
|
||||
return course?.sections?.[0]?.lectures?.[0]?.id;
|
||||
}, [course]);
|
||||
return (post as CourseDto)?.sections?.[0]?.lectures?.[0]?.id;
|
||||
}, [post]);
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
return (
|
||||
// <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">
|
||||
{isLoading || !course ? (
|
||||
{isLoading || !post ? (
|
||||
<Skeleton active paragraph={{ rows: 4 }} />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
|
@ -39,7 +39,7 @@ export const CourseDetailDescription: React.FC = () => {
|
|||
<div
|
||||
className="w-full rounded-xl aspect-video bg-cover bg-center z-0"
|
||||
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: {
|
||||
learningPosts: {
|
||||
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="flex flex-col gap-2">
|
||||
<div className="flex gap-2 flex-wrap items-center float-start">
|
||||
{course?.subTitle && <div>{course?.subTitle}</div>}
|
||||
<TermInfo terms={course.terms}></TermInfo>
|
||||
{post?.subTitle && <div>{post?.subTitle}</div>}
|
||||
<TermInfo terms={post.terms}></TermInfo>
|
||||
</div>
|
||||
</div>
|
||||
<Paragraph
|
||||
|
@ -81,7 +81,7 @@ export const CourseDetailDescription: React.FC = () => {
|
|||
symbol: "展开",
|
||||
onExpand: () => console.log("展开"),
|
||||
}}>
|
||||
{course?.content}
|
||||
{post?.content}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -4,18 +4,17 @@ import React, { useContext, useRef, useState } from "react";
|
|||
import { VideoPlayer } from "@web/src/components/presentation/video-player/VideoPlayer";
|
||||
import { CourseDetailDescription } from "./CourseDetailDescription";
|
||||
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 { Skeleton } from "antd";
|
||||
import ResourcesShower from "@web/src/components/common/uploader/ResourceShower";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import CourseDetailTitle from "./CourseDetailTitle";
|
||||
|
||||
|
||||
export const CourseDetailDisplayArea: React.FC = () => {
|
||||
// 创建滚动动画效果
|
||||
const {
|
||||
course,
|
||||
|
||||
isLoading,
|
||||
canEdit,
|
||||
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 { useContext, useState } from "react";
|
||||
import { CourseDetailContext } from "./CourseDetailContext";
|
||||
import { CourseDetailContext } from "./PostDetailContext";
|
||||
|
||||
import CourseDetailDisplayArea from "./CourseDetailDisplayArea";
|
||||
import { CourseSyllabus } from "./CourseSyllabus/CourseSyllabus";
|
||||
import { CourseDetailHeader } from "./CourseDetailHeader/CourseDetailHeader";
|
||||
import { CourseDto } from "packages/common/dist";
|
||||
|
||||
export default function CourseDetailLayout() {
|
||||
const {
|
||||
course,
|
||||
post,
|
||||
|
||||
setSelectedLectureId,
|
||||
} = useContext(CourseDetailContext);
|
||||
|
@ -19,7 +19,6 @@ export default function CourseDetailLayout() {
|
|||
const [isSyllabusOpen, setIsSyllabusOpen] = useState(true);
|
||||
return (
|
||||
<div className="relative">
|
||||
|
||||
<div className="pt-12 px-32">
|
||||
{" "}
|
||||
{/* 添加这个包装 div */}
|
||||
|
@ -36,7 +35,7 @@ export default function CourseDetailLayout() {
|
|||
</motion.div>
|
||||
{/* 课程大纲侧边栏 */}
|
||||
<CourseSyllabus
|
||||
sections={course?.sections || []}
|
||||
sections={(post as CourseDto)?.sections || []}
|
||||
onLectureClick={handleLectureClick}
|
||||
isOpen={isSyllabusOpen}
|
||||
onToggle={() => setIsSyllabusOpen(!isSyllabusOpen)}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { useContext } from "react";
|
||||
import { CourseDetailContext } from "./CourseDetailContext";
|
||||
import { CourseDetailContext } from "./PostDetailContext";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
BookOutlined,
|
||||
|
@ -9,11 +9,14 @@ import {
|
|||
ReloadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import dayjs from "dayjs";
|
||||
import CourseOperationBtns from "./JoinLearingButton";
|
||||
import CourseOperationBtns from "./CourseOperationBtns/CourseOperationBtns";
|
||||
|
||||
export default function CourseDetailTitle() {
|
||||
const { course, lecture, selectedLectureId } =
|
||||
useContext(CourseDetailContext);
|
||||
const {
|
||||
post: course,
|
||||
lecture,
|
||||
selectedLectureId,
|
||||
} = useContext(CourseDetailContext);
|
||||
return (
|
||||
<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">
|
||||
|
|
|
@ -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 { useContext, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { CourseDetailContext } from "./CourseDetailContext";
|
||||
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 {
|
||||
CheckCircleFilled,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleFilled,
|
||||
CloseCircleOutlined,
|
||||
EditFilled,
|
||||
EditTwoTone,
|
||||
LoginOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
export default function CourseOperationBtns() {
|
||||
const { id } = useParams();
|
||||
const { isAuthenticated, user, hasSomePermissions, hasEveryPermissions } =
|
||||
useAuth();
|
||||
export default function JoinButton() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { course, canEdit, userIsLearning, setUserIsLearning} = useContext(CourseDetailContext);
|
||||
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 },
|
||||
connect: { id: post.id },
|
||||
},
|
||||
},
|
||||
});
|
||||
setUserIsLearning(true)
|
||||
setUserIsLearning(true);
|
||||
toast.success("加入学习成功");
|
||||
} else {
|
||||
|
||||
await update.mutateAsync({
|
||||
where: { id: user?.id },
|
||||
data: {
|
||||
learningPosts: {
|
||||
disconnect: {
|
||||
id: course.id,
|
||||
id: post.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
toast.success("退出学习成功");
|
||||
setUserIsLearning(false)
|
||||
setUserIsLearning(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
|
@ -83,19 +76,6 @@ export default function CourseOperationBtns() {
|
|||
</span>
|
||||
</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 type { TabsProps } from "antd";
|
||||
import { PlayCircleOutlined } from "@ant-design/icons";
|
||||
import { CourseDetailContext } from "../CourseDetailContext";
|
||||
import { CourseDetailContext } from "../PostDetailContext";
|
||||
export function CoursePreview() {
|
||||
const { course, isLoading, lecture, lectureIsLoading, selectedLectureId } =
|
||||
const { post, isLoading, lecture, lectureIsLoading, selectedLectureId } =
|
||||
useContext(CourseDetailContext);
|
||||
return (
|
||||
<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="relative w-[600px] h-[340px] m-4 overflow-hidden flex justify-center items-center">
|
||||
<Image
|
||||
src={isLoading ? "error" : course?.meta?.thumbnail}
|
||||
src={isLoading ? "error" : post?.meta?.thumbnail}
|
||||
alt="example"
|
||||
preview={false}
|
||||
className="w-full h-full object-cover z-0"
|
||||
|
@ -28,13 +28,13 @@ export function CoursePreview() {
|
|||
) : (
|
||||
<>
|
||||
<span className="text-3xl font-bold my-3 ">
|
||||
{course.title}
|
||||
{post.title}
|
||||
</span>
|
||||
<span className="text-xl font-semibold my-3 text-gray-700">
|
||||
{course.subTitle}
|
||||
{post.subTitle}
|
||||
</span>
|
||||
<span className="text-lg font-light my-3 text-gray-500 text-clip">
|
||||
{course.content}
|
||||
{post.content}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -10,7 +10,7 @@ import { SectionDto, TaxonomySlug } from "@nice/common";
|
|||
import { SyllabusHeader } from "./SyllabusHeader";
|
||||
import { SectionItem } from "./SectionItem";
|
||||
import { CollapsedButton } from "./CollapsedButton";
|
||||
import { CourseDetailContext } from "../CourseDetailContext";
|
||||
import { CourseDetailContext } from "../PostDetailContext";
|
||||
import { api } from "@nice/client";
|
||||
|
||||
interface CourseSyllabusProps {
|
||||
|
|
|
@ -4,6 +4,7 @@ import {
|
|||
CourseDto,
|
||||
Lecture,
|
||||
lectureDetailSelect,
|
||||
PostDto,
|
||||
RolePerms,
|
||||
VisitType,
|
||||
} from "@nice/common";
|
||||
|
@ -19,7 +20,7 @@ import { useNavigate, useParams } from "react-router-dom";
|
|||
|
||||
interface CourseDetailContextType {
|
||||
editId?: string; // 添加 editId
|
||||
course?: CourseDto;
|
||||
post?: PostDto;
|
||||
lecture?: Lecture;
|
||||
selectedLectureId?: string | undefined;
|
||||
setSelectedLectureId?: React.Dispatch<React.SetStateAction<string>>;
|
||||
|
@ -39,7 +40,7 @@ interface CourseFormProviderProps {
|
|||
|
||||
export const CourseDetailContext =
|
||||
createContext<CourseDetailContextType | null>(null);
|
||||
export function CourseDetailProvider({
|
||||
export function PostDetailProvider({
|
||||
children,
|
||||
editId,
|
||||
}: CourseFormProviderProps) {
|
||||
|
@ -48,28 +49,24 @@ export function CourseDetailProvider({
|
|||
const { user, hasSomePermissions, isAuthenticated } = useAuth();
|
||||
const { lectureId } = useParams();
|
||||
|
||||
const { data: course, isLoading }: { data: CourseDto; isLoading: boolean } =
|
||||
(api.post as any).findFirst.useQuery(
|
||||
const { data: post, isLoading }: { data: PostDto; isLoading: boolean } = (
|
||||
api.post as any
|
||||
).findFirst.useQuery(
|
||||
{
|
||||
where: { id: editId },
|
||||
select: courseDetailSelect,
|
||||
},
|
||||
{ enabled: Boolean(editId) }
|
||||
);
|
||||
|
||||
// const userIsLearning = useMemo(() => {
|
||||
// return (course?.studentIds || []).includes(user?.id);
|
||||
// }, [user, course, isLoading]);
|
||||
const [userIsLearning, setUserIsLearning] = useState(false);
|
||||
useEffect(() => {
|
||||
console.log(course?.studentIds,user?.id)
|
||||
setUserIsLearning((course?.studentIds || []).includes(user?.id));
|
||||
},[user, course, isLoading])
|
||||
setUserIsLearning((post?.studentIds || []).includes(user?.id));
|
||||
}, [user, post, isLoading]);
|
||||
const canEdit = useMemo(() => {
|
||||
const isAuthor = isAuthenticated && user?.id === course?.authorId;
|
||||
const isAuthor = isAuthenticated && user?.id === post?.authorId;
|
||||
const isRoot = hasSomePermissions(RolePerms?.MANAGE_ANY_POST);
|
||||
return isAuthor || isRoot;
|
||||
}, [user, course]);
|
||||
}, [user, post]);
|
||||
|
||||
const [selectedLectureId, setSelectedLectureId] = useState<
|
||||
string | undefined
|
||||
|
@ -86,8 +83,6 @@ export function CourseDetailProvider({
|
|||
|
||||
useEffect(() => {
|
||||
if (lectureId) {
|
||||
console.log(123);
|
||||
console.log(lectureId);
|
||||
read.mutateAsync({
|
||||
data: {
|
||||
visitorId: user?.id || null,
|
||||
|
@ -96,8 +91,6 @@ export function CourseDetailProvider({
|
|||
},
|
||||
});
|
||||
} else {
|
||||
console.log(321);
|
||||
console.log(editId);
|
||||
read.mutateAsync({
|
||||
data: {
|
||||
visitorId: user?.id || null,
|
||||
|
@ -117,7 +110,7 @@ export function CourseDetailProvider({
|
|||
<CourseDetailContext.Provider
|
||||
value={{
|
||||
editId,
|
||||
course,
|
||||
post,
|
||||
lecture,
|
||||
selectedLectureId,
|
||||
setSelectedLectureId,
|
||||
|
@ -127,7 +120,7 @@ export function CourseDetailProvider({
|
|||
setIsHeaderVisible,
|
||||
canEdit,
|
||||
userIsLearning,
|
||||
setUserIsLearning
|
||||
setUserIsLearning,
|
||||
}}>
|
||||
{children}
|
||||
</CourseDetailContext.Provider>
|
|
@ -1,15 +1,110 @@
|
|||
import { api } from "@nice/client";
|
||||
import { Select } from "antd";
|
||||
import { useState } from "react";
|
||||
import { Button, Select } from "antd";
|
||||
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() {
|
||||
api.post.findMany.useQuery({});
|
||||
const [search, setSearch] = useState("");
|
||||
export default function PostSelect({
|
||||
value,
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
width: 200,
|
||||
}}>
|
||||
<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>
|
||||
</>
|
||||
</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;
|
||||
FILE_PORT: string;
|
||||
SERVER_PORT: string;
|
||||
WEB_PORT: string;
|
||||
} = {
|
||||
APP_NAME: import.meta.env.PROD
|
||||
? (window as any).env.VITE_APP_APP_NAME
|
||||
|
@ -14,6 +15,9 @@ export const env: {
|
|||
FILE_PORT: import.meta.env.PROD
|
||||
? (window as any).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
|
||||
? (window as any).env.VITE_APP_SERVER_PORT
|
||||
: import.meta.env.VITE_APP_SERVER_PORT,
|
||||
|
|
|
@ -45,7 +45,6 @@ export type PostDto = Post & {
|
|||
watchableStaffs: Staff[];
|
||||
terms: TermDto[];
|
||||
depts: DepartmentDto[];
|
||||
|
||||
studentIds?: string[];
|
||||
};
|
||||
export type PostMeta = {
|
||||
|
@ -64,6 +63,7 @@ export type LectureMeta = PostMeta & {
|
|||
};
|
||||
|
||||
export type Lecture = Post & {
|
||||
courseId?: string;
|
||||
resources?: ResourceDto[];
|
||||
meta?: LectureMeta;
|
||||
};
|
||||
|
@ -72,6 +72,7 @@ export type SectionMeta = PostMeta & {
|
|||
objectives?: string[];
|
||||
};
|
||||
export type Section = Post & {
|
||||
courseId?: string;
|
||||
meta?: SectionMeta;
|
||||
};
|
||||
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")}`;
|
||||
};
|
||||
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