全新popiart网站 react项目重构
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

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>
);
}