feat(database): add gearTxt field to parsed results and update related functions
This commit is contained in:
@@ -10,14 +10,19 @@ import {
|
|||||||
Layout,
|
Layout,
|
||||||
Modal,
|
Modal,
|
||||||
Pagination,
|
Pagination,
|
||||||
|
Progress,
|
||||||
Row,
|
Row,
|
||||||
Select,
|
Select,
|
||||||
Slider,
|
Slider,
|
||||||
|
Space,
|
||||||
|
Switch,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
|
Typography,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {AppstoreOutlined, FilterOutlined, ReloadOutlined, UserOutlined} from '@ant-design/icons';
|
import {AppstoreOutlined, FilterOutlined, ReloadOutlined, UserOutlined} from '@ant-design/icons';
|
||||||
import * as App from '../../wailsjs/go/service/App';
|
import * as App from '../../wailsjs/go/service/App';
|
||||||
|
import {EventsOn} from '../../wailsjs/runtime';
|
||||||
import {useMessage} from '../utils/useMessage';
|
import {useMessage} from '../utils/useMessage';
|
||||||
import {
|
import {
|
||||||
BonusStats,
|
BonusStats,
|
||||||
@@ -30,17 +35,28 @@ import {
|
|||||||
emptyBonusStats,
|
emptyBonusStats,
|
||||||
emptyFilters,
|
emptyFilters,
|
||||||
emptySetFilters,
|
emptySetFilters,
|
||||||
isFourPieceSet,
|
getSetFilterPieceTotal,
|
||||||
isTwoPieceSet,
|
isValidSetFilterSelection,
|
||||||
} from '../utils/optimizer';
|
} from '../utils/optimizer';
|
||||||
import './optimizer.css';
|
import './optimizer.css';
|
||||||
|
|
||||||
const {Content} = Layout;
|
const {Content} = Layout;
|
||||||
const MAX_ITEMS_PER_SLOT = 150;
|
const {Text} = Typography;
|
||||||
const MAX_COMBOS = 200000;
|
const MAX_ITEMS_PER_SLOT = 300;
|
||||||
const MAX_RESULTS = 200;
|
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_SLOTS = ['Weapon', 'Helmet', 'Armor', 'Necklace', 'Ring', 'Boots'] as const;
|
||||||
|
|
||||||
|
const GEAR_LABELS: Record<string, string> = {
|
||||||
|
Weapon: '武器',
|
||||||
|
Helmet: '头盔',
|
||||||
|
Armor: '铠甲',
|
||||||
|
Necklace: '项链',
|
||||||
|
Ring: '戒指',
|
||||||
|
Boots: '鞋子',
|
||||||
|
};
|
||||||
|
|
||||||
type BuildResult = {
|
type BuildResult = {
|
||||||
key: string;
|
key: string;
|
||||||
sets: string;
|
sets: string;
|
||||||
@@ -56,6 +72,14 @@ type BuildResult = {
|
|||||||
items: RawItem[];
|
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}> = [
|
const STAT_LABELS: Array<{key: StatKey; label: string}> = [
|
||||||
{key: 'atk', label: '\u653b\u51fb'},
|
{key: 'atk', label: '\u653b\u51fb'},
|
||||||
{key: 'def', label: '\u9632\u5fa1'},
|
{key: 'def', label: '\u9632\u5fa1'},
|
||||||
@@ -67,6 +91,107 @@ const STAT_LABELS: Array<{key: StatKey; label: string}> = [
|
|||||||
{key: 'res', label: '\u62b5\u6297'},
|
{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) => {
|
const getHeroStatValue = (hero: RawHero | null, key: StatKey) => {
|
||||||
if (!hero) return 0;
|
if (!hero) return 0;
|
||||||
switch (key) {
|
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() {
|
export default function OptimizerPage() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [parsedItems, setParsedItems] = useState<RawItem[]>([]);
|
const [parsedItems, setParsedItems] = useState<RawItem[]>([]);
|
||||||
@@ -98,28 +233,21 @@ export default function OptimizerPage() {
|
|||||||
const [selectedHeroId, setSelectedHeroId] = useState<string | number | null>(null);
|
const [selectedHeroId, setSelectedHeroId] = useState<string | number | null>(null);
|
||||||
const [setFilters, setSetFilters] = useState<SetFilters>(emptySetFilters);
|
const [setFilters, setSetFilters] = useState<SetFilters>(emptySetFilters);
|
||||||
const [statFilters, setStatFilters] = useState<Filters>(emptyFilters);
|
const [statFilters, setStatFilters] = useState<Filters>(emptyFilters);
|
||||||
const [mainStatFilters, setMainStatFilters] = useState<{necklace: MainStatKey | 'All'; ring: MainStatKey | 'All'; boots: MainStatKey | 'All'}>({
|
const [mainStatFilters, setMainStatFilters] = useState<OptimizerPreferenceOptions['mainStatFilters']>(createDefaultMainStatFilters);
|
||||||
necklace: 'All',
|
const [weightValues, setWeightValues] = useState<Record<StatKey, number>>(createDefaultWeightValues);
|
||||||
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 [results, setResults] = useState<BuildResult[]>([]);
|
||||||
const [selectedResult, setSelectedResult] = useState<BuildResult | null>(null);
|
const [selectedResult, setSelectedResult] = useState<BuildResult | null>(null);
|
||||||
const [totalCombos, setTotalCombos] = useState(0);
|
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 {success, error, info} = useMessage();
|
||||||
const [bonusByHeroId, setBonusByHeroId] = useState<Record<string, BonusStats>>({});
|
const [bonusByHeroId, setBonusByHeroId] = useState<Record<string, BonusStats>>({});
|
||||||
const [bonusEditorOpen, setBonusEditorOpen] = useState(false);
|
const [bonusEditorOpen, setBonusEditorOpen] = useState(false);
|
||||||
const [bonusDraft, setBonusDraft] = useState<BonusStats>(emptyBonusStats);
|
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 leftPanelsRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [leftPanelsHeight, setLeftPanelsHeight] = useState<number | 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 selectedHeroKey = selectedHeroId == null ? '' : String(selectedHeroId);
|
||||||
const selectedHeroBonus = bonusByHeroId[selectedHeroKey] || emptyBonusStats;
|
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 () => {
|
const loadLatestData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -388,6 +550,146 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
|||||||
loadLatestData();
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
if (!leftPanelsRef.current) return;
|
if (!leftPanelsRef.current) return;
|
||||||
const observer = new ResizeObserver(entries => {
|
const observer = new ResizeObserver(entries => {
|
||||||
@@ -425,22 +727,84 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
|||||||
|
|
||||||
const resetPreferences = () => {
|
const resetPreferences = () => {
|
||||||
setWeightValues({
|
setWeightValues({
|
||||||
atk: 3,
|
atk: 0,
|
||||||
def: 3,
|
def: 0,
|
||||||
hp: 3,
|
hp: 0,
|
||||||
spd: 3,
|
spd: 0,
|
||||||
cr: 3,
|
cr: 0,
|
||||||
cd: 3,
|
cd: 0,
|
||||||
acc: 3,
|
acc: 0,
|
||||||
res: 3,
|
res: 0,
|
||||||
});
|
});
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setSelectedResult(null);
|
setSelectedResult(null);
|
||||||
setTotalCombos(0);
|
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 () => {
|
const buildResults = async () => {
|
||||||
console.log('OptimizerPage buildResults clicked');
|
console.log('OptimizerPage buildResults clicked');
|
||||||
|
if (optimizing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!selectedHero) {
|
if (!selectedHero) {
|
||||||
info('\u8bf7\u5148\u9009\u62e9\u82f1\u96c4');
|
info('\u8bf7\u5148\u9009\u62e9\u82f1\u96c4');
|
||||||
return;
|
return;
|
||||||
@@ -449,8 +813,18 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
|||||||
info('\u6682\u65e0\u88c5\u5907\u6570\u636e');
|
info('\u6682\u65e0\u88c5\u5907\u6570\u636e');
|
||||||
return;
|
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 {
|
try {
|
||||||
console.log('OptimizeBuilds calling', {
|
console.log('StartOptimizeBuilds calling', {
|
||||||
heroId: String(selectedHero.id ?? ''),
|
heroId: String(selectedHero.id ?? ''),
|
||||||
setFilters,
|
setFilters,
|
||||||
statFilters,
|
statFilters,
|
||||||
@@ -458,7 +832,7 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
|||||||
weightValues,
|
weightValues,
|
||||||
bonusStats: selectedHeroBonus,
|
bonusStats: selectedHeroBonus,
|
||||||
});
|
});
|
||||||
const resp = await App.OptimizeBuilds({
|
await App.StartOptimizeBuilds({
|
||||||
heroId: String(selectedHero.code ?? ''),
|
heroId: String(selectedHero.code ?? ''),
|
||||||
setFilters,
|
setFilters,
|
||||||
statFilters,
|
statFilters,
|
||||||
@@ -469,34 +843,84 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
|||||||
maxCombos: MAX_COMBOS,
|
maxCombos: MAX_COMBOS,
|
||||||
maxResults: MAX_RESULTS,
|
maxResults: MAX_RESULTS,
|
||||||
} as any);
|
} as any);
|
||||||
console.log('OptimizeBuilds returned', resp);
|
console.log('StartOptimizeBuilds started');
|
||||||
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) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : '\u914d\u88c5\u5931\u8d25';
|
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);
|
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 = [
|
const resultColumns = [
|
||||||
{title: '套装', dataIndex: 'sets', key: 'sets', render: (v: string) => <Tag>{v}</Tag>},
|
{
|
||||||
{title: '攻击', dataIndex: 'atk', key: 'atk'},
|
title: '套装',
|
||||||
{title: '防御', dataIndex: 'def', key: 'def'},
|
dataIndex: 'sets',
|
||||||
{title: '生命', dataIndex: 'hp', key: 'hp'},
|
key: 'sets',
|
||||||
{title: '速度', dataIndex: 'spd', key: 'spd'},
|
width: 156,
|
||||||
{title: '暴击', dataIndex: 'cr', key: 'cr'},
|
ellipsis: true,
|
||||||
{title: '爆伤', dataIndex: 'cd', key: 'cd'},
|
render: (v: string) => <Tag className="optimizer-result-set-tag">{v}</Tag>,
|
||||||
{title: '命中', dataIndex: 'acc', key: 'acc'},
|
},
|
||||||
{title: '抵抗', dataIndex: 'res', key: 'res'},
|
{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 (
|
return (
|
||||||
@@ -528,7 +952,7 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={6}>
|
<Col span={4}>
|
||||||
<div style={{fontWeight: 500, marginBottom: 8}}>属性</div>
|
<div style={{fontWeight: 500, marginBottom: 8}}>属性</div>
|
||||||
<div style={{display: 'flex', flexDirection: 'column', gap: 8}}>
|
<div style={{display: 'flex', flexDirection: 'column', gap: 8}}>
|
||||||
{STAT_LABELS.map(stat => (
|
{STAT_LABELS.map(stat => (
|
||||||
@@ -574,7 +998,7 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
|||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
<Col
|
<Col
|
||||||
span={6}
|
span={8}
|
||||||
style={{display: 'flex', flexDirection: 'column', justifyContent: 'flex-start', gap: 10}}
|
style={{display: 'flex', flexDirection: 'column', justifyContent: 'flex-start', gap: 10}}
|
||||||
ref={rightPanelRef}
|
ref={rightPanelRef}
|
||||||
>
|
>
|
||||||
@@ -604,7 +1028,10 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
|||||||
<span className="optimizer-set-badge">{renderSetBadge(setFilters.set3)}</span>
|
<span className="optimizer-set-badge">{renderSetBadge(setFilters.set3)}</span>
|
||||||
<Button
|
<Button
|
||||||
className="optimizer-set-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)}
|
onClick={(event) => openSetPicker('set3', event)}
|
||||||
>
|
>
|
||||||
{getSetLabel(setFilters.set3)}
|
{getSetLabel(setFilters.set3)}
|
||||||
@@ -642,123 +1069,54 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="optimizer-weight-panel" style={leftPanelsHeight ? {height: leftPanelsHeight} : undefined}>
|
<div className="optimizer-weight-panel" style={leftPanelsHeight ? {height: leftPanelsHeight} : undefined}>
|
||||||
<div className="optimizer-weight-row">
|
<div className="optimizer-weight-header">
|
||||||
<span className="optimizer-weight-icon">攻</span>
|
<span>优先级</span>
|
||||||
<span className="optimizer-weight-label">攻击</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
|
<Slider
|
||||||
min={0}
|
min={0}
|
||||||
max={5}
|
max={5}
|
||||||
step={1}
|
step={1}
|
||||||
dots
|
dots
|
||||||
|
tooltip={{open: false, formatter: null}}
|
||||||
className="optimizer-weight-slider"
|
className="optimizer-weight-slider"
|
||||||
value={weightValues.atk}
|
value={weightValues[option.key]}
|
||||||
onChange={value => setWeightValues(prev => ({...prev, atk: value as number}))}
|
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>
|
||||||
<div className="optimizer-weight-row">
|
))}
|
||||||
<span className="optimizer-weight-icon">防</span>
|
</div>
|
||||||
<span className="optimizer-weight-label">防御</span>
|
</div>
|
||||||
<Slider
|
</div>
|
||||||
min={0}
|
<div style={{display: 'flex', gap: 12, alignItems: 'center', justifyContent: 'flex-end', width: '100%', flexWrap: 'wrap'}}>
|
||||||
max={5}
|
<Space size={6} align="center" style={{height: 32, flex: '0 0 auto', whiteSpace: 'nowrap'}}>
|
||||||
step={1}
|
<Switch
|
||||||
dots
|
size="small"
|
||||||
className="optimizer-weight-slider"
|
checked={rememberPreferences}
|
||||||
value={weightValues.def}
|
disabled={optimizing || loading}
|
||||||
onChange={value => setWeightValues(prev => ({...prev, def: value as number}))}
|
onChange={updateRememberPreferences}
|
||||||
/>
|
/>
|
||||||
</div>
|
<Text style={{whiteSpace: 'nowrap'}}>记录偏好</Text>
|
||||||
<div className="optimizer-weight-row">
|
</Space>
|
||||||
<span className="optimizer-weight-icon">生</span>
|
<Button icon={<ReloadOutlined/>} onClick={loadLatestData} loading={loading} disabled={optimizing} style={{height: 32, display: 'none'}}>
|
||||||
<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>
|
||||||
<Button icon={<ReloadOutlined/>} onClick={resetSetsAndAttrs} style={{height: 32}}>
|
<Button icon={<ReloadOutlined/>} onClick={resetSetsAndAttrs} disabled={optimizing} style={{height: 32}}>
|
||||||
{'\u91cd\u7f6e\u5957\u88c5'}
|
{'\u91cd\u7f6e\u5957\u88c5'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button icon={<FilterOutlined/>} onClick={resetPreferences} style={{height: 32}}>
|
<Button icon={<FilterOutlined/>} onClick={resetPreferences} disabled={optimizing} style={{height: 32}}>
|
||||||
{'\u91cd\u7f6e\u504f\u597d'}
|
{'\u91cd\u7f6e\u504f\u597d'}
|
||||||
</Button>
|
</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'}
|
{'\u5f00\u59cb\u914d\u88c5'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -767,24 +1125,61 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="配装结果" style={{marginBottom: 16}}>
|
<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>
|
<div>
|
||||||
组合数量:<b>{totalCombos.toLocaleString()}</b> |
|
组合数量:<b>{totalCombos.toLocaleString()}</b> |
|
||||||
结果数量:<b>{results.length.toLocaleString()}</b>
|
结果数量:<b>{visibleResults.length.toLocaleString()}</b>
|
||||||
|
{visibleResults.length !== results.length ? (
|
||||||
|
<> / {results.length.toLocaleString()}</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<Pagination
|
<Pagination
|
||||||
size="small"
|
size="small"
|
||||||
total={results.length}
|
total={visibleResults.length}
|
||||||
pageSize={10}
|
pageSize={10}
|
||||||
current={1}
|
current={1}
|
||||||
showSizeChanger={false}
|
showSizeChanger={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Table
|
<Table
|
||||||
dataSource={results}
|
className="optimizer-result-table"
|
||||||
|
dataSource={visibleResults}
|
||||||
columns={resultColumns}
|
columns={resultColumns}
|
||||||
pagination={{pageSize: 10}}
|
pagination={{pageSize: 10}}
|
||||||
scroll={{x: true}}
|
scroll={{x: RESULT_TABLE_SCROLL_X}}
|
||||||
|
showSorterTooltip={false}
|
||||||
|
tableLayout="fixed"
|
||||||
rowKey="key"
|
rowKey="key"
|
||||||
onRow={record => ({
|
onRow={record => ({
|
||||||
onClick: () => setSelectedResult(record),
|
onClick: () => setSelectedResult(record),
|
||||||
@@ -794,6 +1189,7 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
|||||||
|
|
||||||
<Card title="配装详情">
|
<Card title="配装详情">
|
||||||
{selectedResult ? (
|
{selectedResult ? (
|
||||||
|
<>
|
||||||
<div style={{display: 'flex', gap: 24}}>
|
<div style={{display: 'flex', gap: 24}}>
|
||||||
<div>
|
<div>
|
||||||
<Avatar size={48} icon={<UserOutlined/>}/>
|
<Avatar size={48} icon={<UserOutlined/>}/>
|
||||||
@@ -814,12 +1210,53 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
<div style={{color: '#999'}}>暂无配装结果</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</Content>
|
</Content>
|
||||||
</Layout>
|
</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
|
<Modal
|
||||||
open={bonusEditorOpen}
|
open={bonusEditorOpen}
|
||||||
title={'\u989d\u5916\u52a0\u6210'}
|
title={'\u989d\u5916\u52a0\u6210'}
|
||||||
@@ -907,28 +1344,7 @@ const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStat
|
|||||||
className="optimizer-set-modal-item"
|
className="optimizer-set-modal-item"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const nextValue = option.setKey ? [option.setKey] : [];
|
const nextValue = option.setKey ? [option.setKey] : [];
|
||||||
if (setPickerTarget === 'set1') {
|
applySetSelection(buildNextSetFilters(setPickerTarget, nextValue));
|
||||||
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-icon">◎</span>
|
||||||
|
|||||||
@@ -186,19 +186,6 @@
|
|||||||
color: #9fb3e5;
|
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 {
|
.optimizer-left-panels {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -206,83 +193,139 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.optimizer-side-panel {
|
.optimizer-side-panel {
|
||||||
display: flex;
|
display: grid;
|
||||||
gap: 8px;
|
grid-template-columns: minmax(124px, 0.82fr) minmax(176px, 1.18fr);
|
||||||
align-items: flex-start;
|
gap: 10px;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.optimizer-left-panels {
|
.optimizer-left-panels {
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.optimizer-weight-panel {
|
.optimizer-weight-panel {
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
box-sizing: border-box;
|
||||||
|
background: linear-gradient(180deg, #18243c 0%, #11192d 100%);
|
||||||
.optimizer-weight-panel {
|
border: 1px solid rgba(145, 174, 226, 0.18);
|
||||||
background: linear-gradient(180deg, #1f2c49 0%, #1a233b 100%);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
align-self: flex-start;
|
align-self: stretch;
|
||||||
height: fit-content;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.optimizer-weight-row {
|
.optimizer-weight-header {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 20px 34px 1fr;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: space-between;
|
||||||
margin-bottom: 6px;
|
height: 20px;
|
||||||
background: rgba(6, 10, 20, 0.55);
|
color: #e4ecff;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
font-size: 12px;
|
||||||
border-radius: 8px;
|
font-weight: 600;
|
||||||
padding: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.optimizer-weight-row-no-label {
|
.optimizer-weight-scale {
|
||||||
grid-template-columns: 20px 1fr;
|
min-width: 36px;
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
height: 18px;
|
height: 18px;
|
||||||
border-radius: 50%;
|
border-radius: 999px;
|
||||||
background: rgba(255, 255, 255, 0.12);
|
background: rgba(123, 180, 255, 0.14);
|
||||||
color: #c9d7f7;
|
color: #9fc4ff;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
.optimizer-weight-label {
|
||||||
color: #c9d7f7;
|
color: #d2def7;
|
||||||
font-size: 12px;
|
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 {
|
.optimizer-weight-slider .ant-slider-rail {
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: rgba(255, 255, 255, 0.12);
|
||||||
height: 4px;
|
height: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.optimizer-weight-slider .ant-slider-track {
|
.optimizer-weight-slider .ant-slider-track {
|
||||||
background-color: #7bb4ff;
|
background-color: #83bcff;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +333,7 @@
|
|||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
margin-top: -1px;
|
margin-top: -1px;
|
||||||
border-color: #7bb4ff;
|
border-color: #83bcff;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
@@ -299,15 +342,151 @@
|
|||||||
.optimizer-weight-slider .ant-slider-handle::after {
|
.optimizer-weight-slider .ant-slider-handle::after {
|
||||||
display: none;
|
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 {
|
.optimizer-progress-bar {
|
||||||
background-color: #6aa9ff;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.optimizer-weight-row .ant-slider-handle {
|
.optimizer-progress-bar .ant-progress-outer {
|
||||||
border-color: #6aa9ff;
|
width: 100%;
|
||||||
background-color: #ffffff;
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
429
frontend/src/utils/optimizer.ts
Normal file
429
frontend/src/utils/optimizer.ts
Normal 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;
|
||||||
|
};
|
||||||
8
frontend/wailsjs/go/service/App.d.ts
vendored
8
frontend/wailsjs/go/service/App.d.ts
vendored
@@ -2,6 +2,8 @@
|
|||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
import {model} from '../models';
|
import {model} from '../models';
|
||||||
|
|
||||||
|
export function CancelOptimizeBuilds():Promise<void>;
|
||||||
|
|
||||||
export function DeleteParsedSession(arg1:number):Promise<void>;
|
export function DeleteParsedSession(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function ExportCurrentData(arg1:string):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 GetNetworkInterfaces():Promise<Array<model.NetworkInterface>>;
|
||||||
|
|
||||||
|
export function GetOptimizerPreference(arg1:string):Promise<string>;
|
||||||
|
|
||||||
export function GetParsedDataByID(arg1:number):Promise<model.ParsedResult>;
|
export function GetParsedDataByID(arg1:number):Promise<model.ParsedResult>;
|
||||||
|
|
||||||
export function GetParsedSessions():Promise<Array<model.ParsedSession>>;
|
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 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 SaveParsedDataToDatabase(arg1:string,arg2:string,arg3:string,arg4:string):Promise<void>;
|
||||||
|
|
||||||
export function StartCapture(arg1:string):Promise<void>;
|
export function StartCapture(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function StartCaptureWithFilter(arg1:string,arg2: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 StopAndParseCapture():Promise<model.ParsedResult>;
|
||||||
|
|
||||||
export function StopCapture():Promise<void>;
|
export function StopCapture():Promise<void>;
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
|
||||||
|
export function CancelOptimizeBuilds() {
|
||||||
|
return window['go']['service']['App']['CancelOptimizeBuilds']();
|
||||||
|
}
|
||||||
|
|
||||||
export function DeleteParsedSession(arg1) {
|
export function DeleteParsedSession(arg1) {
|
||||||
return window['go']['service']['App']['DeleteParsedSession'](arg1);
|
return window['go']['service']['App']['DeleteParsedSession'](arg1);
|
||||||
}
|
}
|
||||||
@@ -46,6 +50,10 @@ export function GetNetworkInterfaces() {
|
|||||||
return window['go']['service']['App']['GetNetworkInterfaces']();
|
return window['go']['service']['App']['GetNetworkInterfaces']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetOptimizerPreference(arg1) {
|
||||||
|
return window['go']['service']['App']['GetOptimizerPreference'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetParsedDataByID(arg1) {
|
export function GetParsedDataByID(arg1) {
|
||||||
return window['go']['service']['App']['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);
|
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) {
|
export function SaveParsedDataToDatabase(arg1, arg2, arg3, arg4) {
|
||||||
return window['go']['service']['App']['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);
|
return window['go']['service']['App']['StartCaptureWithFilter'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function StartOptimizeBuilds(arg1) {
|
||||||
|
return window['go']['service']['App']['StartOptimizeBuilds'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function StopAndParseCapture() {
|
export function StopAndParseCapture() {
|
||||||
return window['go']['service']['App']['StopAndParseCapture']();
|
return window['go']['service']['App']['StopAndParseCapture']();
|
||||||
}
|
}
|
||||||
|
|||||||
3
go.mod
3
go.mod
@@ -6,7 +6,7 @@ toolchain go1.24.4
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/google/gopacket v1.1.19
|
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
|
go.uber.org/zap v1.26.0
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
modernc.org/sqlite v1.45.0
|
modernc.org/sqlite v1.45.0
|
||||||
@@ -18,6 +18,7 @@ require (
|
|||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
github.com/google/uuid v1.6.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/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||||
github.com/labstack/echo/v4 v4.13.3 // indirect
|
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||||
github.com/labstack/gommon v0.4.2 // indirect
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
|
|||||||
6
go.sum
6
go.sum
@@ -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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
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=
|
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/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 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
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.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
||||||
github.com/wailsapp/wails/v2 v2.10.1/go.mod h1:zrebnFV6MQf9kx8HI4iAv63vsR5v67oS7GTEZ7Pz1TY=
|
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 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
|
||||||
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
|
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
|
||||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||||
|
|||||||
@@ -87,9 +87,17 @@ func (d *Database) initTables() error {
|
|||||||
updated_at INTEGER NOT NULL
|
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{
|
tables := []string{
|
||||||
parsedDataTable,
|
parsedDataTable,
|
||||||
settingsTable,
|
settingsTable,
|
||||||
|
optimizerPreferencesTable,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
@@ -221,6 +229,9 @@ func (d *Database) GetSetting(key string) (string, error) {
|
|||||||
var value string
|
var value string
|
||||||
err := d.db.QueryRow(stmt, key).Scan(&value)
|
err := d.db.QueryRow(stmt, key).Scan(&value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return value, nil
|
return value, nil
|
||||||
@@ -246,3 +257,26 @@ func (d *Database) GetAllSettings() (map[string]string, error) {
|
|||||||
|
|
||||||
return settings, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
2115
internal/optimizer/optimizer.go
Normal file
2115
internal/optimizer/optimizer.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -116,3 +116,22 @@ func (s *DatabaseService) GetAllAppSettings() (map[string]string, error) {
|
|||||||
|
|
||||||
return settings, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"equipment-analyzer/internal/capture"
|
"equipment-analyzer/internal/capture"
|
||||||
@@ -23,6 +25,8 @@ type App struct {
|
|||||||
parserService *ParserService
|
parserService *ParserService
|
||||||
database *model.Database
|
database *model.Database
|
||||||
databaseService *DatabaseService
|
databaseService *DatabaseService
|
||||||
|
optimizeMu sync.Mutex
|
||||||
|
optimizeCancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewApp(cfg *config.Config, logger *utils.Logger) *App {
|
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) {
|
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()
|
parsedResult, err := a.GetLatestParsedDataFromDatabase()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[service] OptimizeBuilds get data failed: %v", err)
|
log.Printf("[service] OptimizeBuilds get data failed: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if parsedResult == nil || (len(parsedResult.Items) == 0 && len(parsedResult.Heroes) == 0) {
|
itemCount := 0
|
||||||
log.Printf("[service] OptimizeBuilds no data items=%d heroes=%d", len(parsedResult.Items), len(parsedResult.Heroes))
|
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
|
return &model.OptimizeResponse{TotalCombos: 0, Results: []model.OptimizeResult{}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,13 +225,37 @@ func (a *App) OptimizeBuilds(req model.OptimizeRequest) (*model.OptimizeResponse
|
|||||||
heroes = templateHeroes
|
heroes = templateHeroes
|
||||||
|
|
||||||
log.Printf("[service] OptimizeBuilds parsed items=%d heroes=%d", len(items), len(heroes))
|
log.Printf("[service] OptimizeBuilds parsed items=%d heroes=%d", len(items), len(heroes))
|
||||||
resp, err := optimizer.Optimize(items, heroes, req)
|
return optimizer.OptimizeWithContext(ctx, items, heroes, req, progress)
|
||||||
if err != nil {
|
|
||||||
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
|
func (a *App) emitOptimizeProgress(checked int, total int, matched int) {
|
||||||
|
if a.ctx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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.
|
// GetNetworkInterfaces returns available network interfaces.
|
||||||
@@ -529,6 +638,28 @@ func (a *App) GetAllAppSettings() (map[string]string, error) {
|
|||||||
return a.databaseService.GetAllAppSettings()
|
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{}) {
|
func logMissingSetStats(items []interface{}) {
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user