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>
This commit is contained in:
aifeilong 2026-10-15 17:52:15 +08:00
parent 00d5558fe2
commit 9195a0a899
38 changed files with 1393 additions and 585 deletions

3
.gitignore vendored
View File

@ -12,6 +12,9 @@ dist
dist-ssr dist-ssr
*.local *.local
# 设计参考稿 / 切片源文件,正式素材已整理进 public/assets
images/
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json

View 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`

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 997 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View 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",
}}
/>
);
}

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

View 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
View 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",
};

View File

@ -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" },
],
},
];

View File

@ -1,6 +1,7 @@
{ {
"brand": "STAR International", "brand": "STAR International",
"brandTagline": "Scientificity, Transparency and Applicability Rankings for Practice Guidelines", "brandTagline": "Scientificity, Transparency and Applicability",
"brandWords": ["Scientificity", "Transparency", "Applicability"],
"nav": { "nav": {
"home": "Home", "home": "Home",
"ranking": "Ranking", "ranking": "Ranking",

View File

@ -1,6 +1,7 @@
{ {
"brand": "STAR International", "brand": "STAR International",
"brandTagline": "科学 · 透明 · 适用", "brandTagline": "科学 · 透明 · 适用",
"brandWords": ["科学", "透明", "适用"],
"nav": { "nav": {
"home": "首页", "home": "首页",
"ranking": "排名", "ranking": "排名",

View File

@ -1,22 +1,27 @@
@import "tailwindcss"; @import "tailwindcss";
@theme { @theme {
--color-star-primary: #181b62; --color-star-primary: #0b1a3d;
--color-star-topbar: #1d2b40; --color-star-accent: #3a61c2;
--color-star-bg: #f6f8fc; --color-star-topbar: #0b1a3d;
--color-star-bg: #f4f7fc;
--color-star-soft: #e8f1ff;
--font-sans: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, --font-sans: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial,
sans-serif; sans-serif;
--font-hero: "Georgia", "Times New Roman", "Songti SC", serif;
} }
:root { :root {
--star-primary: #181b62; --star-primary: #0b1a3d;
--star-topbar: rgb(29, 43, 64); --star-accent: #3a61c2;
--star-bg: #f6f8fc; --star-topbar: #0b1a3d;
--star-bg: #f4f7fc;
--star-soft: #e8f1ff;
--star-muted: #999; --star-muted: #999;
--star-nav-hover: rgba(24, 27, 98, 0.06); --star-nav-hover: rgba(255, 255, 255, 0.08);
--star-nav-active-bar: #181b62; --star-nav-active-bar: #ffffff;
--star-nav-dropdown-hover: #f5f7fa; --star-nav-dropdown-hover: #eef3fb;
--star-nav-border: #eee; --star-nav-border: rgba(255, 255, 255, 0.12);
--star-select-border: #d8dce8; --star-select-border: #d8dce8;
} }

View File

@ -1,11 +1,4 @@
import { import { useEffect, useMemo, useState } from "react";
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { import {
Outlet, Outlet,
Link, Link,
@ -15,50 +8,75 @@ import {
} from "react-router-dom"; } from "react-router-dom";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Globe, Settings, Menu, X, ChevronDown } from "lucide-react";
import { setUiLanguage } from "@/i18n"; import { setUiLanguage } from "@/i18n";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { HEADER_OFFSET } from "@/config/nav"; import { HEADER_OFFSET, INTL_TOP_NAV } from "@/config/nav";
import { contentApi } from "@/services";
import BrandLogo from "@/components/site/BrandLogo";
import {
buildColumnNavTree,
fallbackFooterNav,
fallbackTopNav,
} from "@/utils/navTree";
/** /**
* 详情页归属到列表栏目,避免进入详情后顶栏 active 消失。 * 详情页归属到列表栏目,保证顶栏高亮正确。
* key: 详情 pathname,value: 应对齐高亮的列表 path。
*/ */
const DETAIL_PARENT_PATHS = { const DETAIL_PARENT_PATHS = {
"/fingerpostDetail": "/guidelineAndConsensus", "/fingerpostDetail": "/research",
"/organization/detail": "/organization", "/organization/detail": "/cooperation",
"/specialist/specialistDetail": "/specialist", "/specialist/specialistDetail": "/people",
"/specialist": "/people",
"/newsDetail": "/newsList", "/newsDetail": "/newsList",
"/publicationDetail": "/publication", "/publicationDetail": "/research",
"/publication": "/research",
"/cooperation/join": "/cooperation",
"/about": "/people",
}; };
function resolveActivePathname(pathname) { function resolveActivePathname(pathname) {
return DETAIL_PARENT_PATHS[pathname] || pathname; return DETAIL_PARENT_PATHS[pathname] || pathname;
} }
function navActive(pathname, search, to) { function navActive(pathname, to) {
const activePath = resolveActivePathname(pathname); const activePath = resolveActivePathname(pathname);
const [path, query = ""] = to.split("?"); const path = to.split("#")[0].split("?")[0];
if (path === "/index") { if (path === "/index") {
return activePath === "/" || activePath === "/index"; return activePath === "/" || activePath === "/index";
} }
if (!activePath.startsWith(path)) return false; if (path === "/people") {
if (!query) return true; return (
// 详情页映射到列表后,不再用 query 细分子项(避免排名子菜单误高亮) activePath === "/people" ||
if (activePath !== pathname) return true; activePath.startsWith("/specialist") ||
const want = new URLSearchParams(query); activePath === "/about"
const cur = new URLSearchParams(search); );
for (const [k, v] of want.entries()) {
if (cur.get(k) !== v) return false;
} }
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() { export default function PublicLayout() {
@ -68,34 +86,9 @@ export default function PublicLayout() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const memberToken = useSelector((s) => s.member?.token); const memberToken = useSelector((s) => s.member?.token);
const memberProfile = useSelector((s) => s.member?.profile); const memberProfile = useSelector((s) => s.member?.profile);
const [keyword, setKeyword] = useState("");
const [openMenu, setOpenMenu] = useState(null);
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const [columns, setColumns] = useState(null); const [openMenu, setOpenMenu] = useState(null);
const navRef = useRef(null); const [settingsOpen, setSettingsOpen] = useState(false);
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;
};
}, []);
useEffect(() => { useEffect(() => {
if (memberToken && !memberProfile) { if (memberToken && !memberProfile) {
@ -103,249 +96,57 @@ export default function PublicLayout() {
} }
}, [memberToken, memberProfile, dispatch]); }, [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(() => { useEffect(() => {
const onResize = () => updateIndicator(); setMobileOpen(false);
window.addEventListener("resize", onResize); setOpenMenu(null);
return () => window.removeEventListener("resize", onResize); setSettingsOpen(false);
}, [updateIndicator]); }, [location.pathname, location.search]);
// 桌面主导航(指示条由共享元素滑动,不在单项上画) const topNav = useMemo(() => INTL_TOP_NAV, []);
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)",
);
return ( return (
<div className="flex min-h-screen w-full flex-col bg-white"> <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"> <header
<div className="fixed top-0 right-0 left-0 z-9999 border-b border-white/10"
className="text-white"
style={{ background: "var(--star-topbar)" }} 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 <div
className="hidden border-b bg-white sm:block" className="mx-auto flex max-w-300 items-center gap-4 px-4 md:px-6"
style={{ borderColor: "var(--star-nav-border)" }} style={{ height: HEADER_OFFSET }}
>
<nav
ref={navRef}
className="relative mx-auto flex h-10 max-w-300 items-stretch justify-between px-2"
> >
<StarMark />
<nav className="ml-auto hidden items-center gap-1 md:flex">
{topNav.map((item) => {topNav.map((item) =>
item.children?.length ? ( item.children?.length ? (
<div <div
key={item.id} key={item.id}
ref={setItemRef(item.id)}
className="relative" className="relative"
onMouseEnter={() => setOpenMenu(item.id)} onMouseEnter={() => setOpenMenu(item.id)}
onMouseLeave={() => setOpenMenu(null)} onMouseLeave={() => setOpenMenu(null)}
> >
<NavLink <NavLink
to={item.to} 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> </NavLink>
{openMenu === item.id && ( {openMenu === item.id && (
<div <div className="absolute top-full left-0 min-w-48 rounded-b-md bg-white py-2 shadow-lg">
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)" }}
>
{item.children.map((child) => ( {item.children.map((child) => (
<Link <Link
key={child.id} key={child.id}
to={child.to} to={child.to}
className={dropdownItemClass( className="block px-4 py-2.5 text-sm text-[#1a2b56] no-underline hover:bg-(--star-nav-dropdown-hover)"
navActive(
location.pathname,
location.search,
child.to,
),
)}
onClick={() => setOpenMenu(null)}
> >
{child.name} {child.label}
</Link> </Link>
))} ))}
</div> </div>
@ -354,143 +155,170 @@ export default function PublicLayout() {
) : ( ) : (
<NavLink <NavLink
key={item.id} key={item.id}
ref={setItemRef(item.id)}
to={item.to} 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> </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> </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> </div>
{mobileOpen && ( {mobileOpen && (
<div <div className="border-t border-white/10 bg-[#0b1a3d] md:hidden">
className="border-b bg-white sm:hidden"
style={{ borderColor: "var(--star-nav-border)" }}
>
{topNav.map((item) => ( {topNav.map((item) => (
<div key={item.id}> <div key={item.id} className="border-b border-white/5">
<Link <Link
to={item.to} 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)} onClick={() => setMobileOpen(false)}
> >
{item.name} {item.label}
</Link> </Link>
{item.children?.map((child) => ( {item.children?.map((child) => (
<Link <Link
key={child.id} key={child.id}
to={child.to} to={child.to}
className={cn( className="block px-8 py-2.5 text-sm text-[#ffffff] no-underline opacity-85"
"block px-9 py-2.5 text-base no-underline transition-colors", style={{ color: "#ffffff" }}
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)",
)}
onClick={() => setMobileOpen(false)} onClick={() => setMobileOpen(false)}
> >
{child.name} {child.label}
</Link> </Link>
))} ))}
</div> </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> </div>
)} )}
</header> </header>
<main <main
style={{ style={{ paddingTop: HEADER_OFFSET }}
paddingTop: mobileOpen ? undefined : HEADER_OFFSET,
}}
className={cn( className={cn(
"flex flex-1 flex-col bg-white", "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 overflow-visible *:min-h-full *:flex-1">
<div className="flex min-h-0 flex-1 flex-col *:min-h-full *:flex-1">
<Outlet /> <Outlet />
</div> </div>
</main> </main>
<footer <footer
className="mt-auto shrink-0" className="mt-auto h-16 shrink-0 md:h-20"
style={{ background: "var(--star-topbar)" }} 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> </div>
); );
} }

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

View File

@ -1,206 +1,276 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next"; import CoopCardIcon from "@/components/site/CoopCardIcon";
import SectionTitle from "@/components/site/SectionTitle"; import { HexPatternBg } from "@/components/site/PlaceholderMedia";
import HomeCarousel from "@/components/site/HomeCarousel"; import SiteHero from "@/components/site/SiteHero";
import { FALLBACK_BANNERS } from "@/config/nav"; import { INTL } from "@/config/intlAssets";
import { contentApi } from "@/services"; import { contentApi } from "@/services";
import { cn } from "@/lib/utils";
const FALLBACK_REPORTS = [ const FALLBACK_REPORTS = [
{ {
id: "r1", id: "r1",
name: "STAR Report 2024", name: "Language models for data extraction from abstracts",
year: 2024, year: 2025,
logo: "/assets/img/01@2x.jpg",
file_path: "#",
}, },
{ {
id: "r2", id: "r2",
name: "STAR Report 2023", name: "Language models for data extraction from abstracts",
year: 2023, year: 2025,
logo: "/assets/img/02@2x.jpg",
file_path: "#",
}, },
{ {
id: "r3", id: "r3",
name: "STAR Report 2022", name: "Language models for data extraction from abstracts",
year: 2022, year: 2025,
logo: "/assets/img/03@2x.jpg",
file_path: "#",
}, },
{ {
id: "r4", id: "r4",
name: "STAR Report 2021", name: "Language models for data extraction from abstracts",
year: 2021, year: 2025,
logo: "/assets/img/04@2x.jpg",
file_path: "#",
}, },
]; ];
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) { function formatDate(value) {
if (!value) return ""; if (!value) return "2025-01-12";
return String(value).slice(0, 10); return String(value).slice(0, 10);
} }
export default function HomePage() { export default function HomePage() {
const { t } = useTranslation(); const [activeTab, setActiveTab] = useState("report");
const [news, setNews] = useState([]);
const [publications, setPublications] = useState([]);
const [reports, setReports] = useState(FALLBACK_REPORTS); const [reports, setReports] = useState(FALLBACK_REPORTS);
const [banners, setBanners] = useState(FALLBACK_BANNERS);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
(async () => { contentApi
try { .listReports({ limit: 8 })
const [newsRes, pubRes, reportRes, bannerRes] = await Promise.all([ .then((res) => {
contentApi.listNews({ limit: 8 }),
contentApi.listPublications({ limit: 8 }),
contentApi.listReports({ limit: 20 }),
contentApi.listBanners(),
]);
if (!alive) return; if (!alive) return;
setNews(newsRes?.results || []); const remote = res?.results || [];
setPublications(pubRes?.results || []);
const remote = reportRes?.results || [];
setReports(remote.length ? remote : FALLBACK_REPORTS); setReports(remote.length ? remote : FALLBACK_REPORTS);
const bannerList = Array.isArray(bannerRes) })
? bannerRes .catch(() => {
: bannerRes?.results || []; if (alive) setReports(FALLBACK_REPORTS);
const enabled = bannerList.filter((b) => !b.is_disabled); });
setBanners(enabled.length ? enabled : FALLBACK_BANNERS);
} catch {
if (alive) {
setReports(FALLBACK_REPORTS);
setBanners(FALLBACK_BANNERS);
}
}
})();
return () => { return () => {
alive = false; alive = false;
}; };
}, []); }, []);
const tab = FEATURE_TABS.find((item) => item.id === activeTab) || FEATURE_TABS[0];
return ( return (
<div> <div>
<HomeCarousel banners={banners} /> <SiteHero />
{/* 新闻通知 + 资源中心 */}
<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>
&nbsp;&nbsp;{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>
&nbsp;&nbsp;{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");
};
{/* 三 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 ( 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 <button
key={item.id} key={item.id}
type="button" type="button"
onClick={() => onDownload(item)} onClick={() => setActiveTab(item.id)}
className="group relative w-65 shrink-0 cursor-pointer border-0 bg-transparent p-0 text-left" 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 <img
src={item.logo || "/assets/img/01@2x.jpg"} src={item.icon}
alt={item.name} alt=""
className="max-h-75 w-full object-contain" className="h-12 w-12 object-contain"
/> />
<span <span className="max-w-56 text-sm font-medium leading-snug md:text-[15px]">
className="absolute right-5 bottom-5 h-6.5 w-7 bg-contain bg-no-repeat opacity-90 group-hover:opacity-100" {item.title}
style={{ backgroundImage: "url(/assets/img/xiazai2@2x.png)" }} </span>
/>
<div className="mt-2 truncate text-center text-sm text-[#333]">
{item.name || item.year}
</div>
</button> </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>
</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 &amp; 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>
); );
} }

View 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 &gt;&gt;
</Link>
</div>
)}
</section>
))}
</div>
</div>
);
}

View File

@ -1,50 +1,218 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next"; import SiteHero from "@/components/site/SiteHero";
import SectionTitle from "@/components/site/SectionTitle"; import {
HexPatternBg,
PlaceholderMedia,
} from "@/components/site/PlaceholderMedia";
import { INTL } from "@/config/intlAssets";
import { contentApi } from "@/services"; 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() { export default function NewsListPage() {
const { t } = useTranslation(); const [list, setList] = useState(FALLBACK_NEWS);
const [loading, setLoading] = useState(true); const [fromApi, setFromApi] = useState(false);
const [list, setList] = useState([]);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 4000);
contentApi contentApi
.listNews({ limit: 50 }) .listNews({ limit: 50 })
.then((res) => alive && setList(res.results || [])) .then((res) => {
.finally(() => alive && setLoading(false)); if (!alive) return;
const rows = res?.results || [];
if (rows.length) {
setList(rows);
setFromApi(true);
}
})
.catch(() => {
/* 保持演示数据 */
})
.finally(() => {
clearTimeout(timer);
});
return () => { return () => {
alive = false; alive = false;
clearTimeout(timer);
controller.abort();
}; };
}, []); }, []);
const featured = list.slice(0, 3);
const rest = list.slice(3);
return ( return (
<div className="flex flex-col bg-star-bg pb-14"> <div className="bg-white">
<SectionTitle title={t("news.title")} /> <SiteHero />
<ul className="site-container mt-8 flex-1 list-none bg-white p-0">
{loading && ( <HexPatternBg className="pb-16">
<li className="py-10 text-center text-[#999]">{t("common.loading")}</li> <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) => ( {/* 精选三卡:不再等 loading,避免后端超时导致整页空白 */}
<li key={item.id} className="site-list-row mx-5"> <div className="mb-10 grid grid-cols-1 gap-6 md:grid-cols-3">
<Link {featured.map((item) => {
to={`/newsDetail?id=${item.id}`} const date = formatCardDate(item.publication_time);
className="text-inherit no-underline" 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"> {item.cover || item.image_url ? (
{String(item.publication_time || "").slice(0, 10)} <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> </span>
&nbsp;&nbsp;{item.title}
</Link> </Link>
</li> </li>
))} );
{!loading && !list.length && ( })}
<li className="py-10 text-center text-[#999]">{t("news.empty")}</li>
)}
</ul> </ul>
</div> </div>
</HexPatternBg>
</div>
); );
} }

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

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

View File

@ -4,6 +4,10 @@ import PublicLayout from "@/layouts/PublicLayout";
import AdminLayout from "@/layouts/AdminLayout"; import AdminLayout from "@/layouts/AdminLayout";
import RouteErrorPage from "@/pages/RouteErrorPage"; import RouteErrorPage from "@/pages/RouteErrorPage";
import HomePage from "@/pages/public/HomePage"; 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 ArticleListPage from "@/pages/public/ArticleListPage";
import ArticleDetailPage from "@/pages/public/ArticleDetailPage"; import ArticleDetailPage from "@/pages/public/ArticleDetailPage";
import RankingPage from "@/pages/public/RankingPage"; import RankingPage from "@/pages/public/RankingPage";
@ -67,6 +71,10 @@ const router = createBrowserRouter([
children: [ children: [
{ index: true, element: <Navigate to="/index" replace /> }, { index: true, element: <Navigate to="/index" replace /> },
{ path: "index", element: <HomePage /> }, { 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: "guidelineAndConsensus", element: <ArticleListPage /> },
{ path: "fingerpostDetail", element: <ArticleDetailPage /> }, { path: "fingerpostDetail", element: <ArticleDetailPage /> },
{ path: "guideRanking", element: <RankingPage kind="article" /> }, { path: "guideRanking", element: <RankingPage kind="article" /> },

View File

@ -5,9 +5,11 @@ export default {
extend: { extend: {
colors: { colors: {
star: { star: {
primary: "#181b62", primary: "#0b1a3d",
topbar: "#1d2b40", accent: "#3a61c2",
bg: "#f6f8fc", topbar: "#0b1a3d",
bg: "#f4f7fc",
soft: "#e8f1ff",
}, },
}, },
maxWidth: { maxWidth: {