You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
327 lines
15 KiB
327 lines
15 KiB
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Link } from "react-router-dom";
|
|
import { getTeachingCategoryList, getTeachingContentList } from "@/api/api";
|
|
import type { TeachingCategoryItem, TeachingContentItem } from "@/api/api";
|
|
import { getMemberLevelLabel, hasMemberLevelLabel, ROUTES } from "@/constants";
|
|
import { openSubscribePlanDialog } from "@/dialog";
|
|
import { useMemberLevelStore } from "@/store/useMemberLevelStore";
|
|
import "./index.scss";
|
|
|
|
const TEACHING_ASSETS = {
|
|
heroCharacter: "https://www.figma.com/api/mcp/asset/a8d8968a-f6c2-4854-b3fd-5c4c5ace1ed3",
|
|
communityQr: "https://www.figma.com/api/mcp/asset/e3bcf4cc-75ec-4b87-8703-0ab20ac0d632",
|
|
memberBackground: "https://www.figma.com/api/mcp/asset/876e35c8-c7e9-4329-be61-47ff5b86ea3f",
|
|
search: new URL("@/assets/images/common/icons/search.png", import.meta.url).href,
|
|
levelIcon: "https://www.figma.com/api/mcp/asset/cf3f3615-a62d-4bd9-8b4c-43a5ede56f78",
|
|
} as const;
|
|
|
|
type TeachingFilterId = string;
|
|
type TeachingLevelVariant = "purple" | "orange" | "dark";
|
|
type CommunityPanelId = "community" | "member";
|
|
|
|
interface TeachingFilterConfig {
|
|
id: TeachingFilterId;
|
|
label: string;
|
|
categoryId?: number;
|
|
}
|
|
|
|
interface TeachingMemberLevelDisplay {
|
|
label: string;
|
|
variant: TeachingLevelVariant;
|
|
showIcon: boolean;
|
|
}
|
|
|
|
function toError(value: unknown): Error {
|
|
return value instanceof Error ? value : new Error(String(value));
|
|
}
|
|
|
|
function normalizeTeachingFilters(categories: TeachingCategoryItem[]): TeachingFilterConfig[] {
|
|
return categories
|
|
.filter((category) => category.status === 1 && !category.deleted && category.code.trim().length > 0 && category.name.trim().length > 0)
|
|
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.id - b.id)
|
|
.map((category) => ({
|
|
id: String(category.id),
|
|
label: category.name,
|
|
categoryId: category.id,
|
|
}));
|
|
}
|
|
|
|
function normalizeTeachingContentItems(items: TeachingContentItem[]): TeachingContentItem[] {
|
|
return items
|
|
.filter((item) => item.deleted !== true && (item.status == null || item.status === 1))
|
|
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.id - b.id);
|
|
}
|
|
|
|
function normalizeTeachingMemberLevels(memberLevels: readonly number[] | undefined): number[] {
|
|
return [...new Set((memberLevels ?? []).map(Number).filter(Number.isFinite))].sort((a, b) => a - b);
|
|
}
|
|
|
|
function getFirstTeachingTag(tags: readonly string[] | undefined): string {
|
|
return tags?.map((tag) => tag.trim()).find((tag) => tag.length > 0) ?? "";
|
|
}
|
|
|
|
function getTeachingInstructorName(item: TeachingContentItem): string {
|
|
return item.userInfo?.name?.trim() ?? "";
|
|
}
|
|
|
|
function getTeachingInstructorAvatar(item: TeachingContentItem): string {
|
|
return item.userInfo?.avatar?.trim() ?? "";
|
|
}
|
|
|
|
function getTeachingDocumentPath(courseId: number): string {
|
|
return `${ROUTES.TEACHING_DOCUMENT}/${encodeURIComponent(String(courseId))}`;
|
|
}
|
|
|
|
function getTeachingDocumentRouteState(item: TeachingContentItem) {
|
|
return {
|
|
courseId: item.id,
|
|
courseDescription: item.desp?.trim() ?? "",
|
|
};
|
|
}
|
|
|
|
function getTeachingMemberLevelDisplay(memberLevels: readonly number[] | undefined, memberLevelMap: Partial<Record<number, string>>, translate: (key: string) => string): TeachingMemberLevelDisplay | null {
|
|
const levels = normalizeTeachingMemberLevels(memberLevels);
|
|
const lowestLevel = levels.find((level) => hasMemberLevelLabel(memberLevelMap, level));
|
|
if (lowestLevel == null) return null;
|
|
|
|
const variant: TeachingLevelVariant = lowestLevel >= 3 ? "orange" : lowestLevel <= 0 ? "dark" : "purple";
|
|
|
|
return {
|
|
label: getMemberLevelLabel(memberLevelMap, lowestLevel, translate),
|
|
variant,
|
|
showIcon: lowestLevel > 0,
|
|
};
|
|
}
|
|
|
|
export default function Teaching() {
|
|
const { t } = useTranslation();
|
|
const memberLevelMap = useMemberLevelStore((s) => s.memberLevelMap);
|
|
const [activeFilterId, setActiveFilterId] = useState<TeachingFilterId>("all");
|
|
const [teachingFilters, setTeachingFilters] = useState<TeachingFilterConfig[]>([]);
|
|
const [teachingItems, setTeachingItems] = useState<TeachingContentItem[]>([]);
|
|
const [isTeachingListLoading, setIsTeachingListLoading] = useState(true);
|
|
const [teachingListError, setTeachingListError] = useState<Error | null>(null);
|
|
const [activeCommunityPanelId, setActiveCommunityPanelId] = useState<CommunityPanelId>("community");
|
|
const [searchValue, setSearchValue] = useState("");
|
|
const isCommunityPanelActive = activeCommunityPanelId === "community";
|
|
const isMemberPanelActive = activeCommunityPanelId === "member";
|
|
const displayedTeachingFilters: TeachingFilterConfig[] = [{ id: "all", label: t("pages.teaching.filters.all") }, ...teachingFilters];
|
|
const activeFilter = displayedTeachingFilters.find((filter) => filter.id === activeFilterId);
|
|
const activeCategoryId = activeFilter?.categoryId;
|
|
const shouldShowTeachingStatus = isTeachingListLoading || teachingListError != null || teachingItems.length === 0;
|
|
const teachingStatusText = isTeachingListLoading ? t("common.loading") : teachingListError?.message || t("pages.teaching.empty");
|
|
|
|
const handleOpenSubscribePlan = (): void => {
|
|
void openSubscribePlanDialog();
|
|
};
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
void getTeachingCategoryList()
|
|
.then((response) => {
|
|
if (cancelled) return;
|
|
setTeachingFilters(normalizeTeachingFilters(response.data ?? []));
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setTeachingFilters([]);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
setIsTeachingListLoading(true);
|
|
setTeachingListError(null);
|
|
|
|
const keyword = searchValue.trim();
|
|
|
|
void getTeachingContentList({
|
|
...(keyword ? { keyword } : {}),
|
|
...(activeCategoryId != null ? { categoryId: activeCategoryId } : {}),
|
|
})
|
|
.then((response) => {
|
|
if (cancelled) return;
|
|
setTeachingItems(normalizeTeachingContentItems(response.data?.list ?? []));
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (cancelled) return;
|
|
setTeachingItems([]);
|
|
setTeachingListError(toError(error));
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setIsTeachingListLoading(false);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [activeCategoryId, searchValue]);
|
|
|
|
return (
|
|
<section className="teachingPage" aria-labelledby="teaching-page-title">
|
|
<div className="teachingPage__inner">
|
|
<div className="teachingPage__heroRow">
|
|
<div className="teachingPage__introCard">
|
|
<div className="teachingPage__introCopy">
|
|
<h1 className="teachingPage__introTitle" id="teaching-page-title">
|
|
{t("pages.teaching.hero.title")}
|
|
</h1>
|
|
<p className="teachingPage__introDescription">
|
|
<span>{t("pages.teaching.hero.descriptionLine1")}</span>
|
|
<span>{t("pages.teaching.hero.descriptionLine2")}</span>
|
|
</p>
|
|
</div>
|
|
<img className="teachingPage__introImage" src={TEACHING_ASSETS.heroCharacter} alt="" />
|
|
</div>
|
|
<div className={["teachingPage__communityCard", isMemberPanelActive ? "teachingPage__communityCard_memberActive" : "teachingPage__communityCard_communityActive"].join(" ")}>
|
|
<button
|
|
type="button"
|
|
className={[
|
|
"teachingPage__communityPanel",
|
|
"teachingPage__communityPanel_community",
|
|
isCommunityPanelActive ? "teachingPage__communityPanel_expanded" : "teachingPage__communityPanel_collapsed",
|
|
].join(" ")}
|
|
aria-pressed={isCommunityPanelActive}
|
|
onClick={() => {
|
|
setActiveCommunityPanelId("community");
|
|
}}
|
|
>
|
|
<span className="teachingPage__communityCopy">
|
|
<span className="teachingPage__communityTitle">{isCommunityPanelActive ? t("pages.teaching.community.title") : t("pages.teaching.community.collapsedTitle")}</span>
|
|
<span className="teachingPage__communityDescription">
|
|
<span>{isCommunityPanelActive ? t("pages.teaching.community.descriptionLine1") : t("pages.teaching.community.collapsedDescriptionLine1")}</span>
|
|
<span>{isCommunityPanelActive ? t("pages.teaching.community.descriptionLine2") : t("pages.teaching.community.collapsedDescriptionLine2")}</span>
|
|
</span>
|
|
{isCommunityPanelActive ? <span className="teachingPage__communityScan">{t("pages.teaching.community.scan")}</span> : null}
|
|
</span>
|
|
{isCommunityPanelActive ? (
|
|
<span className="teachingPage__communityQrWrap">
|
|
<img className="teachingPage__communityQr" src={TEACHING_ASSETS.communityQr} alt={t("pages.teaching.community.qrAlt")} />
|
|
</span>
|
|
) : null}
|
|
</button>
|
|
{isMemberPanelActive ? (
|
|
<section className="teachingPage__memberPanel teachingPage__memberPanel_expanded" aria-label={t("pages.teaching.member.expandedTitleLine1")}>
|
|
<span className="teachingPage__memberBackground" aria-hidden="true">
|
|
<img className="teachingPage__memberBackgroundImage" src={TEACHING_ASSETS.memberBackground} alt="" />
|
|
</span>
|
|
<span className="teachingPage__memberCopy">
|
|
<span className="teachingPage__memberTitle">
|
|
<span>{t("pages.teaching.member.expandedTitleLine1")}</span>
|
|
<span>{t("pages.teaching.member.expandedTitleLine2")}</span>
|
|
</span>
|
|
<span className="teachingPage__memberDescription">
|
|
<span>{t("pages.teaching.member.descriptionLine1")}</span>
|
|
<span>{t("pages.teaching.member.descriptionLine2")}</span>
|
|
</span>
|
|
</span>
|
|
<button type="button" className="teachingPage__memberAction" onClick={handleOpenSubscribePlan}>
|
|
{t("pages.teaching.member.action")}
|
|
</button>
|
|
</section>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="teachingPage__memberPanel teachingPage__memberPanel_collapsed"
|
|
aria-pressed={isMemberPanelActive}
|
|
onClick={() => {
|
|
setActiveCommunityPanelId("member");
|
|
}}
|
|
>
|
|
<span className="teachingPage__memberCopy">
|
|
<span className="teachingPage__memberTitle">{t("pages.teaching.member.title")}</span>
|
|
<span className="teachingPage__memberDescription">
|
|
<span>{t("pages.teaching.member.descriptionLine1")}</span>
|
|
<span>{t("pages.teaching.member.descriptionLine2")}</span>
|
|
</span>
|
|
</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="teachingPage__toolbar">
|
|
<div className="teachingPage__filters" role="list" aria-label={t("pages.teaching.filters.aria")}>
|
|
{displayedTeachingFilters.map((filter) => {
|
|
const isActive = activeFilterId === filter.id;
|
|
return (
|
|
<button
|
|
key={filter.id}
|
|
type="button"
|
|
className={["teachingPage__filterButton", isActive ? "teachingPage__filterButton_active" : ""].filter(Boolean).join(" ")}
|
|
aria-pressed={isActive}
|
|
onClick={() => {
|
|
setActiveFilterId(filter.id);
|
|
}}
|
|
>
|
|
<span className="teachingPage__filterText">{filter.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<label className="teachingPage__search">
|
|
<img className="teachingPage__searchIcon" src={TEACHING_ASSETS.search} alt="" />
|
|
<input
|
|
className="teachingPage__searchInput"
|
|
type="search"
|
|
value={searchValue}
|
|
onChange={(event) => {
|
|
setSearchValue(event.target.value);
|
|
}}
|
|
aria-label={t("pages.teaching.search.aria")}
|
|
placeholder={t("pages.teaching.search.placeholder")}
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
{shouldShowTeachingStatus ? (
|
|
<p className={["teachingPage__empty", teachingListError ? "teachingPage__empty_error" : ""].filter(Boolean).join(" ")} role={teachingListError ? "alert" : undefined}>
|
|
{teachingStatusText}
|
|
</p>
|
|
) : (
|
|
<div className="teachingPage__grid" aria-label={t("pages.teaching.grid.aria")}>
|
|
{teachingItems.map((item) => {
|
|
const cover = item.cover?.trim() ?? "";
|
|
const title = item.name.trim() || t("pages.teaching.untitled");
|
|
const firstTag = getFirstTeachingTag(item.tags);
|
|
const instructorName = getTeachingInstructorName(item);
|
|
const instructorAvatar = getTeachingInstructorAvatar(item);
|
|
const memberLevelDisplay = getTeachingMemberLevelDisplay(item.memberLevels, memberLevelMap, t);
|
|
|
|
return (
|
|
<Link className="teachingPage__teachingCard" key={item.id} to={getTeachingDocumentPath(item.id)} state={getTeachingDocumentRouteState(item)} aria-label={title}>
|
|
{cover ? <img className="teachingPage__teachingImage" src={resolveOssImageThumbUrl(cover)} alt="" loading="lazy" decoding="async" /> : <span className="teachingPage__teachingCoverPlaceholder" aria-hidden="true" />}
|
|
{firstTag ? <span className="teachingPage__teachingTag">{firstTag}</span> : null}
|
|
<div className="teachingPage__teachingInfo">
|
|
{instructorAvatar ? <img className="teachingPage__teachingInstructorAvatar" src={resolveOssImageThumbUrl(instructorAvatar)} alt={instructorName} loading="lazy" decoding="async" /> : null}
|
|
<div className="teachingPage__teachingText">
|
|
<h2 className="teachingPage__teachingTitle">{title}</h2>
|
|
{instructorName ? (
|
|
<p className="teachingPage__teachingInstructor">
|
|
<span className="teachingPage__teachingInstructorPrefix">{t("pages.teaching.instructorPrefix")}</span>
|
|
<span className="teachingPage__teachingInstructorName">{instructorName}</span>
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
{memberLevelDisplay ? (
|
|
<span className={["teachingPage__teachingLevel", `teachingPage__teachingLevel_${memberLevelDisplay.variant}`].join(" ")}>
|
|
<span className="teachingPage__teachingLevelText">{memberLevelDisplay.label}</span>
|
|
{memberLevelDisplay.showIcon ? <img className="teachingPage__teachingLevelIcon" src={TEACHING_ASSETS.levelIcon} alt="" /> : null}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|