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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -2,6 +2,8 @@
// This file is automatically generated. DO NOT EDIT // 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>;

View File

@@ -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
View File

@@ -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
View File

@@ -14,6 +14,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/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=

View File

@@ -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
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -3,8 +3,10 @@ package service
import ( 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 func (a *App) emitOptimizeProgress(checked int, total int, matched int) {
if a.ctx == nil {
return
} }
log.Printf("[service] OptimizeBuilds done results=%d totalCombos=%d", len(resp.Results), resp.TotalCombos) percent := 0.0
return resp, nil 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