980 lines
47 KiB
TypeScript
980 lines
47 KiB
TypeScript
import React, {useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
|
||
import {createPortal} from 'react-dom';
|
||
import {
|
||
Avatar,
|
||
Button,
|
||
Card,
|
||
Col,
|
||
Divider,
|
||
InputNumber,
|
||
Layout,
|
||
Modal,
|
||
Pagination,
|
||
Row,
|
||
Select,
|
||
Slider,
|
||
Table,
|
||
Tag,
|
||
} from 'antd';
|
||
import {AppstoreOutlined, FilterOutlined, ReloadOutlined, UserOutlined} from '@ant-design/icons';
|
||
import * as App from '../../wailsjs/go/service/App';
|
||
import {useMessage} from '../utils/useMessage';
|
||
import {
|
||
BonusStats,
|
||
Filters,
|
||
MainStatKey,
|
||
RawHero,
|
||
RawItem,
|
||
SetFilters,
|
||
StatKey,
|
||
emptyBonusStats,
|
||
emptyFilters,
|
||
emptySetFilters,
|
||
isFourPieceSet,
|
||
isTwoPieceSet,
|
||
} from '../utils/optimizer';
|
||
import './optimizer.css';
|
||
|
||
const {Content} = Layout;
|
||
const MAX_ITEMS_PER_SLOT = 150;
|
||
const MAX_COMBOS = 200000;
|
||
const MAX_RESULTS = 200;
|
||
const GEAR_SLOTS = ['Weapon', 'Helmet', 'Armor', 'Necklace', 'Ring', 'Boots'] as const;
|
||
|
||
type BuildResult = {
|
||
key: string;
|
||
sets: string;
|
||
atk: number;
|
||
def: number;
|
||
hp: number;
|
||
spd: number;
|
||
cr: number;
|
||
cd: number;
|
||
acc: number;
|
||
res: number;
|
||
score: number;
|
||
items: RawItem[];
|
||
};
|
||
|
||
const STAT_LABELS: Array<{key: StatKey; label: string}> = [
|
||
{key: 'atk', label: '\u653b\u51fb'},
|
||
{key: 'def', label: '\u9632\u5fa1'},
|
||
{key: 'hp', label: '\u751f\u547d'},
|
||
{key: 'spd', label: '\u901f\u5ea6'},
|
||
{key: 'cr', label: '\u66b4\u51fb'},
|
||
{key: 'cd', label: '\u7206\u4f24'},
|
||
{key: 'acc', label: '\u547d\u4e2d'},
|
||
{key: 'res', label: '\u62b5\u6297'},
|
||
];
|
||
|
||
const getHeroStatValue = (hero: RawHero | null, key: StatKey) => {
|
||
if (!hero) return 0;
|
||
switch (key) {
|
||
case 'atk':
|
||
return hero.atk ?? hero.baseAtk ?? 0;
|
||
case 'def':
|
||
return hero.def ?? hero.baseDef ?? 0;
|
||
case 'hp':
|
||
return hero.hp ?? hero.baseHp ?? 0;
|
||
case 'spd':
|
||
return hero.spd ?? hero.baseSpd ?? 0;
|
||
case 'cr':
|
||
return hero.cr ?? hero.baseCr ?? 0;
|
||
case 'cd':
|
||
return hero.cd ?? hero.baseCd ?? 0;
|
||
case 'acc':
|
||
return hero.eff ?? hero.baseEff ?? 0;
|
||
case 'res':
|
||
return hero.res ?? hero.baseRes ?? 0;
|
||
default:
|
||
return 0;
|
||
}
|
||
};
|
||
|
||
export default function OptimizerPage() {
|
||
const [loading, setLoading] = useState(false);
|
||
const [parsedItems, setParsedItems] = useState<RawItem[]>([]);
|
||
const [parsedHeroes, setParsedHeroes] = useState<RawHero[]>([]);
|
||
const [selectedHeroId, setSelectedHeroId] = useState<string | number | null>(null);
|
||
const [setFilters, setSetFilters] = useState<SetFilters>(emptySetFilters);
|
||
const [statFilters, setStatFilters] = useState<Filters>(emptyFilters);
|
||
const [mainStatFilters, setMainStatFilters] = useState<{necklace: MainStatKey | 'All'; ring: MainStatKey | 'All'; boots: MainStatKey | 'All'}>({
|
||
necklace: 'All',
|
||
ring: 'All',
|
||
boots: 'All',
|
||
});
|
||
const [weightValues, setWeightValues] = useState<Record<StatKey, number>>({
|
||
atk: 3,
|
||
def: 3,
|
||
hp: 3,
|
||
spd: 3,
|
||
cr: 3,
|
||
cd: 3,
|
||
acc: 3,
|
||
res: 3,
|
||
});
|
||
const [results, setResults] = useState<BuildResult[]>([]);
|
||
const [selectedResult, setSelectedResult] = useState<BuildResult | null>(null);
|
||
const [totalCombos, setTotalCombos] = useState(0);
|
||
const {success, error, info} = useMessage();
|
||
const [bonusByHeroId, setBonusByHeroId] = useState<Record<string, BonusStats>>({});
|
||
const [bonusEditorOpen, setBonusEditorOpen] = useState(false);
|
||
const [bonusDraft, setBonusDraft] = useState<BonusStats>(emptyBonusStats);
|
||
|
||
const leftPanelsRef = useRef<HTMLDivElement | null>(null);
|
||
const [leftPanelsHeight, setLeftPanelsHeight] = useState<number | null>(null);
|
||
const renderSetBadge = (values: string[]) => (values.length === 0 ? 'All' : '已选');
|
||
|
||
const [setPickerOpen, setSetPickerOpen] = useState(false);
|
||
const [setPickerTarget, setSetPickerTarget] = useState<'set1' | 'set2' | 'set3'>('set1');
|
||
const [setPickerPos, setSetPickerPos] = useState<{top: number; left: number}>({top: 120, left: 120});
|
||
const [mainPickerOpen, setMainPickerOpen] = useState(false);
|
||
const [mainPickerTarget, setMainPickerTarget] = useState<'necklace' | 'ring' | 'boots'>('necklace');
|
||
const [mainPickerPos, setMainPickerPos] = useState<{top: number; left: number}>({top: 120, left: 120});
|
||
const bodyStyleRef = useRef<{overflow: string; paddingRight: string}>({overflow: '', paddingRight: ''});
|
||
const filterCardRef = useRef<HTMLDivElement | null>(null);
|
||
const rightPanelRef = useRef<HTMLDivElement | null>(null);
|
||
|
||
const INGAME_SET_MAP: Record<string, string> = {
|
||
set_acc: 'HitSet',
|
||
set_att: 'AttackSet',
|
||
set_coop: 'UnitySet',
|
||
set_counter: 'CounterSet',
|
||
set_cri_dmg: 'DestructionSet',
|
||
set_cri: 'CriticalSet',
|
||
set_def: 'DefenseSet',
|
||
set_immune: 'ImmunitySet',
|
||
set_max_hp: 'HealthSet',
|
||
set_penetrate: 'PenetrationSet',
|
||
set_rage: 'RageSet',
|
||
set_res: 'ResistSet',
|
||
set_revenge: 'RevengeSet',
|
||
set_scar: 'InjurySet',
|
||
set_speed: 'SpeedSet',
|
||
set_vampire: 'LifestealSet',
|
||
set_shield: 'ProtectionSet',
|
||
set_torrent: 'TorrentSet',
|
||
set_revenant: 'ReversalSet',
|
||
set_riposte: 'RiposteSet',
|
||
set_chase: 'PursuitSet',
|
||
set_opener: 'WarfareSet',
|
||
};
|
||
|
||
const setPickerOptions = [
|
||
{key: 'All', label: '全部', setKey: ''},
|
||
{key: 'AttackSet', label: '攻击套装', setKey: 'AttackSet'},
|
||
{key: 'DefenseSet', label: '防御套装', setKey: 'DefenseSet'},
|
||
{key: 'HealthSet', label: '生命值套装', setKey: 'HealthSet'},
|
||
{key: 'SpeedSet', label: '速度套装', setKey: 'SpeedSet'},
|
||
{key: 'CriticalSet', label: '暴击套装', setKey: 'CriticalSet'},
|
||
{key: 'HitSet', label: '命中套装', setKey: 'HitSet'},
|
||
{key: 'ResistSet', label: '抵抗套装', setKey: 'ResistSet'},
|
||
{key: 'LifestealSet', label: '吸血套装', setKey: 'LifestealSet'},
|
||
{key: 'UnitySet', label: '夹攻套装', setKey: 'UnitySet'},
|
||
{key: 'RageSet', label: '愤怒套装', setKey: 'RageSet'},
|
||
{key: 'RevengeSet', label: '憨恨套装', setKey: 'RevengeSet'},
|
||
{key: 'DestructionSet', label: '破灭套装', setKey: 'DestructionSet'},
|
||
{key: 'CounterSet', label: '反击套装', setKey: 'CounterSet'},
|
||
{key: 'ImmunitySet', label: '免疫套装', setKey: 'ImmunitySet'},
|
||
{key: 'PenetrationSet', label: '穿透套装', setKey: 'PenetrationSet'},
|
||
{key: 'InjurySet', label: '伤口套装', setKey: 'InjurySet'},
|
||
{key: 'ProtectionSet', label: '守护套装', setKey: 'ProtectionSet'},
|
||
{key: 'TorrentSet', label: '激流套装', setKey: 'TorrentSet'},
|
||
{key: 'ReversalSet', label: '逆袭套装', setKey: 'ReversalSet'},
|
||
{key: 'RiposteSet', label: '回击套装', setKey: 'RiposteSet'},
|
||
{key: 'WarfareSet', label: '开战套装', setKey: 'WarfareSet'},
|
||
{key: 'PursuitSet', label: '追击套装', setKey: 'PursuitSet'},
|
||
];
|
||
|
||
const setPickerRows = Math.ceil(setPickerOptions.length / 2);
|
||
const setPickerListHeight = 28 + setPickerRows * 46 + Math.max(0, setPickerRows - 1) * 8;
|
||
|
||
|
||
const setCounts = useMemo(() => {
|
||
const counts: Record<string, number> = {};
|
||
let total = 0;
|
||
parsedItems.forEach(item => {
|
||
const setKey = item.set || (item.f ? INGAME_SET_MAP[item.f as string] : undefined);
|
||
if (!setKey) return;
|
||
counts[setKey] = (counts[setKey] || 0) + 1;
|
||
total += 1;
|
||
});
|
||
counts[''] = total;
|
||
return counts;
|
||
}, [parsedItems]);
|
||
|
||
|
||
const getSetLabel = (values: string[]) => {
|
||
const key = values[0];
|
||
if (!key) return '套装';
|
||
const option = setPickerOptions.find(item => item.setKey === key);
|
||
return option ? option.label : '套装';
|
||
};
|
||
|
||
const openSetPicker = (target: 'set1' | 'set2' | 'set3', event: React.MouseEvent<HTMLElement>) => {
|
||
const modalWidth = 520;
|
||
const modalHeight = Math.min(window.innerHeight - 20, setPickerListHeight);
|
||
const offset = 0;
|
||
let left = 0;
|
||
let top = 0;
|
||
|
||
const cardRect = filterCardRef.current?.getBoundingClientRect();
|
||
const rightPanelRect = rightPanelRef.current?.getBoundingClientRect();
|
||
if (rightPanelRect) {
|
||
left = rightPanelRect.left - modalWidth - offset;
|
||
top = rightPanelRect.top;
|
||
} else {
|
||
const rect = event.currentTarget.getBoundingClientRect();
|
||
left = rect.left - modalWidth - offset;
|
||
top = rect.top - offset;
|
||
}
|
||
|
||
if (left < 0) {
|
||
left = 0;
|
||
}
|
||
if (top < 0) {
|
||
top = 0;
|
||
}
|
||
if (top + modalHeight > window.innerHeight) {
|
||
top = Math.max(0, window.innerHeight - modalHeight);
|
||
}
|
||
|
||
setSetPickerTarget(target);
|
||
setSetPickerPos({top, left});
|
||
setSetPickerOpen(true);
|
||
};
|
||
|
||
const openMainPicker = (
|
||
target: 'necklace' | 'ring' | 'boots',
|
||
event: React.MouseEvent<HTMLElement>
|
||
) => {
|
||
const modalWidth = 300;
|
||
const modalHeight = Math.min(520, 88 + getMainStatOptions(target).length * 36);
|
||
const offset = 0;
|
||
let left = 0;
|
||
let top = 0;
|
||
|
||
const rightPanelRect = rightPanelRef.current?.getBoundingClientRect();
|
||
if (rightPanelRect) {
|
||
left = rightPanelRect.left - modalWidth - offset;
|
||
top = rightPanelRect.top + 120;
|
||
} else {
|
||
const rect = event.currentTarget.getBoundingClientRect();
|
||
left = rect.left - modalWidth - offset;
|
||
top = rect.top - offset;
|
||
}
|
||
|
||
if (left < 0) {
|
||
left = 0;
|
||
}
|
||
if (top < 0) {
|
||
top = 0;
|
||
}
|
||
if (top + modalHeight > window.innerHeight) {
|
||
top = Math.max(0, window.innerHeight - modalHeight);
|
||
}
|
||
|
||
setMainPickerTarget(target);
|
||
setMainPickerPos({top, left});
|
||
setMainPickerOpen(true);
|
||
};
|
||
|
||
const heroOptions = useMemo(() => {
|
||
return parsedHeroes.map(hero => ({
|
||
label: hero.name || String(hero.id || ''),
|
||
value: hero.id || '',
|
||
}));
|
||
}, [parsedHeroes]);
|
||
|
||
const getMainStatOptions = (target: 'necklace' | 'ring' | 'boots'): Array<{label: string; value: MainStatKey | 'All'}> => {
|
||
if (target === 'necklace') {
|
||
return [
|
||
{label: '\u5168\u90e8', value: 'All'},
|
||
{label: '\u653b\u51fb\u529b', value: 'Attack'},
|
||
{label: '\u9632\u5fa1\u529b', value: 'Defense'},
|
||
{label: '\u751f\u547d\u503c', value: 'Health'},
|
||
{label: '\u653b\u51fb\u529b%', value: 'AttackPercent'},
|
||
{label: '\u9632\u5fa1\u529b%', value: 'DefensePercent'},
|
||
{label: '\u751f\u547d\u503c%', value: 'HealthPercent'},
|
||
{label: '\u66b4\u51fb\u7387', value: 'CriticalHitChancePercent'},
|
||
{label: '\u66b4\u51fb\u4f24\u5bb3', value: 'CriticalHitDamagePercent'},
|
||
];
|
||
}
|
||
if (target === 'ring') {
|
||
return [
|
||
{label: '\u5168\u90e8', value: 'All'},
|
||
{label: '\u653b\u51fb\u529b', value: 'Attack'},
|
||
{label: '\u9632\u5fa1\u529b', value: 'Defense'},
|
||
{label: '\u751f\u547d\u503c', value: 'Health'},
|
||
{label: '\u653b\u51fb\u529b%', value: 'AttackPercent'},
|
||
{label: '\u9632\u5fa1\u529b%', value: 'DefensePercent'},
|
||
{label: '\u751f\u547d\u503c%', value: 'HealthPercent'},
|
||
{label: '\u6548\u679c\u547d\u4e2d', value: 'EffectivenessPercent'},
|
||
{label: '\u6548\u679c\u6297\u6027', value: 'EffectResistancePercent'},
|
||
];
|
||
}
|
||
return [
|
||
{label: '\u5168\u90e8', value: 'All'},
|
||
{label: '\u653b\u51fb\u529b', value: 'Attack'},
|
||
{label: '\u9632\u5fa1\u529b', value: 'Defense'},
|
||
{label: '\u751f\u547d\u503c', value: 'Health'},
|
||
{label: '\u653b\u51fb\u529b%', value: 'AttackPercent'},
|
||
{label: '\u9632\u5fa1\u529b%', value: 'DefensePercent'},
|
||
{label: '\u751f\u547d\u503c%', value: 'HealthPercent'},
|
||
{label: '\u901f\u5ea6', value: 'Speed'},
|
||
];
|
||
};
|
||
|
||
const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStatKey | 'All') => {
|
||
const option = getMainStatOptions(target).find(item => item.value === value);
|
||
return option ? option.label : 'All';
|
||
};
|
||
|
||
const selectedHero = useMemo(() => {
|
||
return parsedHeroes.find(hero => hero.id === selectedHeroId) || null;
|
||
}, [parsedHeroes, selectedHeroId]);
|
||
|
||
const selectedHeroKey = selectedHeroId == null ? '' : String(selectedHeroId);
|
||
const selectedHeroBonus = bonusByHeroId[selectedHeroKey] || emptyBonusStats;
|
||
|
||
const loadLatestData = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const [parsed, templates] = await Promise.all([
|
||
App.GetLatestParsedDataFromDatabase(),
|
||
App.GetHeroTemplates(),
|
||
]);
|
||
const items = (parsed?.items || []) as RawItem[];
|
||
const heroTemplates = (templates || []).map(t => ({
|
||
id: t.code,
|
||
code: t.code,
|
||
name: t.name,
|
||
baseAtk: t.baseAtk,
|
||
baseDef: t.baseDef,
|
||
baseHp: t.baseHp,
|
||
baseSpd: t.baseSpd,
|
||
baseCr: t.baseCr,
|
||
baseCd: t.baseCd,
|
||
baseEff: t.baseEff,
|
||
baseRes: t.baseRes,
|
||
atk: t.baseAtk,
|
||
def: t.baseDef,
|
||
hp: t.baseHp,
|
||
spd: t.baseSpd,
|
||
cr: t.baseCr,
|
||
cd: t.baseCd,
|
||
eff: t.baseEff,
|
||
res: t.baseRes,
|
||
})) as RawHero[];
|
||
setParsedItems(items);
|
||
setParsedHeroes(heroTemplates);
|
||
if (heroTemplates.length > 0) {
|
||
setSelectedHeroId(heroTemplates[0].id || '');
|
||
}
|
||
if (items.length === 0 && heroTemplates.length === 0) {
|
||
info('暂无解析数据');
|
||
} else {
|
||
success('数据加载成功');
|
||
}
|
||
} catch (err) {
|
||
error('加载数据失败');
|
||
console.error('Load optimizer data error:', err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadLatestData();
|
||
}, []);
|
||
|
||
useLayoutEffect(() => {
|
||
if (!leftPanelsRef.current) return;
|
||
const observer = new ResizeObserver(entries => {
|
||
for (const entry of entries) {
|
||
const next = Math.round(entry.contentRect.height);
|
||
setLeftPanelsHeight(next > 0 ? next : null);
|
||
}
|
||
});
|
||
observer.observe(leftPanelsRef.current);
|
||
return () => observer.disconnect();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (setPickerOpen) {
|
||
bodyStyleRef.current = {
|
||
overflow: document.body.style.overflow,
|
||
paddingRight: document.body.style.paddingRight,
|
||
};
|
||
document.body.style.overflow = 'auto';
|
||
document.body.style.paddingRight = '';
|
||
} else {
|
||
document.body.style.overflow = bodyStyleRef.current.overflow;
|
||
document.body.style.paddingRight = bodyStyleRef.current.paddingRight;
|
||
}
|
||
}, [setPickerOpen]);
|
||
|
||
const resetSetsAndAttrs = () => {
|
||
setSetFilters(emptySetFilters);
|
||
setStatFilters(emptyFilters);
|
||
setMainStatFilters({necklace: 'All', ring: 'All', boots: 'All'});
|
||
setResults([]);
|
||
setSelectedResult(null);
|
||
setTotalCombos(0);
|
||
};
|
||
|
||
const resetPreferences = () => {
|
||
setWeightValues({
|
||
atk: 3,
|
||
def: 3,
|
||
hp: 3,
|
||
spd: 3,
|
||
cr: 3,
|
||
cd: 3,
|
||
acc: 3,
|
||
res: 3,
|
||
});
|
||
setResults([]);
|
||
setSelectedResult(null);
|
||
setTotalCombos(0);
|
||
};
|
||
|
||
const buildResults = async () => {
|
||
console.log('OptimizerPage buildResults clicked');
|
||
if (!selectedHero) {
|
||
info('\u8bf7\u5148\u9009\u62e9\u82f1\u96c4');
|
||
return;
|
||
}
|
||
if (parsedItems.length === 0) {
|
||
info('\u6682\u65e0\u88c5\u5907\u6570\u636e');
|
||
return;
|
||
}
|
||
try {
|
||
console.log('OptimizeBuilds calling', {
|
||
heroId: String(selectedHero.id ?? ''),
|
||
setFilters,
|
||
statFilters,
|
||
mainStatFilters,
|
||
weightValues,
|
||
bonusStats: selectedHeroBonus,
|
||
});
|
||
const resp = await App.OptimizeBuilds({
|
||
heroId: String(selectedHero.code ?? ''),
|
||
setFilters,
|
||
statFilters,
|
||
mainStatFilters,
|
||
weightValues,
|
||
bonusStats: selectedHeroBonus,
|
||
maxItemsPerSlot: MAX_ITEMS_PER_SLOT,
|
||
maxCombos: MAX_COMBOS,
|
||
maxResults: MAX_RESULTS,
|
||
} as any);
|
||
console.log('OptimizeBuilds returned', resp);
|
||
const nextResults = (resp?.results || []) as BuildResult[];
|
||
setTotalCombos(resp?.totalCombos || 0);
|
||
setResults(nextResults);
|
||
setSelectedResult(nextResults[0] || null);
|
||
if (nextResults.length > 0) {
|
||
success(`\u914d\u88c5\u5b8c\u6210\uff0c\u5171 ${nextResults.length} \u6761\u7ed3\u679c`);
|
||
}
|
||
if (nextResults.length === 0) {
|
||
info('\u6ca1\u6709\u7b26\u5408\u6761\u4ef6\u7684\u914d\u88c5\u7ed3\u679c');
|
||
}
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : '\u914d\u88c5\u5931\u8d25';
|
||
error(msg);
|
||
console.error('OptimizeBuilds error:', err);
|
||
}
|
||
};
|
||
|
||
const resultColumns = [
|
||
{title: '套装', dataIndex: 'sets', key: 'sets', render: (v: string) => <Tag>{v}</Tag>},
|
||
{title: '攻击', dataIndex: 'atk', key: 'atk'},
|
||
{title: '防御', dataIndex: 'def', key: 'def'},
|
||
{title: '生命', dataIndex: 'hp', key: 'hp'},
|
||
{title: '速度', dataIndex: 'spd', key: 'spd'},
|
||
{title: '暴击', dataIndex: 'cr', key: 'cr'},
|
||
{title: '爆伤', dataIndex: 'cd', key: 'cd'},
|
||
{title: '命中', dataIndex: 'acc', key: 'acc'},
|
||
{title: '抵抗', dataIndex: 'res', key: 'res'},
|
||
];
|
||
|
||
return (
|
||
<>
|
||
<Layout style={{minHeight: '100vh'}}>
|
||
<Content style={{padding: 16}}>
|
||
<Card style={{marginBottom: 16, position: 'relative'}} ref={filterCardRef}>
|
||
<Row gutter={16} align="top">
|
||
<Col span={6} style={{display: 'flex', alignItems: 'flex-start'}}>
|
||
<Avatar size={64} icon={<UserOutlined/>}/>
|
||
<div style={{display: 'flex', flexDirection: 'column', gap: 8, marginLeft: 16}}>
|
||
<div style={{fontWeight: 500}}>英雄</div>
|
||
<Select
|
||
style={{width: 180}}
|
||
value={selectedHeroId ?? undefined}
|
||
onChange={value => setSelectedHeroId(value)}
|
||
options={heroOptions}
|
||
placeholder="请选择英雄"
|
||
/>
|
||
<Button
|
||
size="small"
|
||
onClick={() => {
|
||
if (!selectedHeroKey) return;
|
||
setBonusDraft({...selectedHeroBonus});
|
||
setBonusEditorOpen(true);
|
||
}}
|
||
>
|
||
{'\u7f16\u8f91\u989d\u5916\u52a0\u6210'}
|
||
</Button>
|
||
</div>
|
||
</Col>
|
||
<Col span={6}>
|
||
<div style={{fontWeight: 500, marginBottom: 8}}>属性</div>
|
||
<div style={{display: 'flex', flexDirection: 'column', gap: 8}}>
|
||
{STAT_LABELS.map(stat => (
|
||
<div key={stat.key}>
|
||
{stat.label}: <b>{getHeroStatValue(selectedHero, stat.key)}</b>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Col>
|
||
<Col span={6}>
|
||
<div style={{fontWeight: 500, marginBottom: 8}}>属性过滤</div>
|
||
<div style={{display: 'flex', flexDirection: 'column', gap: 8}}>
|
||
{STAT_LABELS.map(stat => (
|
||
<div key={stat.key} style={{display: 'flex', alignItems: 'center', gap: 4}}>
|
||
<span style={{minWidth: 36}}>{stat.label}</span>
|
||
<InputNumber
|
||
size="small"
|
||
placeholder="最小"
|
||
style={{width: 72}}
|
||
value={statFilters[stat.key].min}
|
||
onChange={value => {
|
||
setStatFilters(prev => ({
|
||
...prev,
|
||
[stat.key]: {...prev[stat.key], min: value ?? undefined},
|
||
}));
|
||
}}
|
||
/>
|
||
<span>~</span>
|
||
<InputNumber
|
||
size="small"
|
||
placeholder="最大"
|
||
style={{width: 72}}
|
||
value={statFilters[stat.key].max}
|
||
onChange={value => {
|
||
setStatFilters(prev => ({
|
||
...prev,
|
||
[stat.key]: {...prev[stat.key], max: value ?? undefined},
|
||
}));
|
||
}}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Col>
|
||
<Col
|
||
span={6}
|
||
style={{display: 'flex', flexDirection: 'column', justifyContent: 'flex-start', gap: 10}}
|
||
ref={rightPanelRef}
|
||
>
|
||
<div className="optimizer-side-panel">
|
||
<div className="optimizer-left-panels" ref={leftPanelsRef}>
|
||
<div className="optimizer-set-panel">
|
||
<div className="optimizer-set-row optimizer-set-row-tight">
|
||
<span className="optimizer-set-badge">{renderSetBadge(setFilters.set1)}</span>
|
||
<Button
|
||
className="optimizer-set-button"
|
||
onClick={(event) => openSetPicker('set1', event)}
|
||
>
|
||
{getSetLabel(setFilters.set1)}
|
||
</Button>
|
||
</div>
|
||
<div className="optimizer-set-row optimizer-set-row-tight">
|
||
<span className="optimizer-set-badge">{renderSetBadge(setFilters.set2)}</span>
|
||
<Button
|
||
className="optimizer-set-button"
|
||
disabled={setFilters.set1.length === 0}
|
||
onClick={(event) => openSetPicker('set2', event)}
|
||
>
|
||
{getSetLabel(setFilters.set2)}
|
||
</Button>
|
||
</div>
|
||
<div className="optimizer-set-row optimizer-set-row-tight">
|
||
<span className="optimizer-set-badge">{renderSetBadge(setFilters.set3)}</span>
|
||
<Button
|
||
className="optimizer-set-button"
|
||
disabled={setFilters.set1.some(isFourPieceSet) || setFilters.set2.length === 0}
|
||
onClick={(event) => openSetPicker('set3', event)}
|
||
>
|
||
{getSetLabel(setFilters.set3)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="optimizer-set-panel">
|
||
<div className="optimizer-set-row">
|
||
<span className="optimizer-set-badge">项链</span>
|
||
<Button
|
||
className="optimizer-set-button"
|
||
onClick={(event) => openMainPicker('necklace', event)}
|
||
>
|
||
{getMainStatLabel('necklace', mainStatFilters.necklace)}
|
||
</Button>
|
||
</div>
|
||
<div className="optimizer-set-row">
|
||
<span className="optimizer-set-badge">戒指</span>
|
||
<Button
|
||
className="optimizer-set-button"
|
||
onClick={(event) => openMainPicker('ring', event)}
|
||
>
|
||
{getMainStatLabel('ring', mainStatFilters.ring)}
|
||
</Button>
|
||
</div>
|
||
<div className="optimizer-set-row">
|
||
<span className="optimizer-set-badge">鞋子</span>
|
||
<Button
|
||
className="optimizer-set-button"
|
||
onClick={(event) => openMainPicker('boots', event)}
|
||
>
|
||
{getMainStatLabel('boots', mainStatFilters.boots)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="optimizer-weight-panel" style={leftPanelsHeight ? {height: leftPanelsHeight} : undefined}>
|
||
<div className="optimizer-weight-row">
|
||
<span className="optimizer-weight-icon">攻</span>
|
||
<span className="optimizer-weight-label">攻击</span>
|
||
<Slider
|
||
min={0}
|
||
max={5}
|
||
step={1}
|
||
dots
|
||
className="optimizer-weight-slider"
|
||
value={weightValues.atk}
|
||
onChange={value => setWeightValues(prev => ({...prev, atk: value as number}))}
|
||
/>
|
||
</div>
|
||
<div className="optimizer-weight-row">
|
||
<span className="optimizer-weight-icon">防</span>
|
||
<span className="optimizer-weight-label">防御</span>
|
||
<Slider
|
||
min={0}
|
||
max={5}
|
||
step={1}
|
||
dots
|
||
className="optimizer-weight-slider"
|
||
value={weightValues.def}
|
||
onChange={value => setWeightValues(prev => ({...prev, def: value as number}))}
|
||
/>
|
||
</div>
|
||
<div className="optimizer-weight-row">
|
||
<span className="optimizer-weight-icon">生</span>
|
||
<span className="optimizer-weight-label">生命</span>
|
||
<Slider
|
||
min={0}
|
||
max={5}
|
||
step={1}
|
||
dots
|
||
className="optimizer-weight-slider"
|
||
value={weightValues.hp}
|
||
onChange={value => setWeightValues(prev => ({...prev, hp: value as number}))}
|
||
/>
|
||
</div>
|
||
<div className="optimizer-weight-row">
|
||
<span className="optimizer-weight-icon">速</span>
|
||
<span className="optimizer-weight-label">速度</span>
|
||
<Slider
|
||
min={0}
|
||
max={5}
|
||
step={1}
|
||
dots
|
||
className="optimizer-weight-slider"
|
||
value={weightValues.spd}
|
||
onChange={value => setWeightValues(prev => ({...prev, spd: value as number}))}
|
||
/>
|
||
</div>
|
||
<div className="optimizer-weight-row">
|
||
<span className="optimizer-weight-icon">暴</span>
|
||
<span className="optimizer-weight-label">暴击</span>
|
||
<Slider
|
||
min={0}
|
||
max={5}
|
||
step={1}
|
||
dots
|
||
className="optimizer-weight-slider"
|
||
value={weightValues.cr}
|
||
onChange={value => setWeightValues(prev => ({...prev, cr: value as number}))}
|
||
/>
|
||
</div>
|
||
<div className="optimizer-weight-row">
|
||
<span className="optimizer-weight-icon">伤</span>
|
||
<span className="optimizer-weight-label">爆伤</span>
|
||
<Slider
|
||
min={0}
|
||
max={5}
|
||
step={1}
|
||
dots
|
||
className="optimizer-weight-slider"
|
||
value={weightValues.cd}
|
||
onChange={value => setWeightValues(prev => ({...prev, cd: value as number}))}
|
||
/>
|
||
</div>
|
||
<div className="optimizer-weight-row">
|
||
<span className="optimizer-weight-icon">命</span>
|
||
<span className="optimizer-weight-label">命中</span>
|
||
<Slider
|
||
min={0}
|
||
max={5}
|
||
step={1}
|
||
dots
|
||
className="optimizer-weight-slider"
|
||
value={weightValues.acc}
|
||
onChange={value => setWeightValues(prev => ({...prev, acc: value as number}))}
|
||
/>
|
||
</div>
|
||
<div className="optimizer-weight-row">
|
||
<span className="optimizer-weight-icon">抗</span>
|
||
<span className="optimizer-weight-label">抵抗</span>
|
||
<Slider
|
||
min={0}
|
||
max={5}
|
||
step={1}
|
||
dots
|
||
className="optimizer-weight-slider"
|
||
value={weightValues.res}
|
||
onChange={value => setWeightValues(prev => ({...prev, res: value as number}))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style={{display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%'}}>
|
||
<Button icon={<ReloadOutlined/>} onClick={loadLatestData} loading={loading} style={{height: 32}}>
|
||
重新加载数据
|
||
</Button>
|
||
<Button icon={<ReloadOutlined/>} onClick={resetSetsAndAttrs} style={{height: 32}}>
|
||
{'\u91cd\u7f6e\u5957\u88c5'}
|
||
</Button>
|
||
<Button icon={<FilterOutlined/>} onClick={resetPreferences} style={{height: 32}}>
|
||
{'\u91cd\u7f6e\u504f\u597d'}
|
||
</Button>
|
||
<Button type="primary" icon={<AppstoreOutlined/>} onClick={buildResults} style={{height: 32}}>
|
||
{'\u5f00\u59cb\u914d\u88c5'}
|
||
</Button>
|
||
</div>
|
||
</Col>
|
||
</Row>
|
||
</Card>
|
||
|
||
<Card title="配装结果" style={{marginBottom: 16}}>
|
||
<div style={{display: 'flex', justifyContent: 'space-between', marginBottom: 8}}>
|
||
<div>
|
||
组合数量:<b>{totalCombos.toLocaleString()}</b> |
|
||
结果数量:<b>{results.length.toLocaleString()}</b>
|
||
</div>
|
||
<Pagination
|
||
size="small"
|
||
total={results.length}
|
||
pageSize={10}
|
||
current={1}
|
||
showSizeChanger={false}
|
||
/>
|
||
</div>
|
||
<Table
|
||
dataSource={results}
|
||
columns={resultColumns}
|
||
pagination={{pageSize: 10}}
|
||
scroll={{x: true}}
|
||
rowKey="key"
|
||
onRow={record => ({
|
||
onClick: () => setSelectedResult(record),
|
||
})}
|
||
/>
|
||
</Card>
|
||
|
||
<Card title="配装详情">
|
||
{selectedResult ? (
|
||
<div style={{display: 'flex', gap: 24}}>
|
||
<div>
|
||
<Avatar size={48} icon={<UserOutlined/>}/>
|
||
<div style={{marginTop: 8}}>{selectedHero?.name || '未知英雄'}</div>
|
||
</div>
|
||
<Divider type="vertical" style={{height: 80}}/>
|
||
<div>
|
||
<div>
|
||
套装:<Tag color="blue">{selectedResult.sets}</Tag>
|
||
</div>
|
||
<div>
|
||
攻击:{selectedResult.atk},防御:{selectedResult.def},生命:
|
||
{selectedResult.hp},速度:{selectedResult.spd}
|
||
</div>
|
||
<div>
|
||
暴击:{selectedResult.cr}% ,爆伤:{selectedResult.cd}% ,命中:
|
||
{selectedResult.acc}% ,抵抗:{selectedResult.res}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div style={{color: '#999'}}>暂无配装结果</div>
|
||
)}
|
||
</Card>
|
||
</Content>
|
||
</Layout>
|
||
<Modal
|
||
open={bonusEditorOpen}
|
||
title={'\u989d\u5916\u52a0\u6210'}
|
||
onCancel={() => setBonusEditorOpen(false)}
|
||
onOk={() => {
|
||
if (!selectedHeroKey) return;
|
||
setBonusByHeroId(prev => ({...prev, [selectedHeroKey]: bonusDraft}));
|
||
setBonusEditorOpen(false);
|
||
}}
|
||
>
|
||
<div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12}}>
|
||
<div>
|
||
<div>{'\u653b\u51fb'}</div>
|
||
<InputNumber value={bonusDraft.atk} onChange={v => setBonusDraft(prev => ({...prev, atk: Number(v || 0)}))} style={{width: '100%'}} />
|
||
</div>
|
||
<div>
|
||
<div>{'\u653b\u51fb%'}</div>
|
||
<InputNumber value={bonusDraft.atkPct} onChange={v => setBonusDraft(prev => ({...prev, atkPct: Number(v || 0)}))} style={{width: '100%'}} />
|
||
</div>
|
||
<div>
|
||
<div>{'\u9632\u5fa1'}</div>
|
||
<InputNumber value={bonusDraft.def} onChange={v => setBonusDraft(prev => ({...prev, def: Number(v || 0)}))} style={{width: '100%'}} />
|
||
</div>
|
||
<div>
|
||
<div>{'\u9632\u5fa1%'}</div>
|
||
<InputNumber value={bonusDraft.defPct} onChange={v => setBonusDraft(prev => ({...prev, defPct: Number(v || 0)}))} style={{width: '100%'}} />
|
||
</div>
|
||
<div>
|
||
<div>{'\u751f\u547d'}</div>
|
||
<InputNumber value={bonusDraft.hp} onChange={v => setBonusDraft(prev => ({...prev, hp: Number(v || 0)}))} style={{width: '100%'}} />
|
||
</div>
|
||
<div>
|
||
<div>{'\u751f\u547d%'}</div>
|
||
<InputNumber value={bonusDraft.hpPct} onChange={v => setBonusDraft(prev => ({...prev, hpPct: Number(v || 0)}))} style={{width: '100%'}} />
|
||
</div>
|
||
<div>
|
||
<div>{'\u901f\u5ea6'}</div>
|
||
<InputNumber value={bonusDraft.spd} onChange={v => setBonusDraft(prev => ({...prev, spd: Number(v || 0)}))} style={{width: '100%'}} />
|
||
</div>
|
||
<div>
|
||
<div>{'\u66b4\u51fb\u7387'}</div>
|
||
<InputNumber value={bonusDraft.cr} onChange={v => setBonusDraft(prev => ({...prev, cr: Number(v || 0)}))} style={{width: '100%'}} />
|
||
</div>
|
||
<div>
|
||
<div>{'\u66b4\u51fb\u4f24\u5bb3'}</div>
|
||
<InputNumber value={bonusDraft.cd} onChange={v => setBonusDraft(prev => ({...prev, cd: Number(v || 0)}))} style={{width: '100%'}} />
|
||
</div>
|
||
<div>
|
||
<div>{'\u6548\u679c\u547d\u4e2d'}</div>
|
||
<InputNumber value={bonusDraft.eff} onChange={v => setBonusDraft(prev => ({...prev, eff: Number(v || 0)}))} style={{width: '100%'}} />
|
||
</div>
|
||
<div>
|
||
<div>{'\u6548\u679c\u6297\u6027'}</div>
|
||
<InputNumber value={bonusDraft.res} onChange={v => setBonusDraft(prev => ({...prev, res: Number(v || 0)}))} style={{width: '100%'}} />
|
||
</div>
|
||
<div>
|
||
<div>{'\u6700\u7ec8\u653b\u51fb\u500d\u7387%'}</div>
|
||
<InputNumber value={bonusDraft.finalAtkMultiplier} onChange={v => setBonusDraft(prev => ({...prev, finalAtkMultiplier: Number(v || 0)}))} style={{width: '100%'}} />
|
||
</div>
|
||
<div>
|
||
<div>{'\u6700\u7ec8\u9632\u5fa1\u500d\u7387%'}</div>
|
||
<InputNumber value={bonusDraft.finalDefMultiplier} onChange={v => setBonusDraft(prev => ({...prev, finalDefMultiplier: Number(v || 0)}))} style={{width: '100%'}} />
|
||
</div>
|
||
<div>
|
||
<div>{'\u6700\u7ec8\u751f\u547d\u500d\u7387%'}</div>
|
||
<InputNumber value={bonusDraft.finalHpMultiplier} onChange={v => setBonusDraft(prev => ({...prev, finalHpMultiplier: Number(v || 0)}))} style={{width: '100%'}} />
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
{setPickerOpen &&
|
||
createPortal(
|
||
<div
|
||
className="optimizer-set-popover-overlay"
|
||
onMouseDown={() => setSetPickerOpen(false)}
|
||
>
|
||
<div
|
||
className="optimizer-set-popover"
|
||
style={{top: setPickerPos.top, left: setPickerPos.left}}
|
||
onMouseDown={(event) => event.stopPropagation()}
|
||
>
|
||
<div className="optimizer-set-modal-list" style={{maxHeight: setPickerListHeight}}>
|
||
{setPickerOptions.map(option => (
|
||
<button
|
||
key={option.key}
|
||
className="optimizer-set-modal-item"
|
||
onClick={() => {
|
||
const nextValue = option.setKey ? [option.setKey] : [];
|
||
if (setPickerTarget === 'set1') {
|
||
const hasFour = nextValue.some(isFourPieceSet);
|
||
const hasTwo = nextValue.some(isTwoPieceSet);
|
||
if (hasFour && hasTwo) {
|
||
info('第一组只能选择纯4件套或纯2件套');
|
||
return;
|
||
}
|
||
setSetFilters({set1: nextValue, set2: [], set3: []});
|
||
} else if (setPickerTarget === 'set2') {
|
||
if (nextValue.some(isFourPieceSet)) {
|
||
info('第二组仅支持2件套');
|
||
return;
|
||
}
|
||
setSetFilters(prev => ({...prev, set2: nextValue, set3: []}));
|
||
} else {
|
||
if (nextValue.some(isFourPieceSet)) {
|
||
info('第三组仅支持2件套');
|
||
return;
|
||
}
|
||
setSetFilters(prev => ({...prev, set3: nextValue}));
|
||
}
|
||
setSetPickerOpen(false);
|
||
}}
|
||
>
|
||
<span className="optimizer-set-modal-icon">◎</span>
|
||
<span className="optimizer-set-modal-label">{option.label}</span>
|
||
<span className="optimizer-set-modal-count">{setCounts[option.setKey] || 0}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
)}
|
||
{mainPickerOpen &&
|
||
createPortal(
|
||
<div
|
||
className="optimizer-set-popover-overlay"
|
||
onMouseDown={() => setMainPickerOpen(false)}
|
||
>
|
||
<div
|
||
className="optimizer-main-popover"
|
||
style={{top: mainPickerPos.top, left: mainPickerPos.left}}
|
||
onMouseDown={(event) => event.stopPropagation()}
|
||
>
|
||
<div className="optimizer-main-modal-list">
|
||
{getMainStatOptions(mainPickerTarget).map(option => (
|
||
<button
|
||
key={option.value}
|
||
className="optimizer-set-modal-item"
|
||
onClick={() => {
|
||
setMainStatFilters(prev => ({
|
||
...prev,
|
||
[mainPickerTarget]: option.value,
|
||
}));
|
||
setMainPickerOpen(false);
|
||
}}
|
||
>
|
||
<span className="optimizer-set-modal-icon">◎</span>
|
||
<span className="optimizer-set-modal-label">{option.label}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
)}
|
||
</>
|
||
);
|
||
}
|