feat(database): add gearTxt field to parsed results and update related functions

This commit is contained in:
kever
2026-05-31 13:52:47 +08:00
parent 886c5fda08
commit bcf5d3d657
11 changed files with 3635 additions and 285 deletions

View File

@@ -10,14 +10,19 @@ import {
Layout,
Modal,
Pagination,
Progress,
Row,
Select,
Slider,
Space,
Switch,
Table,
Tag,
Typography,
} from 'antd';
import {AppstoreOutlined, FilterOutlined, ReloadOutlined, UserOutlined} from '@ant-design/icons';
import * as App from '../../wailsjs/go/service/App';
import {EventsOn} from '../../wailsjs/runtime';
import {useMessage} from '../utils/useMessage';
import {
BonusStats,
@@ -30,17 +35,28 @@ import {
emptyBonusStats,
emptyFilters,
emptySetFilters,
isFourPieceSet,
isTwoPieceSet,
getSetFilterPieceTotal,
isValidSetFilterSelection,
} from '../utils/optimizer';
import './optimizer.css';
const {Content} = Layout;
const MAX_ITEMS_PER_SLOT = 150;
const MAX_COMBOS = 200000;
const MAX_RESULTS = 200;
const {Text} = Typography;
const MAX_ITEMS_PER_SLOT = 300;
const MAX_COMBOS = 2000000;
const MAX_RESULTS = 5000;
const RESULT_TABLE_SCROLL_X = 866;
const GEAR_SLOTS = ['Weapon', 'Helmet', 'Armor', 'Necklace', 'Ring', 'Boots'] as const;
const GEAR_LABELS: Record<string, string> = {
Weapon: '武器',
Helmet: '头盔',
Armor: '铠甲',
Necklace: '项链',
Ring: '戒指',
Boots: '鞋子',
};
type BuildResult = {
key: string;
sets: string;
@@ -56,6 +72,14 @@ type BuildResult = {
items: RawItem[];
};
type OptimizerPreferenceOptions = {
setFilters: SetFilters;
statFilters: Filters;
mainStatFilters: {necklace: MainStatKey | 'All'; ring: MainStatKey | 'All'; boots: MainStatKey | 'All'};
weightValues: Record<StatKey, number>;
resultFilters: Filters;
};
const STAT_LABELS: Array<{key: StatKey; label: string}> = [
{key: 'atk', label: '\u653b\u51fb'},
{key: 'def', label: '\u9632\u5fa1'},
@@ -67,6 +91,107 @@ const STAT_LABELS: Array<{key: StatKey; label: string}> = [
{key: 'res', label: '\u62b5\u6297'},
];
const WEIGHT_OPTIONS: Array<{key: StatKey; label: string; icon: string}> = [
{key: 'atk', label: '攻击', icon: '攻'},
{key: 'def', label: '防御', icon: '防'},
{key: 'hp', label: '生命', icon: '生'},
{key: 'spd', label: '速度', icon: '速'},
{key: 'cr', label: '暴击', icon: '暴'},
{key: 'cd', label: '爆伤', icon: '伤'},
{key: 'acc', label: '命中', icon: '命'},
{key: 'res', label: '抵抗', icon: '抗'},
];
const createEmptyResultFilters = (): Filters => ({
atk: {},
def: {},
hp: {},
spd: {},
cr: {},
cd: {},
acc: {},
res: {},
});
const STAT_TYPE_LABELS: Record<string, string> = {
Attack: '攻击力',
Defense: '防御力',
Health: '生命值',
Speed: '速度',
AttackPercent: '攻击力',
DefensePercent: '防御力',
HealthPercent: '生命值',
CriticalHitChancePercent: '暴击率',
CriticalHitDamagePercent: '暴击伤害',
EffectivenessPercent: '效果命中',
EffectResistancePercent: '效果抗性',
DualAttackChancePercent: '夹攻率',
};
const PERCENT_STAT_TYPES = new Set([
'AttackPercent',
'DefensePercent',
'HealthPercent',
'CriticalHitChancePercent',
'CriticalHitDamagePercent',
'EffectivenessPercent',
'EffectResistancePercent',
'DualAttackChancePercent',
]);
const formatItemStat = (stat?: {type?: string; value?: number}) => {
if (!stat?.type) return '-';
const label = STAT_TYPE_LABELS[stat.type] || stat.type;
const rawValue = Number(stat.value ?? 0);
const rounded = Math.round(rawValue * 10) / 10;
const valueText = Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
return PERCENT_STAT_TYPES.has(stat.type) ? `${label} ${valueText}%` : `${label} ${valueText}`;
};
const createDefaultMainStatFilters = (): OptimizerPreferenceOptions['mainStatFilters'] => ({
necklace: 'All',
ring: 'All',
boots: 'All',
});
const createDefaultWeightValues = (): Record<StatKey, number> => ({
atk: 0,
def: 0,
hp: 0,
spd: 0,
cr: 0,
cd: 0,
acc: 0,
res: 0,
});
const cloneFilters = (filters: Partial<Record<StatKey, {min?: number; max?: number}>> = {}): Filters => {
return STAT_LABELS.reduce((out, stat) => {
out[stat.key] = {...(filters[stat.key] || {})};
return out;
}, {} as Filters);
};
const cloneSetFilters = (filters: Partial<SetFilters> = {}): SetFilters => ({
set1: [...(filters.set1 || [])],
set2: [...(filters.set2 || [])],
set3: [...(filters.set3 || [])],
});
const defaultOptimizerPreferenceOptions = (): OptimizerPreferenceOptions => ({
setFilters: cloneSetFilters(emptySetFilters),
statFilters: cloneFilters(emptyFilters),
mainStatFilters: createDefaultMainStatFilters(),
weightValues: createDefaultWeightValues(),
resultFilters: createEmptyResultFilters(),
});
const orderItemsByGearSlot = (items: RawItem[]) => {
return GEAR_SLOTS
.map(slot => items.find(item => item.gear === slot))
.filter((item): item is RawItem => Boolean(item));
};
const getHeroStatValue = (hero: RawHero | null, key: StatKey) => {
if (!hero) return 0;
switch (key) {
@@ -91,6 +216,16 @@ const getHeroStatValue = (hero: RawHero | null, key: StatKey) => {
}
};
const passesResultFilters = (result: BuildResult, filters: Filters) => {
return STAT_LABELS.every(stat => {
const range = filters[stat.key];
const value = Number(result[stat.key] ?? 0);
if (range.min !== undefined && value < range.min) return false;
if (range.max !== undefined && value > range.max) return false;
return true;
});
};
export default function OptimizerPage() {
const [loading, setLoading] = useState(false);
const [parsedItems, setParsedItems] = useState<RawItem[]>([]);
@@ -98,28 +233,21 @@ export default function OptimizerPage() {
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 [mainStatFilters, setMainStatFilters] = useState<OptimizerPreferenceOptions['mainStatFilters']>(createDefaultMainStatFilters);
const [weightValues, setWeightValues] = useState<Record<StatKey, number>>(createDefaultWeightValues);
const [results, setResults] = useState<BuildResult[]>([]);
const [selectedResult, setSelectedResult] = useState<BuildResult | null>(null);
const [totalCombos, setTotalCombos] = useState(0);
const [optimizing, setOptimizing] = useState(false);
const [optimizeProgress, setOptimizeProgress] = useState({checked: 0, matched: 0, total: 0, percent: 0});
const [resultFilters, setResultFilters] = useState<Filters>(() => createEmptyResultFilters());
const {success, error, info} = useMessage();
const [bonusByHeroId, setBonusByHeroId] = useState<Record<string, BonusStats>>({});
const [bonusEditorOpen, setBonusEditorOpen] = useState(false);
const [bonusDraft, setBonusDraft] = useState<BonusStats>(emptyBonusStats);
const [rememberPreferences, setRememberPreferences] = useState(false);
const preferenceLoadRef = useRef({initialized: false, applying: false, skipNextSave: false, heroKey: ''});
const preferenceSaveTimerRef = useRef<number | null>(null);
const leftPanelsRef = useRef<HTMLDivElement | null>(null);
const [leftPanelsHeight, setLeftPanelsHeight] = useState<number | null>(null);
@@ -336,6 +464,40 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
const selectedHeroKey = selectedHeroId == null ? '' : String(selectedHeroId);
const selectedHeroBonus = bonusByHeroId[selectedHeroKey] || emptyBonusStats;
const visibleResults = useMemo(() => {
return results.filter(result => passesResultFilters(result, resultFilters));
}, [results, resultFilters]);
const buildPreferenceOptions = (): OptimizerPreferenceOptions => ({
setFilters: cloneSetFilters(setFilters),
statFilters: cloneFilters(statFilters),
mainStatFilters: {...mainStatFilters},
weightValues: {...weightValues},
resultFilters: cloneFilters(resultFilters),
});
const applyPreferenceOptions = (options?: Partial<OptimizerPreferenceOptions>) => {
const defaults = defaultOptimizerPreferenceOptions();
setSetFilters(options?.setFilters ? cloneSetFilters(options.setFilters) : defaults.setFilters);
setStatFilters(options?.statFilters ? cloneFilters(options.statFilters) : defaults.statFilters);
setMainStatFilters({
...defaults.mainStatFilters,
...(options?.mainStatFilters || {}),
});
setWeightValues({
...defaults.weightValues,
...(options?.weightValues || {}),
});
setResultFilters(options?.resultFilters ? cloneFilters(options.resultFilters) : defaults.resultFilters);
setResults([]);
setSelectedResult(null);
setTotalCombos(0);
};
const saveCurrentPreference = async (heroKey: string) => {
if (!heroKey) return;
await App.SaveOptimizerPreference(heroKey, JSON.stringify(buildPreferenceOptions()));
};
const loadLatestData = async () => {
setLoading(true);
@@ -388,6 +550,146 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
loadLatestData();
}, []);
useEffect(() => {
let canceled = false;
App.GetAppSetting('optimizer.rememberPreferences')
.then(value => {
if (!canceled) {
setRememberPreferences(value === 'true');
}
})
.catch(err => {
console.error('Load optimizer remember setting error:', err);
})
.finally(() => {
if (!canceled) {
preferenceLoadRef.current.initialized = true;
}
});
return () => {
canceled = true;
};
}, []);
useEffect(() => {
if (!preferenceLoadRef.current.initialized || !selectedHeroKey) {
return;
}
if (!rememberPreferences) {
return;
}
if (preferenceLoadRef.current.heroKey === selectedHeroKey) {
return;
}
let canceled = false;
preferenceLoadRef.current.applying = true;
App.GetOptimizerPreference(selectedHeroKey)
.then(value => {
if (canceled) return;
if (value) {
applyPreferenceOptions(JSON.parse(value) as OptimizerPreferenceOptions);
} else {
applyPreferenceOptions(defaultOptimizerPreferenceOptions());
}
preferenceLoadRef.current.skipNextSave = true;
preferenceLoadRef.current.heroKey = selectedHeroKey;
})
.catch(err => {
console.error('Load optimizer preference error:', err);
preferenceLoadRef.current.heroKey = selectedHeroKey;
})
.finally(() => {
if (!canceled) {
window.setTimeout(() => {
preferenceLoadRef.current.applying = false;
}, 0);
}
});
return () => {
canceled = true;
};
}, [rememberPreferences, selectedHeroKey]);
useEffect(() => {
if (!preferenceLoadRef.current.initialized || !rememberPreferences || !selectedHeroKey || preferenceLoadRef.current.applying) {
return;
}
if (preferenceLoadRef.current.skipNextSave) {
preferenceLoadRef.current.skipNextSave = false;
return;
}
if (preferenceSaveTimerRef.current !== null) {
window.clearTimeout(preferenceSaveTimerRef.current);
}
preferenceSaveTimerRef.current = window.setTimeout(() => {
saveCurrentPreference(selectedHeroKey).catch(err => {
console.error('Save optimizer preference error:', err);
});
}, 400);
}, [rememberPreferences, selectedHeroKey, setFilters, statFilters, mainStatFilters, weightValues, resultFilters]);
useEffect(() => {
return () => {
if (preferenceSaveTimerRef.current !== null) {
window.clearTimeout(preferenceSaveTimerRef.current);
}
};
}, []);
useEffect(() => {
if (visibleResults.length === 0) {
setSelectedResult(null);
return;
}
if (!selectedResult || !visibleResults.some(result => result.key === selectedResult.key)) {
setSelectedResult(visibleResults[0]);
}
}, [visibleResults, selectedResult?.key]);
useEffect(() => {
const normalizePayload = <T,>(payload: T | T[]) => Array.isArray(payload) ? payload[0] : payload;
const offProgress = EventsOn('optimize:progress', (payload?: {checked?: number; matched?: number; total?: number; percent?: number} | Array<{checked?: number; matched?: number; total?: number; percent?: number}>) => {
const data = normalizePayload(payload || {});
const checked = Number(data?.checked || 0);
const matched = Number(data?.matched || 0);
const total = Number(data?.total || 0);
const rawPercent = Number(data?.percent || 0);
const percent = Math.max(0, Math.min(100, Math.round(rawPercent * 10) / 10));
setOptimizeProgress({checked, matched, total, percent});
});
const offDone = EventsOn('optimize:done', (payload?: BuildResult[] | any) => {
const resp = normalizePayload(payload || {});
const nextResults = ((resp?.results || []) as BuildResult[]).slice(0, MAX_RESULTS);
setTotalCombos(resp?.totalCombos || 0);
setResults(nextResults);
setSelectedResult(nextResults[0] || null);
setOptimizing(false);
setOptimizeProgress(prev => ({...prev, matched: resp?.totalCombos || prev.matched, percent: 100}));
if (nextResults.length > 0) {
success(`配装完成,共 ${nextResults.length} 条结果`);
} else {
info('没有符合条件的配装结果');
}
});
const offError = EventsOn('optimize:error', (payload?: string | string[]) => {
const msg = String(normalizePayload(payload || '配装失败'));
setOptimizing(false);
error(msg);
});
const offCanceled = EventsOn('optimize:canceled', () => {
setOptimizing(false);
info('配装计算已中断');
});
return () => {
offProgress();
offDone();
offError();
offCanceled();
};
}, []);
useLayoutEffect(() => {
if (!leftPanelsRef.current) return;
const observer = new ResizeObserver(entries => {
@@ -425,22 +727,84 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
const resetPreferences = () => {
setWeightValues({
atk: 3,
def: 3,
hp: 3,
spd: 3,
cr: 3,
cd: 3,
acc: 3,
res: 3,
atk: 0,
def: 0,
hp: 0,
spd: 0,
cr: 0,
cd: 0,
acc: 0,
res: 0,
});
setResults([]);
setSelectedResult(null);
setTotalCombos(0);
};
const buildNextSetFilters = (
target: 'set1' | 'set2' | 'set3',
nextValue: string[]
): SetFilters => {
if (target === 'set1') {
return {set1: nextValue, set2: [], set3: []};
}
if (target === 'set2') {
return {...setFilters, set2: nextValue, set3: []};
}
return {...setFilters, set3: nextValue};
};
const applySetSelection = (nextFilters: SetFilters) => {
if (!isValidSetFilterSelection(nextFilters)) {
info('套装组合最多只能占用6件装备');
return;
}
setSetFilters(nextFilters);
setSetPickerOpen(false);
};
const updateResultFilter = (key: StatKey, boundary: 'min' | 'max', value: number | null) => {
setResultFilters(prev => ({
...prev,
[key]: {
...prev[key],
[boundary]: value ?? undefined,
},
}));
};
const resetResultFilters = () => {
setResultFilters(createEmptyResultFilters());
};
const updateRememberPreferences = async (checked: boolean) => {
setRememberPreferences(checked);
preferenceLoadRef.current.initialized = true;
if (!checked && preferenceSaveTimerRef.current !== null) {
window.clearTimeout(preferenceSaveTimerRef.current);
preferenceSaveTimerRef.current = null;
}
if (checked && selectedHeroKey) {
preferenceLoadRef.current.heroKey = selectedHeroKey;
preferenceLoadRef.current.skipNextSave = false;
}
try {
await App.SaveAppSetting('optimizer.rememberPreferences', checked ? 'true' : 'false');
if (checked && selectedHeroKey) {
await saveCurrentPreference(selectedHeroKey);
preferenceLoadRef.current.heroKey = selectedHeroKey;
}
} catch (err) {
console.error('Save optimizer remember setting error:', err);
error('保存记录偏好设置失败');
}
};
const buildResults = async () => {
console.log('OptimizerPage buildResults clicked');
if (optimizing) {
return;
}
if (!selectedHero) {
info('\u8bf7\u5148\u9009\u62e9\u82f1\u96c4');
return;
@@ -449,8 +813,18 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
info('\u6682\u65e0\u88c5\u5907\u6570\u636e');
return;
}
if (getSetFilterPieceTotal(setFilters) === 0) {
info('请先选择套装');
return;
}
if (!STAT_LABELS.some(stat => weightValues[stat.key] > 0)) {
info('请至少设置一个属性偏好');
return;
}
setOptimizing(true);
setOptimizeProgress({checked: 0, matched: 0, total: 0, percent: 0});
try {
console.log('OptimizeBuilds calling', {
console.log('StartOptimizeBuilds calling', {
heroId: String(selectedHero.id ?? ''),
setFilters,
statFilters,
@@ -458,7 +832,7 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
weightValues,
bonusStats: selectedHeroBonus,
});
const resp = await App.OptimizeBuilds({
await App.StartOptimizeBuilds({
heroId: String(selectedHero.code ?? ''),
setFilters,
statFilters,
@@ -469,34 +843,84 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
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');
}
console.log('StartOptimizeBuilds started');
} catch (err) {
const msg = err instanceof Error ? err.message : '\u914d\u88c5\u5931\u8d25';
setOptimizing(false);
if (msg.includes('中断') || msg.toLowerCase().includes('canceled') || msg.toLowerCase().includes('cancelled')) {
info('配装计算已中断');
} else {
error(msg);
console.error('OptimizeBuilds error:', err);
}
console.error('StartOptimizeBuilds error:', err);
}
};
const cancelOptimize = async () => {
try {
await App.CancelOptimizeBuilds();
} catch (err) {
console.error('CancelOptimizeBuilds 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'},
{
title: '套装',
dataIndex: 'sets',
key: 'sets',
width: 156,
ellipsis: true,
render: (v: string) => <Tag className="optimizer-result-set-tag">{v}</Tag>,
},
{title: '攻击', dataIndex: 'atk', key: 'atk', width: 92, ellipsis: true, align: 'right' as const, sorter: (a: BuildResult, b: BuildResult) => a.atk - b.atk},
{title: '防御', dataIndex: 'def', key: 'def', width: 92, ellipsis: true, align: 'right' as const, sorter: (a: BuildResult, b: BuildResult) => a.def - b.def},
{title: '生命', dataIndex: 'hp', key: 'hp', width: 96, ellipsis: true, align: 'right' as const, sorter: (a: BuildResult, b: BuildResult) => a.hp - b.hp},
{title: '速度', dataIndex: 'spd', key: 'spd', width: 86, ellipsis: true, align: 'right' as const, sorter: (a: BuildResult, b: BuildResult) => a.spd - b.spd},
{title: '暴击', dataIndex: 'cr', key: 'cr', width: 86, ellipsis: true, align: 'right' as const, sorter: (a: BuildResult, b: BuildResult) => a.cr - b.cr},
{title: '爆伤', dataIndex: 'cd', key: 'cd', width: 86, ellipsis: true, align: 'right' as const, sorter: (a: BuildResult, b: BuildResult) => a.cd - b.cd},
{title: '命中', dataIndex: 'acc', key: 'acc', width: 86, ellipsis: true, align: 'right' as const, sorter: (a: BuildResult, b: BuildResult) => a.acc - b.acc},
{title: '抵抗', dataIndex: 'res', key: 'res', width: 86, ellipsis: true, align: 'right' as const, sorter: (a: BuildResult, b: BuildResult) => a.res - b.res},
];
const getItemSetKey = (item: RawItem) => item.set || (item.f ? INGAME_SET_MAP[item.f] : '');
const detailItemColumns = [
{
title: '部位',
key: 'gear',
width: 90,
render: (_: unknown, item: RawItem) => GEAR_LABELS[item.gear || ''] || item.gear || '-',
},
{
title: '套装类别',
key: 'set',
width: 140,
render: (_: unknown, item: RawItem) => {
const setKey = getItemSetKey(item);
return setKey ? <Tag color="blue">{getSetLabel([setKey])}</Tag> : '-';
},
},
{
title: '强化',
key: 'enhance',
width: 80,
render: (_: unknown, item: RawItem) => item.enhance == null ? '-' : `+${item.enhance}`,
},
{
title: '主属性',
key: 'main',
width: 170,
render: (_: unknown, item: RawItem) => formatItemStat(item.main),
},
{
title: '副属性',
key: 'substats',
render: (_: unknown, item: RawItem) => {
if (!item.substats || item.substats.length === 0) return '-';
return item.substats.map(stat => formatItemStat(stat)).join(' / ');
},
},
];
return (
@@ -528,7 +952,7 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
</Button>
</div>
</Col>
<Col span={6}>
<Col span={4}>
<div style={{fontWeight: 500, marginBottom: 8}}></div>
<div style={{display: 'flex', flexDirection: 'column', gap: 8}}>
{STAT_LABELS.map(stat => (
@@ -574,7 +998,7 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
</div>
</Col>
<Col
span={6}
span={8}
style={{display: 'flex', flexDirection: 'column', justifyContent: 'flex-start', gap: 10}}
ref={rightPanelRef}
>
@@ -604,7 +1028,10 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
<span className="optimizer-set-badge">{renderSetBadge(setFilters.set3)}</span>
<Button
className="optimizer-set-button"
disabled={setFilters.set1.some(isFourPieceSet) || setFilters.set2.length === 0}
disabled={
setFilters.set2.length === 0 ||
(setFilters.set3.length === 0 && getSetFilterPieceTotal(setFilters) >= 6)
}
onClick={(event) => openSetPicker('set3', event)}
>
{getSetLabel(setFilters.set3)}
@@ -642,123 +1069,54 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
</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>
<div className="optimizer-weight-header">
<span></span>
<span className="optimizer-weight-scale">0-5</span>
</div>
<div className="optimizer-weight-list">
{WEIGHT_OPTIONS.map(option => (
<div className="optimizer-weight-row" key={option.key}>
<span className="optimizer-weight-icon">{option.icon}</span>
<span className="optimizer-weight-label">{option.label}</span>
<Slider
min={0}
max={5}
step={1}
dots
tooltip={{open: false, formatter: null}}
className="optimizer-weight-slider"
value={weightValues.atk}
onChange={value => setWeightValues(prev => ({...prev, atk: value as number}))}
value={weightValues[option.key]}
onChange={value => {
const nextValue = Array.isArray(value) ? value[0] : value;
setWeightValues(prev => ({...prev, [option.key]: nextValue}));
}}
/>
<span className="optimizer-weight-value">{weightValues[option.key]}</span>
</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>
</div>
<div style={{display: 'flex', gap: 12, alignItems: 'center', justifyContent: 'flex-end', width: '100%', flexWrap: 'wrap'}}>
<Space size={6} align="center" style={{height: 32, flex: '0 0 auto', whiteSpace: 'nowrap'}}>
<Switch
size="small"
checked={rememberPreferences}
disabled={optimizing || loading}
onChange={updateRememberPreferences}
/>
</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}}>
<Text style={{whiteSpace: 'nowrap'}}></Text>
</Space>
<Button icon={<ReloadOutlined/>} onClick={loadLatestData} loading={loading} disabled={optimizing} style={{height: 32, display: 'none'}}>
</Button>
<Button icon={<ReloadOutlined/>} onClick={resetSetsAndAttrs} style={{height: 32}}>
<Button icon={<ReloadOutlined/>} onClick={resetSetsAndAttrs} disabled={optimizing} style={{height: 32}}>
{'\u91cd\u7f6e\u5957\u88c5'}
</Button>
<Button icon={<FilterOutlined/>} onClick={resetPreferences} style={{height: 32}}>
<Button icon={<FilterOutlined/>} onClick={resetPreferences} disabled={optimizing} style={{height: 32}}>
{'\u91cd\u7f6e\u504f\u597d'}
</Button>
<Button type="primary" icon={<AppstoreOutlined/>} onClick={buildResults} style={{height: 32}}>
<Button type="primary" icon={<AppstoreOutlined/>} onClick={buildResults} loading={optimizing} disabled={optimizing} style={{height: 32}}>
{'\u5f00\u59cb\u914d\u88c5'}
</Button>
</div>
@@ -767,24 +1125,61 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
</Card>
<Card title="配装结果" style={{marginBottom: 16}}>
<div style={{display: 'flex', justifyContent: 'space-between', marginBottom: 8}}>
<div className="optimizer-result-tools">
<div className="optimizer-result-panel">
<div className="optimizer-result-tool-header">
<div className="optimizer-result-tool-title"></div>
<Button size="small" onClick={resetResultFilters}>
</Button>
</div>
<div className="optimizer-result-filter-grid">
{STAT_LABELS.map(stat => (
<div className="optimizer-result-filter-row" key={stat.key}>
<span>{stat.label}</span>
<InputNumber
size="small"
placeholder="最小"
value={resultFilters[stat.key].min}
onChange={value => updateResultFilter(stat.key, 'min', typeof value === 'number' ? value : null)}
/>
<span className="optimizer-result-range-separator">-</span>
<InputNumber
size="small"
placeholder="最大"
value={resultFilters[stat.key].max}
onChange={value => updateResultFilter(stat.key, 'max', typeof value === 'number' ? value : null)}
/>
</div>
))}
</div>
</div>
</div>
<div className="optimizer-result-summary">
<div>
<b>{totalCombos.toLocaleString()}</b> |
<b>{results.length.toLocaleString()}</b>
<b>{visibleResults.length.toLocaleString()}</b>
{visibleResults.length !== results.length ? (
<> / {results.length.toLocaleString()}</>
) : null}
</div>
<Pagination
size="small"
total={results.length}
total={visibleResults.length}
pageSize={10}
current={1}
showSizeChanger={false}
/>
</div>
<Table
dataSource={results}
className="optimizer-result-table"
dataSource={visibleResults}
columns={resultColumns}
pagination={{pageSize: 10}}
scroll={{x: true}}
scroll={{x: RESULT_TABLE_SCROLL_X}}
showSorterTooltip={false}
tableLayout="fixed"
rowKey="key"
onRow={record => ({
onClick: () => setSelectedResult(record),
@@ -794,6 +1189,7 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
<Card title="配装详情">
{selectedResult ? (
<>
<div style={{display: 'flex', gap: 24}}>
<div>
<Avatar size={48} icon={<UserOutlined/>}/>
@@ -814,12 +1210,53 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
</div>
</div>
</div>
<Table
size="small"
style={{marginTop: 16}}
dataSource={orderItemsByGearSlot(selectedResult.items || [])}
columns={detailItemColumns}
pagination={false}
rowKey={item => `${item.gear || 'gear'}-${item.id || ''}`}
scroll={{x: true}}
/>
</>
) : (
<div style={{color: '#999'}}></div>
)}
</Card>
</Content>
</Layout>
<Modal
open={optimizing}
title="正在计算配装"
closable={false}
maskClosable={false}
keyboard={false}
centered
footer={[
<Button key="cancel" danger onClick={cancelOptimize}>
</Button>,
]}
>
<div className="optimizer-progress-modal">
<Progress
className="optimizer-progress-bar"
percent={optimizeProgress.percent}
status="active"
showInfo={false}
strokeColor={{from: '#1677ff', to: '#52c41a'}}
/>
<div className="optimizer-progress-meta">
<span className="optimizer-progress-text">
<b>{optimizeProgress.checked.toLocaleString()}</b>
{' '} <b>{optimizeProgress.matched.toLocaleString()}</b>
{' '}
</span>
<span className="optimizer-progress-percent">{optimizeProgress.percent.toFixed(1)}%</span>
</div>
</div>
</Modal>
<Modal
open={bonusEditorOpen}
title={'\u989d\u5916\u52a0\u6210'}
@@ -907,28 +1344,7 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
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);
applySetSelection(buildNextSetFilters(setPickerTarget, nextValue));
}}
>
<span className="optimizer-set-modal-icon"></span>

View File

@@ -186,19 +186,6 @@
color: #9fb3e5;
}
.optimizer-weight-row {
display: grid;
grid-template-columns: 38px 1fr;
align-items: center;
gap: 10px;
}
.optimizer-weight-label {
color: #c9d7f7;
font-size: 11px;
text-align: right;
}
.optimizer-left-panels {
display: flex;
flex-direction: column;
@@ -206,83 +193,139 @@
}
.optimizer-side-panel {
display: flex;
gap: 8px;
align-items: flex-start;
display: grid;
grid-template-columns: minmax(124px, 0.82fr) minmax(176px, 1.18fr);
gap: 10px;
align-items: stretch;
}
.optimizer-left-panels {
flex: 1;
min-width: 0;
}
.optimizer-weight-panel {
flex: 1;
min-width: 0;
}
.optimizer-weight-panel {
background: linear-gradient(180deg, #1f2c49 0%, #1a233b 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
box-sizing: border-box;
background: linear-gradient(180deg, #18243c 0%, #11192d 100%);
border: 1px solid rgba(145, 174, 226, 0.18);
border-radius: 10px;
padding: 6px;
align-self: flex-start;
height: fit-content;
align-self: stretch;
display: flex;
flex-direction: column;
gap: 6px;
overflow: hidden;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.optimizer-weight-row {
display: grid;
grid-template-columns: 20px 34px 1fr;
.optimizer-weight-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
background: rgba(6, 10, 20, 0.55);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 6px;
justify-content: space-between;
height: 20px;
color: #e4ecff;
font-size: 12px;
font-weight: 600;
}
.optimizer-weight-row-no-label {
grid-template-columns: 20px 1fr;
}
.optimizer-weight-row-no-label .optimizer-weight-label {
display: none;
}
.optimizer-weight-row:last-child {
margin-bottom: 0;
}
.optimizer-weight-row .ant-slider {
margin: 0;
}
.optimizer-weight-icon {
width: 18px;
.optimizer-weight-scale {
min-width: 36px;
height: 18px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
color: #c9d7f7;
border-radius: 999px;
background: rgba(123, 180, 255, 0.14);
color: #9fc4ff;
font-size: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.optimizer-weight-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
min-height: 0;
}
.optimizer-weight-row {
display: grid;
grid-template-columns: 24px 34px minmax(72px, 1fr) 26px;
align-items: center;
gap: 7px;
flex: 1 1 0;
min-height: 0;
line-height: 1;
background: rgba(7, 12, 24, 0.62);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 8px;
padding: 2px 6px;
transition: border-color 0.16s ease, background 0.16s ease;
}
.optimizer-weight-row:hover {
background: rgba(10, 18, 34, 0.82);
border-color: rgba(123, 180, 255, 0.28);
}
.optimizer-weight-row .ant-slider {
align-self: center;
margin: 0;
}
.optimizer-weight-icon {
width: 20px;
height: 20px;
border-radius: 7px;
background: linear-gradient(180deg, rgba(123, 180, 255, 0.26), rgba(123, 180, 255, 0.1));
color: #dbe8ff;
font-size: 11px;
font-weight: 700;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
transform: translateY(-1px);
}
.optimizer-weight-label {
color: #c9d7f7;
color: #d2def7;
font-size: 12px;
text-align: left;
line-height: 1;
white-space: nowrap;
height: 20px;
display: inline-flex;
align-items: center;
transform: translateY(-1px);
}
.optimizer-weight-slider {
min-width: 0;
width: 100%;
}
.optimizer-weight-value {
width: 24px;
height: 20px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
font-size: 12px;
font-weight: 700;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
transform: translateY(-1px);
}
.optimizer-weight-slider .ant-slider-rail {
background: rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.12);
height: 4px;
}
.optimizer-weight-slider .ant-slider-track {
background-color: #7bb4ff;
background-color: #83bcff;
height: 4px;
}
@@ -290,7 +333,7 @@
width: 12px;
height: 12px;
margin-top: -1px;
border-color: #7bb4ff;
border-color: #83bcff;
box-shadow: none;
border-radius: 50%;
background-color: #ffffff;
@@ -299,15 +342,151 @@
.optimizer-weight-slider .ant-slider-handle::after {
display: none;
}
.optimizer-weight-row .ant-slider-rail {
background-color: rgba(255, 255, 255, 0.15);
.optimizer-progress-modal {
padding: 6px 2px 2px;
}
.optimizer-weight-row .ant-slider-track {
background-color: #6aa9ff;
.optimizer-progress-bar {
width: 100%;
}
.optimizer-weight-row .ant-slider-handle {
border-color: #6aa9ff;
background-color: #ffffff;
.optimizer-progress-bar .ant-progress-outer {
width: 100%;
padding-inline-end: 0;
margin-inline-end: 0;
}
.optimizer-progress-bar .ant-progress-inner {
display: block;
}
.optimizer-progress-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
color: #4b5563;
font-size: 13px;
line-height: 22px;
min-height: 22px;
white-space: nowrap;
}
.optimizer-progress-text {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.optimizer-progress-text b {
display: inline-block;
min-width: 86px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.optimizer-progress-percent {
flex: 0 0 54px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.optimizer-result-tools {
margin-bottom: 12px;
}
.optimizer-result-panel {
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fafafa;
padding: 10px;
}
.optimizer-result-tool-title {
color: #1f2937;
font-size: 13px;
font-weight: 600;
}
.optimizer-result-tool-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.optimizer-result-filter-grid {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr));
gap: 8px 10px;
}
.optimizer-result-filter-row {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) 10px minmax(0, 1fr);
align-items: center;
gap: 6px;
color: #4b5563;
font-size: 12px;
}
.optimizer-result-filter-row .ant-input-number {
width: 100%;
}
.optimizer-result-range-separator {
color: #9ca3af;
text-align: center;
}
.optimizer-result-summary {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.optimizer-result-table .ant-table {
table-layout: fixed;
}
.optimizer-result-table .ant-table-thead > tr > th,
.optimizer-result-table .ant-table-tbody > tr > td {
overflow: hidden;
white-space: nowrap;
}
.optimizer-result-table .ant-table-column-sorters {
display: grid;
grid-template-columns: minmax(0, 1fr) 16px;
gap: 4px;
min-width: 0;
}
.optimizer-result-table .ant-table-column-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.optimizer-result-table .ant-table-column-sorter {
width: 16px;
margin-inline-start: 0;
}
.optimizer-result-set-tag {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
}
@media (max-width: 1200px) {
.optimizer-result-filter-grid {
grid-template-columns: repeat(2, minmax(150px, 1fr));
}
}

View File

@@ -0,0 +1,429 @@
export type RawItem = {
id?: string | number;
level?: number;
enhance?: number;
rank?: string;
gear?: string;
set?: string;
f?: string;
main?: { type?: string; value?: number };
substats?: Array<{ type?: string; value?: number }>;
};
export type RawHero = {
id?: string | number;
code?: string;
name?: string;
atk?: number;
def?: number;
hp?: number;
spd?: number;
cr?: number;
cd?: number;
eff?: number;
res?: number;
baseAtk?: number;
baseDef?: number;
baseHp?: number;
baseSpd?: number;
baseCr?: number;
baseCd?: number;
baseEff?: number;
baseRes?: number;
};
export type StatKey = 'atk' | 'def' | 'hp' | 'spd' | 'cr' | 'cd' | 'acc' | 'res';
export type MainStatKey =
| 'Attack'
| 'Defense'
| 'Health'
| 'AttackPercent'
| 'DefensePercent'
| 'HealthPercent'
| 'CriticalHitChancePercent'
| 'CriticalHitDamagePercent'
| 'EffectivenessPercent'
| 'EffectResistancePercent'
| 'Speed';
export type StatRange = { min?: number; max?: number };
export type Filters = Record<StatKey, StatRange>;
export type ItemStats = {
atk: number;
def: number;
hp: number;
spd: number;
cr: number;
cd: number;
acc: number;
res: number;
atkPct: number;
defPct: number;
hpPct: number;
};
export type BonusStats = {
atk: number;
def: number;
hp: number;
atkPct: number;
defPct: number;
hpPct: number;
spd: number;
cr: number;
cd: number;
eff: number;
res: number;
finalAtkMultiplier: number;
finalDefMultiplier: number;
finalHpMultiplier: number;
};
export type SetFilters = {
set1: string[];
set2: string[];
set3: string[];
};
export const emptyFilters: Filters = {
atk: {},
def: {},
hp: {},
spd: {},
cr: {},
cd: {},
acc: {},
res: {},
};
export const emptySetFilters: SetFilters = {
set1: [],
set2: [],
set3: [],
};
export const emptyBonusStats: BonusStats = {
atk: 0,
def: 0,
hp: 0,
atkPct: 0,
defPct: 0,
hpPct: 0,
spd: 0,
cr: 0,
cd: 0,
eff: 0,
res: 0,
finalAtkMultiplier: 0,
finalDefMultiplier: 0,
finalHpMultiplier: 0,
};
export const FOUR_PIECE_SETS = new Set([
'AttackSet',
'SpeedSet',
'DestructionSet',
'LifestealSet',
'ProtectionSet',
'CounterSet',
'RageSet',
'RevengeSet',
'InjurySet',
'ReversalSet',
'RiposteSet',
'WarfareSet',
]);
export const TWO_PIECE_SETS = new Set([
'HealthSet',
'DefenseSet',
'CriticalSet',
'HitSet',
'ResistSet',
'UnitySet',
'ImmunitySet',
'PenetrationSet',
'TorrentSet',
'PursuitSet',
]);
export const toNumber = (value: any) => {
if (typeof value !== 'number' || Number.isNaN(value)) return 0;
return value;
};
export const buildItemStats = (item: RawItem): ItemStats => {
const stats: ItemStats = {
atk: 0,
def: 0,
hp: 0,
spd: 0,
cr: 0,
cd: 0,
acc: 0,
res: 0,
atkPct: 0,
defPct: 0,
hpPct: 0,
};
const applyStat = (type?: string, value?: number) => {
if (!type) return;
const v = toNumber(value);
switch (type) {
case 'Attack':
stats.atk += v;
break;
case 'Defense':
stats.def += v;
break;
case 'Health':
stats.hp += v;
break;
case 'Speed':
stats.spd += v;
break;
case 'CriticalHitChancePercent':
stats.cr += v;
break;
case 'CriticalHitDamagePercent':
stats.cd += v;
break;
case 'EffectivenessPercent':
stats.acc += v;
break;
case 'EffectResistancePercent':
stats.res += v;
break;
case 'AttackPercent':
stats.atkPct += v;
break;
case 'DefensePercent':
stats.defPct += v;
break;
case 'HealthPercent':
stats.hpPct += v;
break;
default:
break;
}
};
if (item.main) {
applyStat(item.main.type, item.main.value);
}
if (Array.isArray(item.substats)) {
item.substats.forEach(sub => applyStat(sub.type, sub.value));
}
return stats;
};
export const buildBaseStats = (hero?: RawHero) => {
return {
atk: toNumber(hero?.baseAtk ?? hero?.atk),
def: toNumber(hero?.baseDef ?? hero?.def),
hp: toNumber(hero?.baseHp ?? hero?.hp),
spd: toNumber(hero?.baseSpd ?? hero?.spd),
cr: toNumber(hero?.baseCr ?? hero?.cr),
cd: toNumber(hero?.baseCd ?? hero?.cd),
acc: toNumber(hero?.baseEff ?? hero?.eff),
res: toNumber(hero?.baseRes ?? hero?.res),
};
};
export const scoreStats = (stats: ItemStats, baseStats: ReturnType<typeof buildBaseStats>) => {
const atk = stats.atk + baseStats.atk * (stats.atkPct / 100);
const def = stats.def + baseStats.def * (stats.defPct / 100);
const hp = stats.hp + baseStats.hp * (stats.hpPct / 100);
return (
stats.spd * 2 +
stats.cr +
stats.cd +
stats.acc +
stats.res +
(atk + def + hp) / 100
);
};
export const buildTotalStats = (
statsList: ItemStats[],
baseStats: ReturnType<typeof buildBaseStats>,
bonusStats: BonusStats,
items: RawItem[]
) => {
const totals: ItemStats = {
atk: 0,
def: 0,
hp: 0,
spd: 0,
cr: 0,
cd: 0,
acc: 0,
res: 0,
atkPct: 0,
defPct: 0,
hpPct: 0,
};
statsList.forEach(stats => {
totals.atk += stats.atk;
totals.def += stats.def;
totals.hp += stats.hp;
totals.spd += stats.spd;
totals.cr += stats.cr;
totals.cd += stats.cd;
totals.acc += stats.acc;
totals.res += stats.res;
totals.atkPct += stats.atkPct;
totals.defPct += stats.defPct;
totals.hpPct += stats.hpPct;
});
const bonusBaseAtk = baseStats.atk + baseStats.atk * (bonusStats.atkPct / 100) + bonusStats.atk;
const bonusBaseDef = baseStats.def + baseStats.def * (bonusStats.defPct / 100) + bonusStats.def;
const bonusBaseHp = baseStats.hp + baseStats.hp * (bonusStats.hpPct / 100) + bonusStats.hp;
const bonusMaxAtk = 1 + bonusStats.finalAtkMultiplier / 100;
const bonusMaxDef = 1 + bonusStats.finalDefMultiplier / 100;
const bonusMaxHp = 1 + bonusStats.finalHpMultiplier / 100;
const counts = new Map<string, number>();
items.forEach(item => {
if (!item.set) return;
counts.set(item.set, (counts.get(item.set) || 0) + 1);
});
const attackSetBonus = (counts.get('AttackSet') || 0) >= 4 ? 0.45 * baseStats.atk : 0;
const healthSetBonus = Math.floor((counts.get('HealthSet') || 0) / 2) * 0.20 * baseStats.hp;
const defenseSetBonus = Math.floor((counts.get('DefenseSet') || 0) / 2) * 0.20 * baseStats.def;
const speedSetBonus = (counts.get('SpeedSet') || 0) >= 4 ? 0.25 * baseStats.spd : 0;
const revengeSetBonus = (counts.get('RevengeSet') || 0) >= 4 ? 0.12 * baseStats.spd : 0;
const reversalSetBonus = (counts.get('ReversalSet') || 0) >= 4 ? 0.15 * baseStats.spd : 0;
const criticalSetBonus = Math.floor((counts.get('CriticalSet') || 0) / 2) * 12;
const hitSetBonus = Math.floor((counts.get('HitSet') || 0) / 2) * 20;
const resistSetBonus = Math.floor((counts.get('ResistSet') || 0) / 2) * 20;
const destructionSetBonus = (counts.get('DestructionSet') || 0) >= 4 ? 60 : 0;
const warfareSetBonus = (counts.get('WarfareSet') || 0) >= 4 ? 0.20 * baseStats.hp : 0;
const torrentSetPenalty = Math.floor((counts.get('TorrentSet') || 0) / 2) * (-0.10 * baseStats.hp);
const atkFlat = totals.atk + baseStats.atk * (totals.atkPct / 100);
const defFlat = totals.def + baseStats.def * (totals.defPct / 100);
const hpFlat = totals.hp + baseStats.hp * (totals.hpPct / 100);
const atk = (bonusBaseAtk + atkFlat + attackSetBonus) * bonusMaxAtk;
const def = (bonusBaseDef + defFlat + defenseSetBonus) * bonusMaxDef;
const hp = (bonusBaseHp + hpFlat + healthSetBonus + warfareSetBonus + torrentSetPenalty) * bonusMaxHp;
const spd = baseStats.spd + totals.spd + speedSetBonus + revengeSetBonus + reversalSetBonus + bonusStats.spd;
const cr = Math.min(100, baseStats.cr + totals.cr + criticalSetBonus + bonusStats.cr);
const cd = Math.min(350, baseStats.cd + totals.cd + destructionSetBonus + bonusStats.cd);
const acc = baseStats.acc + totals.acc + hitSetBonus + bonusStats.eff;
const res = baseStats.res + totals.res + resistSetBonus + bonusStats.res;
return {atk, def, hp, spd, cr, cd, acc, res};
};
export const passesFilters = (totals: ReturnType<typeof buildTotalStats>, filters: Filters) => {
return Object.keys(filters).every(key => {
const statKey = key as StatKey;
const value = (totals as any)[statKey];
const range = filters[statKey];
if (range.min !== undefined && value < range.min) return false;
if (range.max !== undefined && value > range.max) return false;
return true;
});
};
export const isFourPieceSet = (setName: string) => FOUR_PIECE_SETS.has(setName);
export const isTwoPieceSet = (setName: string) => TWO_PIECE_SETS.has(setName);
export const getSetPieceCount = (setName: string) => {
if (!setName || setName === 'All') return 0;
if (isFourPieceSet(setName)) return 4;
if (isTwoPieceSet(setName)) return 2;
return -1;
};
export const getSelectedSetNames = (setFilters: SetFilters) => {
return [setFilters.set1, setFilters.set2, setFilters.set3]
.flat()
.filter(setName => setName && setName !== 'All');
};
export const getSetFilterPieceTotal = (setFilters: SetFilters) => {
return getSelectedSetNames(setFilters).reduce((total, setName) => {
const pieces = getSetPieceCount(setName);
return pieces > 0 ? total + pieces : total;
}, 0);
};
export const isValidSetFilterSelection = (setFilters: SetFilters) => {
const selectedSets = getSelectedSetNames(setFilters);
if (selectedSets.length === 0) return true;
const totalPieces = selectedSets.reduce((total, setName) => {
const pieces = getSetPieceCount(setName);
if (pieces <= 0) return Number.POSITIVE_INFINITY;
return total + pieces;
}, 0);
return totalPieces <= 6;
};
const buildRequiredSetCounts = (setFilters: SetFilters) => {
const required = new Map<string, number>();
for (const setName of getSelectedSetNames(setFilters)) {
const pieces = getSetPieceCount(setName);
if (pieces <= 0) return null;
required.set(setName, (required.get(setName) || 0) + pieces);
}
return required;
};
export const getCompletedSets = (items: RawItem[]) => {
const counts = new Map<string, number>();
items.forEach(item => {
if (!item.set) return;
counts.set(item.set, (counts.get(item.set) || 0) + 1);
});
const completed: string[] = [];
counts.forEach((count, setName) => {
if (FOUR_PIECE_SETS.has(setName) && count >= 4) {
completed.push(setName);
return;
}
if (TWO_PIECE_SETS.has(setName) && count >= 2) {
const stacks = Math.floor(count / 2);
for (let i = 0; i < stacks; i += 1) {
completed.push(setName);
}
}
});
return completed;
};
export const passesSetFilter = (items: RawItem[], setFilters: SetFilters) => {
const requiredCounts = buildRequiredSetCounts(setFilters);
if (!requiredCounts) return false;
if (requiredCounts.size === 0) {
return true;
}
if (!isValidSetFilterSelection(setFilters)) return false;
const counts = new Map<string, number>();
items.forEach(item => {
if (!item.set) return;
counts.set(item.set, (counts.get(item.set) || 0) + 1);
});
for (const [setName, requiredPieces] of requiredCounts) {
if ((counts.get(setName) || 0) < requiredPieces) {
return false;
}
}
return true;
};

View File

@@ -2,6 +2,8 @@
// This file is automatically generated. DO NOT EDIT
import {model} from '../models';
export function CancelOptimizeBuilds():Promise<void>;
export function DeleteParsedSession(arg1:number):Promise<void>;
export function ExportCurrentData(arg1:string):Promise<void>;
@@ -24,6 +26,8 @@ export function GetLatestParsedDataFromDatabase():Promise<model.ParsedResult>;
export function GetNetworkInterfaces():Promise<Array<model.NetworkInterface>>;
export function GetOptimizerPreference(arg1:string):Promise<string>;
export function GetParsedDataByID(arg1:number):Promise<model.ParsedResult>;
export function GetParsedSessions():Promise<Array<model.ParsedSession>>;
@@ -38,12 +42,16 @@ export function ReadRawJsonFile():Promise<model.ParsedResult>;
export function SaveAppSetting(arg1:string,arg2:string):Promise<void>;
export function SaveOptimizerPreference(arg1:string,arg2:string):Promise<void>;
export function SaveParsedDataToDatabase(arg1:string,arg2:string,arg3:string,arg4:string):Promise<void>;
export function StartCapture(arg1:string):Promise<void>;
export function StartCaptureWithFilter(arg1:string,arg2:string):Promise<void>;
export function StartOptimizeBuilds(arg1:model.OptimizeRequest):Promise<void>;
export function StopAndParseCapture():Promise<model.ParsedResult>;
export function StopCapture():Promise<void>;

View File

@@ -2,6 +2,10 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function CancelOptimizeBuilds() {
return window['go']['service']['App']['CancelOptimizeBuilds']();
}
export function DeleteParsedSession(arg1) {
return window['go']['service']['App']['DeleteParsedSession'](arg1);
}
@@ -46,6 +50,10 @@ export function GetNetworkInterfaces() {
return window['go']['service']['App']['GetNetworkInterfaces']();
}
export function GetOptimizerPreference(arg1) {
return window['go']['service']['App']['GetOptimizerPreference'](arg1);
}
export function GetParsedDataByID(arg1) {
return window['go']['service']['App']['GetParsedDataByID'](arg1);
}
@@ -74,6 +82,10 @@ export function SaveAppSetting(arg1, arg2) {
return window['go']['service']['App']['SaveAppSetting'](arg1, arg2);
}
export function SaveOptimizerPreference(arg1, arg2) {
return window['go']['service']['App']['SaveOptimizerPreference'](arg1, arg2);
}
export function SaveParsedDataToDatabase(arg1, arg2, arg3, arg4) {
return window['go']['service']['App']['SaveParsedDataToDatabase'](arg1, arg2, arg3, arg4);
}
@@ -86,6 +98,10 @@ export function StartCaptureWithFilter(arg1, arg2) {
return window['go']['service']['App']['StartCaptureWithFilter'](arg1, arg2);
}
export function StartOptimizeBuilds(arg1) {
return window['go']['service']['App']['StartOptimizeBuilds'](arg1);
}
export function StopAndParseCapture() {
return window['go']['service']['App']['StopAndParseCapture']();
}

3
go.mod
View File

@@ -6,7 +6,7 @@ toolchain go1.24.4
require (
github.com/google/gopacket v1.1.19
github.com/wailsapp/wails/v2 v2.10.1
github.com/wailsapp/wails/v2 v2.11.0
go.uber.org/zap v1.26.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
modernc.org/sqlite v1.45.0
@@ -18,6 +18,7 @@ require (
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect

6
go.sum
View File

@@ -14,6 +14,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
@@ -67,8 +69,8 @@ github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6N
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.10.1 h1:QWHvWMXII2nI/nXz77gpPG8P3ehl6zKe+u4su5BWIns=
github.com/wailsapp/wails/v2 v2.10.1/go.mod h1:zrebnFV6MQf9kx8HI4iAv63vsR5v67oS7GTEZ7Pz1TY=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=

View File

@@ -87,9 +87,17 @@ func (d *Database) initTables() error {
updated_at INTEGER NOT NULL
);`
optimizerPreferencesTable := `
CREATE TABLE IF NOT EXISTS optimizer_preferences (
hero_id TEXT PRIMARY KEY,
options_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);`
tables := []string{
parsedDataTable,
settingsTable,
optimizerPreferencesTable,
}
for _, table := range tables {
@@ -221,6 +229,9 @@ func (d *Database) GetSetting(key string) (string, error) {
var value string
err := d.db.QueryRow(stmt, key).Scan(&value)
if err != nil {
if err == sql.ErrNoRows {
return "", nil
}
return "", err
}
return value, nil
@@ -246,3 +257,26 @@ func (d *Database) GetAllSettings() (map[string]string, error) {
return settings, nil
}
// SaveOptimizerPreference saves the latest optimizer options for a hero.
func (d *Database) SaveOptimizerPreference(heroID string, optionsJSON string) error {
stmt := `
INSERT OR REPLACE INTO optimizer_preferences (hero_id, options_json, updated_at)
VALUES (?, ?, ?)`
_, err := d.db.Exec(stmt, heroID, optionsJSON, time.Now().Unix())
return err
}
// GetOptimizerPreference returns saved optimizer options for a hero.
func (d *Database) GetOptimizerPreference(heroID string) (string, error) {
stmt := "SELECT options_json FROM optimizer_preferences WHERE hero_id = ?"
var optionsJSON string
err := d.db.QueryRow(stmt, heroID).Scan(&optionsJSON)
if err != nil {
if err == sql.ErrNoRows {
return "", nil
}
return "", err
}
return optionsJSON, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -116,3 +116,22 @@ func (s *DatabaseService) GetAllAppSettings() (map[string]string, error) {
return settings, nil
}
// SaveOptimizerPreference saves the latest optimizer options for a hero.
func (s *DatabaseService) SaveOptimizerPreference(heroID string, optionsJSON string) error {
if err := s.db.SaveOptimizerPreference(heroID, optionsJSON); err != nil {
s.logger.Error("保存配装偏好失败", "error", err, "hero_id", heroID)
return fmt.Errorf("保存配装偏好失败: %w", err)
}
return nil
}
// GetOptimizerPreference gets saved optimizer options for a hero.
func (s *DatabaseService) GetOptimizerPreference(heroID string) (string, error) {
optionsJSON, err := s.db.GetOptimizerPreference(heroID)
if err != nil {
s.logger.Error("获取配装偏好失败", "error", err, "hero_id", heroID)
return "", fmt.Errorf("获取配装偏好失败: %w", err)
}
return optionsJSON, nil
}

View File

@@ -3,8 +3,10 @@ package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"sync"
"time"
"equipment-analyzer/internal/capture"
@@ -23,6 +25,8 @@ type App struct {
parserService *ParserService
database *model.Database
databaseService *DatabaseService
optimizeMu sync.Mutex
optimizeCancel context.CancelFunc
}
func NewApp(cfg *config.Config, logger *utils.Logger) *App {
@@ -84,16 +88,97 @@ func (a *App) Shutdown(ctx context.Context) {
}
}
// OptimizeBuilds runs the optimizer on latest parsed data.
// OptimizeBuilds runs the optimizer on latest parsed data and returns when done.
func (a *App) OptimizeBuilds(req model.OptimizeRequest) (*model.OptimizeResponse, error) {
log.Printf("[service] OptimizeBuilds entry heroId=%s", req.HeroID)
log.Printf("[service] OptimizeBuilds sync entry heroId=%s", req.HeroID)
optCtx, cancel := context.WithCancel(context.Background())
a.optimizeMu.Lock()
if a.optimizeCancel != nil {
a.optimizeMu.Unlock()
cancel()
return nil, fmt.Errorf("已有配装计算正在进行")
}
a.optimizeCancel = cancel
a.optimizeMu.Unlock()
defer func() {
cancel()
a.optimizeMu.Lock()
a.optimizeCancel = nil
a.optimizeMu.Unlock()
}()
resp, err := a.runOptimizeBuilds(optCtx, req, a.emitOptimizeProgress)
if err != nil {
if errors.Is(err, context.Canceled) {
log.Printf("[service] OptimizeBuilds canceled")
return nil, fmt.Errorf("配装计算已中断")
}
log.Printf("[service] OptimizeBuilds optimize failed: %v", err)
return nil, err
}
log.Printf("[service] OptimizeBuilds done results=%d totalCombos=%d", len(resp.Results), resp.TotalCombos)
return resp, nil
}
// StartOptimizeBuilds starts the optimizer in the background and reports progress through Wails events.
func (a *App) StartOptimizeBuilds(req model.OptimizeRequest) error {
log.Printf("[service] StartOptimizeBuilds entry heroId=%s", req.HeroID)
optCtx, cancel := context.WithCancel(context.Background())
a.optimizeMu.Lock()
if a.optimizeCancel != nil {
a.optimizeMu.Unlock()
cancel()
return fmt.Errorf("已有配装计算正在进行")
}
a.optimizeCancel = cancel
a.optimizeMu.Unlock()
go func() {
defer func() {
cancel()
a.optimizeMu.Lock()
a.optimizeCancel = nil
a.optimizeMu.Unlock()
}()
resp, err := a.runOptimizeBuilds(optCtx, req, a.emitOptimizeProgress)
if err != nil {
if errors.Is(err, context.Canceled) {
log.Printf("[service] StartOptimizeBuilds canceled")
if a.ctx != nil {
runtime.EventsEmit(a.ctx, "optimize:canceled", "配装计算已中断")
}
return
}
log.Printf("[service] StartOptimizeBuilds optimize failed: %v", err)
if a.ctx != nil {
runtime.EventsEmit(a.ctx, "optimize:error", err.Error())
}
return
}
log.Printf("[service] StartOptimizeBuilds done results=%d totalCombos=%d", len(resp.Results), resp.TotalCombos)
if a.ctx != nil {
runtime.EventsEmit(a.ctx, "optimize:done", resp)
}
}()
return nil
}
func (a *App) runOptimizeBuilds(ctx context.Context, req model.OptimizeRequest, progress optimizer.ProgressCallback) (*model.OptimizeResponse, error) {
parsedResult, err := a.GetLatestParsedDataFromDatabase()
if err != nil {
log.Printf("[service] OptimizeBuilds get data failed: %v", err)
return nil, err
}
if parsedResult == nil || (len(parsedResult.Items) == 0 && len(parsedResult.Heroes) == 0) {
log.Printf("[service] OptimizeBuilds no data items=%d heroes=%d", len(parsedResult.Items), len(parsedResult.Heroes))
itemCount := 0
heroCount := 0
if parsedResult != nil {
itemCount = len(parsedResult.Items)
heroCount = len(parsedResult.Heroes)
}
if parsedResult == nil || (itemCount == 0 && heroCount == 0) {
log.Printf("[service] OptimizeBuilds no data items=%d heroes=%d", itemCount, heroCount)
return &model.OptimizeResponse{TotalCombos: 0, Results: []model.OptimizeResult{}}, nil
}
@@ -140,13 +225,37 @@ func (a *App) OptimizeBuilds(req model.OptimizeRequest) (*model.OptimizeResponse
heroes = templateHeroes
log.Printf("[service] OptimizeBuilds parsed items=%d heroes=%d", len(items), len(heroes))
resp, err := optimizer.Optimize(items, heroes, req)
if err != nil {
log.Printf("[service] OptimizeBuilds optimize failed: %v", err)
return nil, err
return optimizer.OptimizeWithContext(ctx, items, heroes, req, progress)
}
func (a *App) emitOptimizeProgress(checked int, total int, matched int) {
if a.ctx == nil {
return
}
log.Printf("[service] OptimizeBuilds done results=%d totalCombos=%d", len(resp.Results), resp.TotalCombos)
return resp, nil
percent := 0.0
if total > 0 {
percent = float64(checked) * 100 / float64(total)
if percent > 100 {
percent = 100
}
}
runtime.EventsEmit(a.ctx, "optimize:progress", map[string]interface{}{
"checked": checked,
"matched": matched,
"total": total,
"percent": percent,
})
}
// CancelOptimizeBuilds stops the currently running optimizer, if any.
func (a *App) CancelOptimizeBuilds() error {
a.optimizeMu.Lock()
cancel := a.optimizeCancel
a.optimizeMu.Unlock()
if cancel != nil {
cancel()
}
return nil
}
// GetNetworkInterfaces returns available network interfaces.
@@ -529,6 +638,28 @@ func (a *App) GetAllAppSettings() (map[string]string, error) {
return a.databaseService.GetAllAppSettings()
}
// SaveOptimizerPreference saves optimizer options for a hero.
func (a *App) SaveOptimizerPreference(heroID string, optionsJSON string) error {
if a.databaseService == nil {
return fmt.Errorf("database service not initialized")
}
if heroID == "" {
return fmt.Errorf("hero id is required")
}
return a.databaseService.SaveOptimizerPreference(heroID, optionsJSON)
}
// GetOptimizerPreference gets optimizer options for a hero.
func (a *App) GetOptimizerPreference(heroID string) (string, error) {
if a.databaseService == nil {
return "", fmt.Errorf("database service not initialized")
}
if heroID == "" {
return "", nil
}
return a.databaseService.GetOptimizerPreference(heroID)
}
func logMissingSetStats(items []interface{}) {
if len(items) == 0 {
return