Redesign international public site around People/News/Research/Cooperation.
Use sliced assets under public/assets, shared hero and hex section backgrounds, and ignore the local images/ design source folder. Co-authored-by: Cursor <cursoragent@cursor.com>
3
.gitignore
vendored
@ -12,6 +12,9 @@ dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# 设计参考稿 / 切片源文件,正式素材已整理进 public/assets
|
||||
images/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
18
public/assets/img/intl/README.md
Normal file
@ -0,0 +1,18 @@
|
||||
# 国际站切片素材(来自 images/*_slices.zip)
|
||||
|
||||
| 文件 | 来源 | 用途 |
|
||||
|------|------|------|
|
||||
| tab-report.png | 首页1 The_Rating... | 首页 Tab1 图标 |
|
||||
| tab-ai.png | 首页1 Frame | 首页 Tab2 图标 |
|
||||
| tab-method.png | 首页1 STAR_Multi... | 首页 Tab3 图标 |
|
||||
| report-cover.png | 首页1 占位图 | 报告封面 / Research 卡头 |
|
||||
| feature-ai.png | 首页2 背景2_ | AI Tab 右侧插画 |
|
||||
| feature-method.png | 首页3 背景3 | Methodology Tab 右侧插画 |
|
||||
| bg-coop-light.png | 首页1 背景1 | Cooperation 页浅色区背景 |
|
||||
| bg-coop-dark.png | cooperation-悬浮 背景6 | 首页 Cooperation 深色区底纹 |
|
||||
| bg-mission.png | people 背景4 | People Mission & Vision 左图 |
|
||||
| icon-join/meeting/partner/contact.png | 首页1 加入/会议/合作/联系 | Cooperation 卡片图标 |
|
||||
| icon-improve/training.png | cooperation-悬浮 改进/发展 | Cooperation 额外图标 |
|
||||
| icon-more.png | 首页1 ic_右-后 | more 箭头 |
|
||||
|
||||
Hero「Guidelines Worth Applying」使用原站图:`/assets/img/carousel/jishitang.png`
|
||||
BIN
public/assets/img/intl/bg-coop-dark.png
Normal file
|
After Width: | Height: | Size: 293 KiB |
BIN
public/assets/img/intl/bg-coop-light.png
Normal file
|
After Width: | Height: | Size: 3.5 MiB |
BIN
public/assets/img/intl/bg-mission.png
Normal file
|
After Width: | Height: | Size: 674 KiB |
BIN
public/assets/img/intl/feature-ai.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/assets/img/intl/feature-method.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/assets/img/intl/icon-contact.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/assets/img/intl/icon-improve.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/assets/img/intl/icon-join.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
public/assets/img/intl/icon-meeting.png
Normal file
|
After Width: | Height: | Size: 997 B |
BIN
public/assets/img/intl/icon-more.png
Normal file
|
After Width: | Height: | Size: 366 B |
BIN
public/assets/img/intl/icon-partner.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
public/assets/img/intl/icon-training.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/assets/img/intl/report-cover.png
Normal file
|
After Width: | Height: | Size: 269 KiB |
BIN
public/assets/img/intl/tab-ai.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
public/assets/img/intl/tab-method.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/assets/img/intl/tab-report.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/assets/img/logo-en-white.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/assets/img/logo-star-mark-white.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
public/assets/img/logo-star-mark.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
23
src/components/site/CoopCardIcon.jsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Cooperation 卡片图标:用 mask 上色,随父级 currentColor 变化(含 hover)。
|
||||
*/
|
||||
export default function CoopCardIcon({ src, className }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("mb-4 block h-10 w-10 shrink-0 bg-current", className)}
|
||||
style={{
|
||||
maskImage: `url(${src})`,
|
||||
maskSize: "contain",
|
||||
maskRepeat: "no-repeat",
|
||||
maskPosition: "center",
|
||||
WebkitMaskImage: `url(${src})`,
|
||||
WebkitMaskSize: "contain",
|
||||
WebkitMaskRepeat: "no-repeat",
|
||||
WebkitMaskPosition: "center",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
62
src/components/site/PlaceholderMedia.jsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* 通用占位图:用渐变色块代替正式摄影/插画素材。
|
||||
*/
|
||||
export function PlaceholderMedia({
|
||||
label = "Placeholder",
|
||||
className,
|
||||
tone = "blue",
|
||||
}) {
|
||||
const tones = {
|
||||
blue: "from-[#2b4a9a] via-[#4a6fd0] to-[#9bb6f0]",
|
||||
soft: "from-[#c5d6f5] via-[#e8f1ff] to-[#f7faff]",
|
||||
warm: "from-[#1a2748] via-[#3d4f7a] to-[#c9a06a]",
|
||||
teal: "from-[#1e4a6e] via-[#3a7ca5] to-[#8ec5e0]",
|
||||
paper: "from-[#3a61c2] via-[#5b7fd4] to-[#d6e2ff]",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center justify-center overflow-hidden bg-gradient-to-br text-white/80",
|
||||
tones[tone] || tones.blue,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="relative z-10 px-3 text-center text-xs font-medium tracking-wide uppercase opacity-80">
|
||||
{label}
|
||||
</span>
|
||||
<div className="pointer-events-none absolute -right-6 -bottom-8 h-28 w-28 rounded-full bg-white/15" />
|
||||
<div className="pointer-events-none absolute -top-4 -left-4 h-16 w-16 rounded-full bg-white/10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 合作区六边形纹理背景(深蓝 / 浅蓝),不含素材摄影 */
|
||||
export function HexPatternBg({ className, children, dark = false }) {
|
||||
// 点阵蜂窝:两列错位六边形,stroke 用 URL 编码色值
|
||||
const hexStroke = dark ? "%23ffffff" : "%233a61c2";
|
||||
const hexTile = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='84' height='48' viewBox='0 0 84 48'%3E%3Cpath d='M22 2 L42 14 L42 34 L22 46 L2 34 L2 14 Z' fill='none' stroke='${hexStroke}' stroke-width='1'/%3E%3Cpath d='M64 2 L84 14 L84 34 L64 46 L44 34 L44 14 Z' fill='none' stroke='${hexStroke}' stroke-width='1'/%3E%3C/svg%3E")`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
style={{
|
||||
background: dark
|
||||
? "linear-gradient(135deg, #2f5db8 0%, #3a6fd0 50%, #2a5299 100%)"
|
||||
: "linear-gradient(120deg, #d8e8ff 0%, #eaf3ff 45%, #cfe0f8 100%)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
opacity: dark ? 0.16 : 0.14,
|
||||
backgroundImage: hexTile,
|
||||
backgroundSize: "84px 48px",
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/components/site/SiteHero.jsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** 沿用原站轮播主视觉(图内已含 Guidelines Worth Applying 文案) */
|
||||
export const HERO_BG = "/assets/img/carousel/jishitang.png";
|
||||
|
||||
/**
|
||||
* 全站共用 Hero。背景默认用原站积石堂图。
|
||||
* showTitle=false:图内已有标题时不再叠字。
|
||||
*/
|
||||
export default function SiteHero({
|
||||
title = "Guidelines Worth Applying",
|
||||
className,
|
||||
heightClass = "h-[280px] md:h-[360px]",
|
||||
overlay,
|
||||
showTitle = false,
|
||||
backgroundImage = HERO_BG,
|
||||
/** 允许 overlay 卡片探出 Hero 底部(如 Join a committee) */
|
||||
overflowVisible = false,
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"relative w-full bg-[#071229]",
|
||||
overflowVisible ? "overflow-visible" : "overflow-hidden",
|
||||
heightClass,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{ backgroundImage: `url(${backgroundImage})` }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#050b18]/35 via-transparent to-transparent" />
|
||||
|
||||
{(overlay || showTitle) && (
|
||||
<div
|
||||
className={cn(
|
||||
"site-container relative flex h-full items-center",
|
||||
overflowVisible ? "z-30" : "z-10",
|
||||
)}
|
||||
>
|
||||
{overlay || (
|
||||
<h1
|
||||
className="max-w-xl text-[34px] leading-tight font-bold tracking-wide text-white md:text-[52px]"
|
||||
style={{ fontFamily: "var(--font-hero)" }}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
22
src/config/intlAssets.js
Normal file
@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 国际站切片素材路径(来自 images/*_slices.zip,已整理到 public/assets/img/intl)
|
||||
*/
|
||||
export const INTL = {
|
||||
hero: "/assets/img/carousel/jishitang.png",
|
||||
tabReport: "/assets/img/intl/tab-report.png",
|
||||
tabAi: "/assets/img/intl/tab-ai.png",
|
||||
tabMethod: "/assets/img/intl/tab-method.png",
|
||||
reportCover: "/assets/img/intl/report-cover.png",
|
||||
featureAi: "/assets/img/intl/feature-ai.png",
|
||||
featureMethod: "/assets/img/intl/feature-method.png",
|
||||
bgCoopLight: "/assets/img/intl/bg-coop-light.png",
|
||||
bgCoopDark: "/assets/img/intl/bg-coop-dark.png",
|
||||
bgMission: "/assets/img/intl/bg-mission.png",
|
||||
iconJoin: "/assets/img/intl/icon-join.png",
|
||||
iconMeeting: "/assets/img/intl/icon-meeting.png",
|
||||
iconPartner: "/assets/img/intl/icon-partner.png",
|
||||
iconContact: "/assets/img/intl/icon-contact.png",
|
||||
iconImprove: "/assets/img/intl/icon-improve.png",
|
||||
iconTraining: "/assets/img/intl/icon-training.png",
|
||||
iconMore: "/assets/img/intl/icon-more.png",
|
||||
};
|
||||
@ -85,5 +85,30 @@ export const FALLBACK_BANNERS = [
|
||||
},
|
||||
];
|
||||
|
||||
/** 固定头总高度:顶栏50 + logo行80 + 导航40 */
|
||||
export const HEADER_OFFSET = 170;
|
||||
/** 国际站改版:单层顶栏高度 */
|
||||
export const HEADER_OFFSET = 72;
|
||||
|
||||
/** 国际站一级导航(People / News / Research / Cooperation) */
|
||||
export const INTL_TOP_NAV = [
|
||||
{
|
||||
id: "people",
|
||||
label: "People",
|
||||
to: "/people",
|
||||
children: [
|
||||
{ id: "mission", label: "Mission & Vision", to: "/people" },
|
||||
{ id: "committees", label: "Committees", to: "/people#committees" },
|
||||
{ id: "team", label: "Team Members", to: "/specialist" },
|
||||
],
|
||||
},
|
||||
{ id: "news", label: "News", to: "/newsList" },
|
||||
{ id: "research", label: "Research", to: "/research" },
|
||||
{
|
||||
id: "cooperation",
|
||||
label: "Cooperation",
|
||||
to: "/cooperation",
|
||||
children: [
|
||||
{ id: "join", label: "Join a committee", to: "/cooperation/join" },
|
||||
{ id: "contact", label: "Talk to us", to: "/relation" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
{
|
||||
"brand": "STAR International",
|
||||
"brandTagline": "Scientificity, Transparency and Applicability Rankings for Practice Guidelines",
|
||||
"brandTagline": "Scientificity, Transparency and Applicability",
|
||||
"brandWords": ["Scientificity", "Transparency", "Applicability"],
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"ranking": "Ranking",
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
{
|
||||
"brand": "STAR International",
|
||||
"brandTagline": "科学 · 透明 · 适用",
|
||||
"brandWords": ["科学", "透明", "适用"],
|
||||
"nav": {
|
||||
"home": "首页",
|
||||
"ranking": "排名",
|
||||
|
||||
@ -1,22 +1,27 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-star-primary: #181b62;
|
||||
--color-star-topbar: #1d2b40;
|
||||
--color-star-bg: #f6f8fc;
|
||||
--color-star-primary: #0b1a3d;
|
||||
--color-star-accent: #3a61c2;
|
||||
--color-star-topbar: #0b1a3d;
|
||||
--color-star-bg: #f4f7fc;
|
||||
--color-star-soft: #e8f1ff;
|
||||
--font-sans: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial,
|
||||
sans-serif;
|
||||
--font-hero: "Georgia", "Times New Roman", "Songti SC", serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
--star-primary: #181b62;
|
||||
--star-topbar: rgb(29, 43, 64);
|
||||
--star-bg: #f6f8fc;
|
||||
--star-primary: #0b1a3d;
|
||||
--star-accent: #3a61c2;
|
||||
--star-topbar: #0b1a3d;
|
||||
--star-bg: #f4f7fc;
|
||||
--star-soft: #e8f1ff;
|
||||
--star-muted: #999;
|
||||
--star-nav-hover: rgba(24, 27, 98, 0.06);
|
||||
--star-nav-active-bar: #181b62;
|
||||
--star-nav-dropdown-hover: #f5f7fa;
|
||||
--star-nav-border: #eee;
|
||||
--star-nav-hover: rgba(255, 255, 255, 0.08);
|
||||
--star-nav-active-bar: #ffffff;
|
||||
--star-nav-dropdown-hover: #eef3fb;
|
||||
--star-nav-border: rgba(255, 255, 255, 0.12);
|
||||
--star-select-border: #d8dce8;
|
||||
}
|
||||
|
||||
|
||||
@ -1,11 +1,4 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Outlet,
|
||||
Link,
|
||||
@ -15,50 +8,75 @@ import {
|
||||
} from "react-router-dom";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Globe, Settings, Menu, X, ChevronDown } from "lucide-react";
|
||||
import { setUiLanguage } from "@/i18n";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HEADER_OFFSET } from "@/config/nav";
|
||||
import { contentApi } from "@/services";
|
||||
import BrandLogo from "@/components/site/BrandLogo";
|
||||
import {
|
||||
buildColumnNavTree,
|
||||
fallbackFooterNav,
|
||||
fallbackTopNav,
|
||||
} from "@/utils/navTree";
|
||||
import { HEADER_OFFSET, INTL_TOP_NAV } from "@/config/nav";
|
||||
|
||||
/**
|
||||
* 详情页归属到列表栏目,避免进入详情后顶栏 active 消失。
|
||||
* key: 详情 pathname,value: 应对齐高亮的列表 path。
|
||||
* 详情页归属到列表栏目,保证顶栏高亮正确。
|
||||
*/
|
||||
const DETAIL_PARENT_PATHS = {
|
||||
"/fingerpostDetail": "/guidelineAndConsensus",
|
||||
"/organization/detail": "/organization",
|
||||
"/specialist/specialistDetail": "/specialist",
|
||||
"/fingerpostDetail": "/research",
|
||||
"/organization/detail": "/cooperation",
|
||||
"/specialist/specialistDetail": "/people",
|
||||
"/specialist": "/people",
|
||||
"/newsDetail": "/newsList",
|
||||
"/publicationDetail": "/publication",
|
||||
"/publicationDetail": "/research",
|
||||
"/publication": "/research",
|
||||
"/cooperation/join": "/cooperation",
|
||||
"/about": "/people",
|
||||
};
|
||||
|
||||
function resolveActivePathname(pathname) {
|
||||
return DETAIL_PARENT_PATHS[pathname] || pathname;
|
||||
}
|
||||
|
||||
function navActive(pathname, search, to) {
|
||||
function navActive(pathname, to) {
|
||||
const activePath = resolveActivePathname(pathname);
|
||||
const [path, query = ""] = to.split("?");
|
||||
const path = to.split("#")[0].split("?")[0];
|
||||
if (path === "/index") {
|
||||
return activePath === "/" || activePath === "/index";
|
||||
}
|
||||
if (!activePath.startsWith(path)) return false;
|
||||
if (!query) return true;
|
||||
// 详情页映射到列表后,不再用 query 细分子项(避免排名子菜单误高亮)
|
||||
if (activePath !== pathname) return true;
|
||||
const want = new URLSearchParams(query);
|
||||
const cur = new URLSearchParams(search);
|
||||
for (const [k, v] of want.entries()) {
|
||||
if (cur.get(k) !== v) return false;
|
||||
if (path === "/people") {
|
||||
return (
|
||||
activePath === "/people" ||
|
||||
activePath.startsWith("/specialist") ||
|
||||
activePath === "/about"
|
||||
);
|
||||
}
|
||||
return true;
|
||||
if (path === "/research") {
|
||||
return (
|
||||
activePath === "/research" ||
|
||||
activePath.startsWith("/publication") ||
|
||||
activePath === "/guidelineAndConsensus" ||
|
||||
activePath === "/fingerpostDetail"
|
||||
);
|
||||
}
|
||||
if (path === "/cooperation") {
|
||||
return (
|
||||
activePath.startsWith("/cooperation") ||
|
||||
activePath === "/relation" ||
|
||||
activePath.startsWith("/organization")
|
||||
);
|
||||
}
|
||||
return activePath === path || activePath.startsWith(`${path}/`);
|
||||
}
|
||||
|
||||
/** 顶栏品牌:仅 STAR 字标(红三角 A) */
|
||||
function StarMark() {
|
||||
return (
|
||||
<Link
|
||||
to="/index"
|
||||
className="inline-flex h-11 shrink-0 items-center no-underline md:h-12"
|
||||
>
|
||||
<img
|
||||
src="/assets/img/logo-star-mark-white.png"
|
||||
alt="STAR"
|
||||
className="h-8 w-auto object-contain object-left md:h-9"
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PublicLayout() {
|
||||
@ -68,34 +86,9 @@ export default function PublicLayout() {
|
||||
const dispatch = useDispatch();
|
||||
const memberToken = useSelector((s) => s.member?.token);
|
||||
const memberProfile = useSelector((s) => s.member?.profile);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [openMenu, setOpenMenu] = useState(null);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [columns, setColumns] = useState(null);
|
||||
const navRef = useRef(null);
|
||||
const itemRefs = useRef(new Map());
|
||||
const [indicator, setIndicator] = useState({
|
||||
left: 0,
|
||||
width: 0,
|
||||
visible: false,
|
||||
});
|
||||
const [indicatorReady, setIndicatorReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
contentApi
|
||||
.listColumns({ limit: 200 })
|
||||
.then((res) => {
|
||||
if (!alive) return;
|
||||
setColumns(res.results || []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setColumns([]);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
const [openMenu, setOpenMenu] = useState(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (memberToken && !memberProfile) {
|
||||
@ -103,249 +96,57 @@ export default function PublicLayout() {
|
||||
}
|
||||
}, [memberToken, memberProfile, dispatch]);
|
||||
|
||||
const topNav = useMemo(() => {
|
||||
if (!columns) return fallbackTopNav(t);
|
||||
const tree = buildColumnNavTree(columns, 1);
|
||||
return tree.length ? tree : fallbackTopNav(t);
|
||||
}, [columns, t]);
|
||||
|
||||
const footerNav = useMemo(() => {
|
||||
if (!columns) return fallbackFooterNav(t);
|
||||
const tree = buildColumnNavTree(columns, 3);
|
||||
return tree.length ? tree : fallbackFooterNav(t);
|
||||
}, [columns, t]);
|
||||
|
||||
const hotWords = useMemo(
|
||||
() =>
|
||||
i18n.language === "zh"
|
||||
? ["影像", "指南", "共识"]
|
||||
: ["imaging", "guideline", "consensus"],
|
||||
[i18n.language],
|
||||
);
|
||||
|
||||
const onSearch = () => {
|
||||
const q = keyword.trim();
|
||||
if (q) navigate(`/search?q=${encodeURIComponent(q)}`);
|
||||
};
|
||||
|
||||
const isItemActive = (item) => {
|
||||
if (
|
||||
item.children?.some((c) =>
|
||||
navActive(location.pathname, location.search, c.to),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return navActive(location.pathname, location.search, item.to);
|
||||
};
|
||||
|
||||
const activeNavId = useMemo(() => {
|
||||
const active = topNav.find((item) => isItemActive(item));
|
||||
return active?.id ?? null;
|
||||
}, [topNav, location.pathname, location.search]);
|
||||
|
||||
const setItemRef = (id) => (el) => {
|
||||
if (el) itemRefs.current.set(id, el);
|
||||
else itemRefs.current.delete(id);
|
||||
};
|
||||
|
||||
const updateIndicator = useCallback(() => {
|
||||
const navEl = navRef.current;
|
||||
if (!navEl || activeNavId == null) {
|
||||
setIndicator((prev) => ({ ...prev, width: 0, visible: false }));
|
||||
return;
|
||||
}
|
||||
const el = itemRefs.current.get(activeNavId);
|
||||
if (!el) {
|
||||
setIndicator((prev) => ({ ...prev, width: 0, visible: false }));
|
||||
return;
|
||||
}
|
||||
const navRect = navEl.getBoundingClientRect();
|
||||
const rect = el.getBoundingClientRect();
|
||||
const inset = 10;
|
||||
setIndicator({
|
||||
left: rect.left - navRect.left + inset,
|
||||
width: Math.max(0, rect.width - inset * 2),
|
||||
visible: true,
|
||||
});
|
||||
setIndicatorReady(true);
|
||||
}, [activeNavId]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
updateIndicator();
|
||||
}, [updateIndicator, topNav, i18n.language]);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => updateIndicator();
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, [updateIndicator]);
|
||||
setMobileOpen(false);
|
||||
setOpenMenu(null);
|
||||
setSettingsOpen(false);
|
||||
}, [location.pathname, location.search]);
|
||||
|
||||
// 桌面主导航(指示条由共享元素滑动,不在单项上画)
|
||||
const linkClass = (active) =>
|
||||
cn(
|
||||
"inline-flex h-10 items-center px-5 text-base no-underline transition-colors",
|
||||
"text-(--star-primary) hover:bg-(--star-nav-hover)",
|
||||
active ? "font-bold" : "font-semibold",
|
||||
);
|
||||
|
||||
// 移动端一级项:左侧指示条
|
||||
const mobileLinkClass = (active) =>
|
||||
cn(
|
||||
"relative flex h-10 items-center px-5 text-base no-underline transition-colors",
|
||||
"text-(--star-primary)",
|
||||
active
|
||||
? "bg-(--star-nav-hover) font-bold before:absolute before:top-2 before:bottom-2 before:left-0 before:w-0.5 before:rounded-full before:bg-(--star-nav-active-bar)"
|
||||
: "font-semibold hover:bg-(--star-nav-hover)",
|
||||
);
|
||||
|
||||
const dropdownItemClass = (active) =>
|
||||
cn(
|
||||
"relative block px-5 py-2.5 text-base no-underline transition-colors",
|
||||
active
|
||||
? "bg-(--star-nav-hover) font-bold text-(--star-primary) before:absolute before:top-1.5 before:bottom-1.5 before:left-0 before:w-0.5 before:rounded-full before:bg-(--star-nav-active-bar)"
|
||||
: "font-medium text-[#333] hover:bg-(--star-nav-dropdown-hover) hover:text-(--star-primary)",
|
||||
);
|
||||
const topNav = useMemo(() => INTL_TOP_NAV, []);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-full flex-col bg-white">
|
||||
<header className="fixed top-0 right-0 left-0 z-9999 bg-white shadow-sm">
|
||||
<div
|
||||
className="text-white"
|
||||
<header
|
||||
className="fixed top-0 right-0 left-0 z-9999 border-b border-white/10"
|
||||
style={{ background: "var(--star-topbar)" }}
|
||||
>
|
||||
<div className="mx-auto flex h-12.5 max-w-300 items-center justify-end gap-5 px-4 text-sm">
|
||||
<div className="mr-auto hidden items-center gap-2 sm:flex">
|
||||
<span className="text-white/70">{t("nav.hotSearch")}</span>
|
||||
{hotWords.map((w, i) => (
|
||||
<span key={w} className="inline-flex items-center gap-2">
|
||||
{i > 0 && (
|
||||
<span className="text-white/35" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer border-0 bg-transparent p-0 text-white/85 transition-opacity hover:text-white hover:underline"
|
||||
onClick={() =>
|
||||
navigate(`/search?q=${encodeURIComponent(w)}`)
|
||||
}
|
||||
>
|
||||
{w}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{memberToken ? (
|
||||
<span className="inline-flex items-center gap-3">
|
||||
<Link
|
||||
to="/myCollections"
|
||||
className="text-white/85 no-underline transition-opacity hover:text-white hover:underline"
|
||||
>
|
||||
{t("member.myCollections")}
|
||||
</Link>
|
||||
<span className="text-white/90">
|
||||
{memberProfile?.name || t("member.account")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer border-0 bg-transparent p-0 text-white/85 transition-opacity hover:text-white hover:underline"
|
||||
onClick={() => dispatch.member.logout()}
|
||||
>
|
||||
{t("actions.logout")}
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
to={`/login?redirect=${encodeURIComponent(
|
||||
`${location.pathname}${location.search}`,
|
||||
)}`}
|
||||
className="text-white/85 no-underline transition-opacity hover:text-white hover:underline"
|
||||
>
|
||||
{t("actions.login")}
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer border-0 bg-transparent p-0 text-white/85 transition-opacity hover:text-white hover:underline"
|
||||
onClick={() =>
|
||||
setUiLanguage(i18n.language === "zh" ? "en" : "zh")
|
||||
}
|
||||
>
|
||||
{i18n.language === "zh" ? t("common.langEn") : t("common.langZh")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex h-20 max-w-300 items-center gap-4 px-4">
|
||||
<BrandLogo variant="header" />
|
||||
<div className="ml-auto hidden items-center sm:flex">
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && onSearch()}
|
||||
placeholder={t("search.placeholderShort")}
|
||||
className="h-8 w-55 rounded-full border-[#ccc] px-3 text-sm focus:border-(--star-primary)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSearch}
|
||||
className="mr-1.5 h-8 w-15 cursor-pointer rounded-full border-0 bg-(--star-primary) text-sm text-white"
|
||||
>
|
||||
{t("actions.search")}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="border border-(--star-primary) px-3 py-1 text-sm text-(--star-primary) sm:hidden"
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
>
|
||||
Menu
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="hidden border-b bg-white sm:block"
|
||||
style={{ borderColor: "var(--star-nav-border)" }}
|
||||
>
|
||||
<nav
|
||||
ref={navRef}
|
||||
className="relative mx-auto flex h-10 max-w-300 items-stretch justify-between px-2"
|
||||
className="mx-auto flex max-w-300 items-center gap-4 px-4 md:px-6"
|
||||
style={{ height: HEADER_OFFSET }}
|
||||
>
|
||||
<StarMark />
|
||||
|
||||
<nav className="ml-auto hidden items-center gap-1 md:flex">
|
||||
{topNav.map((item) =>
|
||||
item.children?.length ? (
|
||||
<div
|
||||
key={item.id}
|
||||
ref={setItemRef(item.id)}
|
||||
className="relative"
|
||||
onMouseEnter={() => setOpenMenu(item.id)}
|
||||
onMouseLeave={() => setOpenMenu(null)}
|
||||
>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
className={linkClass(isItemActive(item))}
|
||||
className={cn(
|
||||
"inline-flex h-[72px] items-center gap-1 px-4 text-[15px] font-medium text-[#ffffff] no-underline transition-opacity hover:opacity-100",
|
||||
navActive(location.pathname, item.to)
|
||||
? "border-b-[3px] border-white font-semibold opacity-100"
|
||||
: "opacity-95",
|
||||
)}
|
||||
style={{ color: "#ffffff" }}
|
||||
>
|
||||
{item.name}
|
||||
{item.label}
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-80" />
|
||||
</NavLink>
|
||||
{openMenu === item.id && (
|
||||
<div
|
||||
className="absolute top-full left-0 z-50 min-w-50 rounded-sm border bg-white py-1.5 shadow-md"
|
||||
style={{ borderColor: "var(--star-nav-border)" }}
|
||||
>
|
||||
<div className="absolute top-full left-0 min-w-48 rounded-b-md bg-white py-2 shadow-lg">
|
||||
{item.children.map((child) => (
|
||||
<Link
|
||||
key={child.id}
|
||||
to={child.to}
|
||||
className={dropdownItemClass(
|
||||
navActive(
|
||||
location.pathname,
|
||||
location.search,
|
||||
child.to,
|
||||
),
|
||||
)}
|
||||
onClick={() => setOpenMenu(null)}
|
||||
className="block px-4 py-2.5 text-sm text-[#1a2b56] no-underline hover:bg-(--star-nav-dropdown-hover)"
|
||||
>
|
||||
{child.name}
|
||||
{child.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
@ -354,143 +155,170 @@ export default function PublicLayout() {
|
||||
) : (
|
||||
<NavLink
|
||||
key={item.id}
|
||||
ref={setItemRef(item.id)}
|
||||
to={item.to}
|
||||
className={linkClass(isItemActive(item))}
|
||||
className={cn(
|
||||
"inline-flex h-[72px] items-center px-4 text-[15px] font-medium text-[#ffffff] no-underline transition-opacity hover:opacity-100",
|
||||
navActive(location.pathname, item.to)
|
||||
? "border-b-[3px] border-white font-semibold opacity-100"
|
||||
: "opacity-95",
|
||||
)}
|
||||
style={{ color: "#ffffff" }}
|
||||
>
|
||||
{item.name}
|
||||
{item.label}
|
||||
</NavLink>
|
||||
),
|
||||
)}
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute bottom-0 h-0.5 rounded-full bg-(--star-nav-active-bar)",
|
||||
indicatorReady &&
|
||||
"transition-[left,width,opacity] duration-300 ease-out",
|
||||
)}
|
||||
style={{
|
||||
left: indicator.left,
|
||||
width: indicator.width,
|
||||
opacity: indicator.visible ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<div className="ml-2 hidden items-center gap-3 md:flex">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 border-0 bg-transparent p-0 text-sm text-[#ffffff] hover:opacity-100"
|
||||
style={{ color: "#ffffff" }}
|
||||
onClick={() =>
|
||||
setUiLanguage(i18n.language === "zh" ? "en" : "zh")
|
||||
}
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
{i18n.language === "zh" ? "中文" : "English"}
|
||||
<ChevronDown className="h-3 w-3 opacity-80" />
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="settings"
|
||||
className="inline-flex cursor-pointer items-center border-0 bg-transparent p-1 text-[#ffffff] hover:opacity-100"
|
||||
style={{ color: "#ffffff" }}
|
||||
onClick={() => setSettingsOpen((v) => !v)}
|
||||
>
|
||||
<Settings className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
{settingsOpen && (
|
||||
<div className="absolute top-full right-0 mt-2 min-w-44 rounded-md bg-white py-2 shadow-lg">
|
||||
{memberToken ? (
|
||||
<>
|
||||
<Link
|
||||
to="/myCollections"
|
||||
className="block px-4 py-2 text-sm text-[#1a2b56] no-underline hover:bg-(--star-nav-dropdown-hover)"
|
||||
>
|
||||
{t("member.myCollections")}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full cursor-pointer border-0 bg-transparent px-4 py-2 text-left text-sm text-[#1a2b56] hover:bg-(--star-nav-dropdown-hover)"
|
||||
onClick={() => dispatch.member.logout()}
|
||||
>
|
||||
{t("actions.logout")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
to={`/login?redirect=${encodeURIComponent(
|
||||
`${location.pathname}${location.search}`,
|
||||
)}`}
|
||||
className="block px-4 py-2 text-sm text-[#1a2b56] no-underline hover:bg-(--star-nav-dropdown-hover)"
|
||||
>
|
||||
{t("actions.login")}
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full cursor-pointer border-0 bg-transparent px-4 py-2 text-left text-sm text-[#1a2b56] hover:bg-(--star-nav-dropdown-hover)"
|
||||
onClick={() => navigate("/search")}
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
<Link
|
||||
to="/admin"
|
||||
className="block px-4 py-2 text-sm text-[#1a2b56] no-underline hover:bg-(--star-nav-dropdown-hover)"
|
||||
>
|
||||
{t("nav.admin")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto inline-flex cursor-pointer items-center border-0 bg-transparent p-1 text-white md:hidden"
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
aria-label="menu"
|
||||
>
|
||||
{mobileOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="border-b bg-white sm:hidden"
|
||||
style={{ borderColor: "var(--star-nav-border)" }}
|
||||
>
|
||||
<div className="border-t border-white/10 bg-[#0b1a3d] md:hidden">
|
||||
{topNav.map((item) => (
|
||||
<div key={item.id}>
|
||||
<div key={item.id} className="border-b border-white/5">
|
||||
<Link
|
||||
to={item.to}
|
||||
className={mobileLinkClass(isItemActive(item))}
|
||||
className={cn(
|
||||
"block px-5 py-3 text-base text-[#ffffff] no-underline",
|
||||
navActive(location.pathname, item.to) &&
|
||||
"bg-white/10 font-semibold",
|
||||
)}
|
||||
style={{ color: "#ffffff" }}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
{item.name}
|
||||
{item.label}
|
||||
</Link>
|
||||
{item.children?.map((child) => (
|
||||
<Link
|
||||
key={child.id}
|
||||
to={child.to}
|
||||
className={cn(
|
||||
"block px-9 py-2.5 text-base no-underline transition-colors",
|
||||
navActive(location.pathname, location.search, child.to)
|
||||
? "font-semibold text-(--star-primary)"
|
||||
: "font-medium text-[#555] hover:bg-(--star-nav-dropdown-hover) hover:text-(--star-primary)",
|
||||
)}
|
||||
className="block px-8 py-2.5 text-sm text-[#ffffff] no-underline opacity-85"
|
||||
style={{ color: "#ffffff" }}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
{child.name}
|
||||
{child.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center justify-between px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer border-0 bg-transparent p-0 text-sm text-[#ffffff]"
|
||||
style={{ color: "#ffffff" }}
|
||||
onClick={() =>
|
||||
setUiLanguage(i18n.language === "zh" ? "en" : "zh")
|
||||
}
|
||||
>
|
||||
{i18n.language === "zh" ? "English" : "中文"}
|
||||
</button>
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-sm text-[#ffffff] no-underline"
|
||||
style={{ color: "#ffffff" }}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
{t("actions.login")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<main
|
||||
style={{
|
||||
paddingTop: mobileOpen ? undefined : HEADER_OFFSET,
|
||||
}}
|
||||
style={{ paddingTop: HEADER_OFFSET }}
|
||||
className={cn(
|
||||
"flex flex-1 flex-col bg-white",
|
||||
mobileOpen ? "pt-[180px]" : "max-sm:pt-[114px]",
|
||||
mobileOpen && "max-md:pt-[280px]",
|
||||
)}
|
||||
>
|
||||
{/* 子页面根节点随主栏撑满,避免短内容页中间高度不足、页脚上顶 */}
|
||||
<div className="flex min-h-0 flex-1 flex-col *:min-h-full *:flex-1">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-visible *:min-h-full *:flex-1">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer
|
||||
className="mt-auto shrink-0"
|
||||
className="mt-auto h-16 shrink-0 md:h-20"
|
||||
style={{ background: "var(--star-topbar)" }}
|
||||
>
|
||||
<div className="mx-auto flex max-w-300 flex-col justify-between gap-8 px-6 py-9 text-white md:flex-row md:px-9">
|
||||
<div className="flex flex-wrap gap-x-[60px] gap-y-8 md:gap-x-[90px]">
|
||||
{footerNav.map((group) => (
|
||||
<div key={group.id} className="flex flex-col">
|
||||
<div className="mb-5 text-base">{group.name}</div>
|
||||
{(
|
||||
group.children || [
|
||||
{ id: group.id, name: group.name, to: group.to },
|
||||
]
|
||||
).map((link) => (
|
||||
<Link
|
||||
key={link.id}
|
||||
to={link.to}
|
||||
className="mb-4 text-sm font-light text-white no-underline last:mb-0 hover:underline"
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center text-center text-xs md:w-75 md:shrink-0">
|
||||
<BrandLogo variant="footer" to={null} className="mb-2" />
|
||||
<div className="mb-3 flex items-center gap-4">
|
||||
<img
|
||||
src="/assets/img/weixin@2x.png"
|
||||
alt="WeChat"
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
<img
|
||||
src="/assets/img/weibo@2x.png"
|
||||
alt="Weibo"
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
</div>
|
||||
<p className="m-0 leading-6 opacity-90">
|
||||
{t("footer.copyrightLine")}
|
||||
</p>
|
||||
<p
|
||||
className="mt-2 flex items-center justify-center gap-1.5 leading-5 opacity-80"
|
||||
style={{
|
||||
backgroundImage: "url(/assets/img/gongan.png)",
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundPosition: "left center",
|
||||
backgroundSize: "14px",
|
||||
paddingLeft: 18,
|
||||
}}
|
||||
>
|
||||
{t("footer.icp")}
|
||||
</p>
|
||||
<Link
|
||||
to="/admin"
|
||||
className="mt-3 text-white/70 no-underline hover:text-white"
|
||||
>
|
||||
{t("nav.admin")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
116
src/pages/public/CooperationPage.jsx
Normal file
@ -0,0 +1,116 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import CoopCardIcon from "@/components/site/CoopCardIcon";
|
||||
import { HexPatternBg } from "@/components/site/PlaceholderMedia";
|
||||
import SiteHero from "@/components/site/SiteHero";
|
||||
import { INTL } from "@/config/intlAssets";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const CARDS = [
|
||||
{
|
||||
id: "join",
|
||||
icon: INTL.iconJoin,
|
||||
title: "Join a committee",
|
||||
desc: "Contribute to STAR advisory committees and help improve guideline quality.",
|
||||
to: "/cooperation/join",
|
||||
},
|
||||
{
|
||||
id: "meeting",
|
||||
icon: INTL.iconMeeting,
|
||||
title: "Come to a meeting",
|
||||
desc: "Attend STAR meetings and workshops to exchange ideas with peers.",
|
||||
to: "/relation",
|
||||
},
|
||||
{
|
||||
id: "work",
|
||||
icon: INTL.iconPartner,
|
||||
title: "Work with us",
|
||||
desc: "Explore institutional partnerships and collaborative research projects.",
|
||||
to: "/relation",
|
||||
},
|
||||
{
|
||||
id: "improve",
|
||||
icon: INTL.iconImprove,
|
||||
title: "Help us improve",
|
||||
desc: "Share feedback on STAR tools, reports, and evaluation methodology.",
|
||||
to: "/relation",
|
||||
},
|
||||
{
|
||||
id: "training",
|
||||
icon: INTL.iconTraining,
|
||||
title: "STAR training and development opportunities",
|
||||
desc: "Access training resources and capacity-building opportunities.",
|
||||
to: "/research",
|
||||
},
|
||||
{
|
||||
id: "talk",
|
||||
icon: INTL.iconContact,
|
||||
title: "Talk to us",
|
||||
desc: "Reach out to the STAR team for questions, media, or collaboration.",
|
||||
to: "/relation",
|
||||
},
|
||||
];
|
||||
|
||||
export default function CooperationPage() {
|
||||
const [hoverId, setHoverId] = useState("meeting");
|
||||
|
||||
return (
|
||||
<div className="bg-white">
|
||||
<SiteHero />
|
||||
{/* 浅色六边形底:去掉素材感过强的实验室手套主视觉 */}
|
||||
<HexPatternBg className="py-12 md:py-16">
|
||||
<div className="site-container grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{CARDS.map((card) => {
|
||||
const active = hoverId === card.id;
|
||||
return (
|
||||
<Link
|
||||
key={card.id}
|
||||
to={card.to}
|
||||
onMouseEnter={() => setHoverId(card.id)}
|
||||
className={cn(
|
||||
"block rounded-md p-6 no-underline shadow-sm transition-colors",
|
||||
active ? "bg-(--star-primary)" : "bg-white",
|
||||
)}
|
||||
style={{ color: active ? "#ffffff" : "#1a2b56" }}
|
||||
>
|
||||
<CoopCardIcon src={card.icon} />
|
||||
<h3
|
||||
className="mb-2 text-lg font-bold"
|
||||
style={{ color: active ? "#ffffff" : "#1a2b56" }}
|
||||
>
|
||||
{card.title}
|
||||
</h3>
|
||||
<p
|
||||
className="text-sm leading-6"
|
||||
style={{
|
||||
color: active ? "rgba(255,255,255,0.9)" : "#556666",
|
||||
}}
|
||||
>
|
||||
{card.desc}
|
||||
</p>
|
||||
<span
|
||||
className="mt-5 inline-flex items-center gap-1 text-sm"
|
||||
style={{
|
||||
color: active ? "#ffffff" : "var(--star-accent)",
|
||||
}}
|
||||
>
|
||||
more
|
||||
<img
|
||||
src={INTL.iconMore}
|
||||
alt=""
|
||||
className="h-3 w-3"
|
||||
style={
|
||||
active
|
||||
? { filter: "brightness(0) invert(1)" }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</HexPatternBg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,206 +1,276 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SectionTitle from "@/components/site/SectionTitle";
|
||||
import HomeCarousel from "@/components/site/HomeCarousel";
|
||||
import { FALLBACK_BANNERS } from "@/config/nav";
|
||||
import CoopCardIcon from "@/components/site/CoopCardIcon";
|
||||
import { HexPatternBg } from "@/components/site/PlaceholderMedia";
|
||||
import SiteHero from "@/components/site/SiteHero";
|
||||
import { INTL } from "@/config/intlAssets";
|
||||
import { contentApi } from "@/services";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const FALLBACK_REPORTS = [
|
||||
{
|
||||
id: "r1",
|
||||
name: "STAR Report 2024",
|
||||
year: 2024,
|
||||
logo: "/assets/img/01@2x.jpg",
|
||||
file_path: "#",
|
||||
name: "Language models for data extraction from abstracts",
|
||||
year: 2025,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
name: "STAR Report 2023",
|
||||
year: 2023,
|
||||
logo: "/assets/img/02@2x.jpg",
|
||||
file_path: "#",
|
||||
name: "Language models for data extraction from abstracts",
|
||||
year: 2025,
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
name: "STAR Report 2022",
|
||||
year: 2022,
|
||||
logo: "/assets/img/03@2x.jpg",
|
||||
file_path: "#",
|
||||
name: "Language models for data extraction from abstracts",
|
||||
year: 2025,
|
||||
},
|
||||
{
|
||||
id: "r4",
|
||||
name: "STAR Report 2021",
|
||||
year: 2021,
|
||||
logo: "/assets/img/04@2x.jpg",
|
||||
file_path: "#",
|
||||
name: "Language models for data extraction from abstracts",
|
||||
year: 2025,
|
||||
},
|
||||
];
|
||||
|
||||
const FEATURE_TABS = [
|
||||
{
|
||||
id: "report",
|
||||
title: "The Rating Guidelines Report",
|
||||
icon: INTL.tabReport,
|
||||
heading: "The Rating Guidelines Report",
|
||||
desc: "Explore STAR rating guideline reports and related research outputs. Placeholder copy for layout preview.",
|
||||
},
|
||||
{
|
||||
id: "ai",
|
||||
title: "STAR AI-Driven Assessment System",
|
||||
icon: INTL.tabAi,
|
||||
heading: "STAR AI-Driven Assessment System",
|
||||
desc: "An AI-assisted assessment workflow for guideline appraisal. Placeholder introduction text for the redesign mock.",
|
||||
illustration: INTL.featureAi,
|
||||
},
|
||||
{
|
||||
id: "method",
|
||||
title: "STAR Multi-dimensional Evaluation Methodology",
|
||||
icon: INTL.tabMethod,
|
||||
heading: "STAR Multi-dimensional Evaluation Methodology",
|
||||
desc: "A multi-dimensional methodology for transparent guideline evaluation. Placeholder system introduction text.",
|
||||
illustration: INTL.featureMethod,
|
||||
},
|
||||
];
|
||||
|
||||
const COOP_CARDS = [
|
||||
{
|
||||
icon: INTL.iconJoin,
|
||||
title: "Become a Committee Member",
|
||||
desc: "Our guidance and quality standards are developed by independent committees. Join and help improve guideline quality.",
|
||||
to: "/cooperation/join",
|
||||
},
|
||||
{
|
||||
icon: INTL.iconMeeting,
|
||||
title: "Attend our Events",
|
||||
desc: "Advisory meetings and workshops are open to peers and partners. Come along and see how we work.",
|
||||
to: "/relation",
|
||||
},
|
||||
{
|
||||
icon: INTL.iconPartner,
|
||||
title: "Collaborate With Us",
|
||||
desc: "Explore institutional partnerships and collaborative research projects with STAR.",
|
||||
to: "/cooperation",
|
||||
},
|
||||
{
|
||||
icon: INTL.iconContact,
|
||||
title: "Connect With Us",
|
||||
desc: "Have a question or comment? Reach out to the STAR team for collaboration.",
|
||||
to: "/relation",
|
||||
},
|
||||
];
|
||||
|
||||
const PARTNERS = [
|
||||
"WHO",
|
||||
"G-I-N",
|
||||
"GRADE",
|
||||
"Cochrane",
|
||||
"McMaster",
|
||||
"Lanzhou U",
|
||||
"Partner A",
|
||||
"Partner B",
|
||||
];
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return "";
|
||||
if (!value) return "2025-01-12";
|
||||
return String(value).slice(0, 10);
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const { t } = useTranslation();
|
||||
const [news, setNews] = useState([]);
|
||||
const [publications, setPublications] = useState([]);
|
||||
const [activeTab, setActiveTab] = useState("report");
|
||||
const [reports, setReports] = useState(FALLBACK_REPORTS);
|
||||
const [banners, setBanners] = useState(FALLBACK_BANNERS);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const [newsRes, pubRes, reportRes, bannerRes] = await Promise.all([
|
||||
contentApi.listNews({ limit: 8 }),
|
||||
contentApi.listPublications({ limit: 8 }),
|
||||
contentApi.listReports({ limit: 20 }),
|
||||
contentApi.listBanners(),
|
||||
]);
|
||||
contentApi
|
||||
.listReports({ limit: 8 })
|
||||
.then((res) => {
|
||||
if (!alive) return;
|
||||
setNews(newsRes?.results || []);
|
||||
setPublications(pubRes?.results || []);
|
||||
const remote = reportRes?.results || [];
|
||||
const remote = res?.results || [];
|
||||
setReports(remote.length ? remote : FALLBACK_REPORTS);
|
||||
const bannerList = Array.isArray(bannerRes)
|
||||
? bannerRes
|
||||
: bannerRes?.results || [];
|
||||
const enabled = bannerList.filter((b) => !b.is_disabled);
|
||||
setBanners(enabled.length ? enabled : FALLBACK_BANNERS);
|
||||
} catch {
|
||||
if (alive) {
|
||||
setReports(FALLBACK_REPORTS);
|
||||
setBanners(FALLBACK_BANNERS);
|
||||
}
|
||||
}
|
||||
})();
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setReports(FALLBACK_REPORTS);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const tab = FEATURE_TABS.find((item) => item.id === activeTab) || FEATURE_TABS[0];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<HomeCarousel banners={banners} />
|
||||
|
||||
{/* 新闻通知 + 资源中心 */}
|
||||
<div className="site-container flex flex-col md:flex-row">
|
||||
<div className="w-full md:w-1/2">
|
||||
<SectionTitle title={t("home.newsNotify")} to="/newsList" />
|
||||
<ul className="mx-5 my-5 list-none p-0 text-base">
|
||||
{news.map((item) => (
|
||||
<li key={item.id} className="site-list-row">
|
||||
<Link
|
||||
to={`/newsDetail?id=${item.id}`}
|
||||
className="text-inherit no-underline"
|
||||
>
|
||||
<span className="font-bold">
|
||||
{formatDate(item.publication_time)}
|
||||
</span>
|
||||
{item.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
{!news.length && (
|
||||
<li className="px-5 py-4 text-[#999]">{t("news.empty")}</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="w-full md:w-1/2">
|
||||
<SectionTitle title={t("home.resourceCenter")} to="/publication" />
|
||||
<ul className="mx-5 my-5 list-none p-0 text-base">
|
||||
{publications.map((item) => (
|
||||
<li key={item.id} className="site-list-row">
|
||||
<Link
|
||||
to={`/publicationDetail?id=${item.id}`}
|
||||
className="text-inherit no-underline"
|
||||
>
|
||||
<span className="font-bold">
|
||||
{formatDate(item.publication_time)}
|
||||
</span>
|
||||
{item.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
{!publications.length && (
|
||||
<li className="px-5 py-4 text-[#999]">
|
||||
{t("publication.empty")}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 报告下载(对齐国内首页,非指南推荐) */}
|
||||
<div className="bg-star-bg pb-10">
|
||||
<SectionTitle title={t("home.reportDownload")} />
|
||||
<ReportCarousel reports={reports} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportCarousel({ reports }) {
|
||||
const scrollerRef = useRef(null);
|
||||
|
||||
const scrollBy = (dir) => {
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
el.scrollBy({ left: dir * 320, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const onDownload = (item) => {
|
||||
if (!item.file_path || item.file_path === "#") return;
|
||||
window.open(item.file_path, "_blank");
|
||||
};
|
||||
<SiteHero />
|
||||
|
||||
{/* 三 Tab 功能区 */}
|
||||
<section className="border-b border-[#e8eaf2] bg-white">
|
||||
<div className="site-container grid grid-cols-1 gap-2 py-8 md:grid-cols-3 md:gap-0">
|
||||
{FEATURE_TABS.map((item) => {
|
||||
const active = item.id === activeTab;
|
||||
return (
|
||||
<div className="site-container relative mt-6 px-10">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="prev"
|
||||
onClick={() => scrollBy(-1)}
|
||||
className="absolute top-1/2 left-0 z-10 -translate-y-1/2 cursor-pointer border-0 bg-transparent p-0"
|
||||
>
|
||||
<img src="/assets/img/qian@2x.png" alt="" className="h-6.25 w-6.25" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="next"
|
||||
onClick={() => scrollBy(1)}
|
||||
className="absolute top-1/2 right-0 z-10 -translate-y-1/2 cursor-pointer border-0 bg-transparent p-0"
|
||||
>
|
||||
<img src="/assets/img/hou@2x.png" alt="" className="h-6.25 w-6.25" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
className="flex gap-6 overflow-x-auto scroll-smooth px-2 py-2 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{reports.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onDownload(item)}
|
||||
className="group relative w-65 shrink-0 cursor-pointer border-0 bg-transparent p-0 text-left"
|
||||
onClick={() => setActiveTab(item.id)}
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-col items-center gap-3 border-0 bg-transparent px-4 py-3 text-center transition-colors",
|
||||
active
|
||||
? "border-b-[3px] border-(--star-accent) text-(--star-accent)"
|
||||
: "border-b-[3px] border-transparent text-[#4a5568] hover:text-(--star-accent)",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={item.logo || "/assets/img/01@2x.jpg"}
|
||||
alt={item.name}
|
||||
className="max-h-75 w-full object-contain"
|
||||
src={item.icon}
|
||||
alt=""
|
||||
className="h-12 w-12 object-contain"
|
||||
/>
|
||||
<span
|
||||
className="absolute right-5 bottom-5 h-6.5 w-7 bg-contain bg-no-repeat opacity-90 group-hover:opacity-100"
|
||||
style={{ backgroundImage: "url(/assets/img/xiazai2@2x.png)" }}
|
||||
/>
|
||||
<div className="mt-2 truncate text-center text-sm text-[#333]">
|
||||
{item.name || item.year}
|
||||
</div>
|
||||
<span className="max-w-56 text-sm font-medium leading-snug md:text-[15px]">
|
||||
{item.title}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tab 内容 */}
|
||||
<section className="bg-white py-10 md:py-14">
|
||||
<div className="site-container">
|
||||
{activeTab === "report" ? (
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{reports.slice(0, 4).map((item, idx) => (
|
||||
<article key={item.id || idx} className="group">
|
||||
<img
|
||||
src={item.logo || INTL.reportCover}
|
||||
alt=""
|
||||
className="mb-3 aspect-[4/3] w-full rounded-sm object-cover"
|
||||
/>
|
||||
<h3 className="mb-2 line-clamp-2 text-[15px] font-semibold leading-snug text-[#1a2b56]">
|
||||
{item.name ||
|
||||
item.title ||
|
||||
"Language models for data extraction from abstracts"}
|
||||
</h3>
|
||||
<div className="flex items-center justify-between text-sm text-[#888]">
|
||||
<span>
|
||||
{formatDate(
|
||||
item.year ? `${item.year}-01-01` : item.updated_at,
|
||||
)}
|
||||
</span>
|
||||
<a
|
||||
href={
|
||||
item.file_path && item.file_path !== "#"
|
||||
? item.file_path
|
||||
: "/research"
|
||||
}
|
||||
className="inline-flex items-center gap-1 text-(--star-accent) no-underline group-hover:underline"
|
||||
>
|
||||
more
|
||||
<img src={INTL.iconMore} alt="" className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 items-center gap-10 md:grid-cols-2">
|
||||
<div>
|
||||
<h2 className="mb-4 text-2xl font-bold text-[#1a2b56] md:text-[28px]">
|
||||
{tab.heading}
|
||||
</h2>
|
||||
<p className="mb-8 max-w-md text-[15px] leading-7 text-[#556]">
|
||||
{tab.desc}
|
||||
</p>
|
||||
<Link
|
||||
to="/research"
|
||||
className="inline-flex rounded-full bg-(--star-accent) px-7 py-2.5 text-sm font-medium text-white no-underline hover:opacity-90"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</div>
|
||||
<img
|
||||
src={tab.illustration}
|
||||
alt=""
|
||||
className="w-full rounded-lg object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Cooperation:干净品牌蓝 + 六边形纹理,避免素材感过强的实验室底图 */}
|
||||
<HexPatternBg dark className="py-12 md:py-16">
|
||||
<div className="site-container">
|
||||
<h2 className="mb-8 text-center text-3xl font-semibold text-white md:mb-10">
|
||||
Cooperation
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{COOP_CARDS.map((card) => (
|
||||
<article
|
||||
key={card.title}
|
||||
className="flex flex-col rounded-md bg-white p-5 text-[#1a2b56] shadow-sm"
|
||||
>
|
||||
<CoopCardIcon src={card.icon} />
|
||||
<h3 className="mb-2 text-base font-bold text-[#1a2b56]">
|
||||
{card.title}
|
||||
</h3>
|
||||
<p className="mb-5 flex-1 text-sm leading-6 text-[#556]">
|
||||
{card.desc}
|
||||
</p>
|
||||
<Link
|
||||
to={card.to}
|
||||
className="inline-flex items-center gap-1 text-sm text-(--star-accent) no-underline hover:underline"
|
||||
>
|
||||
more
|
||||
<img src={INTL.iconMore} alt="" className="h-3 w-3" />
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</HexPatternBg>
|
||||
|
||||
{/* Partners */}
|
||||
<section className="bg-white py-12 md:py-14">
|
||||
<div className="site-container">
|
||||
<h2 className="mb-8 text-center text-2xl font-semibold text-[#1a2b56] md:text-[28px]">
|
||||
Supporters & Partners
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-8">
|
||||
{PARTNERS.map((name) => (
|
||||
<div
|
||||
key={name}
|
||||
className="flex aspect-square items-center justify-center rounded-md border border-[#e8eaf2] bg-[#fafbfd] px-2 text-center text-xs font-semibold text-[#6b7a99]"
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
139
src/pages/public/JoinCommitteePage.jsx
Normal file
@ -0,0 +1,139 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import SiteHero from "@/components/site/SiteHero";
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
title: "Why join a STAR committee?",
|
||||
body: "Committees help shape evaluation priorities, review methodology updates, and strengthen the credibility of guideline appraisal. Placeholder content for redesign preview.",
|
||||
quote:
|
||||
"Working with STAR committees is a unique opportunity to improve how guidelines are assessed and applied.",
|
||||
},
|
||||
{
|
||||
title: "What you will do",
|
||||
bullets: [
|
||||
"Review draft reports and methodological proposals",
|
||||
"Participate in scheduled virtual meetings",
|
||||
"Advise on dissemination and stakeholder engagement",
|
||||
],
|
||||
quote:
|
||||
"Your expertise helps keep STAR transparent, rigorous, and useful for decision makers.",
|
||||
},
|
||||
{
|
||||
title: "How to apply",
|
||||
body: "Submit a short expression of interest with your background and preferred committee. Placeholder application instructions for redesign preview. For questions, please contact the STAR office.",
|
||||
},
|
||||
];
|
||||
|
||||
function QuoteBox({ text }) {
|
||||
return (
|
||||
<aside className="relative overflow-hidden rounded-md bg-(--star-accent) p-6 text-white">
|
||||
<span
|
||||
className="pointer-events-none absolute -top-2 right-3 text-[96px] leading-none font-serif text-white/20"
|
||||
aria-hidden
|
||||
>
|
||||
”
|
||||
</span>
|
||||
<p className="relative z-10 m-0 text-[15px] leading-7">{text}</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default function JoinCommitteePage() {
|
||||
return (
|
||||
<div
|
||||
className="bg-[#f3f4f6] pb-16"
|
||||
style={{ minHeight: "auto", flex: "none", overflow: "visible" }}
|
||||
>
|
||||
{/* Hero + 悬浮介绍卡:absolute 贴 Hero 底边再下移,避开 Layout flex 对负 margin 的影响 */}
|
||||
<div className="relative z-10" style={{ overflow: "visible" }}>
|
||||
<SiteHero
|
||||
overflowVisible
|
||||
heightClass="h-[240px] md:h-[300px]"
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 z-30"
|
||||
style={{ transform: "translateY(45%)" }}
|
||||
>
|
||||
<div className="site-container pointer-events-auto">
|
||||
<div
|
||||
className="max-w-xl rounded-md p-6 shadow-xl md:p-8"
|
||||
style={{
|
||||
background: "var(--star-primary)",
|
||||
color: "#ffffff",
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
className="mb-3 text-2xl font-bold md:text-3xl"
|
||||
style={{ color: "#ffffff" }}
|
||||
>
|
||||
Join a committee
|
||||
</h1>
|
||||
<p
|
||||
className="mb-5 text-sm leading-6"
|
||||
style={{ color: "rgba(255,255,255,0.9)" }}
|
||||
>
|
||||
Help STAR improve guideline evaluation through advisory
|
||||
participation. Placeholder intro for the redesign mock.
|
||||
</p>
|
||||
<Link
|
||||
to="/relation"
|
||||
className="inline-flex rounded-md px-5 py-2.5 text-sm font-medium no-underline hover:opacity-90"
|
||||
style={{
|
||||
background: "var(--star-accent)",
|
||||
color: "#ffffff",
|
||||
}}
|
||||
>
|
||||
View our latest vacancies
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 给探出卡片留出高度,避免正文顶上来 */}
|
||||
<div className="site-container relative z-0 space-y-10 pt-40 md:pt-48">
|
||||
{SECTIONS.map((section) => (
|
||||
<section key={section.title}>
|
||||
{section.quote ? (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-[1.4fr_1fr]">
|
||||
<div>
|
||||
<h2 className="mb-3 text-xl font-bold text-[#1a2b56]">
|
||||
{section.title}
|
||||
</h2>
|
||||
{section.body && (
|
||||
<p className="m-0 text-[15px] leading-7 text-[#445]">
|
||||
{section.body}
|
||||
</p>
|
||||
)}
|
||||
{section.bullets && (
|
||||
<ul className="mt-3 list-disc space-y-2 pl-5 text-[15px] leading-7 text-[#445]">
|
||||
{section.bullets.map((b) => (
|
||||
<li key={b}>{b}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<QuoteBox text={section.quote} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md bg-white/70 p-6 md:p-8">
|
||||
<h2 className="mb-3 text-xl font-bold text-[#1a2b56]">
|
||||
{section.title}
|
||||
</h2>
|
||||
<p className="m-0 text-[15px] leading-7 text-[#445]">
|
||||
{section.body}
|
||||
</p>
|
||||
<Link
|
||||
to="/relation"
|
||||
className="mt-4 inline-block text-sm text-(--star-accent) no-underline hover:underline"
|
||||
>
|
||||
Contact us >>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,50 +1,218 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SectionTitle from "@/components/site/SectionTitle";
|
||||
import SiteHero from "@/components/site/SiteHero";
|
||||
import {
|
||||
HexPatternBg,
|
||||
PlaceholderMedia,
|
||||
} from "@/components/site/PlaceholderMedia";
|
||||
import { INTL } from "@/config/intlAssets";
|
||||
import { contentApi } from "@/services";
|
||||
|
||||
/** 后端不可用时的演示数据,避免整页空白 */
|
||||
const FALLBACK_NEWS = [
|
||||
{
|
||||
id: "demo-1",
|
||||
title: "STAR releases updated rating guidelines report",
|
||||
summary:
|
||||
"The latest rating guidelines report summarizes appraisal findings and methodology updates for clinical practice guidelines.",
|
||||
publication_time: "2025-01-12",
|
||||
},
|
||||
{
|
||||
id: "demo-2",
|
||||
title: "International collaboration advances trustworthy guideline appraisal",
|
||||
summary:
|
||||
"Partners from multiple institutions join STAR workshops to strengthen transparent and applicable guideline evaluation.",
|
||||
publication_time: "2025-01-08",
|
||||
},
|
||||
{
|
||||
id: "demo-3",
|
||||
title: "AI-assisted assessment system enters pilot phase",
|
||||
summary:
|
||||
"STAR begins a pilot of its AI-driven assessment workflow to support faster and more consistent guideline reviews.",
|
||||
publication_time: "2024-12-20",
|
||||
},
|
||||
{
|
||||
id: "demo-4",
|
||||
title: "Call for advisory committee members now open",
|
||||
summary:
|
||||
"Experts are invited to join STAR advisory committees and contribute to ongoing methodology development.",
|
||||
publication_time: "2024-12-05",
|
||||
},
|
||||
{
|
||||
id: "demo-5",
|
||||
title: "New research publications added to the STAR resource center",
|
||||
summary:
|
||||
"Recent publications on multi-dimensional evaluation methods are now available in the research section.",
|
||||
publication_time: "2024-11-18",
|
||||
},
|
||||
{
|
||||
id: "demo-6",
|
||||
title: "STAR training workshop announced for spring",
|
||||
summary:
|
||||
"Registration will open soon for the upcoming training and development opportunities focused on guideline appraisal.",
|
||||
publication_time: "2024-11-02",
|
||||
},
|
||||
];
|
||||
|
||||
function formatCardDate(value) {
|
||||
const s = String(value || "2025-01-12").slice(0, 10);
|
||||
const [y, m, d] = s.split("-");
|
||||
return { top: `${m || "01"}-${d || "12"}`, year: y || "2025", full: s };
|
||||
}
|
||||
|
||||
/** 列表接口无 summary 字段时,从 content 抽一段纯文本摘要 */
|
||||
function newsSummary(item) {
|
||||
if (item.summary) return item.summary;
|
||||
if (item.abstract) return item.abstract;
|
||||
if (!item.content) return "";
|
||||
return String(item.content)
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 160);
|
||||
}
|
||||
|
||||
export default function NewsListPage() {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [list, setList] = useState([]);
|
||||
const [list, setList] = useState(FALLBACK_NEWS);
|
||||
const [fromApi, setFromApi] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 4000);
|
||||
|
||||
contentApi
|
||||
.listNews({ limit: 50 })
|
||||
.then((res) => alive && setList(res.results || []))
|
||||
.finally(() => alive && setLoading(false));
|
||||
.then((res) => {
|
||||
if (!alive) return;
|
||||
const rows = res?.results || [];
|
||||
if (rows.length) {
|
||||
setList(rows);
|
||||
setFromApi(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* 保持演示数据 */
|
||||
})
|
||||
.finally(() => {
|
||||
clearTimeout(timer);
|
||||
});
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const featured = list.slice(0, 3);
|
||||
const rest = list.slice(3);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col bg-star-bg pb-14">
|
||||
<SectionTitle title={t("news.title")} />
|
||||
<ul className="site-container mt-8 flex-1 list-none bg-white p-0">
|
||||
{loading && (
|
||||
<li className="py-10 text-center text-[#999]">{t("common.loading")}</li>
|
||||
<div className="bg-white">
|
||||
<SiteHero />
|
||||
|
||||
<HexPatternBg className="pb-16">
|
||||
<div className="site-container py-10 md:py-12">
|
||||
{!fromApi && (
|
||||
<p className="mb-6 text-center text-xs text-[#999]">
|
||||
Showing preview content (API unavailable)
|
||||
</p>
|
||||
)}
|
||||
{!loading &&
|
||||
list.map((item) => (
|
||||
<li key={item.id} className="site-list-row mx-5">
|
||||
<Link
|
||||
to={`/newsDetail?id=${item.id}`}
|
||||
className="text-inherit no-underline"
|
||||
|
||||
{/* 精选三卡:不再等 loading,避免后端超时导致整页空白 */}
|
||||
<div className="mb-10 grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{featured.map((item) => {
|
||||
const date = formatCardDate(item.publication_time);
|
||||
const summary =
|
||||
newsSummary(item) ||
|
||||
"Brief summary placeholder for the news card layout.";
|
||||
const to = String(item.id).startsWith("demo-")
|
||||
? "/newsList"
|
||||
: `/newsDetail?id=${item.id}`;
|
||||
return (
|
||||
<article
|
||||
key={item.id}
|
||||
className="flex flex-col rounded-md bg-white/90 p-4 shadow-sm"
|
||||
>
|
||||
<span className="font-bold">
|
||||
{String(item.publication_time || "").slice(0, 10)}
|
||||
{item.cover || item.image_url ? (
|
||||
<img
|
||||
src={item.cover || item.image_url}
|
||||
alt=""
|
||||
className="mb-4 aspect-[16/10] w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<PlaceholderMedia
|
||||
label="News image"
|
||||
tone="warm"
|
||||
className="mb-4 aspect-[16/10] w-full"
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<div className="shrink-0 text-left">
|
||||
<div className="text-xl font-bold leading-none text-[#1a2b56]">
|
||||
{date.top}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-[#889]">{date.year}</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="mb-2 line-clamp-2 text-[15px] font-bold text-(--star-accent)">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="mb-3 line-clamp-3 text-sm leading-6 text-[#556]">
|
||||
{summary}
|
||||
</p>
|
||||
<Link
|
||||
to={to}
|
||||
className="inline-flex items-center gap-1 text-sm text-(--star-accent) no-underline hover:underline"
|
||||
>
|
||||
more
|
||||
<img src={INTL.iconMore} alt="" className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ul className="m-0 list-none divide-y divide-[#e4eaf4] overflow-hidden rounded-md border border-[#e4eaf4] bg-white/90 p-0 shadow-sm">
|
||||
{(rest.length ? rest : list).map((item) => {
|
||||
const date = formatCardDate(item.publication_time);
|
||||
const summary =
|
||||
newsSummary(item) ||
|
||||
"Brief description snippet for the news list row.";
|
||||
const to = String(item.id).startsWith("demo-")
|
||||
? "/newsList"
|
||||
: `/newsDetail?id=${item.id}`;
|
||||
return (
|
||||
<li key={`row-${item.id}`}>
|
||||
<Link
|
||||
to={to}
|
||||
className="flex flex-col gap-2 px-4 py-5 no-underline md:flex-row md:items-center md:gap-6"
|
||||
>
|
||||
<span className="w-28 shrink-0 text-sm font-semibold text-[#1a2b56]">
|
||||
{date.full}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="mb-1 block font-semibold text-[#1a2b56]">
|
||||
{item.title}
|
||||
</span>
|
||||
<span className="line-clamp-1 text-sm text-[#778]">
|
||||
{summary}
|
||||
</span>
|
||||
</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-1 text-sm text-(--star-accent)">
|
||||
more
|
||||
<img src={INTL.iconMore} alt="" className="h-3 w-3" />
|
||||
</span>
|
||||
{item.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
{!loading && !list.length && (
|
||||
<li className="py-10 text-center text-[#999]">{t("news.empty")}</li>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</HexPatternBg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
122
src/pages/public/PeoplePage.jsx
Normal file
@ -0,0 +1,122 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import SiteHero from "@/components/site/SiteHero";
|
||||
import {
|
||||
HexPatternBg,
|
||||
PlaceholderMedia,
|
||||
} from "@/components/site/PlaceholderMedia";
|
||||
import { INTL } from "@/config/intlAssets";
|
||||
import { contentApi } from "@/services";
|
||||
|
||||
const FALLBACK_MEMBERS = [
|
||||
{ id: 1, name: "Gordon Guyatt", org: "McMaster University, Canada" },
|
||||
{ id: 2, name: "Gordon Guyatt", org: "McMaster University, Canada" },
|
||||
{ id: 3, name: "Gordon Guyatt", org: "McMaster University, Canada" },
|
||||
{ id: 4, name: "Gordon Guyatt", org: "McMaster University, Canada" },
|
||||
];
|
||||
|
||||
export default function PeoplePage() {
|
||||
const [members, setMembers] = useState(FALLBACK_MEMBERS);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
contentApi
|
||||
.listProfessors({ limit: 8 })
|
||||
.then((res) => {
|
||||
if (!alive) return;
|
||||
const list = res?.results || [];
|
||||
if (list.length) {
|
||||
setMembers(
|
||||
list.slice(0, 4).map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name || p.name_en || "Member",
|
||||
org: p.organization_name || p.title || "Affiliation",
|
||||
logo: p.logo,
|
||||
})),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-white">
|
||||
<SiteHero />
|
||||
|
||||
<HexPatternBg className="pb-16">
|
||||
{/* Mission & Vision */}
|
||||
<section className="site-container grid grid-cols-1 items-center gap-10 py-12 md:grid-cols-2 md:py-16">
|
||||
<img
|
||||
src={INTL.bgMission}
|
||||
alt="Mission & Vision"
|
||||
className="aspect-[5/4] w-full rounded-tr-[80px] object-cover"
|
||||
/>
|
||||
<div>
|
||||
<h2 className="mb-3 text-xl font-bold italic text-(--star-accent)">
|
||||
— Mission
|
||||
</h2>
|
||||
<p className="mb-8 text-[15px] leading-7 text-[#334]">
|
||||
Promote trustworthy, transparent, and applicable clinical practice
|
||||
guidelines through rigorous evaluation and open collaboration.
|
||||
Placeholder mission text for redesign preview.
|
||||
</p>
|
||||
<h2 className="mb-3 text-xl font-bold italic text-(--star-accent)">
|
||||
— Vision
|
||||
</h2>
|
||||
<p className="text-[15px] leading-7 text-[#334]">
|
||||
A world where guideline users can quickly identify recommendations
|
||||
worth applying. Placeholder vision text for redesign preview.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Advisory Committees */}
|
||||
<section id="committees" className="site-container pb-6">
|
||||
<h2 className="mb-10 text-center text-2xl font-bold italic text-[#1a2b56] md:text-[30px]">
|
||||
Advisory Committees
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-5 md:grid-cols-4 md:gap-6">
|
||||
{members.map((m) => (
|
||||
<article key={m.id} className="text-center">
|
||||
{m.logo ? (
|
||||
<img
|
||||
src={m.logo}
|
||||
alt={m.name}
|
||||
className="mb-0 aspect-[3/4] w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<PlaceholderMedia
|
||||
label="Portrait"
|
||||
tone="warm"
|
||||
className="aspect-[3/4] w-full"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="relative -mt-px bg-(--star-primary) px-3 py-2.5 text-sm font-semibold text-white"
|
||||
style={{
|
||||
clipPath: "polygon(0 0, 100% 0, 92% 100%, 0 100%)",
|
||||
}}
|
||||
>
|
||||
{m.name}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-[#778]">{m.org}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-10 text-center">
|
||||
<Link
|
||||
to="/specialist"
|
||||
className="inline-flex items-center gap-1 text-sm text-(--star-accent) no-underline hover:underline"
|
||||
>
|
||||
View team members
|
||||
<img src={INTL.iconMore} alt="" className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</HexPatternBg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
src/pages/public/ResearchPage.jsx
Normal file
@ -0,0 +1,141 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import SiteHero from "@/components/site/SiteHero";
|
||||
import { HexPatternBg } from "@/components/site/PlaceholderMedia";
|
||||
import { INTL } from "@/config/intlAssets";
|
||||
import { contentApi } from "@/services";
|
||||
|
||||
const FALLBACK_REPORTS = Array.from({ length: 10 }).map((_, i) => ({
|
||||
id: `r-${i}`,
|
||||
date: "01/01",
|
||||
title: "report name report name report name",
|
||||
}));
|
||||
|
||||
const FALLBACK_PUBLICATIONS = Array.from({ length: 10 }).map((_, i) => ({
|
||||
id: `p-${i}`,
|
||||
date: "01/01",
|
||||
title: "report name report name report name",
|
||||
}));
|
||||
|
||||
function formatShortDate(value) {
|
||||
if (!value) return "01/01";
|
||||
const s = String(value).slice(0, 10);
|
||||
const parts = s.split("-");
|
||||
if (parts.length >= 3) return `${parts[1]}/${parts[2]}`;
|
||||
return s;
|
||||
}
|
||||
|
||||
function ListCard({ title, items, moreTo }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md border border-[#e4e8f2] bg-white">
|
||||
<div
|
||||
className="relative flex items-end justify-between bg-cover bg-center px-5 pt-8 pb-4"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(120deg, rgba(58,97,194,0.75), rgba(200,215,255,0.55)), url(${INTL.reportCover})`,
|
||||
}}
|
||||
>
|
||||
<h2 className="relative z-10 text-xl font-bold text-[#1a2b56] md:text-2xl">
|
||||
{title}
|
||||
</h2>
|
||||
<Link
|
||||
to={moreTo}
|
||||
className="relative z-10 inline-flex items-center gap-1 text-sm text-(--star-accent) no-underline hover:underline"
|
||||
>
|
||||
more
|
||||
<img src={INTL.iconMore} alt="" className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
<ul className="m-0 list-none p-0">
|
||||
{items.map((item, idx) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className={idx % 2 === 0 ? "bg-white" : "bg-[#f5f8fc]"}
|
||||
>
|
||||
<Link
|
||||
to={item.to || moreTo}
|
||||
className="flex items-center gap-3 px-4 py-3 text-[14px] text-[#334] no-underline hover:text-(--star-accent)"
|
||||
>
|
||||
<span className="shrink-0 text-[#889]">• {item.date}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{item.title}</span>
|
||||
<img
|
||||
src={INTL.iconMore}
|
||||
alt=""
|
||||
className="h-3 w-3 shrink-0 opacity-50"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResearchPage() {
|
||||
const [reports, setReports] = useState(FALLBACK_REPORTS);
|
||||
const [publications, setPublications] = useState(FALLBACK_PUBLICATIONS);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const [reportRes, pubRes] = await Promise.all([
|
||||
contentApi.listReports({ limit: 12 }),
|
||||
contentApi.listPublications({ limit: 12 }),
|
||||
]);
|
||||
if (!alive) return;
|
||||
const r = reportRes?.results || [];
|
||||
const p = pubRes?.results || [];
|
||||
if (r.length) {
|
||||
setReports(
|
||||
r.map((item) => ({
|
||||
id: item.id,
|
||||
date: formatShortDate(
|
||||
item.year ? `${item.year}-01-01` : item.updated_at,
|
||||
),
|
||||
title: item.name || item.title || "Report",
|
||||
to:
|
||||
item.file_path && item.file_path !== "#"
|
||||
? item.file_path
|
||||
: "/research",
|
||||
})),
|
||||
);
|
||||
}
|
||||
if (p.length) {
|
||||
setPublications(
|
||||
p.map((item) => ({
|
||||
id: item.id,
|
||||
date: formatShortDate(item.publication_time),
|
||||
title: item.title || "Publication",
|
||||
to: `/publicationDetail?id=${item.id}`,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* 使用占位列表 */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-white">
|
||||
<SiteHero />
|
||||
<HexPatternBg className="pb-16">
|
||||
<div className="site-container grid grid-cols-1 gap-6 py-10 md:grid-cols-2 md:py-12">
|
||||
<ListCard
|
||||
title="Research Reports"
|
||||
items={reports}
|
||||
moreTo="/publication"
|
||||
/>
|
||||
<ListCard
|
||||
title="Research Publications"
|
||||
items={publications}
|
||||
moreTo="/publication"
|
||||
/>
|
||||
</div>
|
||||
</HexPatternBg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -4,6 +4,10 @@ import PublicLayout from "@/layouts/PublicLayout";
|
||||
import AdminLayout from "@/layouts/AdminLayout";
|
||||
import RouteErrorPage from "@/pages/RouteErrorPage";
|
||||
import HomePage from "@/pages/public/HomePage";
|
||||
import PeoplePage from "@/pages/public/PeoplePage";
|
||||
import ResearchPage from "@/pages/public/ResearchPage";
|
||||
import CooperationPage from "@/pages/public/CooperationPage";
|
||||
import JoinCommitteePage from "@/pages/public/JoinCommitteePage";
|
||||
import ArticleListPage from "@/pages/public/ArticleListPage";
|
||||
import ArticleDetailPage from "@/pages/public/ArticleDetailPage";
|
||||
import RankingPage from "@/pages/public/RankingPage";
|
||||
@ -67,6 +71,10 @@ const router = createBrowserRouter([
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/index" replace /> },
|
||||
{ path: "index", element: <HomePage /> },
|
||||
{ path: "people", element: <PeoplePage /> },
|
||||
{ path: "research", element: <ResearchPage /> },
|
||||
{ path: "cooperation", element: <CooperationPage /> },
|
||||
{ path: "cooperation/join", element: <JoinCommitteePage /> },
|
||||
{ path: "guidelineAndConsensus", element: <ArticleListPage /> },
|
||||
{ path: "fingerpostDetail", element: <ArticleDetailPage /> },
|
||||
{ path: "guideRanking", element: <RankingPage kind="article" /> },
|
||||
|
||||
@ -5,9 +5,11 @@ export default {
|
||||
extend: {
|
||||
colors: {
|
||||
star: {
|
||||
primary: "#181b62",
|
||||
topbar: "#1d2b40",
|
||||
bg: "#f6f8fc",
|
||||
primary: "#0b1a3d",
|
||||
accent: "#3a61c2",
|
||||
topbar: "#0b1a3d",
|
||||
bg: "#f4f7fc",
|
||||
soft: "#e8f1ff",
|
||||
},
|
||||
},
|
||||
maxWidth: {
|
||||
|
||||