diff --git a/apps/server/src/models/post/post.service.ts b/apps/server/src/models/post/post.service.ts index 6964696..0df249e 100755 --- a/apps/server/src/models/post/post.service.ts +++ b/apps/server/src/models/post/post.service.ts @@ -101,11 +101,7 @@ export class PostService extends BaseTreeService { }, params: { staff?: UserProfile; tx?: Prisma.TransactionClient }, ) { - // const await db.post.findMany({ - // where: { - // type: PostType.COURSE, - // }, - // }); + const { courseDetail } = args; // If no transaction is provided, create a new one if (!params.tx) { @@ -128,6 +124,7 @@ export class PostService extends BaseTreeService { ) { args.data.authorId = params?.staff?.id; args.data.updatedAt = dayjs().toDate(); + const result = await super.create(args); EventBus.emit('dataChanged', { type: ObjectType.POST, @@ -166,19 +163,7 @@ export class PostService extends BaseTreeService { ); return transDto; } - // async findMany(args: Prisma.PostFindManyArgs, staff?: UserProfile) { - // if (!args.where) args.where = {}; - // args.where.OR = await this.preFilter(args.where.OR, staff); - // return this.wrapResult(super.findMany(args), async (result) => { - // await Promise.all( - // result.map(async (item) => { - // await setPostRelation({ data: item, staff }); - // await this.setPerms(item, staff); - // }), - // ); - // return { ...result }; - // }); - // } + async findManyWithCursor(args: Prisma.PostFindManyArgs, staff?: UserProfile) { if (!args.where) args.where = {}; args.where.OR = await this.preFilter(args.where.OR, staff); @@ -255,6 +240,7 @@ export class PostService extends BaseTreeService { // 批量执行更新 return updates.length > 0 ? await db.$transaction(updates) : []; } + protected async setPerms(data: Post, staff?: UserProfile) { if (!staff) return; const perms: ResPerm = { @@ -306,37 +292,37 @@ export class PostService extends BaseTreeService { staff?.id && { authorId: staff.id, }, - staff?.id && { - watchableStaffs: { - some: { - id: staff.id, - }, - }, - }, - deptId && { - watchableDepts: { - some: { - id: { - in: parentDeptIds, - }, - }, - }, - }, + // staff?.id && { + // watchableStaffs: { + // some: { + // id: staff.id, + // }, + // }, + // }, + // deptId && { + // watchableDepts: { + // some: { + // id: { + // in: parentDeptIds, + // }, + // }, + // }, + // }, - { - AND: [ - { - watchableStaffs: { - none: {}, // 匹配 watchableStaffs 为空 - }, - }, - { - watchableDepts: { - none: {}, // 匹配 watchableDepts 为空 - }, - }, - ], - }, + // { + // AND: [ + // { + // watchableStaffs: { + // none: {}, // 匹配 watchableStaffs 为空 + // }, + // }, + // { + // watchableDepts: { + // none: {}, // 匹配 watchableDepts 为空 + // }, + // }, + // ], + // }, ].filter(Boolean); if (orCondition?.length > 0) return orCondition; diff --git a/apps/server/src/models/post/utils.ts b/apps/server/src/models/post/utils.ts index 0e1f835..57b0fd0 100755 --- a/apps/server/src/models/post/utils.ts +++ b/apps/server/src/models/post/utils.ts @@ -168,6 +168,21 @@ export async function setCourseInfo({ data }: { data: Post }) { (lecture) => lecture.parentId === section.id, ) as any as Lecture[]; }); - Object.assign(data, { sections, lectureCount }); + + 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, { sections, lectureCount, studentIds }); } } diff --git a/apps/server/src/queue/models/post/post.queue.service.ts b/apps/server/src/queue/models/post/post.queue.service.ts old mode 100644 new mode 100755 diff --git a/apps/server/src/queue/models/post/utils.ts b/apps/server/src/queue/models/post/utils.ts old mode 100644 new mode 100755 index 51d2b04..51bffca --- a/apps/server/src/queue/models/post/utils.ts +++ b/apps/server/src/queue/models/post/utils.ts @@ -23,7 +23,7 @@ export async function updateTotalCourseViewCount(type: VisitType) { views: true, }, where: { - postId: { in: lectures.map((lecture) => lecture.id) }, + postId: { in: posts.map((post) => post.id) }, type: type, }, }); diff --git a/apps/web/package.json b/apps/web/package.json index f10e338..1456e66 100755 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,6 +36,7 @@ "@nice/iconer": "workspace:^", "@nice/utils": "workspace:^", "mind-elixir": "workspace:^", + "@mind-elixir/node-menu": "workspace:*", "@nice/ui": "workspace:^", "@tanstack/query-async-storage-persister": "^5.51.9", "@tanstack/react-query": "^5.51.21", diff --git a/apps/web/src/app/main/course/preview/components/couresPreviewTabmsg.tsx b/apps/web/src/app/main/course/preview/components/couresPreviewTabmsg.tsx old mode 100644 new mode 100755 diff --git a/apps/web/src/app/main/course/preview/components/courseCatalog.tsx b/apps/web/src/app/main/course/preview/components/courseCatalog.tsx old mode 100644 new mode 100755 diff --git a/apps/web/src/app/main/course/preview/components/coursePreviewAllmsg.tsx b/apps/web/src/app/main/course/preview/components/coursePreviewAllmsg.tsx old mode 100644 new mode 100755 diff --git a/apps/web/src/app/main/course/preview/page.tsx b/apps/web/src/app/main/course/preview/page.tsx old mode 100644 new mode 100755 diff --git a/apps/web/src/app/main/course/preview/type.ts b/apps/web/src/app/main/course/preview/type.ts old mode 100644 new mode 100755 diff --git a/apps/web/src/app/main/courses/components/CourseCard.tsx b/apps/web/src/app/main/courses/components/CourseCard.tsx index 5de3a9d..fa4b0d5 100755 --- a/apps/web/src/app/main/courses/components/CourseCard.tsx +++ b/apps/web/src/app/main/courses/components/CourseCard.tsx @@ -1,5 +1,6 @@ import { Card, Tag, Typography, Button } from "antd"; import { + BookOutlined, EyeOutlined, PlayCircleOutlined, TeamOutlined, @@ -9,13 +10,18 @@ import { useNavigate } from "react-router-dom"; interface CourseCardProps { course: CourseDto; + edit?: boolean; } const { Title, Text } = Typography; -export default function CourseCard({ course }: CourseCardProps) { +export default function CourseCard({ course, edit = false }: CourseCardProps) { const navigate = useNavigate(); const handleClick = (course: CourseDto) => { - navigate(`/course/${course.id}/detail`); - window.scrollTo({ top: 0, behavior: "smooth", }) + if (!edit) { + navigate(`/course/${course.id}/detail`); + } else { + navigate(`/course/${course.id}/editor`); + } + window.scrollTo({ top: 0, behavior: "smooth" }); }; return (
-
+
{course?.terms?.map((term) => { return ( <> @@ -46,10 +52,10 @@ export default function CourseCard({ course }: CourseCardProps) { key={term.id} color={ term?.taxonomy?.slug === - TaxonomySlug.CATEGORY + TaxonomySlug.CATEGORY ? "blue" : term?.taxonomy?.slug === - TaxonomySlug.LEVEL + TaxonomySlug.LEVEL ? "green" : "orange" } @@ -68,10 +74,10 @@ export default function CourseCard({ course }: CourseCardProps) { -
+
- + {course?.depts?.length > 1 ? `${course.depts[0].name}等` : course?.depts?.[0]?.name} @@ -79,10 +85,15 @@ export default function CourseCard({ course }: CourseCardProps) { {/* {course?.depts?.map((dept)=>{return dept.name})} */}
- - {course?.meta?.views - ? `观看次数 ${course?.meta?.views}` - : null} +
+
+ + + {`观看次数 ${course?.meta?.views || 0}`} + + + + {`学习人数 ${course?.studentIds?.length || 0}`}
@@ -91,7 +102,7 @@ export default function CourseCard({ course }: CourseCardProps) { size="large" className="w-full shadow-[0_8px_20px_-6px_rgba(59,130,246,0.5)] hover:shadow-[0_12px_24px_-6px_rgba(59,130,246,0.6)] transform hover:translate-y-[-2px] transition-all duration-500 ease-out"> - 立即学习 + {edit ? "编辑" : "立即学习"}
diff --git a/apps/web/src/app/main/courses/components/CoursesContainer.tsx b/apps/web/src/app/main/courses/components/CoursesContainer.tsx old mode 100644 new mode 100755 index 9e2d871..5f16cc1 --- a/apps/web/src/app/main/courses/components/CoursesContainer.tsx +++ b/apps/web/src/app/main/courses/components/CoursesContainer.tsx @@ -1,22 +1,21 @@ -import CourseList from "@web/src/components/models/course/list/CourseList"; import { useMainContext } from "../../layout/MainProvider"; import { PostType, Prisma } from "@nice/common"; +import PostList from "@web/src/components/models/course/list/PostList"; import { useMemo } from "react"; +import CourseCard from "./CourseCard"; export function CoursesContainer() { - const { searchValue, selectedTerms } = useMainContext(); + const { selectedTerms, searchCondition } = useMainContext(); const termFilters = useMemo(() => { return Object.entries(selectedTerms) .filter(([, terms]) => terms.length > 0) .map(([, terms]) => terms); }, [selectedTerms]); - const searchCondition: Prisma.StringNullableFilter = { - contains: searchValue, - mode: "insensitive" as Prisma.QueryMode, // 使用类型断言 - }; + return ( <> - } params={{ pageSize: 12, where: { @@ -30,21 +29,10 @@ export function CoursesContainer() { }, }, })), - OR: [ - { title: searchCondition }, - { subTitle: searchCondition }, - { content: searchCondition }, - { - terms: { - some: { - name: searchCondition, - }, - }, - }, - ], + ...searchCondition, }, }} - cols={4}> + cols={4}> ); } diff --git a/apps/web/src/app/main/courses/components/FilterSection.tsx b/apps/web/src/app/main/courses/components/FilterSection.tsx index 01ffba6..110ba8c 100755 --- a/apps/web/src/app/main/courses/components/FilterSection.tsx +++ b/apps/web/src/app/main/courses/components/FilterSection.tsx @@ -19,7 +19,7 @@ export default function FilterSection() { }); }; return ( -
+
{taxonomies?.map((tax, index) => { const items = Object.entries(selectedTerms).find( ([key, items]) => key === tax.slug @@ -31,35 +31,16 @@ export default function FilterSection() { handleTermChange( tax?.slug, selected as string[] ) } - taxonomyId={tax?.id} - > - {/* ( -
{menu}
- )} - dropdownStyle={{ maxHeight: 400, overflow: "auto" }} - multiple - taxonomyId={tax?.id} - onChange={(selected) => - handleTermChange( - tax?.slug, - selected as string[] - ) - }>
- {index < taxonomies.length - 1 && ( - - )} */} + taxonomyId={tax?.id}> +
); })} diff --git a/apps/web/src/app/main/courses/layout/AllCoursesLayout.tsx b/apps/web/src/app/main/courses/layout/AllCoursesLayout.tsx old mode 100644 new mode 100755 diff --git a/apps/web/src/app/main/home/components/CategorySection.tsx b/apps/web/src/app/main/home/components/CategorySection.tsx index d589d35..4c8141e 100755 --- a/apps/web/src/app/main/home/components/CategorySection.tsx +++ b/apps/web/src/app/main/home/components/CategorySection.tsx @@ -19,11 +19,11 @@ const CategorySection = () => { taxonomy: { slug: TaxonomySlug.CATEGORY, }, - parentId : null + parentId: null, }, take: 8, }); - const navigate = useNavigate() + const navigate = useNavigate(); const handleMouseEnter = useCallback((index: number) => { setHoveredIndex(index); @@ -33,13 +33,13 @@ const CategorySection = () => { setHoveredIndex(null); }, []); - const handleMouseClick = useCallback((categoryId:string) => { + const handleMouseClick = useCallback((categoryId: string) => { setSelectedTerms({ - [TaxonomySlug.CATEGORY] : [categoryId] - }) - navigate('/courses') - window.scrollTo({top: 0,behavior: "smooth",}) - },[]); + [TaxonomySlug.CATEGORY]: [categoryId], + }); + navigate("/courses"); + window.scrollTo({ top: 0, behavior: "smooth" }); + }, []); return (
@@ -57,7 +57,7 @@ const CategorySection = () => { {isLoading ? ( ) : ( - courseCategoriesData.map((category, index) => { + courseCategoriesData?.map((category, index) => { const categoryColor = stringToColor(category.name); const isHovered = hoveredIndex === index; diff --git a/apps/web/src/app/main/home/components/CategorySectionCard.tsx b/apps/web/src/app/main/home/components/CategorySectionCard.tsx old mode 100644 new mode 100755 diff --git a/apps/web/src/app/main/home/components/CoursesSection.tsx b/apps/web/src/app/main/home/components/CoursesSection.tsx index b012fa6..bd2f45b 100755 --- a/apps/web/src/app/main/home/components/CoursesSection.tsx +++ b/apps/web/src/app/main/home/components/CoursesSection.tsx @@ -3,8 +3,9 @@ import { Typography, Skeleton } from "antd"; import { TaxonomySlug, TermDto } from "@nice/common"; import { api } from "@nice/client"; import { CoursesSectionTag } from "./CoursesSectionTag"; -import CourseList from "@web/src/components/models/course/list/CourseList"; import LookForMore from "./LookForMore"; +import PostList from "@web/src/components/models/course/list/PostList"; +import CourseCard from "../../courses/components/CourseCard"; interface GetTaxonomyProps { categories: string[]; isLoading: boolean; @@ -80,7 +81,8 @@ const CoursesSection: React.FC = ({ )}
- } params={{ page: 1, pageSize: initialVisibleCoursesCount, @@ -95,7 +97,7 @@ const CoursesSection: React.FC = ({ }, }} showPagination={false} - cols={4}> + cols={4}>
diff --git a/apps/web/src/app/main/home/components/CoursesSectionTag.tsx b/apps/web/src/app/main/home/components/CoursesSectionTag.tsx old mode 100644 new mode 100755 diff --git a/apps/web/src/app/main/home/components/HeroSection.tsx b/apps/web/src/app/main/home/components/HeroSection.tsx index eca460d..4073109 100755 --- a/apps/web/src/app/main/home/components/HeroSection.tsx +++ b/apps/web/src/app/main/home/components/HeroSection.tsx @@ -1,4 +1,10 @@ -import React, { useRef, useCallback, useEffect, useMemo, useState } from "react"; +import React, { + useRef, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; import { Carousel, Typography } from "antd"; import { TeamOutlined, @@ -30,13 +36,29 @@ interface PlatformStat { const HeroSection = () => { const carouselRef = useRef(null); const { statistics, slides } = useAppConfig(); - const [countStatistics, setCountStatistics] = useState(4) + const [countStatistics, setCountStatistics] = useState(4); const platformStats: PlatformStat[] = useMemo(() => { return [ - { icon: , value: statistics.staffs, label: "注册学员" }, - { icon: , value: statistics.courses, label: "精品课程" }, - { icon: , value: statistics.lectures, label: '课程章节' }, - { icon: , value: statistics.reads, label: "观看次数" }, + { + icon: , + value: statistics.staffs, + label: "注册学员", + }, + { + icon: , + value: statistics.courses, + label: "精品课程", + }, + { + icon: , + value: statistics.lectures, + label: "课程章节", + }, + { + icon: , + value: statistics.reads, + label: "观看次数", + }, ]; }, [statistics]); const handlePrev = useCallback(() => { @@ -48,7 +70,7 @@ const HeroSection = () => { }, []); const countNonZeroValues = (statistics: Record): number => { - return Object.values(statistics).filter(value => value !== 0).length; + return Object.values(statistics).filter((value) => value !== 0).length; }; useEffect(() => { @@ -67,8 +89,8 @@ const HeroSection = () => { dots={{ className: "carousel-dots !bottom-32 !z-20", }}> - {Array.isArray(slides) ? - (slides.map((item, index) => ( + {Array.isArray(slides) ? ( + slides.map((item, index) => (
{
)) - ) : ( -
- )} + ) : ( +
+ )} {/* Navigation Buttons */} @@ -108,31 +130,30 @@ const HeroSection = () => {
{/* Stats Container */} - { - countStatistics > 1 && ( -
-
- {platformStats.map((stat, index) => { - return stat.value - ? (
-
- {stat.icon} -
-
- {stat.value} -
-
- {stat.label} -
+ {countStatistics > 1 && ( +
+
+ {platformStats.map((stat, index) => { + return stat.value ? ( +
+
+ {stat.icon}
- ) : null - })} -
+
+ {stat.value} +
+
+ {stat.label} +
+
+ ) : null; + })}
- ) - } +
+ )} ); }; diff --git a/apps/web/src/app/main/home/components/LookForMore.tsx b/apps/web/src/app/main/home/components/LookForMore.tsx old mode 100644 new mode 100755 diff --git a/apps/web/src/app/main/layout/MainFooter.tsx b/apps/web/src/app/main/layout/MainFooter.tsx index e37d149..5ebe46f 100755 --- a/apps/web/src/app/main/layout/MainFooter.tsx +++ b/apps/web/src/app/main/layout/MainFooter.tsx @@ -1,68 +1,76 @@ -import { CloudOutlined, FileSearchOutlined, HomeOutlined, MailOutlined, PhoneOutlined } from '@ant-design/icons'; -import { Layout, Typography } from 'antd'; +import { + CloudOutlined, + FileSearchOutlined, + HomeOutlined, + MailOutlined, + PhoneOutlined, +} from "@ant-design/icons"; + export function MainFooter() { - return ( -
-
-
- {/* 开发组织信息 */} -
-

- 软件与数据小组 -

-

- 提供技术支持 -

-
+ return ( +
+
+
+ {/* 开发组织信息 */} +
+

+ 软件与数据小组 +

+

+ 提供技术支持 +

+
- {/* 联系方式 */} -
-
- - 628118 -
-
- - gcsjs6@tx3l.nb.kj -
-
+ {/* 联系方式 */} +
+
+ + + 628118 + +
+
+ + + gcsjs6@tx3l.nb.kj + +
+
- {/* 系统链接 */} -
-
- - - - - - + {/* 系统链接 */} +
+ -
-
+ + + +
+
+
- {/* 版权信息 */} -
-

- © {new Date().getFullYear()} 南天烽火. All rights reserved. -

-
-
-
- ); + {/* 版权信息 */} +
+

+ © {new Date().getFullYear()} 南天烽火. All rights + reserved. +

+
+
+ + ); } diff --git a/apps/web/src/app/main/layout/MainHeader.tsx b/apps/web/src/app/main/layout/MainHeader.tsx index 1461b4e..d6d288f 100755 --- a/apps/web/src/app/main/layout/MainHeader.tsx +++ b/apps/web/src/app/main/layout/MainHeader.tsx @@ -1,21 +1,26 @@ -import { useContext, useState } from "react"; import { Input, Layout, Avatar, Button, Dropdown } from "antd"; -import { EditFilled, SearchOutlined, UserOutlined } from "@ant-design/icons"; +import { + EditFilled, + PlusOutlined, + SearchOutlined, + UserOutlined, +} from "@ant-design/icons"; import { useAuth } from "@web/src/providers/auth-provider"; import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { UserMenu } from "./UserMenu/UserMenu"; import { NavigationMenu } from "./NavigationMenu"; import { useMainContext } from "./MainProvider"; -const { Header } = Layout; +import { Header } from "antd/es/layout/layout"; export function MainHeader() { const { isAuthenticated, user } = useAuth(); const { id } = useParams(); const navigate = useNavigate(); const { searchValue, setSearchValue } = useMainContext(); + return ( -
-
+
+
navigate("/")} @@ -24,32 +29,31 @@ export function MainHeader() {
-
-
- - } - placeholder="搜索课程" - className="w-72 rounded-full" - value={searchValue} - onChange={(e) => setSearchValue(e.target.value)} - onPressEnter={(e) => { - if ( - !window.location.pathname.startsWith( - "/courses/" - ) - ) { - navigate(`/courses/`); - window.scrollTo({ - top: 0, - behavior: "smooth", - }); - } - }} - /> -
+
+
+ + } + placeholder="搜索课程" + className="w-96 rounded-full" + value={searchValue} + onChange={(e) => setSearchValue(e.target.value)} + onPressEnter={(e) => { + if ( + !window.location.pathname.startsWith("/courses/") && + !window.location.pathname.startsWith("my") + ) { + navigate(`/courses/`); + window.scrollTo({ + top: 0, + behavior: "smooth", + }); + } + }} + /> +
{isAuthenticated && ( <> + )} {isAuthenticated ? ( ) : ( @@ -77,6 +90,6 @@ export function MainHeader() { )}
-
+
); } diff --git a/apps/web/src/app/main/layout/MainLayout.tsx b/apps/web/src/app/main/layout/MainLayout.tsx index ccba498..fa6627a 100755 --- a/apps/web/src/app/main/layout/MainLayout.tsx +++ b/apps/web/src/app/main/layout/MainLayout.tsx @@ -9,13 +9,13 @@ const { Content } = Layout; export function MainLayout() { return ( - +
- + - +
); } diff --git a/apps/web/src/app/main/layout/MainProvider.tsx b/apps/web/src/app/main/layout/MainProvider.tsx old mode 100644 new mode 100755 index 0952268..979d110 --- a/apps/web/src/app/main/layout/MainProvider.tsx +++ b/apps/web/src/app/main/layout/MainProvider.tsx @@ -1,4 +1,11 @@ -import React, { createContext, ReactNode, useContext, useState } from "react"; +import { Prisma } from "packages/common/dist"; +import React, { + createContext, + ReactNode, + useContext, + useMemo, + useState, +} from "react"; interface SelectedTerms { [key: string]: string[]; // 每个 slug 对应一个 string 数组 } @@ -8,6 +15,7 @@ interface MainContextType { selectedTerms?: SelectedTerms; setSearchValue?: React.Dispatch>; setSelectedTerms?: React.Dispatch>; + searchCondition?: Prisma.PostWhereInput; } const MainContext = createContext(null); @@ -18,6 +26,29 @@ interface MainProviderProps { export function MainProvider({ children }: MainProviderProps) { const [searchValue, setSearchValue] = useState(""); const [selectedTerms, setSelectedTerms] = useState({}); // 初始化状态 + + const searchCondition: Prisma.PostWhereInput = useMemo(() => { + const containTextCondition: Prisma.StringNullableFilter = { + contains: searchValue, + mode: "insensitive" as Prisma.QueryMode, // 使用类型断言 + }; + return searchValue + ? { + OR: [ + { title: containTextCondition }, + { subTitle: containTextCondition }, + { content: containTextCondition }, + { + terms: { + some: { + name: containTextCondition, + }, + }, + }, + ], + } + : {}; + }, [searchValue]); return ( {children} diff --git a/apps/web/src/app/main/layout/NavigationMenu.tsx b/apps/web/src/app/main/layout/NavigationMenu.tsx index 53efdb3..63ab7d1 100755 --- a/apps/web/src/app/main/layout/NavigationMenu.tsx +++ b/apps/web/src/app/main/layout/NavigationMenu.tsx @@ -1,15 +1,30 @@ +import { useAuth } from "@web/src/providers/auth-provider"; import { Menu } from "antd"; +import { useMemo } from "react"; import { useNavigate, useLocation } from "react-router-dom"; -const menuItems = [ - { key: "home", path: "/", label: "首页" }, - { key: "courses", path: "/courses", label: "全部课程" }, - { key: "paths", path: "/paths", label: "学习路径" }, -]; - export const NavigationMenu = () => { const navigate = useNavigate(); + const { isAuthenticated } = useAuth(); const { pathname } = useLocation(); + + const menuItems = useMemo(() => { + const baseItems = [ + { key: "home", path: "/", label: "首页" }, + { key: "courses", path: "/courses", label: "全部课程" }, + { key: "path", path: "/path", label: "学习路径" }, + ]; + if (!isAuthenticated) { + return baseItems; + } else { + return [ + ...baseItems, + { key: "my-duty", path: "/my-duty", label: "我创建的" }, + { key: "my-learning", path: "/my-learning", label: "我学习的" }, + ]; + } + }, [isAuthenticated]); + const selectedKey = menuItems.find((item) => item.path === pathname)?.key || ""; return ( diff --git a/apps/web/src/app/main/layout/UserMenu/UserEditModal.tsx b/apps/web/src/app/main/layout/UserMenu/UserEditModal.tsx old mode 100644 new mode 100755 diff --git a/apps/web/src/app/main/layout/UserMenu/UserForm.tsx b/apps/web/src/app/main/layout/UserMenu/UserForm.tsx old mode 100644 new mode 100755 diff --git a/apps/web/src/app/main/layout/UserMenu/UserMenu.tsx b/apps/web/src/app/main/layout/UserMenu/UserMenu.tsx index ea23902..c25fc53 100755 --- a/apps/web/src/app/main/layout/UserMenu/UserMenu.tsx +++ b/apps/web/src/app/main/layout/UserMenu/UserMenu.tsx @@ -86,6 +86,20 @@ export function UserMenu() { setModalOpen(true); }, }, + { + icon: , + label: "我创建的课程", + action: () => { + navigate("/my-duty"); + }, + }, + { + icon: , + label: "我学习的课程", + action: () => { + navigate("/my-learning"); + }, + }, canManageAnyStaff && { icon: , label: "设置", @@ -222,18 +236,20 @@ export function UserMenu() { focus:ring-2 focus:ring-[#00538E]/20 group relative overflow-hidden active:scale-[0.99] - ${item.label === "注销" + ${ + item.label === "注销" ? "text-[#B22234] hover:bg-red-50/80 hover:text-red-700" : "text-[#00538E] hover:bg-[#E6EEF5] hover:text-[#003F6A]" - }`}> + }`}> + group-hover:translate-x-0.5 ${ + item.label === "注销" + ? "group-hover:text-red-600" + : "group-hover:text-[#003F6A]" + }`}> {item.icon} {item.label} diff --git a/apps/web/src/app/main/layout/UserMenu/types.ts b/apps/web/src/app/main/layout/UserMenu/types.ts old mode 100644 new mode 100755 diff --git a/apps/web/src/app/main/my-duty/page.tsx b/apps/web/src/app/main/my-duty/page.tsx new file mode 100755 index 0000000..1031d79 --- /dev/null +++ b/apps/web/src/app/main/my-duty/page.tsx @@ -0,0 +1,27 @@ +import PostList from "@web/src/components/models/course/list/PostList"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { useMainContext } from "../layout/MainProvider"; +import CourseCard from "../courses/components/CourseCard"; + +export default function MyDutyPage() { + const { user } = useAuth(); + const { searchCondition } = useMainContext(); + return ( + <> +
+ ( + + )} + params={{ + pageSize: 12, + where: { + authorId: user.id, + ...searchCondition, + }, + }} + cols={4}> +
+ + ); +} diff --git a/apps/web/src/app/main/my-learning/page.tsx b/apps/web/src/app/main/my-learning/page.tsx new file mode 100755 index 0000000..5f0a7a8 --- /dev/null +++ b/apps/web/src/app/main/my-learning/page.tsx @@ -0,0 +1,31 @@ +import PostList from "@web/src/components/models/course/list/PostList"; +import { useAuth } from "@web/src/providers/auth-provider"; +import { useMainContext } from "../layout/MainProvider"; +import CourseCard from "../courses/components/CourseCard"; + +export default function MyLearningPage() { + const { user } = useAuth(); + const { searchCondition } = useMainContext(); + return ( + <> +
+ ( + + )} + params={{ + pageSize: 12, + where: { + students: { + some: { + id: user?.id, + }, + }, + ...searchCondition, + }, + }} + cols={4}> +
+ + ); +} diff --git a/apps/web/src/app/main/path/components/PathCard.tsx b/apps/web/src/app/main/path/components/PathCard.tsx new file mode 100755 index 0000000..8ccb01f --- /dev/null +++ b/apps/web/src/app/main/path/components/PathCard.tsx @@ -0,0 +1,94 @@ +import { Card, Rate, Tag, Typography, Button } from "antd"; +import { + PlayCircleOutlined, + TeamOutlined, +} from "@ant-design/icons"; +import { PostDto, TaxonomySlug } from "@nice/common"; +import { useNavigate } from "react-router-dom"; +interface pathCardProps { + path: PostDto; +} +const { Title, Text } = Typography; +export default function PathCard({ path }: pathCardProps) { + const navigate = useNavigate(); + const handleClick = (path: PostDto) => { + navigate(`/path/editor/${path.id}`); + window.scrollTo({ top: 0, behavior: "smooth", }) + }; + return ( + handleClick(path)} + key={path.id} + hoverable + className="group overflow-hidden rounded-xl border border-gray-200 bg-white + shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2" + cover={ +
+
+ {/*
*/} +
+ }> +
+
+ {path?.terms?.map((term) => { + return ( + + {term.name} + + ); + })} +
+ + + <button> {path.title}</button> + + +
+ +
+ + {path?.depts?.length > 1 + ? `${path.depts[0].name}等` + : path?.depts?.[0]?.name} + {/* {path?.depts?.map((dept) => {return dept.name.length > 1 ?`${dept.name.slice}等`: dept.name})} */} + {/* {path?.depts?.map((dept)=>{return dept.name})} */} + +
+ + {path?.meta?.views + ? `观看次数 ${path?.meta?.views}` + : null} + +
+
+ +
+
+ + ); +} diff --git a/apps/web/src/app/main/path/components/PathFilter.tsx b/apps/web/src/app/main/path/components/PathFilter.tsx new file mode 100755 index 0000000..01442c8 --- /dev/null +++ b/apps/web/src/app/main/path/components/PathFilter.tsx @@ -0,0 +1,44 @@ + +import { api } from "@nice/client"; +import { useMainContext } from "../../layout/MainProvider"; +import TermParentSelector from "@web/src/components/models/term/term-parent-selector"; + +export default function PathFilter() { + const { data: taxonomies } = api.taxonomy.getAll.useQuery({}); + const { selectedTerms, setSelectedTerms } = useMainContext(); + const handleTermChange = (slug: string, selected: string[]) => { + setSelectedTerms({ + ...selectedTerms, + [slug]: selected, // 更新对应 slug 的选择 + }); + }; + return ( +
+ {taxonomies?.map((tax, index) => { + const items = Object.entries(selectedTerms).find( + ([key, items]) => key === tax.slug + )?.[1]; + return ( +
+

+ {tax?.name} +

+ + handleTermChange( + tax?.slug, + selected as string[] + ) + } + taxonomyId={tax?.id} + > + +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/app/main/path/components/PathListContainer.tsx b/apps/web/src/app/main/path/components/PathListContainer.tsx new file mode 100755 index 0000000..125d9e8 --- /dev/null +++ b/apps/web/src/app/main/path/components/PathListContainer.tsx @@ -0,0 +1,54 @@ +import PostList from "@web/src/components/models/course/list/PostList"; +import { useMainContext } from "../../layout/MainProvider"; +import { PostType, Prisma } from "@nice/common"; +import { useMemo } from "react"; +import PathCard from "./PathCard"; + +export function PathListContainer() { + const { searchValue, selectedTerms } = useMainContext(); + const termFilters = useMemo(() => { + return Object.entries(selectedTerms) + .filter(([, terms]) => terms.length > 0) + .map(([, terms]) => terms); + }, [selectedTerms]); + const searchCondition: Prisma.StringNullableFilter = { + contains: searchValue, + mode: "insensitive" as Prisma.QueryMode, // 使用类型断言 + }; + return ( + <> + } + params={{ + pageSize: 12, + where: { + type: PostType.PATH, + AND: termFilters.map((termFilter) => ({ + terms: { + some: { + id: { + in: termFilter, // 确保至少有一个 term.id 在当前 termFilter 中 + }, + }, + }, + })), + OR: [ + { title: searchCondition }, + { subTitle: searchCondition }, + { content: searchCondition }, + { + terms: { + some: { + name: searchCondition, + }, + }, + }, + ], + }, + }} + cols={4}> + + ); +} + +export default PathListContainer; diff --git a/apps/web/src/app/main/path/editor/page.tsx b/apps/web/src/app/main/path/editor/page.tsx new file mode 100755 index 0000000..bffdec2 --- /dev/null +++ b/apps/web/src/app/main/path/editor/page.tsx @@ -0,0 +1,10 @@ +import MindEditor from "@web/src/components/common/editor/MindEditor"; +import { useParams } from "react-router-dom"; + +export default function PathEditorPage() { + const { id } = useParams(); + + return
+ +
+} diff --git a/apps/web/src/app/main/path/layout/PathListLayout.tsx b/apps/web/src/app/main/path/layout/PathListLayout.tsx new file mode 100755 index 0000000..19ebe94 --- /dev/null +++ b/apps/web/src/app/main/path/layout/PathListLayout.tsx @@ -0,0 +1,20 @@ +import PathFilter from "../components/PathFilter"; +import PathListContainer from "../components/PathListContainer"; + +export function PathListLayout() { + return ( + <> +
+
+
+ +
+
+ +
+
+
+ + ); +} +export default PathListLayout; diff --git a/apps/web/src/app/main/path/page.tsx b/apps/web/src/app/main/path/page.tsx new file mode 100755 index 0000000..825f3f7 --- /dev/null +++ b/apps/web/src/app/main/path/page.tsx @@ -0,0 +1,5 @@ +import PathListLayout from "./layout/PathListLayout"; + +export default function PathPage() { + return +} diff --git a/apps/web/src/app/main/paths/page.tsx b/apps/web/src/app/main/paths/page.tsx deleted file mode 100755 index 9624011..0000000 --- a/apps/web/src/app/main/paths/page.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import MindEditor from "@web/src/components/common/editor/MindEditor"; - -export default function PathsPage() { - // return ; - return <>123 -} diff --git a/apps/web/src/components/common/container/CollapsibleContent.tsx b/apps/web/src/components/common/container/CollapsibleContent.tsx old mode 100644 new mode 100755 diff --git a/apps/web/src/components/common/editor/MindEditor.tsx b/apps/web/src/components/common/editor/MindEditor.tsx index cd06b8e..dd6306e 100755 --- a/apps/web/src/components/common/editor/MindEditor.tsx +++ b/apps/web/src/components/common/editor/MindEditor.tsx @@ -1,27 +1,197 @@ -import { MindElixirInstance } from "mind-elixir"; -import { useRef, useEffect } from "react"; -import MindElixir from "mind-elixir"; +import { Button, Card, Empty, Form, Space, Spin, message, theme } from 'antd'; +import NodeMenu from './NodeMenu'; +import { useEntity, api, usePost } from '@nice/client'; +import { ObjectType, postDetailSelect, PostDto, PostType, Prisma, Taxonomy } from '@nice/common'; +import TermSelect from '../../models/term/term-select'; +import DepartmentSelect from '../../models/department/department-select'; +import { useEffect, useRef, useState } from 'react'; +import toast from 'react-hot-toast'; +import { MindElixirInstance } from 'mind-elixir'; +import MindElixir from 'mind-elixir'; +import { useTusUpload } from '@web/src/hooks/useTusUpload'; +import { useNavigate } from 'react-router-dom'; +const MIND_OPTIONS = { + direction: MindElixir.SIDE, + draggable: true, + contextMenu: true, + toolBar: true, + nodeMenu: true, + keypress: true, + locale: 'zh_CN' as const, + theme: { + name: 'Latte', + palette: [ + '#dd7878', + '#ea76cb', + '#8839ef', + '#e64553', + '#fe640b', + '#df8e1d', + '#40a02b', + '#209fb5', + '#1e66f5', + '#7287fd', + ], + cssVar: { + '--main-color': '#444446', + '--main-bgcolor': '#ffffff', + '--color': '#777777', + '--bgcolor': '#f6f6f6', + '--panel-color': '#444446', + '--panel-bgcolor': '#ffffff', + '--panel-border-color': '#eaeaea', + }, + } +}; +export default function MindEditor({ id }: { id?: string }) { + const containerRef = useRef(null); + const [instance, setInstance] = useState(null); -export default function MindEditor() { - const me = useRef(); + const { data: post, isLoading }: { data: PostDto, isLoading: boolean } = api.post.findFirst.useQuery({ + where: { + id + }, + select: postDetailSelect + }) + const navigate = useNavigate() + const { create, update } = usePost(); + const { data: taxonomies } = api.taxonomy.getAll.useQuery({ + type: ObjectType.COURSE, + }); + const { handleFileUpload } = useTusUpload() + const [form] = Form.useForm() useEffect(() => { - const instance = new MindElixir({ - el: "#map", - direction: MindElixir.SIDE, - draggable: true, // default true - contextMenu: true, // default true - toolBar: true, // default true - nodeMenu: true, // default true - keypress: true, // default true - locale: "zh_CN", + if (post && form && instance && id) { + console.log(post) + instance.refresh((post as any).meta) + const deptIds = (post?.depts || [])?.map((dept) => dept.id); + const formData = { + title: post.title, + deptIds: deptIds, + }; + post.terms?.forEach((term) => { + formData[term.taxonomyId] = term.id; // 假设 taxonomyName 是您在 Form.Item 中使用的 name + }); + form.setFieldsValue(formData); + } + }, [post, form, instance, id]); + + useEffect(() => { + if (!containerRef.current) return; + const mind = new MindElixir({ + ...MIND_OPTIONS, + el: containerRef.current, }); - // instance.install(NodeMenu); - instance.init(MindElixir.new("新主题")); - me.current = instance; + mind.init(MindElixir.new('新学习路径')); + containerRef.current.hidden = true; + setInstance(mind); }, []); + useEffect(() => { + if ((!id || post) && instance) { + containerRef.current.hidden = false + instance.toCenter() + instance.refresh((post as any)?.meta) + } + }, [id, post, instance]) + const handleSave = async () => { + if (!instance) return; + const values = form.getFieldsValue() + const imgBlob = await instance?.exportPng() + handleFileUpload(imgBlob, async (result) => { + const termIds = taxonomies.map((tax) => values[tax.id]).filter((id) => id); + const deptIds = (values?.deptIds || []) as string[]; + const { theme, ...data } = instance.getData(); + try { + if (post && id) { + const params: Prisma.PostUpdateArgs = { + where: { + id + }, + data: { + title: data.nodeData.topic, + meta: { ...data, thumbnail: result.compressedUrl }, + terms: { + set: termIds.map((id) => ({ id })) + }, + depts: { + set: deptIds.map((id) => ({ id })), + }, + updatedAt: new Date() + } + } + await update.mutateAsync(params); + toast.success('更新成功'); + } else { + const params: Prisma.PostCreateInput = { + type: PostType.PATH, + title: data.nodeData.topic, + meta: { ...data, thumbnail: result.compressedUrl }, + terms: { + connect: termIds.map((id) => ({ id })) + }, + depts: { + connect: deptIds.map((id) => ({ id })), + }, + updatedAt: new Date() + }; + + const res = await create.mutateAsync({ data: params }); + navigate(`/path/editor/${res.id}`, { replace: true }) + toast.success('创建成功'); + } + } catch (error) { + toast.error('保存失败'); + throw error; + } + console.log(result) + }, (error) => { }, `mind-thumb-${new Date().toString()}`) + + + }; return ( -
-
+
+ {taxonomies && ( +
{ + console.log(values) + }} form={form} className=' bg-white p-2 '> +
+
+ {taxonomies.map((tax, index) => ( + + + + + ))} + + + +
+ +
+
+ ) + } +
+ {instance && ( + )} + {isLoading &&
+ +
} + {!post && id && !isLoading &&
+ +
}
); } diff --git a/apps/web/src/components/common/editor/NodeMenu.tsx b/apps/web/src/components/common/editor/NodeMenu.tsx new file mode 100755 index 0000000..a73603f --- /dev/null +++ b/apps/web/src/components/common/editor/NodeMenu.tsx @@ -0,0 +1,195 @@ +import React, { useState, useEffect, useRef } 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' // 黄色系 +]; + +interface NodeMenuProps { + mind: MindElixirInstance; +} + +const NodeMenu: React.FC = ({ mind }) => { + const [isOpen, setIsOpen] = useState(false); + const [selectedFontColor, setSelectedFontColor] = useState(''); + const [selectedBgColor, setSelectedBgColor] = useState(''); + const [selectedSize, setSelectedSize] = useState(''); + const [isBold, setIsBold] = useState(false); + const [url, setUrl] = useState(''); + const containerRef = useRef(null); + + 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]); + + useEffect(() => { + if (containerRef.current && mind.container) { + mind.container.appendChild(containerRef.current); + } + + }, [mind.container]); + + const handleColorChange = (type: "font" | "background", color: string) => { + if (type === 'font') { + setSelectedFontColor(color); + } else { + setSelectedBgColor(color); + } + const patch = { style: {} as any }; + if (type === 'font') { + patch.style.color = color; + } else { + patch.style.background = color; + } + mind.reshapeNode(mind.currentNode, patch); + }; + + const handleSizeChange = (size: string) => { + setSelectedSize(size); + mind.reshapeNode(mind.currentNode, { style: { fontSize: size } }); + }; + + const handleBoldToggle = () => { + const fontWeight = isBold ? '' : 'bold'; + setIsBold(!isBold); + mind.reshapeNode(mind.currentNode, { style: { fontWeight } }); + }; + + const handleUrlChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setUrl(value); + mind.reshapeNode(mind.currentNode, { hyperLink: value }); + }; + + return ( +
+
+ {/* Font Size Selector */} +
+

文字样式

+
+ } + /> + {url && !/^https?:\/\/\S+$/.test(url) && ( +

请输入有效的URL地址

+ )} +
+
+
+ ); +}; + +export default NodeMenu; diff --git a/apps/web/src/components/common/editor/i18n.ts b/apps/web/src/components/common/editor/i18n.ts new file mode 100755 index 0000000..ad60d5f --- /dev/null +++ b/apps/web/src/components/common/editor/i18n.ts @@ -0,0 +1,152 @@ +interface I18n { + addChild: string + addParent: string + addSibling: string + removeNode: string + focus: string + cancelFocus: string + moveUp: string + moveDown: string + link: string + clickTips: string + font: string + background: string + tag: string + icon: string + tagsSeparate: string + iconsSeparate: string + url: string + memo?: string +} + +const cn: I18n = { + addChild: '插入子节点', + addParent: '插入父节点', + addSibling: '插入同级节点', + removeNode: '删除节点', + focus: '专注', + cancelFocus: '取消专注', + moveUp: '上移', + moveDown: '下移', + link: '连接', + clickTips: '请点击目标节点', + font: '文字', + background: '背景', + tag: '标签', + icon: '图标', + tagsSeparate: '多个标签半角逗号分隔', + iconsSeparate: '多个图标半角逗号分隔', + url: 'URL', +} + +interface I18nCollection { + cn: I18n + zh_CN: I18n + zh_TW: I18n + en: I18n + ru: I18n + ja: I18n + pt: I18n +} + +const i18n: I18nCollection = { + cn, + zh_CN: cn, + zh_TW: { + addChild: '插入子節點', + addParent: '插入父節點', + addSibling: '插入同級節點', + removeNode: '刪除節點', + focus: '專注', + cancelFocus: '取消專注', + moveUp: '上移', + moveDown: '下移', + link: '連接', + clickTips: '請點擊目標節點', + font: '文字', + background: '背景', + tag: '標簽', + icon: '圖標', + tagsSeparate: '多個標簽半角逗號分隔', + iconsSeparate: '多個圖標半角逗號分隔', + url: 'URL', + }, + en: { + addChild: 'Add child', + addParent: 'Add parent', + addSibling: 'Add sibling', + removeNode: 'Remove node', + focus: 'Focus Mode', + cancelFocus: 'Cancel Focus Mode', + moveUp: 'Move up', + moveDown: 'Move down', + link: 'Link', + clickTips: 'Please click the target node', + font: 'Font', + background: 'Background', + tag: 'Tag', + icon: 'Icon', + tagsSeparate: 'Separate tags by comma', + iconsSeparate: 'Separate icons by comma', + url: 'URL', + }, + ru: { + addChild: 'Добавить дочерний элемент', + addParent: 'Добавить родительский элемент', + addSibling: 'Добавить на этом уровне', + removeNode: 'Удалить узел', + focus: 'Режим фокусировки', + cancelFocus: 'Отменить режим фокусировки', + moveUp: 'Поднять выше', + moveDown: 'Опустить ниже', + link: 'Ссылка', + clickTips: 'Пожалуйста, нажмите на целевой узел', + font: 'Цвет шрифта', + background: 'Цвет фона', + tag: 'Тег', + icon: 'Иконка', + tagsSeparate: 'Разделяйте теги запятой', + iconsSeparate: 'Разделяйте иконки запятой', + url: 'URL', + }, + ja: { + addChild: '子ノードを追加する', + addParent: '親ノードを追加します', + addSibling: '兄弟ノードを追加する', + removeNode: 'ノードを削除', + focus: '集中', + cancelFocus: '集中解除', + moveUp: '上へ移動', + moveDown: '下へ移動', + link: 'コネクト', + clickTips: 'ターゲットノードをクリックしてください', + font: 'フォント', + background: 'バックグラウンド', + tag: 'タグ', + icon: 'アイコン', + tagsSeparate: '複数タグはカンマ区切り', + iconsSeparate: '複数アイコンはカンマ区切り', + url: 'URL', + }, + pt: { + addChild: 'Adicionar item filho', + addParent: 'Adicionar item pai', + addSibling: 'Adicionar item irmao', + removeNode: 'Remover item', + focus: 'Modo Foco', + cancelFocus: 'Cancelar Modo Foco', + moveUp: 'Mover para cima', + moveDown: 'Mover para baixo', + link: 'Link', + clickTips: 'Favor clicar no item alvo', + font: 'Fonte', + background: 'Cor de fundo', + tag: 'Tag', + icon: 'Icone', + tagsSeparate: 'Separe tags por virgula', + iconsSeparate: 'Separe icones por virgula', + url: 'URL', + }, +} + +export default i18n \ No newline at end of file diff --git a/apps/web/src/components/common/editor/types.ts b/apps/web/src/components/common/editor/types.ts new file mode 100755 index 0000000..3810ba7 --- /dev/null +++ b/apps/web/src/components/common/editor/types.ts @@ -0,0 +1,14 @@ +import { MindElixirInstance, MindElixirData } from 'mind-elixir'; +import { PostType, ObjectType } from '@nice/common'; + +export interface MindEditorProps { + initialData?: MindElixirData; + onSave?: (data: MindElixirData) => Promise; + taxonomyType?: ObjectType; +} + +export interface MindEditorState { + instance: MindElixirInstance | null; + isSaving: boolean; + error: Error | null; +} diff --git a/apps/web/src/components/common/input/InputList.tsx b/apps/web/src/components/common/input/InputList.tsx old mode 100644 new mode 100755 diff --git a/apps/web/src/components/common/uploader/MultiAvatarUploader.tsx b/apps/web/src/components/common/uploader/MultiAvatarUploader.tsx old mode 100644 new mode 100755 diff --git a/apps/web/src/components/common/uploader/ResourceShower.tsx b/apps/web/src/components/common/uploader/ResourceShower.tsx old mode 100644 new mode 100755 diff --git a/apps/web/src/components/common/uploader/TusUploader.tsx b/apps/web/src/components/common/uploader/TusUploader.tsx index 1811135..17e2685 100755 --- a/apps/web/src/components/common/uploader/TusUploader.tsx +++ b/apps/web/src/components/common/uploader/TusUploader.tsx @@ -40,7 +40,6 @@ export const TusUploader = ({ })) || [] ); const [uploadResults, setUploadResults] = useState(value || []); - const handleRemoveFile = useCallback( (fileId: string) => { setCompletedFiles((prev) => diff --git a/apps/web/src/components/common/uploader/utils.tsx b/apps/web/src/components/common/uploader/utils.tsx old mode 100644 new mode 100755 diff --git a/apps/web/src/components/models/course/detail/CourseDetailContext.tsx b/apps/web/src/components/models/course/detail/CourseDetailContext.tsx index 2738b7b..34ca11c 100755 --- a/apps/web/src/components/models/course/detail/CourseDetailContext.tsx +++ b/apps/web/src/components/models/course/detail/CourseDetailContext.tsx @@ -28,6 +28,7 @@ interface CourseDetailContextType { isHeaderVisible: boolean; // 新增 setIsHeaderVisible: (visible: boolean) => void; // 新增 canEdit?: boolean; + userIsLearning?: boolean; } interface CourseFormProviderProps { @@ -39,31 +40,25 @@ export const CourseDetailContext =createContext( export function CourseDetailProvider({children,editId}: CourseFormProviderProps) { const navigate = useNavigate(); const { read } = useVisitor(); - const { user, hasSomePermissions } = useAuth(); + const { user, hasSomePermissions, isAuthenticated } = useAuth(); const { lectureId } = useParams(); const { data: course, isLoading }: { data: CourseDto; isLoading: boolean } = (api.post as any).findFirst.useQuery( { where: { id: editId }, - // include: { - // // sections: { include: { lectures: true } }, - // enrollments: true, - // terms:true - // }, - - select:courseDetailSelect + select: courseDetailSelect, }, { enabled: Boolean(editId) } ); + + const userIsLearning = useMemo(() => { + return (course?.studentIds || []).includes(user?.id); + }, [user, course, isLoading]); const canEdit = useMemo(() => { - //先判断登陆再判断是否是作者,三个条件满足一个就有编辑权限 - const isAuthor = user?.id === course?.authorId; - const isDept = course?.depts - ?.map((dept) => dept.id) - .includes(user?.deptId); + const isAuthor = isAuthenticated && user?.id === course?.authorId; const isRoot = hasSomePermissions(RolePerms?.MANAGE_ANY_POST); - return isAuthor || isDept || isRoot; + return isAuthor || isRoot; }, [user, course]); const [selectedLectureId, setSelectedLectureId] = useState< @@ -107,6 +102,7 @@ export function CourseDetailProvider({children,editId}: CourseFormProviderProps) isHeaderVisible, setIsHeaderVisible, canEdit, + userIsLearning, }}> {children} diff --git a/apps/web/src/components/models/course/detail/CourseDetailDescription.tsx b/apps/web/src/components/models/course/detail/CourseDetailDescription.tsx index dbe6ce2..6fdabf6 100755 --- a/apps/web/src/components/models/course/detail/CourseDetailDescription.tsx +++ b/apps/web/src/components/models/course/detail/CourseDetailDescription.tsx @@ -3,11 +3,13 @@ import React, { useContext, useMemo } from "react"; import { Image, Typography, Skeleton, Tag } from "antd"; // 引入 antd 组件 import { CourseDetailContext } from "./CourseDetailContext"; import { + BookOutlined, CalendarOutlined, EditTwoTone, EyeOutlined, PlayCircleOutlined, ReloadOutlined, + TeamOutlined, } from "@ant-design/icons"; import dayjs from "dayjs"; import { useNavigate, useParams } from "react-router-dom"; @@ -22,14 +24,15 @@ export const CourseDetailDescription: React.FC = () => { const navigate = useNavigate(); const { id } = useParams(); return ( -
+ //
+
{isLoading || !course ? ( ) : (
- {!selectedLectureId && ( + {!selectedLectureId && course?.meta?.thumbnail && ( <> -
+
{ onClick={() => { setSelectedLectureId(firstLectureId); }} - className="w-full h-full absolute top-0 z-10 bg-black opacity-30 transition-opacity duration-300 ease-in-out hover:opacity-70 cursor-pointer"> - + className="w-full h-full absolute top-0 z-10 bg-[rgba(0,0,0,0.3)] transition-all duration-300 ease-in-out hover:bg-[rgba(0,0,0,0.7)] cursor-pointer"> +
+ 点击进入学习 +
)}
{"课程简介:"}
-
-
{course?.subTitle}
- { - course.terms.map((term) => { +
+
+ {course?.subTitle &&
{course?.subTitle}
} + {course.terms.map((term) => { return ( {term.name} - ) - }) - } -
-
- - -
- - {dayjs(course?.createdAt).format("YYYY年M月D日")} + ); + })}
-
- - {dayjs(course?.updatedAt).format("YYYY年M月D日")} -
-
- -
{course?.meta?.views || 0}
-
- { - canEdit && ( -
{ - const url = id - ? `/course/${id}/editor` - : "/course/editor"; - navigate(url); - }} - > - - {"点击编辑课程"} -
- ) - }
{ // 创建滚动动画效果 - const { course, isLoading, lecture, lectureIsLoading, selectedLectureId } = - useContext(CourseDetailContext); + const { + course, + isLoading, + canEdit, + lecture, + lectureIsLoading, + selectedLectureId, + } = useContext(CourseDetailContext); + const navigate = useNavigate(); const { scrollY } = useScroll(); const videoOpacity = useTransform(scrollY, [0, 200], [1, 0.8]); return ( @@ -29,7 +46,7 @@ export const CourseDetailDisplayArea: React.FC = () => { {lectureIsLoading && ( )} - + {selectedLectureId && !lectureIsLoading && lecture?.meta?.type === LectureType.VIDEO && ( @@ -63,7 +80,7 @@ export const CourseDetailDisplayArea: React.FC = () => {
)} -
+
{/* 课程内容区域 */} diff --git a/apps/web/src/components/models/course/detail/CourseDetailHeader/CourseDetailHeader.tsx b/apps/web/src/components/models/course/detail/CourseDetailHeader/CourseDetailHeader.tsx index 401484a..15eb7e5 100755 --- a/apps/web/src/components/models/course/detail/CourseDetailHeader/CourseDetailHeader.tsx +++ b/apps/web/src/components/models/course/detail/CourseDetailHeader/CourseDetailHeader.tsx @@ -10,49 +10,75 @@ 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 [searchValue, setSearchValue] = useState(""); const { id } = useParams(); const { isAuthenticated, user, hasSomePermissions, hasEveryPermissions } = useAuth(); const navigate = useNavigate(); - const { course, canEdit } = useContext(CourseDetailContext); + const { course, canEdit, userIsLearning } = useContext(CourseDetailContext); + const { update } = useStaff(); return ( -
-
-
- { - navigate("/"); - }} - className="text-2xl text-primary-500 hover:scale-105 cursor-pointer" - /> - -
- {course?.title} +
+
+
+
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"> + 烽火慕课
- {/* */} +
+
+ {isAuthenticated && ( + + )} {canEdit && ( - <> - - + )} {isAuthenticated ? ( diff --git a/apps/web/src/components/models/course/detail/CourseDetailHeader/CourseDetailHeader_BACKUP.tsx b/apps/web/src/components/models/course/detail/CourseDetailHeader/CourseDetailHeader_BACKUP.tsx deleted file mode 100755 index 0fc9815..0000000 --- a/apps/web/src/components/models/course/detail/CourseDetailHeader/CourseDetailHeader_BACKUP.tsx +++ /dev/null @@ -1,77 +0,0 @@ -// // components/Header.tsx -// import { motion, useScroll, useTransform } from "framer-motion"; -// import { useContext, useEffect, useState } from "react"; -// import { CourseDetailContext } from "../CourseDetailContext"; -// import { Avatar, Button, Dropdown } from "antd"; -// import { UserOutlined } from "@ant-design/icons"; -// import { UserMenu } from "@web/src/app/main/layout/UserMenu"; -// import { useAuth } from "@web/src/providers/auth-provider"; - -// export const CourseDetailHeader = () => { -// const { scrollY } = useScroll(); -// const { user, isAuthenticated } = useAuth(); -// const [lastScrollY, setLastScrollY] = useState(0); -// const { course, isHeaderVisible, setIsHeaderVisible, lecture } = -// useContext(CourseDetailContext); -// useEffect(() => { -// const updateHeader = () => { -// const current = scrollY.get(); -// const direction = current > lastScrollY ? "down" : "up"; - -// if (direction === "down" && current > 100) { -// setIsHeaderVisible(false); -// } else if (direction === "up") { -// setIsHeaderVisible(true); -// } - -// setLastScrollY(current); -// }; - -// // 使用 requestAnimationFrame 来优化性能 -// const unsubscribe = scrollY.on("change", () => { -// requestAnimationFrame(updateHeader); -// }); - -// return () => { -// unsubscribe(); -// }; -// }, [lastScrollY, scrollY, setIsHeaderVisible]); - -// return ( -// -//
-//
-//

{course?.title}

-//
- -// {isAuthenticated ? ( -// } -// trigger={["click"]} -// placement="bottomRight"> -// -// {(user?.showname || -// user?.username || -// "")[0]?.toUpperCase()} -// -// -// ) : ( -// -// )} -//
-//
-// ); -// }; - -// export default CourseDetailHeader; diff --git a/apps/web/src/components/models/course/detail/CourseDetailLayout.tsx b/apps/web/src/components/models/course/detail/CourseDetailLayout.tsx index 42b3d55..56831e4 100755 --- a/apps/web/src/components/models/course/detail/CourseDetailLayout.tsx +++ b/apps/web/src/components/models/course/detail/CourseDetailLayout.tsx @@ -19,12 +19,12 @@ export default function CourseDetailLayout() { const [isSyllabusOpen, setIsSyllabusOpen] = useState(true); return (
- + {/* */} {/* 添加 Header 组件 */} {/* 主内容区域 */} {/* 为了防止 Header 覆盖内容,添加上边距 */} -
+
{" "} {/* 添加这个包装 div */} +
+ {course?.title} +
+
+
+ + {"创建于:"} + {dayjs(course?.createdAt).format("YYYY年M月D日")} +
+
+ + {"更新于:"} + {dayjs(course?.updatedAt).format("YYYY年M月D日")} +
+
+ +
{`观看次数${course?.meta?.views || 0}`}
+
+
+ +
{`学习人数${course?.studentIds?.length || 0}`}
+
+ +
+
+ ); +} diff --git a/apps/web/src/components/models/course/detail/CoursePreview/CoursePreview.tsx b/apps/web/src/components/models/course/detail/CoursePreview/CoursePreview.tsx old mode 100644 new mode 100755 diff --git a/apps/web/src/components/models/course/detail/CoursePreview/couresPreviewTabmsg.tsx b/apps/web/src/components/models/course/detail/CoursePreview/couresPreviewTabmsg.tsx old mode 100644 new mode 100755 diff --git a/apps/web/src/components/models/course/detail/CoursePreview/courseCatalog.tsx b/apps/web/src/components/models/course/detail/CoursePreview/courseCatalog.tsx old mode 100644 new mode 100755 diff --git a/apps/web/src/components/models/course/detail/CourseSyllabus/CourseSyllabus.tsx b/apps/web/src/components/models/course/detail/CourseSyllabus/CourseSyllabus.tsx index 9e6cf84..07b704a 100755 --- a/apps/web/src/components/models/course/detail/CourseSyllabus/CourseSyllabus.tsx +++ b/apps/web/src/components/models/course/detail/CourseSyllabus/CourseSyllabus.tsx @@ -45,7 +45,6 @@ export const CourseSyllabus: React.FC = ({ block: "start", }); }; - return ( <> {/* 收起按钮直接显示 */} @@ -59,9 +58,9 @@ export const CourseSyllabus: React.FC = ({ style={{ width: isOpen ? "25%" : "0", right: 0, - top: isHeaderVisible ? "64px" : "0", + top: isHeaderVisible ? "56px" : "0", }} - className="fixed top-0 bottom-0 z-20 bg-white shadow-xl"> + className="fixed top-0 bottom-0 z-10 bg-white shadow-xl"> {isOpen && (
diff --git a/apps/web/src/components/models/course/detail/CourseSyllabus/LectureItem.tsx b/apps/web/src/components/models/course/detail/CourseSyllabus/LectureItem.tsx index ffc0a49..39bce21 100755 --- a/apps/web/src/components/models/course/detail/CourseSyllabus/LectureItem.tsx +++ b/apps/web/src/components/models/course/detail/CourseSyllabus/LectureItem.tsx @@ -4,6 +4,7 @@ import { Lecture, LectureType, LessonTypeLabel } from "@nice/common"; import React, { useMemo } from "react"; import { ClockCircleOutlined, + EyeOutlined, FileTextOutlined, PlayCircleOutlined, } from "@ant-design/icons"; // 使用 Ant Design 图标 @@ -43,13 +44,17 @@ export const LectureItem: React.FC = ({ {LessonTypeLabel[lecture?.meta?.type]}
)} -
-

{lecture.title}

+
+

{lecture.title}

{lecture.subTitle && ( -

+ {lecture.subTitle} -

+ )} +
+ + {lecture?.meta?.views ? lecture?.meta?.views : 0} +
); diff --git a/apps/web/src/components/models/course/detail/JoinLearingButton.tsx b/apps/web/src/components/models/course/detail/JoinLearingButton.tsx new file mode 100644 index 0000000..68c4243 --- /dev/null +++ b/apps/web/src/components/models/course/detail/JoinLearingButton.tsx @@ -0,0 +1,94 @@ +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 { + CheckCircleFilled, + CheckCircleOutlined, + CloseCircleFilled, + CloseCircleOutlined, + EditFilled, + EditTwoTone, + LoginOutlined, +} from "@ant-design/icons"; + +export default function CourseOperationBtns() { + const { id } = useParams(); + const { isAuthenticated, user, hasSomePermissions, hasEveryPermissions } = + useAuth(); + const navigate = useNavigate(); + const { course, canEdit, userIsLearning } = 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 }, + }, + }, + }); + } else { + await update.mutateAsync({ + where: { id: user?.id }, + data: { + learningPosts: { + disconnect: { + id: course.id, + }, + }, + }, + }); + } + }; + return ( + <> + {isAuthenticated && ( +
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 ? ( + + ) : ( + + ) + ) : ( + + )} + + {userIsLearning + ? isHovered + ? "退出学习" + : "正在学习" + : "加入学习"} + +
+ )} + {canEdit && ( +
{ + const url = course?.id + ? `/course/${course?.id}/editor` + : "/course/editor"; + navigate(url); + }}> + + {"编辑课程"} +
+ )} + + ); +} diff --git a/apps/web/src/components/models/course/editor/context/CourseEditorContext.tsx b/apps/web/src/components/models/course/editor/context/CourseEditorContext.tsx index 0fd8040..88571a3 100755 --- a/apps/web/src/components/models/course/editor/context/CourseEditorContext.tsx +++ b/apps/web/src/components/models/course/editor/context/CourseEditorContext.tsx @@ -99,10 +99,10 @@ export function CourseFormProvider({ }), }, terms: { - connect: termIds.map((id) => ({ id })), // 转换成 connect 格式 + set: termIds.map((id) => ({ id })), // 转换成 connect 格式 }, depts: { - connect: deptIds.map((id) => ({ id })), + set: deptIds.map((id) => ({ id })), }, }; // 删除原始的 taxonomy 字段 diff --git a/apps/web/src/components/models/course/editor/form/CourseBasicForm.tsx b/apps/web/src/components/models/course/editor/form/CourseBasicForm.tsx index cf683f1..4e41477 100755 --- a/apps/web/src/components/models/course/editor/form/CourseBasicForm.tsx +++ b/apps/web/src/components/models/course/editor/form/CourseBasicForm.tsx @@ -32,7 +32,7 @@ export function CourseBasicForm() { + rules={[{ max: 20, message: "副标题最多20个字符" }]}> diff --git a/apps/web/src/components/models/course/list/CourseList.tsx b/apps/web/src/components/models/course/list/PostList.tsx similarity index 76% rename from apps/web/src/components/models/course/list/CourseList.tsx rename to apps/web/src/components/models/course/list/PostList.tsx index 3ba3eaf..e9e51f4 100755 --- a/apps/web/src/components/models/course/list/CourseList.tsx +++ b/apps/web/src/components/models/course/list/PostList.tsx @@ -1,10 +1,9 @@ import { Pagination, Empty, Skeleton } from "antd"; -import CourseCard from "../../../../app/main/courses/components/CourseCard"; import { courseDetailSelect, CourseDto, Prisma } from "@nice/common"; import { api } from "@nice/client"; import { DefaultArgs } from "@prisma/client/runtime/library"; import { useEffect, useMemo, useState } from "react"; -interface CourseListProps { +interface PostListProps { params?: { page?: number; pageSize?: number; @@ -13,21 +12,25 @@ interface CourseListProps { }; cols?: number; showPagination?: boolean; + renderItem: (post: any) => React.ReactNode + } -interface CoursesPagnationProps { +interface PostPagnationProps { data: { items: CourseDto[]; totalPages: number; }; isLoading: boolean; + } -export default function CourseList({ +export default function PostList({ params, cols = 3, showPagination = true, -}: CourseListProps) { + renderItem +}: PostListProps) { const [currentPage, setCurrentPage] = useState(params?.page || 1); - const { data, isLoading }: CoursesPagnationProps = + const { data, isLoading }: PostPagnationProps = api.post.findManyWithPagination.useQuery({ select: courseDetailSelect, ...params, @@ -40,7 +43,7 @@ export default function CourseList({ return 1; }, [data, isLoading]); - const courses = useMemo(() => { + const posts = useMemo(() => { if (data && !isLoading) { return data?.items; } @@ -55,19 +58,23 @@ export default function CourseList({ window.scrollTo({ top: 0, behavior: "smooth" }); } if (isLoading) { - return ; + return ( +
+ +
+ ); } return (
- {courses.length > 0 ? ( + {posts.length > 0 ? ( <>
{isLoading ? ( ) : ( - courses.map((course) => ( - - )) + posts.map((post) =>
+ {renderItem(post)} +
) )}
{showPagination && ( @@ -83,7 +90,10 @@ export default function CourseList({ )} ) : ( - +
+ + +
)}
); diff --git a/apps/web/src/components/models/term/term-parent-selector.tsx b/apps/web/src/components/models/term/term-parent-selector.tsx old mode 100644 new mode 100755 diff --git a/apps/web/src/components/presentation/video-player/VideoDisplay.tsx b/apps/web/src/components/presentation/video-player/VideoDisplay.tsx index 5b48209..32b1109 100755 --- a/apps/web/src/components/presentation/video-player/VideoDisplay.tsx +++ b/apps/web/src/components/presentation/video-player/VideoDisplay.tsx @@ -192,7 +192,7 @@ export const VideoDisplay: React.FC = ({ }, [src, onError, autoPlay]); return ( -
+