This commit is contained in:
Rao 2025-02-26 12:16:25 +08:00
commit 49464054dc
18 changed files with 132 additions and 138 deletions

3
.gitignore vendored
View File

@ -68,4 +68,5 @@ yarn-error.log*
# Ignore .idea files in the Expo monorepo # Ignore .idea files in the Expo monorepo
**/.idea/ **/.idea/
uploads uploads
packages/mind-elixir-core packages/mind-elixir-core
config/nginx/conf.d/web.conf

View File

@ -60,24 +60,6 @@ export default function CourseCard({ course }: CourseCardProps) {
</Tag> </Tag>
); );
})} })}
{/* <Tag
color="blue"
className="px-3 py-1 rounded-full bg-blue-100 text-blue-600 border-0">
{course.terms?.[0].name}
</Tag>
<Tag
color={
course.terms?.[1].name === "入门"
? "green"
: course.terms?.[1].name === "中级"
? "blue"
: "purple"
}
className="px-3 py-1 rounded-full border-0">
{course.terms?.[1].name}
</Tag> */}
</div> </div>
<Title <Title
@ -103,7 +85,6 @@ export default function CourseCard({ course }: CourseCardProps) {
: null} : null}
</span> </span>
</div> </div>
<div className="pt-4 border-t border-gray-100 text-center"> <div className="pt-4 border-t border-gray-100 text-center">
<Button <Button
type="primary" type="primary"

View File

