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 597caf0..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 5f16cc1..72b9499 100755 --- a/apps/web/src/app/main/courses/components/CoursesContainer.tsx +++ b/apps/web/src/app/main/courses/components/CoursesContainer.tsx @@ -1,34 +1,19 @@ 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 { selectedTerms, searchCondition } = useMainContext(); - const termFilters = useMemo(() => { - return Object.entries(selectedTerms) - .filter(([, terms]) => terms.length > 0) - .map(([, terms]) => terms); - }, [selectedTerms]); - + const { searchCondition, termsCondition } = useMainContext(); return ( <> } + renderItem={(post) => } params={{ pageSize: 12, where: { type: PostType.COURSE, - AND: termFilters.map((termFilter) => ({ - terms: { - some: { - id: { - in: termFilter, // 确保至少有一个 term.id 在当前 termFilter 中 - }, - }, - }, - })), + ...termsCondition, ...searchCondition, }, }} diff --git a/apps/web/src/app/main/courses/components/FilterSection.tsx b/apps/web/src/app/main/courses/components/FilterSection.tsx deleted file mode 100755 index b437058..0000000 --- a/apps/web/src/app/main/courses/components/FilterSection.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { Checkbox, Divider, Radio, Space, Spin } from "antd"; - -import { TaxonomySlug, TermDto } from "@nice/common"; - -import { useEffect, useMemo, useState } from "react"; -import { api } from "@nice/client"; -import { useSearchParams } from "react-router-dom"; -import TermSelect from "@web/src/components/models/term/term-select"; -import { useMainContext } from "../../layout/MainProvider"; -import TermParentSelector from "@web/src/components/models/term/term-parent-selector"; - -export default function FilterSection() { - 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} - > - {/* ( -
{menu}
- )} - dropdownStyle={{ maxHeight: 400, overflow: "auto" }} - multiple - taxonomyId={tax?.id} - onChange={(selected) => - handleTermChange( - tax?.slug, - selected as string[] - ) - }>
- {index < taxonomies.length - 1 && ( - - )} */} -
- ); - })} -
- ); -} 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 5062731..0000000 --- a/apps/web/src/app/main/courses/layout/AllCoursesLayout.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import FilterSection from "../components/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 9c9225e..79eda89 100755 --- a/apps/web/src/app/main/courses/page.tsx +++ b/apps/web/src/app/main/courses/page.tsx @@ -1,8 +1,18 @@ -import AllCoursesLayout from "./layout/AllCoursesLayout"; +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 new file mode 100644 index 0000000..09c7852 --- /dev/null +++ b/apps/web/src/app/main/layout/BasePost/BasePostLayout.tsx @@ -0,0 +1,29 @@ +import { ReactNode, useEffect } from "react"; +import FilterSection from "./FilterSection"; +import { useMainContext } from "../MainProvider"; + +export function BasePostLayout({ + children, + showSearchMode = false, +}: { + children: ReactNode; + showSearchMode?: boolean; +}) { + const { setShowSearchMode } = useMainContext(); + useEffect(() => { + setShowSearchMode(showSearchMode); + }, [showSearchMode]); + return ( + <> +
+
+
+ +
+
{children}
+
+
+ + ); +} +export default BasePostLayout; diff --git a/apps/web/src/app/main/path/components/PathFilter.tsx b/apps/web/src/app/main/layout/BasePost/FilterSection.tsx similarity index 58% rename from apps/web/src/app/main/path/components/PathFilter.tsx rename to apps/web/src/app/main/layout/BasePost/FilterSection.tsx index 01442c8..25575d1 100755 --- a/apps/web/src/app/main/path/components/PathFilter.tsx +++ b/apps/web/src/app/main/layout/BasePost/FilterSection.tsx @@ -1,11 +1,12 @@ - +import { Divider } from "antd"; import { api } from "@nice/client"; -import { useMainContext } from "../../layout/MainProvider"; +import { useMainContext } from "../MainProvider"; import TermParentSelector from "@web/src/components/models/term/term-parent-selector"; - -export default function PathFilter() { +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, @@ -13,7 +14,8 @@ export default function PathFilter() { }); }; return ( -
+
+ {showSearchMode && } {taxonomies?.map((tax, index) => { const items = Object.entries(selectedTerms).find( ([key, items]) => key === tax.slug @@ -25,17 +27,16 @@ export default function PathFilter() { handleTermChange( tax?.slug, selected as string[] ) } - taxonomyId={tax?.id} - > - + taxonomyId={tax?.id}> +
); })} 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/MainFooter.tsx b/apps/web/src/app/main/layout/MainFooter.tsx index 9d060dc..b232752 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 { + 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 22e6bcd..9e909fc 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,6 +11,7 @@ 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(); @@ -18,79 +20,90 @@ 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"> + 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" - 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 && ( - <> + placeholder="搜索课程" + 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("/search")) { + navigate(`/search`); + window.scrollTo({ + top: 0, + behavior: "smooth", + }); + } + }} + /> +
+ + {/* 右侧区域 - 可以灵活收缩 */} +
+
+ {isAuthenticated && ( + <> + + + )} + {isAuthenticated && ( - - )} - {isAuthenticated && ( - - )} - {isAuthenticated ? ( - - ) : ( - - )} + )} + {isAuthenticated ? ( + + ) : ( + + )} +
- ); } + diff --git a/apps/web/src/app/main/layout/MainLayout.tsx b/apps/web/src/app/main/layout/MainLayout.tsx index f5b462b..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 979d110..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, @@ -16,6 +16,13 @@ interface MainContextType { setSearchValue?: React.Dispatch>; 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); @@ -24,9 +31,33 @@ 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(() => { + return Object.entries(selectedTerms) + .filter(([, terms]) => terms.length > 0) + ?.map(([, terms]) => terms); + }, [selectedTerms]); + const termsCondition: Prisma.PostWhereInput = useMemo(() => { + return termFilters && termFilters?.length > 0 + ? { + AND: termFilters.map((termFilter) => ({ + terms: { + some: { + id: { + in: termFilter, // 确保至少有一个 term.id 在当前 termFilter 中 + }, + }, + }, + })), + } + : {}; + }, [termFilters]); const searchCondition: Prisma.PostWhereInput = useMemo(() => { const containTextCondition: Prisma.StringNullableFilter = { contains: searchValue, @@ -57,6 +88,11 @@ export function MainProvider({ children }: MainProviderProps) { selectedTerms, 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 63ab7d1..d7aa53f 100755 --- a/apps/web/src/app/main/layout/NavigationMenu.tsx +++ b/apps/web/src/app/main/layout/NavigationMenu.tsx @@ -11,16 +11,18 @@ 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: "我的路径" }, ]; } }, [isAuthenticated]); @@ -31,6 +33,7 @@ export const NavigationMenu = () => { { const selectedItem = menuItems.find((item) => item.key === key); diff --git a/apps/web/src/app/main/layout/UserMenu/UserForm.tsx b/apps/web/src/app/main/layout/UserMenu/UserForm.tsx index efbf12c..a496ed0 100755 --- a/apps/web/src/app/main/layout/UserMenu/UserForm.tsx +++ b/apps/web/src/app/main/layout/UserMenu/UserForm.tsx @@ -12,15 +12,7 @@ import toast from "react-hot-toast"; export default function StaffForm() { const { user } = useAuth(); const { create, update } = useStaff(); // Ensure you have these methods in your hooks - const { - formLoading, - modalOpen, - setModalOpen, - domainId, - setDomainId, - form, - setFormLoading, - } = useContext(UserEditorContext); + const {formLoading,modalOpen,setModalOpen,domainId,setDomainId,form,setFormLoading,} = useContext(UserEditorContext); const { data, isLoading, @@ -76,6 +68,8 @@ export default function StaffForm() { } useEffect(() => { form.resetFields(); + console.log('cc',data); + if (data) { form.setFieldValue("username", data.username); form.setFieldValue("showname", data.showname); diff --git a/apps/web/src/app/main/layout/UserMenu/UserMenu.tsx b/apps/web/src/app/main/layout/UserMenu/UserMenu.tsx index c25fc53..fc63758 100755 --- a/apps/web/src/app/main/layout/UserMenu/UserMenu.tsx +++ b/apps/web/src/app/main/layout/UserMenu/UserMenu.tsx @@ -86,20 +86,7 @@ export function UserMenu() { setModalOpen(true); }, }, - { - icon: , - label: "我创建的课程", - action: () => { - navigate("/my-duty"); - }, - }, - { - icon: , - label: "我学习的课程", - action: () => { - navigate("/my-learning"); - }, - }, + canManageAnyStaff && { icon: , label: "设置", @@ -172,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 new file mode 100644 index 0000000..727e828 --- /dev/null +++ b/apps/web/src/app/main/my-duty/components/MyDutyListContainer.tsx @@ -0,0 +1,27 @@ +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 CourseCard from "@web/src/components/models/post/SubPost/CourseCard"; + +export default function MyDutyListContainer() { + const { user } = useAuth(); + const { searchCondition, termsCondition } = useMainContext(); + return ( + <> + } + params={{ + pageSize: 12, + where: { + type: PostType.COURSE, + authorId: user.id, + ...termsCondition, + ...searchCondition, + }, + }} + cols={4}> + + ); +} diff --git a/apps/web/src/app/main/my-duty/page.tsx b/apps/web/src/app/main/my-duty/page.tsx index 1031d79..fff4c91 100755 --- a/apps/web/src/app/main/my-duty/page.tsx +++ b/apps/web/src/app/main/my-duty/page.tsx @@ -1,27 +1,16 @@ -import PostList from "@web/src/components/models/course/list/PostList"; -import { useAuth } from "@web/src/providers/auth-provider"; +import BasePostLayout from "../layout/BasePost/BasePostLayout"; +import MyDutyListContainer from "./components/MyDutyListContainer"; +import { useEffect } from "react"; import { useMainContext } from "../layout/MainProvider"; -import CourseCard from "../courses/components/CourseCard"; - +import { PostType } from "@nice/common"; export default function MyDutyPage() { - const { user } = useAuth(); - const { searchCondition } = useMainContext(); + const { setSearchMode } = useMainContext(); + useEffect(() => { + setSearchMode(PostType.COURSE); + }, [setSearchMode]); return ( - <> -
- ( - - )} - params={{ - pageSize: 12, - where: { - authorId: user.id, - ...searchCondition, - }, - }} - cols={4}> -
- + + + ); } diff --git a/apps/web/src/app/main/my-learning/components/MyLearningListContainer.tsx b/apps/web/src/app/main/my-learning/components/MyLearningListContainer.tsx new file mode 100644 index 0000000..be281ae --- /dev/null +++ b/apps/web/src/app/main/my-learning/components/MyLearningListContainer.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 { 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(); + const { searchCondition, termsCondition } = useMainContext(); + return ( + <> + } + params={{ + pageSize: 12, + where: { + type: PostType.COURSE, + students: { + some: { + id: user?.id, + }, + }, + ...termsCondition, + ...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 index 5f0a7a8..cee2c94 100755 --- a/apps/web/src/app/main/my-learning/page.tsx +++ b/apps/web/src/app/main/my-learning/page.tsx @@ -1,31 +1,17 @@ -import PostList from "@web/src/components/models/course/list/PostList"; -import { useAuth } from "@web/src/providers/auth-provider"; +import { useEffect } from "react"; +import BasePostLayout from "../layout/BasePost/BasePostLayout"; import { useMainContext } from "../layout/MainProvider"; -import CourseCard from "../courses/components/CourseCard"; +import MyLearningListContainer from "./components/MyLearningListContainer"; +import { PostType } from "@nice/common"; export default function MyLearningPage() { - const { user } = useAuth(); - const { searchCondition } = useMainContext(); + const { setSearchMode } = useMainContext(); + useEffect(() => { + setSearchMode(PostType.COURSE); + }, [setSearchMode]); return ( - <> -
- ( - - )} - params={{ - pageSize: 12, - where: { - students: { - some: { - id: user?.id, - }, - }, - ...searchCondition, - }, - }} - cols={4}> -
- + + + ); } 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 new file mode 100644 index 0000000..fe82bbd --- /dev/null +++ b/apps/web/src/app/main/path/components/DeptInfo.tsx @@ -0,0 +1,41 @@ +import { BookOutlined, EyeOutlined, TeamOutlined } from "@ant-design/icons"; +import { Typography } from "antd"; +import { PostDto } from "@nice/common"; + +const { Title, Text } = Typography; +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; 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 8ccb01f..0000000 --- a/apps/web/src/app/main/path/components/PathCard.tsx +++ /dev/null @@ -1,94 +0,0 @@ -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/PathListContainer.tsx b/apps/web/src/app/main/path/components/PathListContainer.tsx index 125d9e8..4e7ade6 100755 --- a/apps/web/src/app/main/path/components/PathListContainer.tsx +++ b/apps/web/src/app/main/path/components/PathListContainer.tsx @@ -1,54 +1,25 @@ 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"; +import PostCard from "@web/src/components/models/post/PostCard"; +import PathCard from "@web/src/components/models/post/SubPost/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, // 使用类型断言 - }; + const { searchCondition, termsCondition } = useMainContext(); return ( <> } + renderItem={(post) => } 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, - }, - }, - }, - ], + ...termsCondition, + ...searchCondition, }, }} cols={4}> ); } - export default PathListContainer; diff --git a/apps/web/src/app/main/path/components/TermInfo.tsx b/apps/web/src/app/main/path/components/TermInfo.tsx new file mode 100644 index 0000000..718096f --- /dev/null +++ b/apps/web/src/app/main/path/components/TermInfo.tsx @@ -0,0 +1,41 @@ +import { Tag } from "antd"; +import { PostDto, TaxonomySlug } from "@nice/common"; + +const TermInfo = ({ post }: { post: PostDto }) => { + return ( +
+ {post?.terms && post?.terms?.length > 0 ? ( +
+ {post?.terms?.map((term: any) => { + return ( + + {term.name} + + ); + })} +
+ ) : ( +
+ + {"未设置分类"} + +
+ )} +
+ ); +}; + +export default TermInfo; diff --git a/apps/web/src/app/main/path/editor/page.tsx b/apps/web/src/app/main/path/editor/page.tsx index bffdec2..5ce3a20 100755 --- a/apps/web/src/app/main/path/editor/page.tsx +++ b/apps/web/src/app/main/path/editor/page.tsx @@ -2,9 +2,11 @@ import MindEditor from "@web/src/components/common/editor/MindEditor"; import { useParams } from "react-router-dom"; export default function PathEditorPage() { - const { id } = useParams(); + const { id } = useParams(); - return
- -
+ return ( +
+ +
+ ); } diff --git a/apps/web/src/app/main/path/layout/PathListLayout.tsx b/apps/web/src/app/main/path/layout/PathListLayout.tsx deleted file mode 100755 index 19ebe94..0000000 --- a/apps/web/src/app/main/path/layout/PathListLayout.tsx +++ /dev/null @@ -1,20 +0,0 @@ -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 index 825f3f7..65178db 100755 --- a/apps/web/src/app/main/path/page.tsx +++ b/apps/web/src/app/main/path/page.tsx @@ -1,5 +1,17 @@ -import PathListLayout from "./layout/PathListLayout"; +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() { - return + 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 dd6306e..88d745b 100755 --- a/apps/web/src/components/common/editor/MindEditor.tsx +++ b/apps/web/src/components/common/editor/MindEditor.tsx @@ -1,15 +1,24 @@ -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'; +import { Button, Card, Empty, Form, Space, Spin, message, theme } from "antd"; +import NodeMenu from "./NodeMenu"; +import { api, usePost } from "@nice/client"; +import { + ObjectType, + postDetailSelect, + PostDto, + PostType, + Prisma, + RolePerms, + Taxonomy, +} from "@nice/common"; +import TermSelect from "../../models/term/term-select"; +import DepartmentSelect from "../../models/department/department-select"; +import { useEffect, useMemo, useRef, useState } from "react"; +import 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"; +import { useAuth } from "@web/src/providers/auth-provider"; const MIND_OPTIONS = { direction: MindElixir.SIDE, draggable: true, @@ -17,53 +26,60 @@ const MIND_OPTIONS = { toolBar: true, nodeMenu: true, keypress: true, - locale: 'zh_CN' as const, + locale: "zh_CN" as const, theme: { - name: 'Latte', + name: "Latte", palette: [ - '#dd7878', - '#ea76cb', - '#8839ef', - '#e64553', - '#fe640b', - '#df8e1d', - '#40a02b', - '#209fb5', - '#1e66f5', - '#7287fd', + "#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', + "--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); + const { isAuthenticated, user, hasSomePermissions } = useAuth(); - const { data: post, isLoading }: { data: PostDto, isLoading: boolean } = api.post.findFirst.useQuery({ - where: { - id - }, - select: postDetailSelect - }) - const navigate = useNavigate() + const { data: post, isLoading }: { data: PostDto; isLoading: boolean } = + api.post.findFirst.useQuery({ + where: { + id, + }, + select: postDetailSelect, + }); + const canEdit: boolean = useMemo(() => { + //登录了且是作者、超管、无id新建模式 + const isAuth = isAuthenticated && user?.id == post?.author.id + return !Boolean(id) || isAuth || hasSomePermissions(RolePerms.MANAGE_ANY_POST); + }, [user]) + const navigate = useNavigate(); const { create, update } = usePost(); const { data: taxonomies } = api.taxonomy.getAll.useQuery({ type: ObjectType.COURSE, }); - const { handleFileUpload } = useTusUpload() - const [form] = Form.useForm() + const { handleFileUpload } = useTusUpload(); + const [form] = Form.useForm(); useEffect(() => { if (post && form && instance && id) { - console.log(post) - instance.refresh((post as any).meta) + console.log(post); + instance.refresh((post as any).meta); const deptIds = (post?.depts || [])?.map((dept) => dept.id); const formData = { title: post.title, @@ -81,117 +97,145 @@ export default function MindEditor({ id }: { id?: string }) { 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('新学习路径')); + 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) + containerRef.current.hidden = false; + instance.toCenter(); + instance.refresh((post as any)?.meta); } - }, [id, post, instance]) + }, [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: { + 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: { - set: termIds.map((id) => ({ id })) + connect: termIds.map((id) => ({ id })), }, depts: { - set: deptIds.map((id) => ({ id })), + connect: deptIds.map((id) => ({ id })), }, - updatedAt: new Date() - } + updatedAt: new Date(), + }; + + const res = await create.mutateAsync({ data: params }); + navigate(`/path/editor/${res.id}`, { replace: true }); + toast.success("创建成功"); } - 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; } - } catch (error) { - toast.error('保存失败'); - throw error; - } - console.log(result) - }, (error) => { }, `mind-thumb-${new Date().toString()}`) - - + console.log(result); + }, + (error) => { }, + `mind-thumb-${new Date().toString()}` + ); }; return ( -
- {taxonomies && ( -
{ - console.log(values) - }} form={form} className=' bg-white p-2 '> -
-
+
+ {canEdit && taxonomies && ( + +
+
{taxonomies.map((tax, index) => ( + // rules={[{ required: true }]} + noStyle> - ))} - - + +
- +
- ) - } -
- {instance && ( )} - {isLoading &&
- -
} - {!post && id && !isLoading &&
- -
} +
e.preventDefault()} /> + {canEdit && instance && } + {isLoading && ( +
+ +
+ )} + {!post && id && !isLoading && ( +
+ +
+ )}
); } diff --git a/apps/web/src/components/common/uploader/ResourceShower.tsx b/apps/web/src/components/common/uploader/ResourceShower.tsx index 98d14d4..29e7a93 100755 --- a/apps/web/src/components/common/uploader/ResourceShower.tsx +++ b/apps/web/src/components/common/uploader/ResourceShower.tsx @@ -35,7 +35,7 @@ export default function ResourcesShower({ const imageResources = dealedResources.filter((res) => 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 0da1702..1e29d87 100755 --- a/apps/web/src/components/models/course/detail/CourseDetailContext.tsx +++ b/apps/web/src/components/models/course/detail/CourseDetailContext.tsx @@ -64,6 +64,7 @@ export function CourseDetailProvider({ const isRoot = hasSomePermissions(RolePerms?.MANAGE_ANY_POST); return isAuthor || isRoot; }, [user, course]); + const [selectedLectureId, setSelectedLectureId] = useState< string | undefined >(lectureId || undefined); @@ -89,7 +90,9 @@ export function CourseDetailProvider({ } }, [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 05a3126..463dc8b 100755 --- a/apps/web/src/components/models/course/detail/CourseDetailDescription.tsx +++ b/apps/web/src/components/models/course/detail/CourseDetailDescription.tsx @@ -2,27 +2,29 @@ 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, 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]); const navigate = useNavigate(); - const { canEdit } = useContext(CourseDetailContext); const { id } = useParams(); return ( //
@@ -31,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 && ( -
-
+
+
+
{" "} {/* 添加这个包装 div */}
{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/detail/CourseSyllabus/CourseSyllabus.tsx b/apps/web/src/components/models/course/detail/CourseSyllabus/CourseSyllabus.tsx index 10b756a..84463cf 100755 --- a/apps/web/src/components/models/course/detail/CourseSyllabus/CourseSyllabus.tsx +++ b/apps/web/src/components/models/course/detail/CourseSyllabus/CourseSyllabus.tsx @@ -58,7 +58,7 @@ 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"> {isOpen && ( 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 e9e51f4..ce9da18 100755 --- a/apps/web/src/components/models/course/list/PostList.tsx +++ b/apps/web/src/components/models/course/list/PostList.tsx @@ -1,8 +1,8 @@ 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 { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; interface PostListProps { params?: { page?: number; @@ -12,8 +12,7 @@ interface PostListProps { }; cols?: number; showPagination?: boolean; - renderItem: (post: any) => React.ReactNode - + renderItem: (post: PostDto) => React.ReactNode; } interface PostPagnationProps { data: { @@ -21,13 +20,12 @@ interface PostPagnationProps { totalPages: number; }; isLoading: boolean; - } export default function PostList({ params, cols = 3, showPagination = true, - renderItem + renderItem, }: PostListProps) { const [currentPage, setCurrentPage] = useState(params?.page || 1); const { data, isLoading }: PostPagnationProps = @@ -72,9 +70,9 @@ export default function PostList({ {isLoading ? ( ) : ( - posts.map((post) =>
- {renderItem(post)} -
) + posts.map((post) => ( +
{renderItem(post)}
+ )) )}
{showPagination && ( @@ -91,7 +89,6 @@ export default function PostList({ ) : (
-
)} 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 34de7f4..65e941c 100755 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -159,5 +159,4 @@ .custom-table .ant-table-tbody>tr:last-child>td { border-bottom: none; /* 去除最后一行的底部边框 */ -} - +} \ 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/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/web-dist/index.html b/web-dist/index.html old mode 100755 new mode 100644