diff --git a/apps/server/src/models/post/post.service.ts b/apps/server/src/models/post/post.service.ts index 0df249e..03d89de 100755 --- a/apps/server/src/models/post/post.service.ts +++ b/apps/server/src/models/post/post.service.ts @@ -101,7 +101,6 @@ export class PostService extends BaseTreeService { }, params: { staff?: UserProfile; tx?: Prisma.TransactionClient }, ) { - const { courseDetail } = args; // If no transaction is provided, create a new one if (!params.tx) { @@ -124,7 +123,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, diff --git a/apps/web/public/logo.svg b/apps/web/public/logo.svg new file mode 100755 index 0000000..39a6980 --- /dev/null +++ b/apps/web/public/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/placeholder.webp b/apps/web/public/placeholder.webp new file mode 100644 index 0000000..1f474a5 Binary files /dev/null and b/apps/web/public/placeholder.webp differ diff --git a/apps/web/public/vite.svg b/apps/web/public/vite.svg index e7b8dfb..78260dd 100755 --- a/apps/web/public/vite.svg +++ b/apps/web/public/vite.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/apps/web/src/app/main/courses/components/CourseCard.tsx b/apps/web/src/app/main/courses/components/CourseCard.tsx deleted file mode 100755 index fa4b0d5..0000000 --- a/apps/web/src/app/main/courses/components/CourseCard.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { Card, Tag, Typography, Button } from "antd"; -import { - BookOutlined, - EyeOutlined, - PlayCircleOutlined, - TeamOutlined, -} from "@ant-design/icons"; -import { CourseDto, TaxonomySlug } from "@nice/common"; -import { useNavigate } from "react-router-dom"; - -interface CourseCardProps { - course: CourseDto; - edit?: boolean; -} -const { Title, Text } = Typography; -export default function CourseCard({ course, edit = false }: CourseCardProps) { - const navigate = useNavigate(); - const handleClick = (course: CourseDto) => { - if (!edit) { - navigate(`/course/${course.id}/detail`); - } else { - navigate(`/course/${course.id}/editor`); - } - window.scrollTo({ top: 0, behavior: "smooth" }); - }; - return ( - handleClick(course)} - key={course.id} - hoverable - className="group overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2" - cover={ -
-
- -
- -
- }> -
-
-
- {course?.terms?.map((term) => { - return ( - <> - - {term.name} - - - ); - })} -
-
- - - <button> {course.title}</button> - - -
- -
- - {course?.depts?.length > 1 - ? `${course.depts[0].name}等` - : course?.depts?.[0]?.name} - {/* {course?.depts?.map((dept) => {return dept.name.length > 1 ?`${dept.name.slice}等`: dept.name})} */} - {/* {course?.depts?.map((dept)=>{return dept.name})} */} - -
-
-
- - - {`观看次数 ${course?.meta?.views || 0}`} - - - - {`学习人数 ${course?.studentIds?.length || 0}`} - -
-
- -
-
- - ); -} diff --git a/apps/web/src/app/main/courses/components/CoursesContainer.tsx b/apps/web/src/app/main/courses/components/CoursesContainer.tsx index 847dbd3..72b9499 100755 --- a/apps/web/src/app/main/courses/components/CoursesContainer.tsx +++ b/apps/web/src/app/main/courses/components/CoursesContainer.tsx @@ -1,17 +1,14 @@ 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"; +import CourseCard from "@web/src/components/models/post/SubPost/CourseCard"; export function CoursesContainer() { - const {searchCondition, termsCondition } = useMainContext(); + const { searchCondition, termsCondition } = useMainContext(); return ( <> ( - - )} + renderItem={(post) => } params={{ pageSize: 12, where: { diff --git a/apps/web/src/app/main/courses/layout/AllCoursesLayout.tsx b/apps/web/src/app/main/courses/layout/AllCoursesLayout.tsx deleted file mode 100755 index 0dd908e..0000000 --- a/apps/web/src/app/main/courses/layout/AllCoursesLayout.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import FilterSection from "../../layout/BasePost/FilterSection"; -import CoursesContainer from "../components/CoursesContainer"; -export function AllCoursesLayout() { - return ( - <> -
-
-
- -
-
- -
-
-
- - ); -} -export default AllCoursesLayout; diff --git a/apps/web/src/app/main/courses/page.tsx b/apps/web/src/app/main/courses/page.tsx index 391c9ac..79eda89 100755 --- a/apps/web/src/app/main/courses/page.tsx +++ b/apps/web/src/app/main/courses/page.tsx @@ -1,6 +1,13 @@ import BasePostLayout from "../layout/BasePost/BasePostLayout"; import CoursesContainer from "./components/CoursesContainer"; +import { useEffect } from "react"; +import { useMainContext } from "../layout/MainProvider"; +import { PostType } from "@nice/common"; export default function CoursesPage() { + const { setSearchMode } = useMainContext(); + useEffect(() => { + setSearchMode(PostType.COURSE); + }, [setSearchMode]); return ( <> diff --git a/apps/web/src/app/main/home/components/CoursesSection.tsx b/apps/web/src/app/main/home/components/CoursesSection.tsx index bd2f45b..3280945 100755 --- a/apps/web/src/app/main/home/components/CoursesSection.tsx +++ b/apps/web/src/app/main/home/components/CoursesSection.tsx @@ -5,7 +5,8 @@ import { api } from "@nice/client"; import { CoursesSectionTag } from "./CoursesSectionTag"; import LookForMore from "./LookForMore"; import PostList from "@web/src/components/models/course/list/PostList"; -import CourseCard from "../../courses/components/CourseCard"; +import PostCard from "@web/src/components/models/post/PostCard"; +import CourseCard from "@web/src/components/models/post/SubPost/CourseCard"; interface GetTaxonomyProps { categories: string[]; isLoading: boolean; @@ -17,7 +18,7 @@ function useGetTaxonomy({ type }): GetTaxonomyProps { taxonomy: { slug: type, }, - parentId: null + parentId: null, }, take: 11, // 只取前10个 }); @@ -82,17 +83,17 @@ const CoursesSection: React.FC = ({ )}
} + renderItem={(post) => } params={{ page: 1, pageSize: initialVisibleCoursesCount, where: { terms: !(selectedCategory === "全部") ? { - some: { - name: selectedCategory, - }, - } + some: { + name: selectedCategory, + }, + } : {}, }, }} diff --git a/apps/web/src/app/main/layout/BasePost/BasePostLayout.tsx b/apps/web/src/app/main/layout/BasePost/BasePostLayout.tsx index d21eda5..09c7852 100644 --- a/apps/web/src/app/main/layout/BasePost/BasePostLayout.tsx +++ b/apps/web/src/app/main/layout/BasePost/BasePostLayout.tsx @@ -1,7 +1,18 @@ -import { ReactNode } from "react"; +import { ReactNode, useEffect } from "react"; import FilterSection from "./FilterSection"; +import { useMainContext } from "../MainProvider"; -export function BasePostLayout({ children }: { children: ReactNode }) { +export function BasePostLayout({ + children, + showSearchMode = false, +}: { + children: ReactNode; + showSearchMode?: boolean; +}) { + const { setShowSearchMode } = useMainContext(); + useEffect(() => { + setShowSearchMode(showSearchMode); + }, [showSearchMode]); return ( <>
@@ -9,7 +20,7 @@ export function BasePostLayout({ children }: { children: ReactNode }) {
-
{children}
+
{children}
diff --git a/apps/web/src/app/main/layout/BasePost/FilterSection.tsx b/apps/web/src/app/main/layout/BasePost/FilterSection.tsx index 8d9ebff..25575d1 100755 --- a/apps/web/src/app/main/layout/BasePost/FilterSection.tsx +++ b/apps/web/src/app/main/layout/BasePost/FilterSection.tsx @@ -1,17 +1,12 @@ -import { Checkbox, Divider, Radio, Space, Spin } from "antd"; - -import { TaxonomySlug, TermDto } from "@nice/common"; - -import { useEffect, useMemo, useState } from "react"; +import { Divider } from "antd"; import { api } from "@nice/client"; -import { useSearchParams } from "react-router-dom"; -import TermSelect from "@web/src/components/models/term/term-select"; import { useMainContext } from "../MainProvider"; import TermParentSelector from "@web/src/components/models/term/term-parent-selector"; - +import SearchModeRadio from "./SearchModeRadio"; export default function FilterSection() { const { data: taxonomies } = api.taxonomy.getAll.useQuery({}); - const { selectedTerms, setSelectedTerms } = useMainContext(); + const { selectedTerms, setSelectedTerms, showSearchMode } = + useMainContext(); const handleTermChange = (slug: string, selected: string[]) => { setSelectedTerms({ ...selectedTerms, @@ -20,6 +15,7 @@ export default function FilterSection() { }; return (
+ {showSearchMode && } {taxonomies?.map((tax, index) => { const items = Object.entries(selectedTerms).find( ([key, items]) => key === tax.slug diff --git a/apps/web/src/app/main/layout/BasePost/SearchModeRadio.tsx b/apps/web/src/app/main/layout/BasePost/SearchModeRadio.tsx new file mode 100644 index 0000000..692bfe2 --- /dev/null +++ b/apps/web/src/app/main/layout/BasePost/SearchModeRadio.tsx @@ -0,0 +1,25 @@ +import { useMainContext } from "../MainProvider"; +import { Radio, Space, Typography } from "antd"; +import { PostType } from "@nice/common"; // Assuming PostType is defined in this path + +export default function SearchModeRadio() { + const { searchMode, setSearchMode } = useMainContext(); + + const handleModeChange = (e) => { + setSearchMode(e.target.value); + }; + + return ( + +

搜索模式

+ + 课程 + 路径 + 全部 + +
+ ); +} diff --git a/apps/web/src/app/main/layout/MainHeader.tsx b/apps/web/src/app/main/layout/MainHeader.tsx index de0d1ce..1735bbd 100755 --- a/apps/web/src/app/main/layout/MainHeader.tsx +++ b/apps/web/src/app/main/layout/MainHeader.tsx @@ -1,3 +1,4 @@ + import { Input, Layout, Avatar, Button, Dropdown } from "antd"; import { EditFilled, @@ -10,8 +11,6 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { UserMenu } from "./UserMenu/UserMenu"; import { NavigationMenu } from "./NavigationMenu"; import { useMainContext } from "./MainProvider"; -import { Header } from "antd/es/layout/layout"; - export function MainHeader() { const { isAuthenticated, user } = useAuth(); const { id } = useParams(); @@ -19,34 +18,41 @@ export function MainHeader() { const { searchValue, setSearchValue } = useMainContext(); return ( -
-
-
-
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"> - 烽火慕课 -
- +
+ {/* 左侧区域 - 设置为不收缩 */} +
+ +
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 whitespace-nowrap"> + 烽火慕课
+
-
+ + {/* 中间搜索区域 - 允许适当收缩但保持可用性 */} +
} placeholder="搜索课程" - className="w-96 rounded-full" + className="w-full md:w-96 rounded-full" value={searchValue} + onClick={(e) => { + if (!window.location.pathname.startsWith("/search")) { + navigate(`/search`); + window.scrollTo({ + top: 0, + behavior: "smooth", + }); + } + }} onChange={(e) => setSearchValue(e.target.value)} onPressEnter={(e) => { - if ( - !window.location.pathname.startsWith("/courses/") && - !window.location.pathname.startsWith("/my") && - !window.location.pathname.startsWith("/path") - ) { - navigate(`/courses/`); + if (!window.location.pathname.startsWith("/search")) { + navigate(`/search`); window.scrollTo({ top: 0, behavior: "smooth", @@ -54,7 +60,11 @@ export function MainHeader() { } }} /> -
+
+ + {/* 右侧区域 - 可以灵活收缩 */} +
+
{isAuthenticated && ( <> @@ -94,3 +104,4 @@ export function MainHeader() {
); } + diff --git a/apps/web/src/app/main/layout/MainLayout.tsx b/apps/web/src/app/main/layout/MainLayout.tsx index fa6627a..739bcaa 100755 --- a/apps/web/src/app/main/layout/MainLayout.tsx +++ b/apps/web/src/app/main/layout/MainLayout.tsx @@ -11,7 +11,7 @@ export function MainLayout() {
- + diff --git a/apps/web/src/app/main/layout/MainProvider.tsx b/apps/web/src/app/main/layout/MainProvider.tsx index 21d1a4a..d928e9d 100755 --- a/apps/web/src/app/main/layout/MainProvider.tsx +++ b/apps/web/src/app/main/layout/MainProvider.tsx @@ -1,4 +1,4 @@ -import { Prisma } from "packages/common/dist"; +import { PostType, Prisma } from "@nice/common"; import React, { createContext, ReactNode, @@ -17,6 +17,12 @@ interface MainContextType { setSelectedTerms?: React.Dispatch>; searchCondition?: Prisma.PostWhereInput; termsCondition?: Prisma.PostWhereInput; + searchMode?: PostType.COURSE | PostType.PATH | "both"; + setSearchMode?: React.Dispatch< + React.SetStateAction + >; + showSearchMode?: boolean; + setShowSearchMode?: React.Dispatch>; } const MainContext = createContext(null); @@ -25,6 +31,10 @@ interface MainProviderProps { } export function MainProvider({ children }: MainProviderProps) { + const [searchMode, setSearchMode] = useState< + PostType.COURSE | PostType.PATH | "both" + >("both"); + const [showSearchMode, setShowSearchMode] = useState(false); const [searchValue, setSearchValue] = useState(""); const [selectedTerms, setSelectedTerms] = useState({}); // 初始化状态 const termFilters = useMemo(() => { @@ -79,6 +89,10 @@ export function MainProvider({ children }: MainProviderProps) { setSelectedTerms, searchCondition, termsCondition, + searchMode, + setSearchMode, + showSearchMode, + setShowSearchMode, }}> {children} diff --git a/apps/web/src/app/main/layout/NavigationMenu.tsx b/apps/web/src/app/main/layout/NavigationMenu.tsx index 557dc2c..d7aa53f 100755 --- a/apps/web/src/app/main/layout/NavigationMenu.tsx +++ b/apps/web/src/app/main/layout/NavigationMenu.tsx @@ -11,16 +11,17 @@ export const NavigationMenu = () => { const menuItems = useMemo(() => { const baseItems = [ { key: "home", path: "/", label: "首页" }, - { key: "courses", path: "/courses", label: "全部课程" }, { key: "path", path: "/path", label: "学习路径" }, + { key: "courses", path: "/courses", label: "全部课程" }, ]; + if (!isAuthenticated) { return baseItems; } else { return [ ...baseItems, - { key: "my-duty", path: "/my-duty", label: "我创建的" }, - { key: "my-learning", path: "/my-learning", label: "我的课表" }, + { key: "my-duty", path: "/my-duty", label: "我的授课" }, + { key: "my-learning", path: "/my-learning", label: "我的课程" }, { key: "my-path", path: "/my-path", label: "我的路径" }, ]; } diff --git a/apps/web/src/app/main/layout/UserMenu/UserMenu.tsx b/apps/web/src/app/main/layout/UserMenu/UserMenu.tsx index 17c3413..fc63758 100755 --- a/apps/web/src/app/main/layout/UserMenu/UserMenu.tsx +++ b/apps/web/src/app/main/layout/UserMenu/UserMenu.tsx @@ -86,7 +86,7 @@ export function UserMenu() { setModalOpen(true); }, }, - + canManageAnyStaff && { icon: , label: "设置", @@ -159,13 +159,6 @@ export function UserMenu() { aria-hidden="true" />
- - {/* 用户信息,显示在 Avatar 右侧 */} -
- - {user?.showname || user?.username} - -
diff --git a/apps/web/src/app/main/my-duty/components/MyDutyListContainer.tsx b/apps/web/src/app/main/my-duty/components/MyDutyListContainer.tsx index f656398..727e828 100644 --- a/apps/web/src/app/main/my-duty/components/MyDutyListContainer.tsx +++ b/apps/web/src/app/main/my-duty/components/MyDutyListContainer.tsx @@ -1,9 +1,9 @@ import PostList from "@web/src/components/models/course/list/PostList"; import { useAuth } from "@web/src/providers/auth-provider"; - -import CourseCard from "../../courses/components/CourseCard"; import { PostType } from "@nice/common"; import { useMainContext } from "../../layout/MainProvider"; +import PostCard from "@web/src/components/models/post/PostCard"; +import CourseCard from "@web/src/components/models/post/SubPost/CourseCard"; export default function MyDutyListContainer() { const { user } = useAuth(); @@ -11,9 +11,7 @@ export default function MyDutyListContainer() { return ( <> ( - - )} + renderItem={(post) => } params={{ pageSize: 12, where: { diff --git a/apps/web/src/app/main/my-duty/page.tsx b/apps/web/src/app/main/my-duty/page.tsx index 4028ab0..fff4c91 100755 --- a/apps/web/src/app/main/my-duty/page.tsx +++ b/apps/web/src/app/main/my-duty/page.tsx @@ -1,7 +1,13 @@ import BasePostLayout from "../layout/BasePost/BasePostLayout"; import MyDutyListContainer from "./components/MyDutyListContainer"; - +import { useEffect } from "react"; +import { useMainContext } from "../layout/MainProvider"; +import { PostType } from "@nice/common"; export default function MyDutyPage() { + const { setSearchMode } = useMainContext(); + useEffect(() => { + setSearchMode(PostType.COURSE); + }, [setSearchMode]); return ( diff --git a/apps/web/src/app/main/my-learning/components/MyLearningListContainer.tsx b/apps/web/src/app/main/my-learning/components/MyLearningListContainer.tsx index a6e8226..be281ae 100644 --- a/apps/web/src/app/main/my-learning/components/MyLearningListContainer.tsx +++ b/apps/web/src/app/main/my-learning/components/MyLearningListContainer.tsx @@ -1,8 +1,9 @@ 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"; import { PostType } from "@nice/common"; +import PostCard from "@web/src/components/models/post/PostCard"; +import CourseCard from "@web/src/components/models/post/SubPost/CourseCard"; export default function MyLearningListContainer() { const { user } = useAuth(); @@ -10,9 +11,7 @@ export default function MyLearningListContainer() { return ( <> ( - - )} + renderItem={(post) => } params={{ pageSize: 12, where: { diff --git a/apps/web/src/app/main/my-learning/page.tsx b/apps/web/src/app/main/my-learning/page.tsx index 58c38b2..cee2c94 100755 --- a/apps/web/src/app/main/my-learning/page.tsx +++ b/apps/web/src/app/main/my-learning/page.tsx @@ -1,7 +1,14 @@ +import { useEffect } from "react"; import BasePostLayout from "../layout/BasePost/BasePostLayout"; +import { useMainContext } from "../layout/MainProvider"; import MyLearningListContainer from "./components/MyLearningListContainer"; +import { PostType } from "@nice/common"; export default function MyLearningPage() { + const { setSearchMode } = useMainContext(); + useEffect(() => { + setSearchMode(PostType.COURSE); + }, [setSearchMode]); return ( diff --git a/apps/web/src/app/main/my-path/components/MyPathListContainer.tsx b/apps/web/src/app/main/my-path/components/MyPathListContainer.tsx new file mode 100644 index 0000000..33e0346 --- /dev/null +++ b/apps/web/src/app/main/my-path/components/MyPathListContainer.tsx @@ -0,0 +1,28 @@ +import PostList from "@web/src/components/models/course/list/PostList"; +import { useAuth } from "@web/src/providers/auth-provider"; + +import { PostType } from "@nice/common"; +import { useMainContext } from "../../layout/MainProvider"; +import PostCard from "@web/src/components/models/post/PostCard"; +import PathCard from "@web/src/components/models/post/SubPost/PathCard"; + +export default function MyPathListContainer() { + const { user } = useAuth(); + const { searchCondition, termsCondition } = useMainContext(); + return ( + <> + } + params={{ + pageSize: 12, + where: { + type: PostType.PATH, + authorId: user.id, + ...termsCondition, + ...searchCondition, + }, + }} + cols={4}> + + ); +} diff --git a/apps/web/src/app/main/my-path/page.tsx b/apps/web/src/app/main/my-path/page.tsx new file mode 100755 index 0000000..81f7298 --- /dev/null +++ b/apps/web/src/app/main/my-path/page.tsx @@ -0,0 +1,17 @@ +import { useEffect } from "react"; +import BasePostLayout from "../layout/BasePost/BasePostLayout"; +import { useMainContext } from "../layout/MainProvider"; +import MyPathListContainer from "./components/MyPathListContainer"; +import { PostType } from "@nice/common"; + +export default function MyPathPage() { + const { setSearchMode } = useMainContext(); + useEffect(() => { + setSearchMode(PostType.PATH); + }, [setSearchMode]); + return ( + + + + ); +} diff --git a/apps/web/src/app/main/path/components/DeptInfo.tsx b/apps/web/src/app/main/path/components/DeptInfo.tsx index 2cec836..fe82bbd 100644 --- a/apps/web/src/app/main/path/components/DeptInfo.tsx +++ b/apps/web/src/app/main/path/components/DeptInfo.tsx @@ -1,22 +1,41 @@ -import { TeamOutlined } from "@ant-design/icons"; +import { BookOutlined, EyeOutlined, TeamOutlined } from "@ant-design/icons"; import { Typography } from "antd"; +import { PostDto } from "@nice/common"; const { Title, Text } = Typography; -const DeptInfo = ({ path }) => { - return ( -
- - {path?.depts && path?.depts?.length > 0 ? ( - - {path?.depts?.length > 1 ? `${path.depts[0].name}等` : path?.depts?.[0]?.name} - - ) : ( - - 未设置单位 - - )} -
- ); +const DeptInfo = ({ post }: { post: PostDto }) => { + return ( +
+
+ + {post?.depts && post?.depts?.length > 0 ? ( + + {post?.depts?.length > 1 + ? `${post.depts[0].name}等` + : post?.depts?.[0]?.name} + + ) : ( + + 未设置单位 + + )} +
+ {post && ( +
+ + + {`${post?.meta?.views || 0}`} + + {post?.studentIds && post?.studentIds?.length > 0 && ( + + + {`${post?.studentIds?.length || 0}`} + + )} +
+ )} +
+ ); }; -export default DeptInfo; \ No newline at end of file +export default DeptInfo; diff --git a/apps/web/src/app/main/path/components/PathCard.tsx b/apps/web/src/app/main/path/components/PathCard.tsx deleted file mode 100755 index 32f5d43..0000000 --- a/apps/web/src/app/main/path/components/PathCard.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { Card, Tag, Typography, Button } from "antd"; -import { - EyeOutlined -} from "@ant-design/icons"; -import { PostDto, TaxonomySlug } from "@nice/common"; -import { useNavigate } from "react-router-dom"; -import DeptInfo from "./DeptInfo"; -import TermInfo from "./TermInfo"; -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={ -
-
- {/*
*/} -
- }> -
- - - <button> {path.title}</button> - - -
- - - - {path?.meta?.views - ? `观看次数 ${path?.meta?.views}` - : 0} - -
-
- -
-
- - ); -} diff --git a/apps/web/src/app/main/path/components/PathListContainer.tsx b/apps/web/src/app/main/path/components/PathListContainer.tsx index 3c272bf..4e7ade6 100755 --- a/apps/web/src/app/main/path/components/PathListContainer.tsx +++ b/apps/web/src/app/main/path/components/PathListContainer.tsx @@ -1,13 +1,15 @@ import PostList from "@web/src/components/models/course/list/PostList"; import { useMainContext } from "../../layout/MainProvider"; import { PostType, Prisma } from "@nice/common"; -import PathCard from "./PathCard"; +import PostCard from "@web/src/components/models/post/PostCard"; +import PathCard from "@web/src/components/models/post/SubPost/PathCard"; + export function PathListContainer() { const { searchCondition, termsCondition } = useMainContext(); return ( <> } + renderItem={(post) => } params={{ pageSize: 12, where: { diff --git a/apps/web/src/app/main/path/components/TermInfo.tsx b/apps/web/src/app/main/path/components/TermInfo.tsx index 421909b..718096f 100644 --- a/apps/web/src/app/main/path/components/TermInfo.tsx +++ b/apps/web/src/app/main/path/components/TermInfo.tsx @@ -1,41 +1,41 @@ import { Tag } from "antd"; -import { TaxonomySlug } from "@nice/common"; +import { PostDto, TaxonomySlug } from "@nice/common"; -const TermInfo = ({ path }) => { - console.log('xx',path?.terms); - - return <> - {path?.terms && path?.terms?.length > 0 ? ( -
- {path?.terms?.map((term:any) => { - return ( - - {term.name} - - ); - })} -
- ) : ( -
- - {"未设置分类"} - -
- )} - +const TermInfo = ({ post }: { post: PostDto }) => { + return ( +
+ {post?.terms && post?.terms?.length > 0 ? ( +
+ {post?.terms?.map((term: any) => { + return ( + + {term.name} + + ); + })} +
+ ) : ( +
+ + {"未设置分类"} + +
+ )} +
+ ); }; -export default TermInfo; \ No newline at end of file +export default TermInfo; diff --git a/apps/web/src/app/main/path/page.tsx b/apps/web/src/app/main/path/page.tsx index d7231d3..65178db 100755 --- a/apps/web/src/app/main/path/page.tsx +++ b/apps/web/src/app/main/path/page.tsx @@ -1,7 +1,14 @@ +import { useEffect } from "react"; import BasePostLayout from "../layout/BasePost/BasePostLayout"; +import { useMainContext } from "../layout/MainProvider"; import PathListContainer from "./components/PathListContainer"; +import { PostType } from "@nice/common"; export default function PathPage() { + const { setSearchMode } = useMainContext(); + useEffect(() => { + setSearchMode(PostType.PATH); + }, [setSearchMode]); return ( diff --git a/apps/web/src/app/main/search/components/SearchContainer.tsx b/apps/web/src/app/main/search/components/SearchContainer.tsx new file mode 100644 index 0000000..292d9b5 --- /dev/null +++ b/apps/web/src/app/main/search/components/SearchContainer.tsx @@ -0,0 +1,33 @@ +import PostList from "@web/src/components/models/course/list/PostList"; +import { useMainContext } from "../../layout/MainProvider"; +import PostCard from "@web/src/components/models/post/PostCard"; +import { PostType } from "@nice/common"; +import CourseCard from "@web/src/components/models/post/SubPost/CourseCard"; +import PathCard from "@web/src/components/models/post/SubPost/PathCard"; +const POST_TYPE_COMPONENTS = { + [PostType.COURSE]: CourseCard, + [PostType.PATH]: PathCard, +}; +export default function SearchListContainer() { + const { searchCondition, termsCondition, searchMode } = useMainContext(); + + return ( + <> + { + const Component = + POST_TYPE_COMPONENTS[post.type] || PostCard; + return ; + }} + params={{ + pageSize: 12, + where: { + type: searchMode === "both" ? undefined : searchMode, + ...termsCondition, + ...searchCondition, + }, + }} + cols={4}> + + ); +} diff --git a/apps/web/src/app/main/search/page.tsx b/apps/web/src/app/main/search/page.tsx new file mode 100755 index 0000000..cfb6153 --- /dev/null +++ b/apps/web/src/app/main/search/page.tsx @@ -0,0 +1,20 @@ +import { useEffect } from "react"; +import BasePostLayout from "../layout/BasePost/BasePostLayout"; +import SearchListContainer from "./components/SearchContainer"; +import { useMainContext } from "../layout/MainProvider"; + +export default function SearchPage() { + const { setShowSearchMode, setSearchValue } = useMainContext(); + useEffect(() => { + setShowSearchMode(true); + return () => { + setShowSearchMode(false); + setSearchValue(""); + }; + }, [setShowSearchMode]); + return ( + + + + ); +} diff --git a/apps/web/src/components/common/container/CollapsibleContent.tsx b/apps/web/src/components/common/container/CollapsibleContent.tsx index 80b9756..7480439 100755 --- a/apps/web/src/components/common/container/CollapsibleContent.tsx +++ b/apps/web/src/components/common/container/CollapsibleContent.tsx @@ -10,7 +10,7 @@ const CollapsibleContent: React.FC = ({ content }) => { const contentWrapperRef = useRef(null); return (
-
+
{/* 包装整个内容区域的容器 */}
{/* 内容区域 */} diff --git a/apps/web/src/components/common/editor/MindEditor.tsx b/apps/web/src/components/common/editor/MindEditor.tsx index 4528b29..4de929c 100755 --- a/apps/web/src/components/common/editor/MindEditor.tsx +++ b/apps/web/src/components/common/editor/MindEditor.tsx @@ -3,64 +3,38 @@ import NodeMenu from "./NodeMenu"; import { api, usePost } from "@nice/client"; import { ObjectType, + PathDto, postDetailSelect, - PostDto, PostType, Prisma, - Taxonomy, + RolePerms, } from "@nice/common"; import TermSelect from "../../models/term/term-select"; import DepartmentSelect from "../../models/department/department-select"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, 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", - }, - }, -}; +import { useAuth } from "@web/src/providers/auth-provider"; +import { MIND_OPTIONS } from "./constant"; export default function MindEditor({ id }: { id?: string }) { const containerRef = useRef(null); const [instance, setInstance] = useState(null); - - const { data: post, isLoading }: { data: PostDto; isLoading: boolean } = + const { isAuthenticated, user, hasSomePermissions } = useAuth(); + const { data: post, isLoading }: { data: PathDto; isLoading: boolean } = api.post.findFirst.useQuery({ where: { id, }, select: postDetailSelect, }); + const canEdit: boolean = useMemo(() => { + //登录了且是作者、超管、无id新建模式 + const isAuth = isAuthenticated && user?.id === post?.author?.id; + return !!id || isAuth || hasSomePermissions(RolePerms.MANAGE_ANY_POST); + }, [user]); const navigate = useNavigate(); const { create, update } = usePost(); const { data: taxonomies } = api.taxonomy.getAll.useQuery({ @@ -83,22 +57,33 @@ export default function MindEditor({ id }: { id?: string }) { form.setFieldsValue(formData); } }, [post, form, instance, id]); - useEffect(() => { if (!containerRef.current) return; const mind = new MindElixir({ ...MIND_OPTIONS, el: containerRef.current, + before: { + beginEdit() { + return canEdit; + }, + }, + draggable: canEdit, // 禁用拖拽 + contextMenu: canEdit, // 禁用右键菜单 + toolBar: canEdit, // 禁用工具栏 + nodeMenu: canEdit, // 禁用节点右键菜单 + keypress: canEdit, // 禁用键盘快捷键 }); mind.init(MindElixir.new("新学习路径")); containerRef.current.hidden = true; setInstance(mind); - }, []); + }, [canEdit]); useEffect(() => { if ((!id || post) && instance) { containerRef.current.hidden = false; instance.toCenter(); - instance.refresh((post as any)?.meta); + if (post?.meta?.nodeData) { + instance.refresh(post?.meta); + } } }, [id, post, instance]); const handleSave = async () => { @@ -164,15 +149,14 @@ export default function MindEditor({ id }: { id?: string }) { `mind-thumb-${new Date().toString()}` ); }; + useEffect(() => { + containerRef.current.style.height = `${Math.floor(window.innerHeight / 1.25)}px`; + }, []); + return ( -
- {taxonomies && ( -
{ - console.log(values); - }} - form={form} - className=" bg-white p-2 "> +
+ {canEdit && taxonomies && ( +
{taxonomies.map((tax, index) => ( @@ -199,14 +183,22 @@ export default function MindEditor({ id }: { id?: string }) { />
-
)} -
- {instance && } +
e.preventDefault()} + /> + {canEdit && instance && } {isLoading && (
res.isImage); const fileResources = dealedResources.filter((res) => !res.isImage); return ( -
+
{imageResources.length > 0 && ( @@ -82,6 +82,7 @@ export default function ResourcesShower({ )} +
附件:
{fileResources.length > 0 && (
diff --git a/apps/web/src/components/models/course/card/CourseCard.tsx b/apps/web/src/components/models/course/card/CourseCard.tsx deleted file mode 100755 index 60e1f48..0000000 --- a/apps/web/src/components/models/course/card/CourseCard.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { CourseDto } from "@nice/common"; -import { Card } from "@web/src/components/common/container/Card"; -import { CourseHeader } from "./CourseHeader"; -import { CourseStats } from "./CourseStats"; -import { Popover } from "@web/src/components/presentation/popover"; -import { useState } from "react"; - -interface CourseCardProps { - course: CourseDto; - onClick?: () => void; -} - -export const CourseCard = ({ course, onClick }: CourseCardProps) => { - return ( - - - - - ); -}; diff --git a/apps/web/src/components/models/course/detail/CourseDetailContext.tsx b/apps/web/src/components/models/course/detail/CourseDetailContext.tsx index 34ca11c..1e29d87 100755 --- a/apps/web/src/components/models/course/detail/CourseDetailContext.tsx +++ b/apps/web/src/components/models/course/detail/CourseDetailContext.tsx @@ -36,8 +36,12 @@ interface CourseFormProviderProps { editId?: string; // 添加 editId 参数 } -export const CourseDetailContext =createContext(null); -export function CourseDetailProvider({children,editId}: CourseFormProviderProps) { +export const CourseDetailContext = + createContext(null); +export function CourseDetailProvider({ + children, + editId, +}: CourseFormProviderProps) { const navigate = useNavigate(); const { read } = useVisitor(); const { user, hasSomePermissions, isAuthenticated } = useAuth(); @@ -60,7 +64,7 @@ export function CourseDetailProvider({children,editId}: CourseFormProviderProps) const isRoot = hasSomePermissions(RolePerms?.MANAGE_ANY_POST); return isAuthor || isRoot; }, [user, course]); - + const [selectedLectureId, setSelectedLectureId] = useState< string | undefined >(lectureId || undefined); @@ -86,7 +90,9 @@ export function CourseDetailProvider({children,editId}: CourseFormProviderProps) } }, [course]); useEffect(() => { - navigate(`/course/${editId}/detail/${selectedLectureId}`); + if (lectureId !== selectedLectureId) { + navigate(`/course/${editId}/detail/${selectedLectureId}`); + } }, [selectedLectureId, editId]); const [isHeaderVisible, setIsHeaderVisible] = useState(true); // 新增 return ( diff --git a/apps/web/src/components/models/course/detail/CourseDetailDescription.tsx b/apps/web/src/components/models/course/detail/CourseDetailDescription.tsx index 6fdabf6..463dc8b 100755 --- a/apps/web/src/components/models/course/detail/CourseDetailDescription.tsx +++ b/apps/web/src/components/models/course/detail/CourseDetailDescription.tsx @@ -2,22 +2,25 @@ import { Course, TaxonomySlug } from "@nice/common"; 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"; +import { useStaff } from "@nice/client"; +import { useAuth } from "@web/src/providers/auth-provider"; +import TermInfo from "@web/src/app/main/path/components/TermInfo"; +import { PictureOutlined } from "@ant-design/icons"; export const CourseDetailDescription: React.FC = () => { - const { course,canEdit, isLoading, selectedLectureId, setSelectedLectureId } = - useContext(CourseDetailContext); - const { Paragraph, Title } = Typography; + const { + course, + canEdit, + isLoading, + selectedLectureId, + setSelectedLectureId, + userIsLearning, + lecture = null, + } = useContext(CourseDetailContext); + const { Paragraph } = Typography; + const { user } = useAuth(); + const { update } = useStaff(); const firstLectureId = useMemo(() => { return course?.sections?.[0]?.lectures?.[0]?.id; }, [course]); @@ -30,49 +33,44 @@ export const CourseDetailDescription: React.FC = () => { ) : (
- {!selectedLectureId && course?.meta?.thumbnail && ( - <> -
+ {!selectedLectureId && ( +
+ { -
{ - setSelectedLectureId(firstLectureId); - }} - 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"> -
- 点击进入学习 -
+ } +
{ + setSelectedLectureId(firstLectureId); + if (!userIsLearning) { + await update.mutateAsync({ + where: { id: user?.id }, + data: { + learningPosts: { + connect: { + id: course.id, + }, + }, + }, + }); + } + }} + 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 group"> +
+ 点击进入学习
- +
)}
{"课程简介:"}
{course?.subTitle &&
{course?.subTitle}
} - {course.terms.map((term) => { - return ( - - {term.name} - - ); - })} +
{ {!lectureIsLoading && selectedLectureId && lecture?.meta?.type === LectureType.ARTICLE && ( -
-
+
+
{course?.title}
+
+ {course?.author?.showname && ( +
+ 发布者: + {course?.author?.showname} +
+ )} + {course?.depts && course?.depts?.length > 0 && ( +
+ 发布单位: + {course?.depts?.map((dept) => dept.name)} +
+ )} +
- {"创建于:"} + {"发布于:"} {dayjs(course?.createdAt).format("YYYY年M月D日")}
- - {"更新于:"} + {"最后更新:"} {dayjs(course?.updatedAt).format("YYYY年M月D日")}
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 88571a3..bc84b18 100755 --- a/apps/web/src/components/models/course/editor/context/CourseEditorContext.tsx +++ b/apps/web/src/components/models/course/editor/context/CourseEditorContext.tsx @@ -98,12 +98,18 @@ export function CourseFormProvider({ thumbnail: values?.meta?.thumbnail, }), }, - terms: { - set: termIds.map((id) => ({ id })), // 转换成 connect 格式 - }, - depts: { - set: deptIds.map((id) => ({ id })), - }, + terms: + termIds?.length > 0 + ? { + set: termIds.map((id) => ({ id })), // 转换成 connect 格式 + } + : undefined, + depts: + deptIds?.length > 0 + ? { + set: deptIds.map((id) => ({ id })), + } + : undefined, }; // 删除原始的 taxonomy 字段 taxonomies.forEach((tax) => { diff --git a/apps/web/src/components/models/course/editor/form/CourseContentForm/SortableLecture.tsx b/apps/web/src/components/models/course/editor/form/CourseContentForm/SortableLecture.tsx index 26282d2..3c9f279 100755 --- a/apps/web/src/components/models/course/editor/form/CourseContentForm/SortableLecture.tsx +++ b/apps/web/src/components/models/course/editor/form/CourseContentForm/SortableLecture.tsx @@ -26,6 +26,7 @@ import CollapsibleContent from "@web/src/components/common/container/Collapsible import { VideoPlayer } from "@web/src/components/presentation/video-player/VideoPlayer"; import MultiAvatarUploader from "@web/src/components/common/uploader/MultiAvatarUploader"; import ResourcesShower from "@web/src/components/common/uploader/ResourceShower"; +import { set } from "idb-keyval"; interface SortableLectureProps { field: Lecture; @@ -82,13 +83,16 @@ export const SortableLecture: React.FC = ({ ? `http://${env.SERVER_IP}:${env.FILE_PORT}/uploads/${videoUrlId}/stream/index.m3u8` : undefined, }, - resources: { - connect: [videoUrlId, ...fileIds] - .filter(Boolean) - .map((fileId) => ({ - fileId, - })), - }, + resources: + [videoUrlId, ...fileIds].filter(Boolean)?.length > 0 + ? { + connect: [videoUrlId, ...fileIds] + .filter(Boolean) + .map((fileId) => ({ + fileId, + })), + } + : undefined, content: values?.content, }, }); @@ -108,13 +112,16 @@ export const SortableLecture: React.FC = ({ ? `http://${env.SERVER_IP}:${env.FILE_PORT}/uploads/${videoUrlId}/stream/index.m3u8` : undefined, }, - resources: { - connect: [videoUrlId, ...fileIds] - .filter(Boolean) - .map((fileId) => ({ - fileId, - })), - }, + resources: + [videoUrlId, ...fileIds].filter(Boolean)?.length > 0 + ? { + connect: [videoUrlId, ...fileIds] + .filter(Boolean) + .map((fileId) => ({ + fileId, + })), + } + : undefined, content: values?.content, }, }); @@ -199,13 +206,7 @@ export const SortableLecture: React.FC = ({ + className="mb-0 flex-1">
diff --git a/apps/web/src/components/models/course/editor/form/CourseContentForm/SortableSection.tsx b/apps/web/src/components/models/course/editor/form/CourseContentForm/SortableSection.tsx index bf6af5b..1b6dfaa 100755 --- a/apps/web/src/components/models/course/editor/form/CourseContentForm/SortableSection.tsx +++ b/apps/web/src/components/models/course/editor/form/CourseContentForm/SortableSection.tsx @@ -135,7 +135,7 @@ export const SortableSection: React.FC = ({ ) : (
- + = ({ /> {field.title || "未命名章节"} - - - -
) } + extra={ + !editing && ( + e.stopPropagation()}> + + + + ) + } key={field.id || "new"}> {children} diff --git a/apps/web/src/components/models/course/list/PostList.tsx b/apps/web/src/components/models/course/list/PostList.tsx index 665e30b..ce9da18 100755 --- a/apps/web/src/components/models/course/list/PostList.tsx +++ b/apps/web/src/components/models/course/list/PostList.tsx @@ -1,5 +1,5 @@ import { Pagination, Empty, Skeleton } from "antd"; -import { courseDetailSelect, CourseDto, Prisma } from "@nice/common"; +import { courseDetailSelect, CourseDto, PostDto, Prisma } from "@nice/common"; import { api } from "@nice/client"; import { DefaultArgs } from "@prisma/client/runtime/library"; import React, { useEffect, useMemo, useState } from "react"; @@ -12,7 +12,7 @@ interface PostListProps { }; cols?: number; showPagination?: boolean; - renderItem: (post: any) => React.ReactNode; + renderItem: (post: PostDto) => React.ReactNode; } interface PostPagnationProps { data: { diff --git a/apps/web/src/components/models/post/PostCard.tsx b/apps/web/src/components/models/post/PostCard.tsx new file mode 100644 index 0000000..aa0e2c5 --- /dev/null +++ b/apps/web/src/components/models/post/PostCard.tsx @@ -0,0 +1,66 @@ +import { Card, Typography, Button, Empty } from "antd"; + +import { PostDto } from "@nice/common"; +import DeptInfo from "@web/src/app/main/path/components/DeptInfo"; +import TermInfo from "@web/src/app/main/path/components/TermInfo"; +import { PictureOutlined } from "@ant-design/icons"; + +interface PostCardProps { + post?: PostDto; + onClick?: (post?: PostDto) => void; +} +const { Title } = Typography; +export default function PostCard({ post, onClick }: PostCardProps) { + const handleClick = (post: PostDto) => { + onClick?.(post); + }; + return ( + handleClick(post)} + key={post?.id} + hoverable + className="group overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2" + cover={ +
+ {post?.meta?.thumbnail ? ( +
+ ) : ( +
+ +
+ )} +
+ }> +
+
+
+ +
+
+ + <button> {post?.title}</button> + +
+ +
+ +
+ +
+
+ + ); +} diff --git a/apps/web/src/components/models/post/SubPost/CourseCard.tsx b/apps/web/src/components/models/post/SubPost/CourseCard.tsx new file mode 100644 index 0000000..6140607 --- /dev/null +++ b/apps/web/src/components/models/post/SubPost/CourseCard.tsx @@ -0,0 +1,14 @@ +import { CourseDto, PostDto } from "@nice/common"; +import { useNavigate } from "react-router-dom"; +import PostCard from "../PostCard"; +export default function CourseCard({ post }: { post: PostDto }) { + const navigate = useNavigate(); + return ( + { + navigate(`/course/${post?.id}/detail`); + window.scrollTo({ top: 0, behavior: "smooth" }); + }}> + ); +} diff --git a/apps/web/src/components/models/post/SubPost/PathCard.tsx b/apps/web/src/components/models/post/SubPost/PathCard.tsx new file mode 100644 index 0000000..9de4ed0 --- /dev/null +++ b/apps/web/src/components/models/post/SubPost/PathCard.tsx @@ -0,0 +1,15 @@ +import { PostDto } from "@nice/common"; +import { useNavigate } from "react-router-dom"; +import PostCard from "../PostCard"; + +export default function PathCard({ post }: { post: PostDto }) { + const navigate = useNavigate(); + return ( + { + navigate(`/path/editor/${post?.id}`); + window.scrollTo({ top: 0, behavior: "smooth" }); + }}> + ); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index f65bd8b..65e941c 100755 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -159,9 +159,4 @@ .custom-table .ant-table-tbody>tr:last-child>td { border-bottom: none; /* 去除最后一行的底部边框 */ -} - -.mind-editor { - height: calc(100vh - 285px); - width: 100%; } \ No newline at end of file diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index d4d4afe..0899fd2 100755 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -22,6 +22,8 @@ import PathEditorPage from "../app/main/path/editor/page"; import { CoursePreview } from "../app/main/course/preview/page"; import MyLearningPage from "../app/main/my-learning/page"; import MyDutyPage from "../app/main/my-duty/page"; +import MyPathPage from "../app/main/my-path/page"; +import SearchPage from "../app/main/search/page"; interface CustomIndexRouteObject extends IndexRouteObject { name?: string; breadcrumb?: string; @@ -68,14 +70,22 @@ export const routes: CustomRouteObject[] = [ }, { path: "editor/:id?", - element: - } - ] + element: , + }, + ], }, { path: "courses", element: , }, + { + path: "my-path", + element: ( + + + + ), + }, { path: "my-duty", element: ( @@ -92,6 +102,10 @@ export const routes: CustomRouteObject[] = [ ), }, + { + path: "search", + element: , + }, { path: "course/:id?/detail/:lectureId?", // 使用 ? 表示 id 参数是可选的 element: , diff --git a/packages/common/prisma/schema.prisma b/packages/common/prisma/schema.prisma index db764d2..2715faf 100755 --- a/packages/common/prisma/schema.prisma +++ b/packages/common/prisma/schema.prisma @@ -226,8 +226,6 @@ model Post { ancestors PostAncestry[] @relation("DescendantPosts") descendants PostAncestry[] @relation("AncestorPosts") resources Resource[] // 附件列表 - // watchableStaffs Staff[] @relation("post_watch_staff") - // watchableDepts Department[] @relation("post_watch_dept") // 可观看的部门列表,关联 Department 模型 meta Json? // 封面url 视频url objectives具体的学习目标 rating评分Int // 索引 diff --git a/packages/common/src/models/post.ts b/packages/common/src/models/post.ts index a12c727..d672bc8 100755 --- a/packages/common/src/models/post.ts +++ b/packages/common/src/models/post.ts @@ -40,19 +40,23 @@ export type PostDto = Post & { delete: boolean; // edit: boolean; }; + meta?: PostMeta; watchableDepts: Department[]; watchableStaffs: Staff[]; - terms: TermDto[] - depts: DepartmentDto[] - meta?: { - thumbnail?: string - views?: number - } -}; + terms: TermDto[]; + depts: DepartmentDto[]; -export type LectureMeta = { - type?: string; + studentIds?: string[]; +}; +export type PostMeta = { + thumbnail?: string; views?: number; + likes?: number; + hates?: number; +}; +export type LectureMeta = PostMeta & { + type?: string; + videoUrl?: string; videoThumbnail?: string; videoIds?: string[]; @@ -64,7 +68,7 @@ export type Lecture = Post & { meta?: LectureMeta; }; -export type SectionMeta = { +export type SectionMeta = PostMeta & { objectives?: string[]; }; export type Section = Post & { @@ -73,15 +77,10 @@ export type Section = Post & { export type SectionDto = Section & { lectures: Lecture[]; }; -export type CourseMeta = { - thumbnail?: string; - +export type CourseMeta = PostMeta & { objectives?: string[]; - views?: number; - likes?: number; - hates?: number; }; -export type Course = Post & { +export type Course = PostDto & { meta?: CourseMeta; }; export type CourseDto = Course & { @@ -92,3 +91,45 @@ export type CourseDto = Course & { depts: Department[]; studentIds: string[]; }; + +export type Summary = { + id: string; + text: string; + parent: string; + start: number; + end: number; +}; +export type NodeObj = { + topic: string; + id: string; + style?: { + fontSize?: string; + color?: string; + background?: string; + fontWeight?: string; + }; + children?: NodeObj[]; +}; +export type Arrow = { + id: string; + label: string; + from: string; + to: string; + delta1: { + x: number; + y: number; + }; + delta2: { + x: number; + y: number; + }; +}; +export type PathMeta = PostMeta & { + nodeData: NodeObj; + arrows?: Arrow[]; + summaries?: Summary[]; + direction?: number; +}; +export type PathDto = PostDto & { + meta: PathMeta; +}; diff --git a/packages/common/src/models/select.ts b/packages/common/src/models/select.ts index d63251a..8245ffe 100755 --- a/packages/common/src/models/select.ts +++ b/packages/common/src/models/select.ts @@ -6,6 +6,8 @@ export const postDetailSelect: Prisma.PostSelect = { title: true, content: true, resources: true, + parent: true, + parentId: true, // watchableDepts: true, // watchableStaffs: true, updatedAt: true, @@ -18,9 +20,9 @@ export const postDetailSelect: Prisma.PostSelect = { select: { id: true, slug: true, - } - } - } + }, + }, + }, }, depts: true, author: { @@ -42,12 +44,14 @@ export const postDetailSelect: Prisma.PostSelect = { }, }, }, - meta: true + meta: true, }; export const postUnDetailSelect: Prisma.PostSelect = { id: true, type: true, title: true, + parent: true, + parentId: true, content: true, resources: true, updatedAt: true, @@ -85,6 +89,8 @@ export const courseDetailSelect: Prisma.PostSelect = { title: true, subTitle: true, type: true, + author: true, + authorId: true, content: true, depts: true, // isFeatured: true, diff --git a/scripts/git_stats.py b/scripts/git_stats.py deleted file mode 100755 index 374834c..0000000 --- a/scripts/git_stats.py +++ /dev/null @@ -1,503 +0,0 @@ -import statistics -from git import Repo -from collections import defaultdict, Counter -from datetime import datetime, timedelta -import os - - -def get_contributor_stats(repo_path, start_date=None, end_date=None, branch='HEAD'): - """ - 获取仓库贡献者的详细统计信息 - - Args: - repo_path: Git仓库路径 - start_date: 开始日期(可选) - end_date: 结束日期(可选) - branch: 要分析的分支(默认为HEAD) - - Returns: - 包含每个贡献者详细统计信息的字典 - """ - # 初始化仓库对象 - repo = Repo(repo_path) - - # 存储统计结果 - stats = defaultdict(lambda: { - 'additions': 0, # 添加的行数 - 'deletions': 0, # 删除的行数 - 'commits': 0, # 提交次数 - 'files_modified': set(), # 修改过的文件集合 - 'file_types': defaultdict(int),# 各类型文件的修改次数 - 'commit_dates': set(), # 提交日期集合 - 'commit_hours': defaultdict(int), # 提交小时分布 - 'commit_weekdays': defaultdict(int), # 提交工作日分布 - 'largest_commit': 0, # 最大单次提交修改量 - 'first_commit': None, # 首次提交时间 - 'last_commit': None, # 最近提交时间 - 'commit_sizes': [], # 每次提交的大小,用于计算平均值和中位数 - 'commit_messages': [], # 提交消息列表 - 'commit_message_lengths': [], # 提交消息长度列表 - 'directories_modified': set(), # 修改过的目录集合 - 'co_authors': set(), # 合作者集合 - 'impact_score': 0, # 影响力得分 - 'complexity_score': 0, # 复杂度得分 - 'commit_by_month': defaultdict(int), # 按月份统计的提交次数 - 'commit_by_quarter': defaultdict(int), # 按季度统计的提交次数 - 'commit_by_year': defaultdict(int), # 按年份统计的提交次数 - 'commit_by_week': defaultdict(int), # 按周统计的提交次数 - 'file_operations': { # 文件操作统计 - 'created': set(), # 创建的文件 - 'deleted': set(), # 删除的文件 - 'modified': set(), # 修改的文件 - }, - 'review_comments': 0, # 代码审查评论数(如果可用) - 'merge_commits': 0, # 合并提交数 - 'commit_streak': 0, # 最长连续提交天数 - 'current_streak': 0, # 当前连续提交天数 - 'contribution_days': [], # 所有贡献的日期列表(用于热图) - 'code_churn': 0, # 代码周转率(添加后又删除的代码) - 'file_ownership': {}, # 文件所有权百分比 - 'key_files_modified': set(), # 修改过的关键文件 - 'refactoring_commits': 0, # 重构提交数(基于提交消息分析) - 'bug_fix_commits': 0, # 修复bug的提交数 - 'feature_commits': 0, # 新功能提交数 - 'documentation_commits': 0, # 文档相关提交数 - 'commit_size_distribution': defaultdict(int), # 提交大小分布 - 'collaboration_score': 0, # 协作得分 - 'consistency_score': 0, # 一致性得分 - 'expertise_areas': defaultdict(float), # 专业领域(目录/语言) - }) - - # 存储所有文件的修改者,用于计算协作指标 - file_authors = defaultdict(set) - - # 存储项目文件的重要性权重 (基于修改频率) - file_importance = Counter() - - # 存储用于检测关键词的正则表达式 - import re - refactor_pattern = re.compile(r'refactor|重构', re.IGNORECASE) - bugfix_pattern = re.compile(r'fix|修复|bug|问题|issue|错误', re.IGNORECASE) - feature_pattern = re.compile(r'feature|功能|新增|add|实现', re.IGNORECASE) - docs_pattern = re.compile(r'doc|文档|注释|comment', re.IGNORECASE) - - # 记录每位贡献者的提交日期,用于计算连续贡献天数 - author_commit_days = defaultdict(set) - - # 定义关键文件路径模式 (可以根据项目自定义) - key_file_patterns = [ - re.compile(r'package\.json$'), - re.compile(r'docker-compose\.yml$'), - re.compile(r'Dockerfile$'), - re.compile(r'tsconfig\..*\.json$'), - re.compile(r'/src/index\.[jt]s$'), - re.compile(r'README\.md$'), - re.compile(r'\.env'), - re.compile(r'/main\.[jt]s$'), - re.compile(r'/app\.[jt]s$'), - ] - - # 遍历所有提交 - for commit in repo.iter_commits(branch): - # 过滤日期 - commit_date = datetime.fromtimestamp(commit.committed_date) - if start_date and commit_date < start_date: - continue - if end_date and commit_date > end_date: - continue - - author = commit.author.name - stats[author]['commits'] += 1 - - # 记录提交日期和时间 - commit_day = commit_date.date() - stats[author]['commit_dates'].add(commit_day) - stats[author]['contribution_days'].append(commit_day) # 用于热图 - stats[author]['commit_hours'][commit_date.hour] += 1 - stats[author]['commit_weekdays'][commit_date.weekday()] += 1 - - # 添加到作者的提交日集合 - author_commit_days[author].add(commit_day) - - # 按时间段统计 - year = commit_date.year - month = commit_date.month - quarter = (month - 1) // 3 + 1 - week_num = commit_date.isocalendar()[1] - stats[author]['commit_by_year'][year] += 1 - stats[author]['commit_by_month'][f"{year}-{month:02d}"] += 1 - stats[author]['commit_by_quarter'][f"{year}-Q{quarter}"] += 1 - stats[author]['commit_by_week'][f"{year}-W{week_num:02d}"] += 1 - - # 分析提交消息,对提交进行分类 - commit_message = commit.message.strip() - if refactor_pattern.search(commit_message): - stats[author]['refactoring_commits'] += 1 - if bugfix_pattern.search(commit_message): - stats[author]['bug_fix_commits'] += 1 - if feature_pattern.search(commit_message): - stats[author]['feature_commits'] += 1 - if docs_pattern.search(commit_message): - stats[author]['documentation_commits'] += 1 - - # 记录首次和最近提交 - if stats[author]['first_commit'] is None or commit_date < stats[author]['first_commit']: - stats[author]['first_commit'] = commit_date - if stats[author]['last_commit'] is None or commit_date > stats[author]['last_commit']: - stats[author]['last_commit'] = commit_date - - # 记录提交消息 - commit_message = commit.message.strip() - stats[author]['commit_messages'].append(commit_message) - stats[author]['commit_message_lengths'].append(len(commit_message)) - - # 检测是否为合并提交 - if len(commit.parents) > 1: - stats[author]['merge_commits'] += 1 - - # 统计添加和删除的行数 - total_changes = 0 - modified_files = set() - created_files = set() - deleted_files = set() - directories = set() - - # 尝试获取提交前后的差异,以确定文件操作类型 - try: - if commit.parents: - parent = commit.parents[0] - diffs = parent.diff(commit) - for diff_item in diffs: - if diff_item.new_file: - if diff_item.b_path: - created_files.add(diff_item.b_path) - elif diff_item.deleted_file: - if diff_item.a_path: - deleted_files.add(diff_item.a_path) - else: - if diff_item.a_path: - modified_files.add(diff_item.a_path) - else: - # 对于首次提交,所有文件都是新创建的 - for file_path in commit.stats.files: - created_files.add(file_path) - except Exception as e: - # 如果获取差异失败,退回到简单的文件修改统计 - modified_files = set(commit.stats.files.keys()) - - for file_path, item in commit.stats.files.items(): - # 统计文件类型 - _, ext = os.path.splitext(file_path) - if ext: # 确保扩展名不为空 - stats[author]['file_types'][ext] += 1 - else: - stats[author]['file_types']['no_extension'] += 1 - - # 记录目录 - directory = os.path.dirname(file_path) - if directory: - directories.add(directory) - - # 记录修改的文件 - modified_files.add(file_path) - - # 记录文件的修改者,用于计算协作指标 - file_authors[file_path].add(author) - - # 统计添加和删除的行数 - stats[author]['additions'] += item['insertions'] - stats[author]['deletions'] += item['deletions'] - total_changes += item['insertions'] + item['deletions'] - - # 更新修改过的文件和目录集合 - stats[author]['files_modified'].update(modified_files) - stats[author]['directories_modified'].update(directories) - stats[author]['file_operations']['created'].update(created_files) - stats[author]['file_operations']['deleted'].update(deleted_files) - stats[author]['file_operations']['modified'].update(modified_files - created_files - deleted_files) - - # 记录本次提交的修改量 - stats[author]['commit_sizes'].append(total_changes) - - # 记录提交大小分布 - commit_size_category = "小型(1-10行)" if total_changes <= 10 else \ - "中型(11-100行)" if total_changes <= 100 else \ - "大型(101-500行)" if total_changes <= 500 else \ - "超大型(500+行)" - stats[author]['commit_size_distribution'][commit_size_category] += 1 - - # 更新最大单次提交修改量 - if total_changes > stats[author]['largest_commit']: - stats[author]['largest_commit'] = total_changes - - # 检查修改的文件是否为关键文件 - for file_path in modified_files: - for pattern in key_file_patterns: - if pattern.search(file_path): - stats[author]['key_files_modified'].add(file_path) - break - - # 更新文件重要性权重 - for file_path in modified_files: - file_importance[file_path] += 1 - - # 计算影响力得分 (基于修改的文件数和总修改行数) - impact = total_changes * len(modified_files) / 100 if modified_files else 0 - stats[author]['impact_score'] += impact - - # 计算文件协作度和文件所有权 - for file_path, authors in file_authors.items(): - # 如果只有一个作者修改了文件,则该作者100%拥有此文件 - if len(authors) == 1: - author = next(iter(authors)) - if 'file_ownership' not in stats[author]: - stats[author]['file_ownership'] = {} - stats[author]['file_ownership'][file_path] = 100.0 - else: - # 如果多个作者修改了文件,则按照每个作者的修改比例计算所有权 - for author in authors: - # 简化处理:平均分配所有权 - ownership_percent = 100.0 / len(authors) - if 'file_ownership' not in stats[author]: - stats[author]['file_ownership'] = {} - stats[author]['file_ownership'][file_path] = ownership_percent - - # 计算每个作者的连续提交天数 - for author, commit_days in author_commit_days.items(): - if not commit_days: - continue - - # 按日期排序 - sorted_days = sorted(commit_days) - - # 计算最长提交连续天数 - current_streak = 1 - max_streak = 1 - - for i in range(1, len(sorted_days)): - # 如果当前日期与前一天相差正好一天,则增加连续计数 - if (sorted_days[i] - sorted_days[i-1]).days == 1: - current_streak += 1 - else: - # 重置当前连续计数 - current_streak = 1 - - max_streak = max(max_streak, current_streak) - - # 记录最长连续提交天数 - stats[author]['commit_streak'] = max_streak - - # 计算当前连续提交天数 (到最后一个日期) - if sorted_days: - today = datetime.now().date() - days_since_last = (today - sorted_days[-1]).days - - if days_since_last <= 1: # 如果最后提交是今天或昨天 - current_streak = 1 - for i in range(len(sorted_days) - 1, 0, -1): - if (sorted_days[i] - sorted_days[i-1]).days == 1: - current_streak += 1 - else: - break - stats[author]['current_streak'] = current_streak - - # 后处理:计算派生指标并转换集合为计数 - for author, data in stats.items(): - # 将文件集合转换为数量 - data['files_count'] = len(data['files_modified']) - data['active_days'] = len(data['commit_dates']) - data['key_files_count'] = len(data['key_files_modified']) - - # 计算平均每次提交的修改量 - if data['commits'] > 0: - data['avg_commit_size'] = sum(data['commit_sizes']) / data['commits'] - data['median_commit_size'] = statistics.median(data['commit_sizes']) if data['commit_sizes'] else 0 - - # 计算代码复杂度得分 (基于修改量、文件数和一致性) - variability = statistics.stdev(data['commit_sizes']) if len(data['commit_sizes']) > 1 else 0 - data['complexity_score'] = (data['avg_commit_size'] * data['files_count'] * (1 + variability / 1000)) / 100 - - # 计算一致性得分 (提交大小和频率的一致性) - if variability > 0: - data['consistency_score'] = 100 * (1 - min(1, variability / data['avg_commit_size'])) - else: - data['consistency_score'] = 100 - else: - data['avg_commit_size'] = 0 - data['median_commit_size'] = 0 - data['complexity_score'] = 0 - data['consistency_score'] = 0 - - # 计算总修改量 - data['total_changes'] = data['additions'] + data['deletions'] - - # 计算代码周转率 (code churn) - 估算值 - if data['additions'] > 0 and data['deletions'] > 0: - data['code_churn'] = min(data['additions'], data['deletions']) / max(data['additions'], data['deletions']) * 100 - - # 计算活跃时长(天) - if data['first_commit'] and data['last_commit']: - delta = data['last_commit'] - data['first_commit'] - data['active_period_days'] = delta.days + 1 - - # 计算活跃密度 (提交数/活跃天数) - if delta.days > 0: - data['activity_density'] = data['commits'] / delta.days - else: - data['activity_density'] = data['commits'] - else: - data['active_period_days'] = 0 - data['activity_density'] = 0 - - # 计算协作得分 (基于参与修改的共享文件比例) - total_files = len(data['files_modified']) - shared_files = sum(1 for f in data['files_modified'] if len(file_authors[f]) > 1) - if total_files > 0: - data['collaboration_score'] = (shared_files / total_files) * 100 - - # 计算专业领域 (基于文件类型和目录) - if data['file_types']: - primary_type = max(data['file_types'].items(), key=lambda x: x[1])[0] - data['primary_file_type'] = primary_type - data['primary_file_type_percent'] = (data['file_types'][primary_type] / sum(data['file_types'].values())) * 100 - - # 统计目录专业度 - if data['directories_modified']: - dir_counts = Counter() - for directory in data['directories_modified']: - dir_counts[directory] += 1 - - # 检查父目录 - parent = os.path.dirname(directory) - while parent: - dir_counts[parent] += 0.5 # 对父目录给予较低的权重 - parent = os.path.dirname(parent) - - # 找出专业领域(最常修改的目录) - if dir_counts: - primary_dir = max(dir_counts.items(), key=lambda x: x[1])[0] - data['primary_directory'] = primary_dir - data['expertise_areas'][primary_dir] = dir_counts[primary_dir] / sum(dir_counts.values()) - - return stats - -def print_stats(stats): - """打印贡献者统计信息的详细报告""" - # 基本信息表头 - print("\n===== 贡献者基本统计 =====") - print("{:<20} {:<10} {:<10} {:<10} {:<10} {:<15} {:<15}".format( - "作者", "提交数", "添加行数", "删除行数", "总修改行数", "修改文件数", "活跃天数")) - print("-" * 90) - - # 按总修改量排序 - for author, data in sorted(stats.items(), key=lambda x: x[1]['total_changes'], reverse=True): - print("{:<20} {:<10} {:<10} {:<10} {:<10} {:<15} {:<15}".format( - author, - data['commits'], - data['additions'], - data['deletions'], - data['total_changes'], - data['files_count'], - data['active_days'] - )) - - # 为每个贡献者打印详细信息 - for author, data in sorted(stats.items(), key=lambda x: x[1]['total_changes'], reverse=True): - print(f"\n\n===== {author} 的详细贡献统计 =====") - - # 活跃时间信息 - if data['first_commit'] and data['last_commit']: - print(f"首次提交时间: {data['first_commit'].strftime('%Y-%m-%d %H:%M:%S')}") - print(f"最近提交时间: {data['last_commit'].strftime('%Y-%m-%d %H:%M:%S')}") - print(f"活跃时长: {data['active_period_days']} 天") - - # 提交规模信息 - print(f"平均每次提交修改: {data['avg_commit_size']:.2f} 行") - print(f"最大单次提交修改: {data['largest_commit']} 行") - - # 文件类型分布 - if data['file_types']: - print("\n文件类型分布:") - for ext, count in sorted(data['file_types'].items(), key=lambda x: x[1], reverse=True): - print(f" {ext}: {count} 次修改") - - # 提交时间分布 - if data['commit_hours']: - print("\n提交时间分布:") - for hour in range(24): - count = data['commit_hours'].get(hour, 0) - if count > 0: - print(f" {hour:02d}:00-{hour+1:02d}:00: {count} 次提交") - - # 工作日分布 - if data['commit_weekdays']: - weekday_names = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] - print("\n工作日分布:") - for day in range(7): - count = data['commit_weekdays'].get(day, 0) - if count > 0: - print(f" {weekday_names[day]}: {count} 次提交") - -def get_team_summary(stats): - """生成团队整体统计摘要""" - summary = { - 'total_commits': 0, - 'total_additions': 0, - 'total_deletions': 0, - 'total_files': set(), - 'contributors': len(stats), - 'first_commit': None, - 'last_commit': None, - } - - for author, data in stats.items(): - summary['total_commits'] += data['commits'] - summary['total_additions'] += data['additions'] - summary['total_deletions'] += data['deletions'] - summary['total_files'].update(data['files_modified']) - - # 更新首次和最近提交 - if data['first_commit']: - if summary['first_commit'] is None or data['first_commit'] < summary['first_commit']: - summary['first_commit'] = data['first_commit'] - - if data['last_commit']: - if summary['last_commit'] is None or data['last_commit'] > summary['last_commit']: - summary['last_commit'] = data['last_commit'] - - return summary - -def print_team_summary(summary): - """打印团队整体统计摘要""" - print("\n===== 团队整体统计 =====") - print(f"贡献者数量: {summary['contributors']}") - print(f"总提交次数: {summary['total_commits']}") - print(f"总添加行数: {summary['total_additions']}") - print(f"总删除行数: {summary['total_deletions']}") - print(f"总修改行数: {summary['total_additions'] + summary['total_deletions']}") - print(f"修改的文件数: {len(summary['total_files'])}") - - if summary['first_commit'] and summary['last_commit']: - print(f"项目起始时间: {summary['first_commit'].strftime('%Y-%m-%d')}") - print(f"最近活动时间: {summary['last_commit'].strftime('%Y-%m-%d')}") - delta = summary['last_commit'] - summary['first_commit'] - print(f"项目活跃时长: {delta.days + 1} 天") -if __name__ == "__main__": - # 设置仓库路径(当前目录) - repo_path = '.' - - # 设置日期范围(示例) - # 注意:这里使用的是2025年的日期,可能需要根据实际情况调整 - start_date = datetime(2025, 1, 1) # 修改为更合理的日期范围 - end_date = datetime(2025, 12, 31) - - print(f"分析Git仓库: {os.path.abspath(repo_path)}") - print(f"时间范围: {start_date.strftime('%Y-%m-%d')} 至 {end_date.strftime('%Y-%m-%d')}") - - # 获取统计信息 - stats = get_contributor_stats(repo_path, start_date, end_date) - - # 打印团队摘要 - team_summary = get_team_summary(stats) - print_team_summary(team_summary) - print(stats) \ No newline at end of file diff --git a/web-dist/index.html b/web-dist/index.html old mode 100755 new mode 100644