feat(api): 添加图片识别功能并优化阵容搜索

- 新增图片识别接口和相关功能
-重构阵容搜索逻辑,支持两组英雄同时搜索
- 优化用户界面,增加上传图片预览和示例图片提示
-调整布局和样式,提升用户体验
This commit is contained in:
hxt
2025-05-03 20:34:37 +08:00
parent 140e2b24c3
commit 063c9bdd54
4 changed files with 411 additions and 233 deletions

8
public/favicon.svg Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="16" fill="#C17F59"/>
<path d="M16 8L20 12H12L16 8Z" fill="#E6B17E"/>
<path d="M16 24L12 20H20L16 24Z" fill="#E6B17E"/>
<path d="M8 16L12 12V20L8 16Z" fill="#E6B17E"/>
<path d="M24 16L20 20V12L24 16Z" fill="#E6B17E"/>
</svg>

After

Width:  |  Height:  |  Size: 402 B

BIN
public/tt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

View File

@@ -1,5 +1,5 @@
import {Api} from "../utils/axios/config";
import type { AxiosRequestConfig } from "axios";
import axios, {AxiosRequestConfig} from "axios";
// 查询参数接口
export interface GvgTeamQueryParams {
@@ -78,3 +78,22 @@ export const getHeroList = async () => {
const response = await Api.get<Hero[]>("/epic/hero/list-all");
return response;
};
export interface ImageRecognitionResult {
heroNames: string[]; // 6个有序的角色名称前3个为一组后3个为一组
}
// 图片识别接口
export const recognizeHeroesFromImage = async (file: File): Promise<ImageRecognitionResult> => {
try {
const response = await Api.upload<string[]>('/epic/hero/recognize', file);
console.log(response)
if (response && Array.isArray(response)) {
return { heroNames: response };
}
throw new Error("图片识别失败");
} catch (error) {
console.error('Failed to recognize heroes from image:', error);
throw error;
}
};

View File

@@ -1,4 +1,4 @@
import React, {useState, useEffect} from "react";
import React, {useState, useEffect, useRef} from "react";
import * as EpicApi from '@/api/index';
import { Character } from '@/api/types';
@@ -7,9 +7,15 @@ const Lineup: React.FC = () => {
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [heroes, setHeroes] = useState<EpicApi.Hero[]>([]);
const [selectedHeroes, setSelectedHeroes] = useState<EpicApi.Hero[]>([]);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [searchInput, setSearchInput] = useState("");
const [selectedHeroes1, setSelectedHeroes1] = useState<EpicApi.Hero[]>([]);
const [selectedHeroes2, setSelectedHeroes2] = useState<EpicApi.Hero[]>([]);
const [isDropdownOpen1, setIsDropdownOpen1] = useState(false);
const [isDropdownOpen2, setIsDropdownOpen2] = useState(false);
const [searchInput1, setSearchInput1] = useState("");
const [searchInput2, setSearchInput2] = useState("");
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [previewImage, setPreviewImage] = useState<string | null>('/tt.png');
// Fetch heroes data
useEffect(() => {
@@ -30,38 +36,43 @@ const Lineup: React.FC = () => {
handleSearch();
}, []);
const handleHeroSelect = (hero: EpicApi.Hero) => {
if (selectedHeroes.length < 3 && !selectedHeroes.find(h => h.id === hero.id)) {
setSelectedHeroes([...selectedHeroes, hero]);
setSearchInput("");
setIsDropdownOpen(false);
const handleHeroSelect = (hero: EpicApi.Hero, group: number) => {
if (group === 1) {
if (selectedHeroes1.length < 3 && !selectedHeroes1.find(h => h.id === hero.id)) {
setSelectedHeroes1([...selectedHeroes1, hero]);
setSearchInput1("");
setIsDropdownOpen1(false);
}
} else {
if (selectedHeroes2.length < 3 && !selectedHeroes2.find(h => h.id === hero.id)) {
setSelectedHeroes2([...selectedHeroes2, hero]);
setSearchInput2("");
setIsDropdownOpen2(false);
}
}
};
const handleHeroRemove = (heroId: number) => {
setSelectedHeroes(selectedHeroes.filter(h => h.id !== heroId));
const handleHeroRemove = (heroId: number, group: number) => {
if (group === 1) {
setSelectedHeroes1(selectedHeroes1.filter(h => h.id !== heroId));
} else {
setSelectedHeroes2(selectedHeroes2.filter(h => h.id !== heroId));
}
};
const handleSearch = async () => {
if (selectedHeroes.length === 0) {
// If no heroes selected, fetch all lineups
const handleSearch = async (group?: number) => {
setLoading(true);
try {
const data = await EpicApi.getGvgTeamList([]);
setLineups(data);
setError(null);
} catch (err) {
console.error('Failed to fetch lineups:', err);
setError(err instanceof Error ? err.message : '获取阵容数据失败,请稍后再试');
} finally {
setLoading(false);
}
return;
let heroCodes: string[] = [];
if (group === 1) {
heroCodes = selectedHeroes1.map(hero => hero.heroCode);
} else if (group === 2) {
heroCodes = selectedHeroes2.map(hero => hero.heroCode);
} else {
// 如果未指定组,使用第一组的英雄
heroCodes = selectedHeroes1.map(hero => hero.heroCode);
}
setLoading(true);
try {
const heroCodes = selectedHeroes.map(hero => hero.heroCode);
const data = await EpicApi.getGvgTeamList(heroCodes);
setLineups(data);
setError(null);
@@ -73,22 +84,188 @@ const Lineup: React.FC = () => {
}
};
const handleReset = () => {
setSelectedHeroes([]);
setSearchInput("");
setLineups([]);
setError(null);
handleSearch(); // Fetch all lineups after reset
const handleReset = (group: number) => {
if (group === 1) {
setSelectedHeroes1([]);
setSearchInput1("");
} else {
setSelectedHeroes2([]);
setSearchInput2("");
}
handleSearch(group);
};
const filteredHeroes = heroes.filter(hero => {
const filteredHeroes = (searchInput: string) => heroes.filter(hero => {
const searchLower = searchInput.toLowerCase();
return hero.heroName.toLowerCase().includes(searchLower) ||
(hero.nickName && hero.nickName.toLowerCase().includes(searchLower));
});
const handleImageUpload = async (file: File) => {
setIsUploading(true);
try {
// 创建预览图片URL
const previewUrl = URL.createObjectURL(file);
setPreviewImage(previewUrl);
const result = await EpicApi.recognizeHeroesFromImage(file);
// 更新第一组英雄,保持顺序
const heroes1 = result.heroNames.slice(0, 3).map(name => {
const hero = heroes.find(h => h.heroName === name);
return hero;
}).filter((hero): hero is EpicApi.Hero => hero !== undefined);
setSelectedHeroes1(heroes1);
// 更新第二组英雄,保持顺序
const heroes2 = result.heroNames.slice(3, 6).map(name => {
const hero = heroes.find(h => h.heroName === name);
return hero;
}).filter((hero): hero is EpicApi.Hero => hero !== undefined);
setSelectedHeroes2(heroes2);
// 自动触发搜索
// handleSearch();
} catch (error) {
console.error('Failed to process image:', error);
setError('图片识别失败,请重试');
} finally {
setIsUploading(false);
}
};
// 清理预览图片URL
useEffect(() => {
return () => {
if (previewImage) {
URL.revokeObjectURL(previewImage);
}
};
}, [previewImage]);
const handlePaste = async (e: React.ClipboardEvent) => {
const items = e.clipboardData.items;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
const file = items[i].getAsFile();
if (file) {
await handleImageUpload(file);
}
break;
}
}
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
await handleImageUpload(file);
}
};
const renderHeroSearch = (group: number) => {
const selectedHeroes = group === 1 ? selectedHeroes1 : selectedHeroes2;
const isDropdownOpen = group === 1 ? isDropdownOpen1 : isDropdownOpen2;
const searchInput = group === 1 ? searchInput1 : searchInput2;
const setSearchInput = group === 1 ? setSearchInput1 : setSearchInput2;
const setIsDropdownOpen = group === 1 ? setIsDropdownOpen1 : setIsDropdownOpen2;
return (
<div className="min-h-screen bg-[#1A1412] text-white font-sans relative">
<div className="relative flex gap-2">
<div
className="bg-[#1A1412] border border-[#C17F59]/30 px-4 py-2 rounded-md text-sm w-[32rem] text-[#E6B17E] cursor-pointer flex items-center justify-between h-12"
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
>
<div className="flex flex-wrap gap-2 flex-1 items-center">
{selectedHeroes.length > 0 ? (
selectedHeroes.map((hero) => (
<div
key={hero.id}
className="flex items-center gap-2 px-2 py-1 bg-[#2A211E] rounded-md"
>
<span>{hero.heroName}</span>
<button
onClick={(e) => {
e.stopPropagation();
handleHeroRemove(hero.id, group);
}}
className="text-[#C17F59] hover:text-[#E6B17E]"
>
×
</button>
</div>
))
) : (
<span className="text-[#9B8579]">3</span>
)}
</div>
<span className={`transform transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''} ml-2`}>
</span>
</div>
<button
className="bg-[#1A1412] border border-[#C17F59]/30 px-4 py-2 rounded-md text-sm text-[#E6B17E] hover:bg-[#2A211E] hover:border-[#C17F59] transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
onClick={() => handleSearch(group)}
disabled={selectedHeroes.length === 0}
>
</button>
<button
className="bg-[#1A1412] border border-[#C17F59]/30 px-4 py-2 rounded-md text-sm text-[#E6B17E] hover:bg-[#2A211E] hover:border-[#C17F59] transition-colors duration-200 whitespace-nowrap"
onClick={() => handleReset(group)}
>
</button>
{isDropdownOpen && (
<div className="absolute top-full left-0 mt-1 bg-[#1A1412] border border-[#C17F59]/30 rounded-md shadow-lg max-h-60 overflow-y-auto z-50 w-[32rem]">
<div className="p-2 border-b border-[#C17F59]/30">
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="输入英雄名称或昵称搜索..."
className="w-full px-3 py-2 bg-[#1A1412] text-[#E6B17E] border border-[#C17F59]/30 rounded-md focus:outline-none focus:border-[#C17F59]"
onClick={(e) => e.stopPropagation()}
/>
</div>
{filteredHeroes(searchInput).map((hero) => (
<div
key={hero.id}
className={`px-4 py-2 cursor-pointer ${
selectedHeroes.find(h => h.id === hero.id)
? "bg-[#2A211E] text-[#E6B17E]"
: "text-[#E6B17E] hover:bg-[#2A211E]"
} ${selectedHeroes.length >= 3 && !selectedHeroes.find(h => h.id === hero.id) ? "opacity-50 cursor-not-allowed" : ""}`}
onClick={() => {
if (selectedHeroes.length < 3 || selectedHeroes.find(h => h.id === hero.id)) {
handleHeroSelect(hero, group);
}
}}
>
<div className="flex items-center gap-2">
<img
src={hero.headImgUrl}
alt={hero.heroName}
className="w-6 h-6 rounded-full"
/>
<span>{hero.heroName}</span>
{hero.nickName && (
<span className="text-[#9B8579] text-sm">({hero.nickName})</span>
)}
</div>
</div>
))}
</div>
)}
</div>
);
};
return (
<div className="min-h-screen bg-[#1A1412] text-white font-sans relative" onPaste={handlePaste}>
{/* 背景装饰 */}
<div className="absolute inset-0 bg-gradient-to-b from-[#1A1412] via-[#2A211E] to-[#1A1412]"></div>
@@ -115,101 +292,65 @@ const Lineup: React.FC = () => {
<span></span>
</div>
{/* 英雄多选下拉框 */}
<div className="relative flex gap-2">
<div
className="bg-[#1A1412] border border-[#C17F59]/30 px-4 py-2 rounded-md text-sm w-[32rem] text-[#E6B17E] cursor-pointer flex items-center justify-between h-12"
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
>
<div className="flex flex-wrap gap-2 flex-1 items-center">
{selectedHeroes.length > 0 ? (
selectedHeroes.map((hero) => (
<div
key={hero.id}
className="flex items-center gap-2 px-2 py-1 bg-[#2A211E] rounded-md"
>
<span>{hero.heroName}</span>
<div className="flex gap-6">
<div className="flex-1">
{/* 图片上传区域 */}
<div className="flex items-center gap-4 mb-4">
<div className="flex-1">
<div className="flex items-center gap-2">
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*"
className="hidden"
/>
<button
onClick={(e) => {
e.stopPropagation();
handleHeroRemove(hero.id);
}}
className="text-[#C17F59] hover:text-[#E6B17E]"
onClick={() => fileInputRef.current?.click()}
className="bg-[#1A1412] border border-[#C17F59]/30 px-4 py-2 rounded-md text-sm text-[#E6B17E] hover:bg-[#2A211E] hover:border-[#C17F59] transition-colors duration-200 whitespace-nowrap"
>
×
</button>
</div>
))
) : (
<span className="text-[#9B8579]">3</span>
)}
</div>
<span className={`transform transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''} ml-2`}>
<span className="text-[#9B8579] text-sm">
(Ctrl+V)
</span>
</div>
<button
className="bg-[#1A1412] border border-[#C17F59]/30 px-4 py-2 rounded-md text-sm text-[#E6B17E] hover:bg-[#2A211E] hover:border-[#C17F59] transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
onClick={handleSearch}
disabled={selectedHeroes.length === 0}
>
</button>
<button
className="bg-[#1A1412] border border-[#C17F59]/30 px-4 py-2 rounded-md text-sm text-[#E6B17E] hover:bg-[#2A211E] hover:border-[#C17F59] transition-colors duration-200 whitespace-nowrap"
onClick={handleReset}
>
</button>
{isDropdownOpen && (
<div className="absolute top-full left-0 mt-1 bg-[#1A1412] border border-[#C17F59]/30 rounded-md shadow-lg max-h-60 overflow-y-auto z-50 w-[32rem]">
<div className="p-2 border-b border-[#C17F59]/30">
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="输入英雄名称或昵称搜索..."
className="w-full px-3 py-2 bg-[#1A1412] text-[#E6B17E] border border-[#C17F59]/30 rounded-md focus:outline-none focus:border-[#C17F59]"
onClick={(e) => e.stopPropagation()}
/>
</div>
{filteredHeroes.map((hero) => (
<div
key={hero.id}
className={`px-4 py-2 cursor-pointer ${
selectedHeroes.find(h => h.id === hero.id)
? "bg-[#2A211E] text-[#E6B17E]"
: "text-[#E6B17E] hover:bg-[#2A211E]"
} ${selectedHeroes.length >= 3 && !selectedHeroes.find(h => h.id === hero.id) ? "opacity-50 cursor-not-allowed" : ""}`}
onClick={() => {
if (selectedHeroes.length < 3 || selectedHeroes.find(h => h.id === hero.id)) {
handleHeroSelect(hero);
}
}}
>
<div className="flex items-center gap-2">
</div>
{/* 第一组搜索 */}
<div className="mb-4">
<h3 className="text-[#E6B17E] mb-2"> 1</h3>
{renderHeroSearch(1)}
</div>
{/* 第二组搜索 */}
<div>
<h3 className="text-[#E6B17E] mb-2"> 2</h3>
{renderHeroSearch(2)}
</div>
</div>
{/* 图片预览区域 */}
{previewImage && (
<div className="w-64 flex-shrink-0">
<h3 className="text-[#E6B17E] mb-2"></h3>
<div className="relative aspect-square rounded-lg overflow-hidden border border-[#C17F59]/30">
<img
src={hero.headImgUrl}
alt={hero.heroName}
className="w-6 h-6 rounded-full"
src={previewImage}
alt="预览图片"
className="w-full h-full object-contain"
/>
<span>{hero.heroName}</span>
{hero.nickName && (
<span className="text-[#9B8579] text-sm">({hero.nickName})</span>
)}
</div>
</div>
))}
{/* 示例图片提示 */}
{previewImage === '/tt.png'}
</div>
)}
</div>
</div>
{/* 加载状态 */}
{loading && (
{(loading || isUploading) && (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-[#E6B17E]"></div>
</div>
@@ -225,7 +366,8 @@ const Lineup: React.FC = () => {
{/* 阵容列表 */}
{!loading && (
<div className="space-y-4">
{lineups.map((lineup) => (
{lineups.length > 0 ? (
lineups.map((lineup) => (
<div
key={lineup.id}
className="bg-gradient-to-br from-[#2A211E] to-[#1A1412] rounded-lg overflow-hidden border border-[#C17F59]/30 hover:border-[#C17F59] transition-all duration-300 transform hover:-translate-y-1 hover:shadow-xl hover:shadow-[#C17F59]/10 backdrop-blur-sm"
@@ -300,7 +442,16 @@ const Lineup: React.FC = () => {
</div>
</div>
</div>
))}
))
) : (
<div className="flex flex-col items-center justify-center py-12 text-[#9B8579]">
<svg className="w-16 h-16 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-lg"></p>
<p className="text-sm mt-2"></p>
</div>
)}
</div>
)}
</div>