@ -41,7 +41,7 @@ const CategorySection = () => {
window.scrollTo({top: 0,behavior: "smooth",}) window.scrollTo({top: 0,behavior: "smooth",})
},[]); },[]);
return ( return (
<section className="py-32 relative overflow-hidden"> <section className="py-8 relative overflow-hidden">
<div className="max-w-screen-2xl mx-auto px-4 relative"> <div className="max-w-screen-2xl mx-auto px-4 relative">
<div className="text-center mb-24"> <div className="text-center mb-24">
<Title <Title

View File

@ -6,37 +6,37 @@ import { CoursesSectionTag } from "./CoursesSectionTag";
import CourseList from "@web/src/components/models/course/list/CourseList"; import CourseList from "@web/src/components/models/course/list/CourseList";
import LookForMore from "./LookForMore"; import LookForMore from "./LookForMore";
interface GetTaxonomyProps { interface GetTaxonomyProps {
categories: string[]; categories: string[];
isLoading: boolean; isLoading: boolean;
} }
function useGetTaxonomy({ type }): GetTaxonomyProps { function useGetTaxonomy({ type }): GetTaxonomyProps {
const { data, isLoading }: { data: TermDto[]; isLoading: boolean } = const { data, isLoading }: { data: TermDto[]; isLoading: boolean } =
api.term.findMany.useQuery({ api.term.findMany.useQuery({
where: { where: {
taxonomy: { taxonomy: {
slug: type, slug: type,
}, },
}, },
take: 10, // 只取前10个 take: 10, // 只取前10个
}); });
const categories = useMemo(() => { const categories = useMemo(() => {
const allCategories = isLoading const allCategories = isLoading
? [] ? []
: data?.map((course) => course.name); : data?.map((course) => course.name);
return [...Array.from(new Set(allCategories))]; return [...Array.from(new Set(allCategories))];
}, [data]); }, [data]);
return { categories, isLoading }; return { categories, isLoading };
} }
const { Title, Text } = Typography; const { Title, Text } = Typography;
interface CoursesSectionProps { interface CoursesSectionProps {
title: string; title: string;
description: string; description: string;
initialVisibleCoursesCount?: number; initialVisibleCoursesCount?: number;
} }
const CoursesSection: React.FC<CoursesSectionProps> = ({ const CoursesSection: React.FC<CoursesSectionProps> = ({
title, title,
description, description,
initialVisibleCoursesCount = 8, initialVisibleCoursesCount = 8,
}) => { }) => {
const [selectedCategory, setSelectedCategory] = useState<string>("全部"); const [selectedCategory, setSelectedCategory] = useState<string>("全部");
const gateGory: GetTaxonomyProps = useGetTaxonomy({ const gateGory: GetTaxonomyProps = useGetTaxonomy({

View File

@ -9,9 +9,9 @@ const { Content } = Layout;
export function MainLayout() { export function MainLayout() {
return ( return (
<MainProvider> <MainProvider>
<Layout className="min-h-screen"> <Layout className="min-h-screen bg-gray-100">
<MainHeader /> <MainHeader />
<Content className="mt-16 bg-gray-50"> <Content className="mt-16 bg-gray-50 ">
<Outlet /> <Outlet />
</Content> </Content>
<MainFooter /> <MainFooter />

View File

@ -1,5 +1,6 @@
import MindEditor from "@web/src/components/common/editor/MindEditor"; import MindEditor from "@web/src/components/common/editor/MindEditor";
export default function PathsPage() { export default function PathsPage() {
return <MindEditor></MindEditor>; // return <MindEditor></MindEditor>;
return <>123</>
} }

View File

@ -1,38 +1,42 @@
import React from 'react'; import React from "react";
import { useLocation, Link, useMatches } from 'react-router-dom'; import { useLocation, Link, useMatches } from "react-router-dom";
import { theme } from 'antd'; import { theme } from "antd";
import { RightOutlined } from '@ant-design/icons'; import { RightOutlined } from "@ant-design/icons";
export default function Breadcrumb() { export default function Breadcrumb() {
let matches = useMatches(); const matches = useMatches();
const { token } = theme.useToken() const { token } = theme.useToken();
let crumbs = matches const crumbs = matches
// first get rid of any matches that don't have handle and crumb // first get rid of any matches that don't have handle and crumb
.filter((match) => Boolean((match.handle as any)?.crumb)) .filter((match) => Boolean((match.handle as any)?.crumb))
// now map them into an array of elements, passing the loader // now map them into an array of elements, passing the loader
// data to each one // data to each one
.map((match) => (match.handle as any).crumb(match.data)); .map((match) => (match.handle as any).crumb(match.data));
return ( return (
<ol className='flex items-center space-x-2 text-gray-600'> <ol className="flex items-center space-x-2 text-gray-600">
{crumbs.map((crumb, index) => ( {crumbs.map((crumb, index) => (
<React.Fragment key={index}> <React.Fragment key={index}>
<li className={`inline-flex items-center `} <li
style={{ className={`inline-flex items-center `}
color: (index === crumbs.length - 1) ? token.colorPrimaryText : token.colorTextSecondary, style={{
fontWeight: (index === crumbs.length - 1) ? "bold" : "normal", color:
}} index === crumbs.length - 1
> ? token.colorPrimaryText
{crumb} : token.colorTextSecondary,
</li> fontWeight:
{index < crumbs.length - 1 && ( index === crumbs.length - 1 ? "bold" : "normal",
<li className='mx-2'> }}>
<RightOutlined></RightOutlined> {crumb}
</li> </li>
)} {index < crumbs.length - 1 && (
</React.Fragment> <li className="mx-2">
))} <RightOutlined></RightOutlined>
</ol> </li>
); )}
</React.Fragment>
))}
</ol>
);
} }

View File

@ -3,10 +3,17 @@ import {
courseDetailSelect, courseDetailSelect,
CourseDto, CourseDto,
Lecture, Lecture,
RolePerms,
VisitType, VisitType,
} from "@nice/common"; } from "@nice/common";
import { useAuth } from "@web/src/providers/auth-provider"; import { useAuth } from "@web/src/providers/auth-provider";
import React, { createContext, ReactNode, useEffect, useState } from "react"; import React, {
createContext,
ReactNode,
useEffect,
useMemo,
useState,
} from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
interface CourseDetailContextType { interface CourseDetailContextType {
@ -19,11 +26,14 @@ interface CourseDetailContextType {
lectureIsLoading?: boolean; lectureIsLoading?: boolean;
isHeaderVisible: boolean; // 新增 isHeaderVisible: boolean; // 新增
setIsHeaderVisible: (visible: boolean) => void; // 新增 setIsHeaderVisible: (visible: boolean) => void; // 新增
canEdit?: boolean;
} }
interface CourseFormProviderProps { interface CourseFormProviderProps {
children: ReactNode; children: ReactNode;
editId?: string; // 添加 editId 参数 editId?: string; // 添加 editId 参数
} }
export const CourseDetailContext = export const CourseDetailContext =
createContext<CourseDetailContextType | null>(null); createContext<CourseDetailContextType | null>(null);
export function CourseDetailProvider({ export function CourseDetailProvider({
@ -32,8 +42,9 @@ export function CourseDetailProvider({
}: CourseFormProviderProps) { }: CourseFormProviderProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { read } = useVisitor(); const { read } = useVisitor();
const { user } = useAuth(); const { user, hasSomePermissions } = useAuth();
const { lectureId } = useParams(); const { lectureId } = useParams();
const { data: course, isLoading }: { data: CourseDto; isLoading: boolean } = const { data: course, isLoading }: { data: CourseDto; isLoading: boolean } =
(api.post as any).findFirst.useQuery( (api.post as any).findFirst.useQuery(
{ {
@ -45,7 +56,14 @@ export function CourseDetailProvider({
}, },
{ enabled: Boolean(editId) } { enabled: Boolean(editId) }
); );
const canEdit = useMemo(() => {
const isAuthor = user?.id === course?.authorId;
const isDept = course?.depts
?.map((dept) => dept.id)
.includes(user?.deptId);
const isRoot = hasSomePermissions(RolePerms?.MANAGE_ANY_POST);
return isAuthor || isDept || isRoot;
}, [user, course]);
const [selectedLectureId, setSelectedLectureId] = useState< const [selectedLectureId, setSelectedLectureId] = useState<
string | undefined string | undefined
>(lectureId || undefined); >(lectureId || undefined);
@ -57,9 +75,9 @@ export function CourseDetailProvider({
}, },
{ enabled: Boolean(editId) } { enabled: Boolean(editId) }
); );
useEffect(() => { useEffect(() => {
if (course) { if (course) {
console.log("read");
read.mutateAsync({ read.mutateAsync({
data: { data: {
visitorId: user?.id || null, visitorId: user?.id || null,
@ -85,6 +103,7 @@ export function CourseDetailProvider({
lectureIsLoading, lectureIsLoading,
isHeaderVisible, isHeaderVisible,
setIsHeaderVisible, setIsHeaderVisible,
canEdit,
}}> }}>
{children} {children}
</CourseDetailContext.Provider> </CourseDetailContext.Provider>

View File

@ -11,14 +11,16 @@ import { useNavigate, useParams } from "react-router-dom";
import { UserMenu } from "@web/src/app/main/layout/UserMenu/UserMenu"; import { UserMenu } from "@web/src/app/main/layout/UserMenu/UserMenu";
import { CourseDetailContext } from "../CourseDetailContext"; import { CourseDetailContext } from "../CourseDetailContext";
const { Header } = Layout; const { Header } = Layout;
export function CourseDetailHeader() { export function CourseDetailHeader() {
const [searchValue, setSearchValue] = useState(""); const [searchValue, setSearchValue] = useState("");
const { id } = useParams(); const { id } = useParams();
const { isAuthenticated, user, hasSomePermissions } = useAuth(); const { isAuthenticated, user, hasSomePermissions, hasEveryPermissions } =
useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { course } = useContext(CourseDetailContext); const { course, canEdit } = useContext(CourseDetailContext);
return ( return (
<Header className="select-none flex items-center justify-center bg-white shadow-md border-b border-gray-100 fixed w-full z-30"> <Header className="select-none flex items-center justify-center bg-white shadow-md border-b border-gray-100 fixed w-full z-30">
@ -49,7 +51,7 @@ export function CourseDetailHeader() {
onChange={(e) => setSearchValue(e.target.value)} onChange={(e) => setSearchValue(e.target.value)}
/> />
</div> </div>
{isAuthenticated && ( {canEdit && (
<> <>
<Button <Button
onClick={() => { onClick={() => {
@ -65,18 +67,7 @@ export function CourseDetailHeader() {
</> </>
)} )}
{isAuthenticated ? ( {isAuthenticated ? (
<Dropdown <UserMenu />
overlay={<UserMenu />}
trigger={["click"]}
placement="bottomRight">
<Avatar
size="large"
className="cursor-pointer hover:scale-105 transition-all bg-gradient-to-r from-blue-500 to-blue-600 text-white font-semibold">
{(user?.showname ||
user?.username ||
"")[0]?.toUpperCase()}
</Avatar>
</Dropdown>
) : ( ) : (
<Button <Button
onClick={() => navigate("/login")} onClick={() => navigate("/login")}

View File

@ -9,9 +9,7 @@ import { CourseDetailHeader } from "./CourseDetailHeader/CourseDetailHeader";
export default function CourseDetailLayout() { export default function CourseDetailLayout() {
const { const {
course, course,
selectedLectureId,
lecture,
isLoading,
setSelectedLectureId, setSelectedLectureId,
} = useContext(CourseDetailContext); } = useContext(CourseDetailContext);
@ -38,10 +36,7 @@ export default function CourseDetailLayout() {
}} }}
transition={{ type: "spring", stiffness: 300, damping: 30 }} transition={{ type: "spring", stiffness: 300, damping: 30 }}
className="relative"> className="relative">
<CourseDetailDisplayArea <CourseDetailDisplayArea />
// course={course}
// isLoading={isLoading}
/>
</motion.div> </motion.div>
{/* 课程大纲侧边栏 */} {/* 课程大纲侧边栏 */}
<CourseSyllabus <CourseSyllabus

View File

@ -24,7 +24,12 @@ export const LectureItem: React.FC<LectureItemProps> = ({
}, [lectureId, lecture]); }, [lectureId, lecture]);
return ( return (
<div <div
className="w-full flex items-center gap-4 p-4 hover:bg-gray-200 text-left transition-colors cursor-pointer" className={`w-full flex items-center gap-4 p-4 text-left transition-colors cursor-pointer
${
isReading
? "bg-blue-50 border-l-4 border-blue-500 hover:bg-blue-50"
: "hover:bg-gray-200"
}`}
onClick={() => onClick(lecture.id)}> onClick={() => onClick(lecture.id)}>
{lecture?.meta?.type === LectureType.VIDEO && ( {lecture?.meta?.type === LectureType.VIDEO && (
<div className="text-blue-500 flex items-center"> <div className="text-blue-500 flex items-center">

View File

@ -1,6 +1,7 @@
import { createContext, useContext, ReactNode, useEffect } from "react"; import { createContext, useContext, ReactNode, useEffect } from "react";
import { Form, FormInstance, message } from "antd"; import { Form, FormInstance, message } from "antd";
import { import {
courseDetailSelect,
CourseDto, CourseDto,
CourseMeta, CourseMeta,
CourseStatus, CourseStatus,
@ -48,9 +49,7 @@ export function CourseFormProvider({
const { data: course }: { data: CourseDto } = api.post.findFirst.useQuery( const { data: course }: { data: CourseDto } = api.post.findFirst.useQuery(
{ {
where: { id: editId }, where: { id: editId },
include: { select: courseDetailSelect,
terms: true,
},
}, },
{ enabled: Boolean(editId) } { enabled: Boolean(editId) }
); );
@ -65,10 +64,12 @@ export function CourseFormProvider({
useEffect(() => { useEffect(() => {
if (course) { if (course) {
const deptIds = (course?.depts || [])?.map((dept) => dept.id);
const formData = { const formData = {
title: course.title, title: course.title,
subTitle: course.subTitle, subTitle: course.subTitle,
content: course.content, content: course.content,
deptIds: deptIds,
meta: { meta: {
thumbnail: course?.meta?.thumbnail, thumbnail: course?.meta?.thumbnail,
}, },
@ -91,7 +92,10 @@ export function CourseFormProvider({
const formattedValues = { const formattedValues = {
...values, ...values,
meta: { meta: {
thumbnail: values?.meta?.thumbnail, ...((course?.meta as CourseMeta) || {}),
...(values?.meta?.thumbnail !== undefined && {
thumbnail: values?.meta?.thumbnail,
}),
}, },
terms: { terms: {
connect: termIds.map((id) => ({ id })), // 转换成 connect 格式 connect: termIds.map((id) => ({ id })), // 转换成 connect 格式
@ -106,12 +110,9 @@ export function CourseFormProvider({
}); });
delete formattedValues.sections; delete formattedValues.sections;
delete formattedValues.deptIds; delete formattedValues.deptIds;
if (course) {
formattedValues.meta = { console.log(course.meta);
...(course?.meta as CourseMeta), console.log(formattedValues?.meta);
thumbnail: values?.meta?.thumbnail,
};
}
try { try {
if (editId) { if (editId) {
const result = await update.mutateAsync({ const result = await update.mutateAsync({

View File

@ -69,6 +69,9 @@ export const SortableSection: React.FC<SortableSectionProps> = ({
}); });
} else { } else {
result = await update.mutateAsync({ result = await update.mutateAsync({
where: {
id: field?.id,
},
data: { data: {
title: values?.title, title: values?.title,
}, },

View File

@ -4,6 +4,7 @@ import { useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { CourseStatus, CourseStatusLabel } from "@nice/common"; import { CourseStatus, CourseStatusLabel } from "@nice/common";
import { useCourseEditor } from "../context/CourseEditorContext"; import { useCourseEditor } from "../context/CourseEditorContext";
import { useAuth } from "@web/src/providers/auth-provider";
const { Title } = Typography; const { Title } = Typography;
@ -16,6 +17,8 @@ const courseStatusVariant: Record<CourseStatus, string> = {
export default function CourseEditorHeader() { export default function CourseEditorHeader() {
const navigate = useNavigate(); const navigate = useNavigate();
const { user, hasSomePermissions } = useAuth();
const { onSubmit, course, form } = useCourseEditor(); const { onSubmit, course, form } = useCourseEditor();
const handleSave = () => { const handleSave = () => {
@ -34,7 +37,13 @@ export default function CourseEditorHeader() {
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<Button <Button
icon={<ArrowLeftOutlined />} icon={<ArrowLeftOutlined />}
onClick={() => navigate(-1)} onClick={() => {
if (course?.id) {
navigate(`/course/${course?.id}/detail`);
} else {
navigate("/");
}
}}
type="text" type="text"
/> />
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">

View File

@ -63,16 +63,6 @@ export const routes: CustomRouteObject[] = [
path: "courses", path: "courses",
element: <CoursesPage></CoursesPage>, element: <CoursesPage></CoursesPage>,
}, },
{
path: "profiles",
},
// // 课程预览页面
// {
// path: "coursePreview/:id?",
// element: <CoursePreview></CoursePreview>,
// },
], ],
}, },
{ {
@ -103,12 +93,6 @@ export const routes: CustomRouteObject[] = [
</WithAuth> </WithAuth>
), ),
}, },
// {
// path: "setting",
// element: (
// <CourseSettingForm></CourseSettingForm>
// ),
// },
], ],
}, },
{ {

View File

@ -100,7 +100,7 @@ server {
# 仅供内部使用 # 仅供内部使用
internal; internal;
# 代理到认证服务 # 代理到认证服务
proxy_pass http://host.docker.internal:3000/auth/file; proxy_pass http://host.docker.internal:/auth/file;
# 请求优化:不传递请求体 # 请求优化:不传递请求体
proxy_pass_request_body off; proxy_pass_request_body off;

View File

@ -8,6 +8,7 @@ export enum PostType {
COURSE = "couse", COURSE = "couse",
SECTION = "section", SECTION = "section",
LECTURE = "lecture", LECTURE = "lecture",
PATH = "path",
} }
export enum LectureType { export enum LectureType {
VIDEO = "video", VIDEO = "video",
@ -100,7 +101,7 @@ export enum RolePerms {
} }
export enum AppConfigSlug { export enum AppConfigSlug {
BASE_SETTING = "base_setting", BASE_SETTING = "base_setting",
} }
// 资源类型的枚举,定义了不同类型的资源,以字符串值表示 // 资源类型的枚举,定义了不同类型的资源,以字符串值表示
export enum ResourceType { export enum ResourceType {

@ -1 +0,0 @@
Subproject commit b911b4ba7629da9d6c622abe241fd25299baf1a5