196 lines
5.9 KiB
TypeScript
196 lines
5.9 KiB
TypeScript
"use client";
|
||
|
||
import { memo, useState } from "react";
|
||
import { City } from "../data/cities";
|
||
import { useTranslation } from "@/i18n/navigation";
|
||
|
||
function ratingToPercent(rating: string): number {
|
||
const m: Record<string, number> = {
|
||
极好: 95,
|
||
很好: 80,
|
||
好: 60,
|
||
一般: 40,
|
||
差: 20,
|
||
};
|
||
return m[rating] || 50;
|
||
}
|
||
|
||
function getBarColor(p: number): string {
|
||
if (p >= 60) return "#22c55e";
|
||
if (p >= 35) return "#eab308";
|
||
return "#ef4444";
|
||
}
|
||
|
||
interface ScoreBarProps {
|
||
icon: string;
|
||
label: string;
|
||
percent: number;
|
||
animate: boolean;
|
||
}
|
||
|
||
const ScoreBar = memo(function ScoreBar({ icon, label, percent, animate }: ScoreBarProps) {
|
||
return (
|
||
<div className="flex items-center gap-2 sm:gap-3">
|
||
<span className="text-white/80 text-xs sm:text-sm w-14 sm:w-16 shrink-0 flex items-center gap-1">
|
||
<span>{icon}</span>
|
||
<span className="hidden sm:inline">{label}</span>
|
||
</span>
|
||
<div className="flex-1 h-2 sm:h-2.5 bg-white/10 rounded-full overflow-hidden">
|
||
<div
|
||
className="h-full rounded-full transition-all duration-700 ease-out"
|
||
style={{
|
||
width: animate ? `${percent}%` : "0%",
|
||
backgroundColor: getBarColor(percent),
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
interface CityCardProps {
|
||
city: City;
|
||
rank: number;
|
||
onClick?: () => void;
|
||
}
|
||
|
||
function CityCardComponent({ city, rank, onClick }: CityCardProps) {
|
||
const [hovered, setHovered] = useState(false);
|
||
const { t } = useTranslation("cityCard");
|
||
|
||
const scores = [
|
||
{
|
||
icon: "⭐",
|
||
label: "综合",
|
||
percent: (city.overallScore / 5) * 100,
|
||
},
|
||
{
|
||
icon: "💵",
|
||
label: "费用",
|
||
percent: Math.max(15, 100 - city.costPerMonth / 200),
|
||
},
|
||
{
|
||
icon: "📡",
|
||
label: "网速",
|
||
percent: Math.min(95, (city.internetSpeed / 120) * 100),
|
||
},
|
||
{
|
||
icon: "👍",
|
||
label: "好评",
|
||
percent: ratingToPercent(city.liked),
|
||
},
|
||
{
|
||
icon: "👮",
|
||
label: "安全",
|
||
percent: ratingToPercent(city.safety),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={onClick}
|
||
onKeyDown={(e) => e.key === "Enter" && onClick?.()}
|
||
className="city-card cursor-pointer"
|
||
style={{
|
||
background: `linear-gradient(135deg, ${city.gradientFrom}, ${city.gradientTo})`,
|
||
}}
|
||
onMouseEnter={() => setHovered(true)}
|
||
onMouseLeave={() => setHovered(false)}
|
||
>
|
||
{/* Icon watermark */}
|
||
<span className="absolute bottom-8 right-2 text-[70px] sm:text-[90px] opacity-[0.12] select-none pointer-events-none leading-none">
|
||
{city.icon}
|
||
</span>
|
||
|
||
{/* ===== Default state ===== */}
|
||
<div
|
||
className={`absolute inset-0 z-10 flex flex-col justify-between p-3 sm:p-4 transition-opacity duration-300 ${hovered ? "opacity-0" : "opacity-100"}`}
|
||
>
|
||
{/* Top row */}
|
||
<div className="flex items-start justify-between">
|
||
<span className="text-white/70 text-base sm:text-xl font-bold">
|
||
{rank}
|
||
</span>
|
||
<span className="text-[10px] sm:text-xs bg-white/20 backdrop-blur-sm rounded-full px-1.5 sm:px-2 py-0.5 sm:py-1 text-white flex items-center gap-0.5">
|
||
📡 {city.internetSpeed}Mbps
|
||
</span>
|
||
</div>
|
||
|
||
{/* Center: city name + country */}
|
||
<div className="text-center -mt-2">
|
||
<h3 className="text-xl sm:text-[28px] font-bold text-white drop-shadow-md tracking-wide">
|
||
{city.name}
|
||
</h3>
|
||
<p className="text-xs sm:text-sm text-white/60 mt-0.5">
|
||
{city.countryEmoji} {city.country}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Bottom: temp + cost + nomads */}
|
||
<div>
|
||
<div className="flex items-center gap-1 mb-1.5">
|
||
<span className="text-[9px] sm:text-[11px] bg-white/15 backdrop-blur-sm rounded-full px-1.5 py-0.5 text-white/80">
|
||
👥 {city.nomadsNow}{t("nomadsHere")}
|
||
</span>
|
||
<span className="text-[9px] sm:text-[11px] bg-white/15 backdrop-blur-sm rounded-full px-1.5 py-0.5 text-white/80">
|
||
🌡 {city.temperature}°C
|
||
</span>
|
||
</div>
|
||
<div className="flex items-end justify-between text-white text-xs sm:text-sm">
|
||
<span className="text-white/80 text-[10px] sm:text-xs line-clamp-1 max-w-[60%]">
|
||
{city.description}
|
||
</span>
|
||
<span className="font-semibold shrink-0">
|
||
¥{city.costPerMonth.toLocaleString()}/{t("perMonth")}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ===== Hover overlay (desktop only) ===== */}
|
||
<div
|
||
className={`absolute inset-0 z-20 bg-[#2a2a2a]/95 hidden sm:flex flex-col justify-between p-4 sm:p-5 transition-opacity duration-300 ${hovered ? "opacity-100" : "opacity-0 pointer-events-none"}`}
|
||
>
|
||
{/* Top icons */}
|
||
<div className="flex items-center justify-between">
|
||
<button className="text-white/50 hover:text-red-400 transition-colors text-base">
|
||
❤️
|
||
</button>
|
||
<button
|
||
className="text-white/30 hover:text-white text-sm transition-colors"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setHovered(false);
|
||
}}
|
||
type="button"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
{/* Score bars */}
|
||
<div className="space-y-2.5 sm:space-y-3">
|
||
{scores.map((s) => (
|
||
<ScoreBar
|
||
key={s.label}
|
||
icon={s.icon}
|
||
label={s.label}
|
||
percent={s.percent}
|
||
animate={hovered}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{/* Bottom: city info small */}
|
||
<div className="text-center text-white/40 text-xs">
|
||
{city.name} · {city.countryEmoji} {city.country}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default memo(CityCardComponent);
|