Files
wails-epic/frontend/src/pages/OptimizerPage.tsx

980 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
)}
</>
);
}