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([]); const [parsedHeroes, setParsedHeroes] = useState([]); const [selectedHeroId, setSelectedHeroId] = useState(null); const [setFilters, setSetFilters] = useState(emptySetFilters); const [statFilters, setStatFilters] = useState(emptyFilters); const [mainStatFilters, setMainStatFilters] = useState<{necklace: MainStatKey | 'All'; ring: MainStatKey | 'All'; boots: MainStatKey | 'All'}>({ necklace: 'All', ring: 'All', boots: 'All', }); const [weightValues, setWeightValues] = useState>({ atk: 3, def: 3, hp: 3, spd: 3, cr: 3, cd: 3, acc: 3, res: 3, }); const [results, setResults] = useState([]); const [selectedResult, setSelectedResult] = useState(null); const [totalCombos, setTotalCombos] = useState(0); const {success, error, info} = useMessage(); const [bonusByHeroId, setBonusByHeroId] = useState>({}); const [bonusEditorOpen, setBonusEditorOpen] = useState(false); const [bonusDraft, setBonusDraft] = useState(emptyBonusStats); const leftPanelsRef = useRef(null); const [leftPanelsHeight, setLeftPanelsHeight] = useState(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(null); const rightPanelRef = useRef(null); const INGAME_SET_MAP: Record = { 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 = {}; 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) => { 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 ) => { 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) => {v}}, {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 ( <> }/>
英雄