feat(character): 更新角色详情页面- 修改角色详情页面布局和样式

- 添加角色属性、职业等信息展示
- 更新技能和配装推荐模块
- 优化页面加载和错误处理逻辑
This commit is contained in:
hxt
2025-05-24 12:22:04 +08:00
parent 5cf5373696
commit f527462274
4 changed files with 197 additions and 141 deletions

View File

@@ -128,7 +128,7 @@ const App: React.FC = () => {
<Route path="/home" element={<Home />} /> <Route path="/home" element={<Home />} />
<Route path="/characters" element={<Characters />} /> <Route path="/characters" element={<Characters />} />
<Route <Route
path="/character/:id" path="/character/:heroCode"
element={<CharacterDetail character={mockCharacterData} />} element={<CharacterDetail character={mockCharacterData} />}
/> />
<Route path="/news" element={<News />} /> <Route path="/news" element={<News />} />

View File

@@ -1,5 +1,4 @@
import {Api} from "../utils/axios/config"; import {Api} from "../utils/axios/config";
import axios, {AxiosRequestConfig} from "axios";
// 查询参数接口 // 查询参数接口
export interface GvgTeamQueryParams { export interface GvgTeamQueryParams {
@@ -54,6 +53,37 @@ export interface Hero {
headImgUrl: string; headImgUrl: string;
} }
// 角色详情接口类型
export interface HeroDetailResp {
heroRespSimpleVO: {
id: string;
heroCode: string;
heroName: string;
nickName: string | null;
headImgUrl: string;
stars: number;
role: string;
attribute: string;
};
hero60AttributeVO: {
cp: number;
atk: number;
hp: number;
spd: number;
def: number;
chc: number;
chd: number;
dac: number;
eff: number;
efr: number;
};
}
// 查询角色详情
export const getHeroDetail = async (heroCode: string): Promise<Response<HeroDetailResp>> => {
return await Api.get<Response<HeroDetailResp>>(`/epic/hero/hero-detail?heroCode=${heroCode}`)
};
// 查询 GVG 阵容列表 // 查询 GVG 阵容列表
export const getGvgTeamList = async (heroCodes?: string[]) => { export const getGvgTeamList = async (heroCodes?: string[]) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -89,7 +119,7 @@ export const recognizeHeroesFromImage = async (file: File): Promise<ImageRecogni
const response = await Api.upload<string[]>('/epic/hero/recognize', file); const response = await Api.upload<string[]>('/epic/hero/recognize', file);
console.log(response) console.log(response)
if (response && Array.isArray(response)) { if (response && Array.isArray(response)) {
return { heroNames: response }; return {heroNames: response};
} }
throw new Error("图片识别失败"); throw new Error("图片识别失败");
} catch (error) { } catch (error) {

View File

@@ -1,4 +1,7 @@
import React, {useEffect} from 'react'; import React, {useEffect, useState} from 'react';
import {useParams} from 'react-router-dom';
import * as EpicApi from '@/api/index';
import {getHeroDetail} from "@/api/index";
export interface Skill { export interface Skill {
name: string; name: string;
@@ -145,19 +148,82 @@ const artifactImgMap: Record<string, string> = {
'伊赛丽亚的誓约': 'https://epic7db.com/images/artifacts/elbris-ritual-sword.webp', '伊赛丽亚的誓约': 'https://epic7db.com/images/artifacts/elbris-ritual-sword.webp',
}; };
const CharacterDetail: React.FC<CharacterDetailProps> = ({character}) => { // 角色职业中文映射
useEffect(() => { const ROLE_LABELS: Record<string, string> = {
window.scrollTo({top: 0, behavior: 'auto'}); knight: '骑士',
}, []); warrior: '战士',
assassin: '盗贼',
ranger: '射手',
mage: '魔导师',
manauser: '精灵师',
all: '全部',
};
// 属性图标映射
const ATTR_ICON_MAP: Record<string, string> = {
attack: '/pic/item/attr/cm_icon_stat_attack.png',
defense: '/pic/item/attr/cm_icon_stat_defense.png',
health: '/pic/item/attr/cm_icon_stat_health.png',
speed: '/pic/item/attr/cm_icon_stat_speed.png',
critChance: '/pic/item/attr/cm_icon_stat_crit_chance.png',
critDamage: '/pic/item/attr/cm_icon_stat_crit_damage.png',
effectiveness: '/pic/item/attr/cm_icon_stat_effectiveness.png',
effectResistance: '/pic/item/attr/cm_icon_stat_effect_resistance.png',
};
// 属性中文映射
const ATTR_LABEL_MAP: Record<string, string> = {
attack: '攻击',
defense: '防御',
health: '生命',
speed: '速度',
critChance: '暴击率',
critDamage: '暴击伤害',
effectiveness: '效果命中',
effectResistance: '效果抗性',
};
const CharacterDetail: React.FC = () => {
const {heroCode} = useParams();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [heroDetail, setHeroDetail] = useState<EpicApi.HeroDetailResp['data'] | null>(null);
useEffect(() => {
if (!heroCode) return;
setLoading(true);
EpicApi.getHeroDetail(heroCode)
.then(res => {
setHeroDetail(res);
setError(null);
})
.catch(() => setError('获取角色详情失败'))
.finally(() => setLoading(false));
}, [heroCode]);
// 星级渲染(详情页专用星星)
const renderStars = (count: number) => { const renderStars = (count: number) => {
return Array(count) return (
.fill(0) <div className="flex items-center gap-1">
.map((_, index) => ( {Array(count).fill(0).map((_, i) => (
<i key={index} className="text-yellow-400 text-lg"></i> <img key={i} src="/pic/star.png" alt="star" className="w-6 h-6 inline-block"/>
)); ))}
</div>
);
}; };
// 属性图标
const getElementIcon = (attribute: string) => {
if (!attribute) return null;
return <img src={`/pic/element/${attribute}.png`} alt={attribute} className="w-8 h-8"/>;
};
if (loading) return <div className="text-center py-24 text-[#E6B17E]">...</div>;
if (error || !heroDetail) return <div className="text-center py-24 text-red-400">{error || '无数据'}</div>;
const {heroRespSimpleVO, hero60AttributeVO} = heroDetail;
const renderSkillType = (type: string) => { const renderSkillType = (type: string) => {
switch (type) { switch (type) {
case 'None': case 'None':
@@ -180,18 +246,6 @@ const CharacterDetail: React.FC<CharacterDetailProps> = ({character}) => {
); );
}; };
// 属性图标占位
const statIcons: Record<string, string> = {
attack: '⚔️',
defense: '🛡️',
health: '❤️',
critChance: '🎯',
critDamage: '💥',
effectiveness: '🎲',
effectResistance: '🚫',
speed: '💨',
};
return ( return (
<div className="min-h-screen bg-[#1A1412] text-white font-sans"> <div className="min-h-screen bg-[#1A1412] text-white font-sans">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex">
@@ -199,33 +253,50 @@ const CharacterDetail: React.FC<CharacterDetailProps> = ({character}) => {
<aside className="w-80 flex-shrink-0 pr-6 flex flex-col pt-12"> <aside className="w-80 flex-shrink-0 pr-6 flex flex-col pt-12">
{/* 头像卡片 */} {/* 头像卡片 */}
<div <div
className="bg-gradient-to-br from-[#2A211E] to-[#1A1412] rounded-lg p-6 mb-12 border border-[#C17F59]/30 flex flex-col items-center"> className="bg-gradient-to-br from-[#2A211E] to-[#1A1412] rounded-lg p-6 mb-12 border border-[#C17F59]/30 flex flex-col items-center relative">
<div className="w-28 h-28 rounded-full overflow-hidden border-4 border-[#C17F59] mb-3"> <div className="w-28 h-28 rounded-full border-4 border-[#C17F59] mb-3 relative">
<img src={character.imageUrl} alt={character.name} className="w-full h-full object-cover"/> <img src={heroRespSimpleVO.headImgUrl} alt={heroRespSimpleVO.heroName} className="w-full h-full object-cover rounded-full" />
{/* 属性图标 */}
{heroRespSimpleVO.attribute && (
<img
src={`/pic/element/${heroRespSimpleVO.attribute}.png`}
alt={heroRespSimpleVO.attribute}
className="absolute -top-3 -right-3 w-10 h-10 z-10"
/>
)}
</div>
<h1 className="text-2xl font-bold text-[#E6B17E] mb-1">{heroRespSimpleVO.heroName}</h1>
<div className="flex mb-1">{renderStars(heroRespSimpleVO.stars)}</div>
{/* 职业展示 */}
<div className="flex items-center text-[#C17F59] text-base font-medium">
{heroRespSimpleVO.role && (
<>
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-[#E6B17E] mr-2">
<img src={`/pic/role/${heroRespSimpleVO.role}.png`} alt={heroRespSimpleVO.role} className="w-6 h-6" />
</span>
<span className="align-middle">{ROLE_LABELS[heroRespSimpleVO.role] || heroRespSimpleVO.role}</span>
</>
)}
</div> </div>
<h1 className="text-2xl font-bold text-[#E6B17E] mb-1">{character.name}</h1>
<div className="flex mb-1">{renderStars(character.stars)}</div>
<span className="text-[#9B8579] mb-2">{character.class}</span>
</div> </div>
{/* 导航卡片 */} {/* 导航卡片 */}
<nav className="sticky top-12 z-30"> <nav className="sticky top-12 z-30">
<div <div
className="bg-gradient-to-br from-[#2A211E] to-[#1A1412] rounded-lg p-6 border border-[#C17F59]/30"> className="bg-gradient-to-br from-[#2A211E] to-[#1A1412] rounded-lg p-6 border border-[#C17F59]/30">
<h2 className="text-lg font-bold mb-3 text-white">Table of Contents</h2> <h2 className="text-lg font-bold mb-3 text-white"> </h2>
<ul className="space-y-2"> <ul className="space-y-2">
<li> <li>
<a href="#base-stats" className="hover:text-[#E6B17E] transition">Base Stats</a> <a href="#base-stats" className="hover:text-[#E6B17E] transition"></a>
</li>
<li>
<a href="#skills" className="hover:text-[#E6B17E] transition">Skills</a>
</li>
<li>
<a href="#imprint" className="hover:text-[#E6B17E] transition">Imprint
Concentration</a>
</li> </li>
<li> <li>
<a href="#builds" className="hover:text-[#E6B17E] transition"></a> <a href="#builds" className="hover:text-[#E6B17E] transition"></a>
</li> </li>
<li>
<a href="#skills" className="hover:text-[#E6B17E] transition"></a>
</li>
<li>
<a href="#imprint" className="hover:text-[#E6B17E] transition"></a>
</li>
</ul> </ul>
</div> </div>
</nav> </nav>
@@ -237,24 +308,44 @@ const CharacterDetail: React.FC<CharacterDetailProps> = ({character}) => {
<section id="base-stats" <section id="base-stats"
className="scroll-mt-24 bg-gradient-to-br from-[#2A211E] to-[#1A1412] rounded-lg p-6 border border-[#C17F59]/30 mt-12"> className="scroll-mt-24 bg-gradient-to-br from-[#2A211E] to-[#1A1412] rounded-lg p-6 border border-[#C17F59]/30 mt-12">
<h2 className="text-xl font-bold text-[#E6B17E] mb-4"></h2> <h2 className="text-xl font-bold text-[#E6B17E] mb-4"></h2>
{hero60AttributeVO ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<i className="fas fa-sword text-[#C17F59]"></i> <img src={ATTR_ICON_MAP.attack} alt="攻击" className="w-6 h-6" />
<span>Attack: {character.baseStats.attack}</span> <span>: {hero60AttributeVO.atk}</span>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<i className="fas fa-heart text-[#C17F59]"></i> <img src={ATTR_ICON_MAP.health} alt="生命" className="w-6 h-6" />
<span>Health: {character.baseStats.health}</span> <span>: {hero60AttributeVO.hp}</span>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<i className="fas fa-shield-alt text-[#C17F59]"></i> <img src={ATTR_ICON_MAP.defense} alt="防御" className="w-6 h-6" />
<span>Defense: {character.baseStats.defense}</span> <span>: {hero60AttributeVO.def}</span>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<i className="fas fa-wind text-[#C17F59]"></i> <img src={ATTR_ICON_MAP.speed} alt="速度" className="w-6 h-6" />
<span>Speed: {character.baseStats.speed}</span> <span>: {hero60AttributeVO.spd}</span>
</div>
<div className="flex items-center space-x-2">
<img src={ATTR_ICON_MAP.critChance} alt="暴击率" className="w-6 h-6" />
<span>: {Math.round(hero60AttributeVO.chc * 100)}%</span>
</div>
<div className="flex items-center space-x-2">
<img src={ATTR_ICON_MAP.critDamage} alt="暴击伤害" className="w-6 h-6" />
<span>: {Math.round(hero60AttributeVO.chd * 100)}%</span>
</div>
<div className="flex items-center space-x-2">
<img src={ATTR_ICON_MAP.effectiveness} alt="效果命中" className="w-6 h-6" />
<span>: {Math.round(hero60AttributeVO.eff * 100)}%</span>
</div>
<div className="flex items-center space-x-2">
<img src={ATTR_ICON_MAP.effectResistance} alt="效果抗性" className="w-6 h-6" />
<span>: {Math.round(hero60AttributeVO.efr * 100)}%</span>
</div> </div>
</div> </div>
) : (
<h3 className="text-lg font-semibold text-[#C17F59] mb-2 text-center"></h3>
)}
</section> </section>
{/* 配装推荐模块 */} {/* 配装推荐模块 */}
@@ -269,8 +360,8 @@ const CharacterDetail: React.FC<CharacterDetailProps> = ({character}) => {
{Object.entries(mockAverageStats).map(([key, value]) => ( {Object.entries(mockAverageStats).map(([key, value]) => (
<div key={key} className="flex items-center justify-between py-2"> <div key={key} className="flex items-center justify-between py-2">
<div className="flex items-center gap-2 min-w-[110px]"> <div className="flex items-center gap-2 min-w-[110px]">
<span>{statIcons[key] || '?'}</span> <img src={ATTR_ICON_MAP[key] || ''} alt={key} className="w-6 h-6" />
<span className="capitalize">{key.replace(/([A-Z])/g, ' $1')}</span> <span>{ATTR_LABEL_MAP[key] || key}</span>
</div> </div>
<span className="font-bold text-[#E6B17E] ml-2"> <span className="font-bold text-[#E6B17E] ml-2">
{value}{['critChance', 'critDamage', 'effectiveness', 'effectResistance'].includes(key) ? '%' : ''} {value}{['critChance', 'critDamage', 'effectiveness', 'effectResistance'].includes(key) ? '%' : ''}
@@ -331,7 +422,7 @@ const CharacterDetail: React.FC<CharacterDetailProps> = ({character}) => {
speed: 175, speed: 175,
}, },
gearSets: ['速度', '命中'], gearSets: ['速度', '命中'],
artifact: { name: 'Elbris Ritual Sword', img: '' }, artifact: {name: 'Elbris Ritual Sword', img: ''},
}).map((build, idx) => ( }).map((build, idx) => (
<div key={idx} <div key={idx}
className="bg-gradient-to-br from-[#2A211E] to-[#1A1412] rounded-lg p-6 border border-[#C17F59]/30 flex flex-col gap-2"> className="bg-gradient-to-br from-[#2A211E] to-[#1A1412] rounded-lg p-6 border border-[#C17F59]/30 flex flex-col gap-2">
@@ -343,8 +434,8 @@ const CharacterDetail: React.FC<CharacterDetailProps> = ({character}) => {
{Object.entries(build.stats).map(([key, value]) => ( {Object.entries(build.stats).map(([key, value]) => (
<div key={key} className="flex items-center justify-between py-2"> <div key={key} className="flex items-center justify-between py-2">
<div className="flex items-center gap-2 min-w-[110px]"> <div className="flex items-center gap-2 min-w-[110px]">
<span>{statIcons[key] || '?'}</span> <img src={ATTR_ICON_MAP[key] || ''} alt={key} className="w-6 h-6" />
<span className="capitalize">{key.replace(/([A-Z])/g, ' $1')}</span> <span>{ATTR_LABEL_MAP[key] || key}</span>
</div> </div>
<span className="font-bold text-[#E6B17E] ml-2"> <span className="font-bold text-[#E6B17E] ml-2">
{value}{['critChance', 'critDamage', 'effectiveness', 'effectResistance'].includes(key) ? '%' : ''} {value}{['critChance', 'critDamage', 'effectiveness', 'effectResistance'].includes(key) ? '%' : ''}
@@ -360,101 +451,36 @@ const CharacterDetail: React.FC<CharacterDetailProps> = ({character}) => {
<div className="flex gap-3 mb-4"> <div className="flex gap-3 mb-4">
{build.gearSets.map((set, i) => ( {build.gearSets.map((set, i) => (
setIconMap[set] ? ( setIconMap[set] ? (
<img key={i} src={setIconMap[set]} alt={set} className="w-10 h-10" /> <img key={i} src={setIconMap[set]} alt={set}
className="w-10 h-10"/>
) : ( ) : (
<span key={i} className="w-10 h-10 inline-flex items-center justify-center text-[#C17F59] text-2xl bg-[#23201c] rounded-full">?</span> <span key={i}
className="w-10 h-10 inline-flex items-center justify-center text-[#C17F59] text-2xl bg-[#23201c] rounded-full">?</span>
) )
))} ))}
</div> </div>
{/* 神器图片 */} {/* 神器图片 */}
<div className="flex-1 flex flex-col items-center justify-center"> <div className="flex-1 flex flex-col items-center justify-center">
{artifactImgMap[build.artifact.name] ? ( {artifactImgMap[build.artifact.name] ? (
<img src={artifactImgMap[build.artifact.name]} alt={build.artifact.name} className="w-48 h48 rounded mb-2 object-contain" /> <img src={artifactImgMap[build.artifact.name]}
alt={build.artifact.name}
className="w-48 h48 rounded mb-2 object-contain"/>
) : build.artifact.img ? ( ) : build.artifact.img ? (
<img src={build.artifact.img} alt={build.artifact.name} className="w-24 h-24 rounded mb-2 object-contain" /> <img src={build.artifact.img} alt={build.artifact.name}
className="w-24 h-24 rounded mb-2 object-contain"/>
) : ( ) : (
<div className="w-24 h-24 rounded bg-[#2A211E] flex items-center justify-center text-3xl text-[#C17F59] mb-2">?</div> <div
className="w-24 h-24 rounded bg-[#2A211E] flex items-center justify-center text-3xl text-[#C17F59] mb-2">?</div>
)} )}
</div> </div>
{/* 神器名称 */} {/* 神器名称 */}
<span className="text-[#E6B17E] font-bold text-base text-center mt-2">{build.artifact.name}</span>
</div>
</div>
</div>
))}
</div>
</section>
{/* 技能列表 */}
<section id="skills" className="scroll-mt-24 space-y-6">
{character.skills.map((skill, index) => (
<div
key={index}
className="bg-gradient-to-br from-[#2A211E] to-[#1A1412] rounded-lg p-6 border border-[#C17F59]/30"
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-bold text-[#E6B17E]">{skill.name}</h3>
<div className="flex items-center">
{renderSkillType(skill.type)}
{renderSoulBurn(skill.soulBurn)}
</div>
</div>
<p className="text-[#9B8579] mb-4">{skill.description}</p>
{skill.enhancements && skill.enhancements.length > 0 && (
<div className="mt-4">
<h4 className="text-lg font-semibold text-[#E6B17E] mb-2">Skill
Enhancements</h4>
<div className="space-y-2">
{skill.enhancements.map((enhancement, idx) => (
<div key={idx} className="flex items-center space-x-4 text-sm">
<div className="flex">{renderStars(enhancement.stars)}</div>
<div className="flex flex-wrap gap-2">
{enhancement.attack && (
<span <span
className="text-[#9B8579]">Attack {enhancement.attack}</span> className="text-[#E6B17E] font-bold text-base text-center mt-2">{build.artifact.name}</span>
)} </div>
{enhancement.health && (
<span
className="text-[#9B8579]">Health {enhancement.health}</span>
)}
{enhancement.defense && (
<span
className="text-[#9B8579]">Defense {enhancement.defense}</span>
)}
{enhancement.effectiveness && (
<span
className="text-[#9B8579]">Effectiveness {enhancement.effectiveness}</span>
)}
</div> </div>
</div> </div>
))} ))}
</div> </div>
</div>
)}
</div>
))}
</section>
{/* 印记集中 */}
<section id="imprint"
className="scroll-mt-24 bg-gradient-to-br from-[#2A211E] to-[#1A1412] rounded-lg p-6 border border-[#C17F59]/30">
<h2 className="text-xl font-bold text-[#E6B17E] mb-4">Imprint Concentration</h2>
{character.imprintConcentration.map((imprint, index) => (
<div key={index} className="mb-4">
<h3 className="text-lg font-semibold text-[#C17F59] mb-2">{imprint.type}</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{imprint.values.map((value, idx) => (
<div
key={idx}
className="flex items-center justify-between bg-[#1A1412] p-2 rounded border border-[#C17F59]/30"
>
<span className="text-[#E6B17E]">{value.rank}</span>
<span className="text-[#9B8579]">{value.value}</span>
</div>
))}
</div>
</div>
))}
</section> </section>
</main> </main>
</div> </div>

View File

@@ -228,7 +228,7 @@ const Characters: React.FC = () => {
<div <div
key={hero.id} key={hero.id}
className="flex items-center bg-[#23201B] rounded-xl border border-[#C17F59]/30 hover:border-[#C17F59] transition-all duration-300 shadow-md px-4 py-3 min-h-[96px] max-h-[96px] cursor-pointer backdrop-blur-sm" className="flex items-center bg-[#23201B] rounded-xl border border-[#C17F59]/30 hover:border-[#C17F59] transition-all duration-300 shadow-md px-4 py-3 min-h-[96px] max-h-[96px] cursor-pointer backdrop-blur-sm"
onClick={() => navigate(`/character/${hero.id}`)} onClick={() => navigate(`/character/${hero.heroCode}`)}
> >
{/* 头像+attr */} {/* 头像+attr */}
<div className="relative mr-4 flex-shrink-0"> <div className="relative mr-4 flex-shrink-0">