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
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# 设计参考稿 / 切片源文件,正式素材已整理进 public/assets
|
||||||
|
images/
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.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",
|
"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",
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"brand": "STAR International",
|
"brand": "STAR International",
|
||||||
"brandTagline": "科学 · 透明 · 适用",
|
"brandTagline": "科学 · 透明 · 适用",
|
||||||
|
"brandWords": ["科学", "透明", "适用"],
|
||||||
"nav": {
|
"nav": {
|
||||||
"home": "首页",
|
"home": "首页",
|
||||||
"ranking": "排名",
|
"ranking": "排名",
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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>
|
|
||||||
{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");
|
|
||||||
};
|
|
||||||
|
|
||||||
|
{/* 三 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 & 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 { 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>
|
||||||
{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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 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" /> },
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||