From 401f2b8db4bae74f9cbec568a74bfc872a403709 Mon Sep 17 00:00:00 2001 From: kever Date: Mon, 16 Feb 2026 13:44:16 +0800 Subject: [PATCH] feat(i18n): integrate i18next for internationalization support and add initial translation setup --- frontend/package-lock.json | 65 ++ frontend/package.json | 2 + frontend/package.json.md5 | 2 +- frontend/src/i18n.ts | 28 + frontend/src/main.tsx | 3 +- frontend/src/pages/OptimizerPage.tsx | 1267 ++++++++++++++++++++++---- frontend/src/pages/optimizer.css | 294 ++++++ internal/service/parser_service.go | 17 +- 8 files changed, 1484 insertions(+), 194 deletions(-) create mode 100644 frontend/src/i18n.ts create mode 100644 frontend/src/pages/optimizer.css diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2232130..6ae4414 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,8 +10,10 @@ "dependencies": { "@ant-design/icons": "^5.2.6", "antd": "^5.12.8", + "i18next": "^23.12.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-i18next": "^14.1.3", "react-router-dom": "^7.6.3", "zustand": "^5.0.6" }, @@ -1831,6 +1833,38 @@ "node": ">=8" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "23.16.8", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz", + "integrity": "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/ignore": { "version": "5.3.2", "dev": true, @@ -2830,6 +2864,28 @@ "react": "^18.3.1" } }, + "node_modules/react-i18next": { + "version": "14.1.3", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-14.1.3.tgz", + "integrity": "sha512-wZnpfunU6UIAiJ+bxwOiTmBOAaB14ha97MjOEnLGac2RJ+h/maIYXZuTHlmyqQVX1UVHmU1YDTQ5vxLmwfXTjw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "18.3.1", "license": "MIT" @@ -3243,6 +3299,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/which": { "version": "2.0.2", "dev": true, diff --git a/frontend/package.json b/frontend/package.json index 3ee64fe..f1382a0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,8 +12,10 @@ "dependencies": { "@ant-design/icons": "^5.2.6", "antd": "^5.12.8", + "i18next": "^23.12.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-i18next": "^14.1.3", "react-router-dom": "^7.6.3", "zustand": "^5.0.6" }, diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 382c539..8113440 100644 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -340ef73da49fc2e1f0f32752fcad55cd \ No newline at end of file +804e76dbe08fe839d4ff8e699a185ad4 \ No newline at end of file diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts new file mode 100644 index 0000000..e3f61cb --- /dev/null +++ b/frontend/src/i18n.ts @@ -0,0 +1,28 @@ +import i18n from 'i18next'; +import {initReactI18next} from 'react-i18next'; + +const DEFAULT_LANG = 'zh'; + +i18n.use(initReactI18next).init({ + lng: DEFAULT_LANG, + fallbackLng: DEFAULT_LANG, + resources: { + [DEFAULT_LANG]: { + translation: {}, + }, + }, + interpolation: { + escapeValue: false, + }, + react: { + useSuspense: false, + }, +}); + +export const applyTranslations = (lang: string, translations: Record) => { + if (!lang) return; + i18n.addResourceBundle(lang, 'translation', translations, true, true); + i18n.changeLanguage(lang); +}; + +export default i18n; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 53ab554..7db7710 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,9 +3,10 @@ import ReactDOM from 'react-dom/client' import App from './App.tsx' import 'antd/dist/reset.css' import './index.css' +import './i18n' ReactDOM.createRoot(document.getElementById('root')!).render( , -) \ No newline at end of file +) diff --git a/frontend/src/pages/OptimizerPage.tsx b/frontend/src/pages/OptimizerPage.tsx index 4347bc2..08a990a 100644 --- a/frontend/src/pages/OptimizerPage.tsx +++ b/frontend/src/pages/OptimizerPage.tsx @@ -1,243 +1,1130 @@ -import React from 'react'; +import React, {useEffect, useMemo, useRef, useState} from 'react'; +import {createPortal} from 'react-dom'; import { Avatar, Button, Card, Col, Divider, - Input, InputNumber, Layout, - Modal, Pagination, Row, Select, + Slider, Table, - Tag + Tag, } from 'antd'; -import {AppstoreOutlined, FilterOutlined, UserOutlined} from '@ant-design/icons'; +import {AppstoreOutlined, FilterOutlined, ReloadOutlined, UserOutlined} from '@ant-design/icons'; +import * as App from '../../wailsjs/go/service/App'; +import {useMessage} from '../utils/useMessage'; +import './optimizer.css'; const {Content} = Layout; -// 静态数据示例 -const heroList = [ - {id: 1, name: '雅娜凯', avatar: 'https://api.dicebear.com/7.x/miniavs/svg?seed=1'}, - {id: 2, name: '艾莉丝', avatar: 'https://api.dicebear.com/7.x/miniavs/svg?seed=2'}, -]; -const hero = heroList[0]; -const attributes = [ - {label: '攻击', value: 1567}, - {label: '防御', value: 1654}, - {label: '生命', value: 24447}, - {label: '速度', value: 188}, - {label: '暴击', value: 49}, - {label: '爆伤', value: 166}, - {label: '命中', value: 46}, - {label: '抵抗', value: 52}, -]; -const setOptions = [ - {label: '任意套装', value: 'any'}, - {label: '暴击套', value: 'crit'}, - {label: '速度套', value: 'speed'}, -]; -const filterOptions = [ - {label: '攻击', value: 'atk'}, - {label: '防御', value: 'def'}, - {label: '生命', value: 'hp'}, - {label: '速度', value: 'spd'}, - {label: '暴击', value: 'cr'}, - {label: '爆伤', value: 'cd'}, - {label: '命中', value: 'acc'}, - {label: '抵抗', value: 'res'}, +type RawItem = { + id?: string | number; + gear?: string; + set?: string; + main?: { type?: string; value?: number }; + substats?: Array<{ type?: string; value?: number }>; +}; + +type RawHero = { + id?: string | number; + 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; +}; + +type StatKey = 'atk' | 'def' | 'hp' | 'spd' | 'cr' | 'cd' | 'acc' | 'res'; +type StatRange = { min?: number; max?: number }; +type Filters = Record; +type MainStatKey = + | 'Attack' + | 'Defense' + | 'Health' + | 'AttackPercent' + | 'DefensePercent' + | 'HealthPercent' + | 'Speed' + | 'CriticalHitChancePercent' + | 'CriticalHitDamagePercent' + | 'EffectivenessPercent' + | 'EffectResistancePercent'; + +type ItemStats = { + atk: number; + def: number; + hp: number; + spd: number; + cr: number; + cd: number; + acc: number; + res: number; + atkPct: number; + defPct: number; + hpPct: number; +}; + +type BuildResult = { + key: string; + sets: string; + atk: number; + def: number; + hp: number; + spd: number; + cr: number; + cd: number; + acc: number; + res: number; + score: number; + items: RawItem[]; +}; + +const STAT_LABELS: Array<{ key: StatKey; label: string }> = [ + {key: 'atk', label: '攻击'}, + {key: 'def', label: '防御'}, + {key: 'hp', label: '生命'}, + {key: 'spd', label: '速度'}, + {key: 'cr', label: '暴击'}, + {key: 'cd', label: '爆伤'}, + {key: 'acc', label: '命中'}, + {key: 'res', label: '抵抗'}, ]; -const resultColumns = [ - {title: '套装', dataIndex: 'set', key: 'set', render: (v: string) => {v}}, - {title: '攻击', dataIndex: 'atk', key: 'atk'}, - {title: '防御', dataIndex: 'def', key: 'def'}, - {title: '生命', dataIndex: 'hp', key: 'hp'}, - {title: '速度', dataIndex: 'spd', key: 'spd'}, - {title: '暴击', dataIndex: 'cr', key: 'cr'}, - {title: '爆伤', dataIndex: 'cd', key: 'cd'}, - {title: '命中', dataIndex: 'acc', key: 'acc'}, - {title: '抵抗', dataIndex: 'res', key: 'res'}, -]; -const resultData = [ - {key: 1, set: '暴击套', atk: 2000, def: 1500, hp: 20000, spd: 200, cr: 100, cd: 250, acc: 30, res: 20}, - {key: 2, set: '速度套', atk: 1800, def: 1400, hp: 21000, spd: 220, cr: 80, cd: 200, acc: 40, res: 30}, -]; +const GEAR_SLOTS = ['Weapon', 'Helmet', 'Armor', 'Necklace', 'Ring', 'Boots']; +const MAX_ITEMS_PER_SLOT = 8; +const MAX_RESULTS = 200; +const MAX_COMBOS = 200000; -export default function OptimizerPage() { - const [editVisible, setEditVisible] = React.useState(false); - const [editData, setEditData] = React.useState({ - artifact: '', - artifactLevel: 0, - formation: '', - exclusive: '', - star: 5, - }); +const FOUR_PIECE_SETS = new Set([ + 'AttackSet', + 'SpeedSet', + 'DestructionSet', + 'LifestealSet', + 'ProtectionSet', + 'CounterSet', + 'RageSet', + 'RevengeSet', + 'InjurySet', + 'ReversalSet', + 'RiposteSet', + 'WarfareSet', +]); + +const TWO_PIECE_SETS = new Set([ + 'HealthSet', + 'DefenseSet', + 'CriticalSet', + 'HitSet', + 'ResistSet', + 'UnitySet', + 'ImmunitySet', + 'PenetrationSet', + 'TorrentSet', + 'PursuitSet', +]); + +type SetFilters = { + set1: string[]; + set2: string[]; + set3: string[]; +}; + +const emptyFilters: Filters = { + atk: {}, + def: {}, + hp: {}, + spd: {}, + cr: {}, + cd: {}, + acc: {}, + res: {}, +}; + +const emptySetFilters: SetFilters = { + set1: [], + set2: [], + set3: [], +}; + +const toNumber = (value: any) => { + if (typeof value !== 'number' || Number.isNaN(value)) return 0; + return value; +}; + +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; +}; + +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), + }; +}; + +const getHeroStatValue = (hero: RawHero | null, key: StatKey) => { + if (!hero) return 0; + if (key === 'acc') return toNumber(hero.eff); + return toNumber((hero as any)[key]); +}; + +const scoreStats = (stats: ItemStats, baseStats: ReturnType) => { + 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 + ); +}; + +const buildTotalStats = (statsList: ItemStats[], baseStats: ReturnType) => { + 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; + }); + + return { + atk: totals.atk + baseStats.atk + baseStats.atk * (totals.atkPct / 100), + def: totals.def + baseStats.def + baseStats.def * (totals.defPct / 100), + hp: totals.hp + baseStats.hp + baseStats.hp * (totals.hpPct / 100), + spd: totals.spd + baseStats.spd, + cr: totals.cr + baseStats.cr, + cd: totals.cd + baseStats.cd, + acc: totals.acc + baseStats.acc, + res: totals.res + baseStats.res, + }; +}; + +const passesFilters = (totals: ReturnType, filters: Filters) => { + return STAT_LABELS.every(stat => { + const value = totals[stat.key]; + const range = filters[stat.key]; + if (range.min !== undefined && value < range.min) return false; + if (range.max !== undefined && value > range.max) return false; + return true; + }); +}; + +const isFourPieceSet = (setName: string) => FOUR_PIECE_SETS.has(setName); +const isTwoPieceSet = (setName: string) => TWO_PIECE_SETS.has(setName); + +const getCompletedSets = (items: RawItem[]) => { + const counts = new Map(); + 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; +}; + +const passesSetFilter = (items: RawItem[], setFilters: SetFilters) => { + if (setFilters.set1.length === 0 && setFilters.set2.length === 0 && setFilters.set3.length === 0) { + return true; + } + + const counts = new Map(); + items.forEach(item => { + if (!item.set) return; + counts.set(item.set, (counts.get(item.set) || 0) + 1); + }); + + const hasAnyMatch = (sets: string[], required: number) => { + if (sets.length === 0) return true; + return sets.some(setName => (counts.get(setName) || 0) >= required); + }; + + const set1HasFour = setFilters.set1.some(isFourPieceSet); + const set1HasTwo = setFilters.set1.some(isTwoPieceSet); + + if (set1HasFour && !set1HasTwo) { + if (!hasAnyMatch(setFilters.set1, 4)) return false; + if (!hasAnyMatch(setFilters.set2, 2)) return false; + return true; + } + + if (set1HasTwo) { + if (!hasAnyMatch(setFilters.set1, 2)) return false; + if (!hasAnyMatch(setFilters.set2, 2)) return false; + if (!hasAnyMatch(setFilters.set3, 2)) return false; + return true; + } + + return true; +}; + +export default function OptimizerPage() { + const [loading, setLoading] = useState(false); + const [parsedItems, setParsedItems] = useState([]); + const [parsedHeroes, setParsedHeroes] = useState([]); + const [selectedHeroId, setSelectedHeroId] = useState(null); + const [setFilters, setSetFilters] = useState(emptySetFilters); + const [statFilters, setStatFilters] = useState(emptyFilters); + const [mainStatFilters, setMainStatFilters] = useState<{necklace: MainStatKey | 'All'; ring: MainStatKey | 'All'; boots: MainStatKey | 'All'}>({ + necklace: 'All', + ring: 'All', + boots: 'All', + }); + const [weightValues, setWeightValues] = useState>({ + atk: 50, + def: 50, + hp: 50, + spd: 50, + cr: 50, + cd: 50, + acc: 50, + res: 50, + }); + const [results, setResults] = useState([]); + const [selectedResult, setSelectedResult] = useState(null); + const [totalCombos, setTotalCombos] = useState(0); + const {success, error, info} = useMessage(); + const renderSetBadge = (values: string[]) => (values.length === 0 ? 'All' : '已选'); + + const [setPickerOpen, setSetPickerOpen] = useState(false); + const [setPickerTarget, setSetPickerTarget] = useState<'set1' | 'set2' | 'set3'>('set1'); + const [setPickerPos, setSetPickerPos] = useState<{top: number; left: number}>({top: 120, left: 120}); + const [mainPickerOpen, setMainPickerOpen] = useState(false); + const [mainPickerTarget, setMainPickerTarget] = useState<'necklace' | 'ring' | 'boots'>('necklace'); + const [mainPickerPos, setMainPickerPos] = useState<{top: number; left: number}>({top: 120, left: 120}); + const bodyStyleRef = useRef<{overflow: string; paddingRight: string}>({overflow: '', paddingRight: ''}); + const filterCardRef = useRef(null); + const rightPanelRef = useRef(null); + + const setPickerOptions = [ + {key: 'All', label: '全部', setKey: ''}, + {key: 'AttackSet', label: '攻击套装', setKey: 'AttackSet'}, + {key: 'DefenseSet', label: '防御套装', setKey: 'DefenseSet'}, + {key: 'HealthSet', label: '生命套装', setKey: 'HealthSet'}, + {key: 'SpeedSet', label: '速度套装', setKey: 'SpeedSet'}, + {key: 'CriticalSet', label: '暴击套装', setKey: 'CriticalSet'}, + {key: 'HitSet', label: '命中套装', setKey: 'HitSet'}, + {key: 'ResistSet', label: '抵抗套装', setKey: 'ResistSet'}, + {key: 'LifestealSet', label: '吸血套装', setKey: 'LifestealSet'}, + {key: 'DestructionSet', label: '破灭套装', setKey: 'DestructionSet'}, + {key: 'CounterSet', label: '反击套装', setKey: 'CounterSet'}, + {key: 'ImmunitySet', label: '免疫套装', setKey: 'ImmunitySet'}, + {key: 'PenetrationSet', label: '穿透套装', setKey: 'PenetrationSet'}, + {key: 'RevengeSet', label: '复仇套装', setKey: 'RevengeSet'}, + {key: 'InjurySet', label: '伤口套装', setKey: 'InjurySet'}, + ]; + + const getSetLabel = (values: string[]) => { + const key = values[0]; + if (!key) return '套装'; + const option = setPickerOptions.find(item => item.setKey === key); + return option ? option.label : '套装'; + }; + + const openSetPicker = (target: 'set1' | 'set2' | 'set3', event: React.MouseEvent) => { + const modalWidth = 520; + const modalHeight = 520; + const offset = 0; + let left = 0; + let top = 0; + + const cardRect = filterCardRef.current?.getBoundingClientRect(); + const rightPanelRect = rightPanelRef.current?.getBoundingClientRect(); + if (rightPanelRect) { + left = rightPanelRect.left - modalWidth - offset; + top = rightPanelRect.top; + } else { + const rect = event.currentTarget.getBoundingClientRect(); + left = rect.left - modalWidth - offset; + top = rect.top - offset; + } + + if (left < 0) { + left = 0; + } + if (top < 0) { + top = 0; + } + if (top + modalHeight > window.innerHeight) { + top = Math.max(0, window.innerHeight - modalHeight); + } + + setSetPickerTarget(target); + setSetPickerPos({top, left}); + setSetPickerOpen(true); + }; + + const openMainPicker = ( + target: 'necklace' | 'ring' | 'boots', + event: React.MouseEvent + ) => { + const modalWidth = 300; + const modalHeight = 320; + const offset = 0; + let left = 0; + let top = 0; + + const rightPanelRect = rightPanelRef.current?.getBoundingClientRect(); + if (rightPanelRect) { + left = rightPanelRect.left - modalWidth - offset; + top = rightPanelRect.top + 120; + } else { + const rect = event.currentTarget.getBoundingClientRect(); + left = rect.left - modalWidth - offset; + top = rect.top - offset; + } + + if (left < 0) { + left = 0; + } + if (top < 0) { + top = 0; + } + if (top + modalHeight > window.innerHeight) { + top = Math.max(0, window.innerHeight - modalHeight); + } + + setMainPickerTarget(target); + setMainPickerPos({top, left}); + setMainPickerOpen(true); + }; + + const heroOptions = useMemo(() => { + return parsedHeroes.map(hero => ({ + label: hero.name || String(hero.id || ''), + value: hero.id || '', + })); + }, [parsedHeroes]); + + const getMainStatOptions = (target: 'necklace' | 'ring' | 'boots'): Array<{label: string; value: MainStatKey | 'All'}> => { + if (target === 'necklace') { + return [ + {label: 'All', value: 'All'}, + {label: '攻击力', value: 'Attack'}, + {label: '防御力', value: 'Defense'}, + {label: '生命值', value: 'Health'}, + {label: '攻击力%', value: 'AttackPercent'}, + {label: '防御力%', value: 'DefensePercent'}, + {label: '生命值%', value: 'HealthPercent'}, + {label: '暴击率', value: 'CriticalHitChancePercent'}, + {label: '暴击伤害', value: 'CriticalHitDamagePercent'}, + ]; + } + if (target === 'ring') { + return [ + {label: 'All', value: 'All'}, + {label: '攻击力', value: 'Attack'}, + {label: '防御力', value: 'Defense'}, + {label: '生命值', value: 'Health'}, + {label: '攻击力%', value: 'AttackPercent'}, + {label: '防御力%', value: 'DefensePercent'}, + {label: '生命值%', value: 'HealthPercent'}, + {label: '命中', value: 'EffectivenessPercent'}, + {label: '抵抗', value: 'EffectResistancePercent'}, + ]; + } + return [ + {label: 'All', value: 'All'}, + {label: '攻击力', value: 'Attack'}, + {label: '防御力', value: 'Defense'}, + {label: '生命值', value: 'Health'}, + {label: '攻击力%', value: 'AttackPercent'}, + {label: '防御力%', value: 'DefensePercent'}, + {label: '生命值%', value: 'HealthPercent'}, + {label: '速度', value: 'Speed'}, + ]; + }; + + const getMainStatLabel = (target: 'necklace' | 'ring' | 'boots', value: MainStatKey | 'All') => { + const option = getMainStatOptions(target).find(item => item.value === value); + return option ? option.label : 'All'; + }; + + const selectedHero = useMemo(() => { + return parsedHeroes.find(hero => hero.id === selectedHeroId) || null; + }, [parsedHeroes, selectedHeroId]); + + const loadLatestData = async () => { + setLoading(true); + try { + const parsed = await App.GetLatestParsedDataFromDatabase(); + const items = (parsed?.items || []) as RawItem[]; + const heroes = (parsed?.heroes || []) as RawHero[]; + setParsedItems(items); + setParsedHeroes(heroes); + if (heroes.length > 0) { + setSelectedHeroId(heroes[0].id || ''); + } + if (items.length === 0 && heroes.length === 0) { + info('暂无解析数据'); + } else { + success('数据加载成功'); + } + } catch (err) { + error('加载数据失败'); + console.error('Load optimizer data error:', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadLatestData(); + }, []); + + useEffect(() => { + if (setPickerOpen) { + bodyStyleRef.current = { + overflow: document.body.style.overflow, + paddingRight: document.body.style.paddingRight, + }; + document.body.style.overflow = 'auto'; + document.body.style.paddingRight = ''; + } else { + document.body.style.overflow = bodyStyleRef.current.overflow; + document.body.style.paddingRight = bodyStyleRef.current.paddingRight; + } + }, [setPickerOpen]); + + const resetFilters = () => { + setStatFilters(emptyFilters); + setSetFilters(emptySetFilters); + setMainStatFilters({necklace: 'All', ring: 'All', boots: 'All'}); + setWeightValues({ + atk: 50, + def: 50, + hp: 50, + spd: 50, + cr: 50, + cd: 50, + acc: 50, + res: 50, + }); + setResults([]); + setSelectedResult(null); + setTotalCombos(0); + }; + + const buildResults = () => { + if (!selectedHero) { + info('请先选择英雄'); + return; + } + if (parsedItems.length === 0) { + info('暂无装备数据'); + return; + } + + const baseStats = buildBaseStats(selectedHero); + const items = parsedItems; + + const itemsByGear = new Map(); + GEAR_SLOTS.forEach(slot => itemsByGear.set(slot, [])); + items.forEach(item => { + if (item.gear && itemsByGear.has(item.gear)) { + itemsByGear.get(item.gear)!.push(item); + } + }); + + const statsCache = new Map(); + const topItemsByGear: RawItem[][] = []; + + for (const slot of GEAR_SLOTS) { + const slotItems = itemsByGear.get(slot) || []; + const scored = slotItems.map(item => { + const stats = buildItemStats(item); + statsCache.set(item, stats); + return { + item, + score: scoreStats(stats, baseStats), + }; + }); + scored.sort((a, b) => b.score - a.score); + topItemsByGear.push(scored.slice(0, MAX_ITEMS_PER_SLOT).map(x => x.item)); + } + + const combos = topItemsByGear.reduce((acc, list) => acc * Math.max(list.length, 1), 1); + setTotalCombos(combos); + + const resultsBuffer: BuildResult[] = []; + let comboCount = 0; + + const [weapons, helmets, armors, necklaces, rings, boots] = topItemsByGear; + for (const w of weapons) { + for (const h of helmets) { + for (const a of armors) { + for (const n of necklaces) { + for (const r of rings) { + for (const b of boots) { + comboCount += 1; + if (comboCount > MAX_COMBOS) break; + const itemsList = [w, h, a, n, r, b]; + if (!passesSetFilter(itemsList, setFilters)) continue; + const statsList = itemsList.map(item => statsCache.get(item) || buildItemStats(item)); + const totals = buildTotalStats(statsList, baseStats); + if (!passesFilters(totals, statFilters)) continue; + + const completedSets = getCompletedSets(itemsList); + const setNames = completedSets.length > 0 ? completedSets.join('/') : '无套装'; + + const score = + totals.spd * 2 + + totals.cr + + totals.cd + + totals.acc + + totals.res + + (totals.atk + totals.def + totals.hp) / 100; + + resultsBuffer.push({ + key: itemsList.map(item => item.id).join('-'), + sets: setNames || '未知套装', + atk: Math.round(totals.atk), + def: Math.round(totals.def), + hp: Math.round(totals.hp), + spd: Math.round(totals.spd), + cr: Math.round(totals.cr), + cd: Math.round(totals.cd), + acc: Math.round(totals.acc), + res: Math.round(totals.res), + score, + items: itemsList, + }); + } + if (comboCount > MAX_COMBOS) break; + } + if (comboCount > MAX_COMBOS) break; + } + if (comboCount > MAX_COMBOS) break; + } + if (comboCount > MAX_COMBOS) break; + } + if (comboCount > MAX_COMBOS) break; + } + + resultsBuffer.sort((a, b) => b.score - a.score); + const finalResults = resultsBuffer.slice(0, MAX_RESULTS); + setResults(finalResults); + setSelectedResult(finalResults[0] || null); + if (finalResults.length === 0) { + info('没有符合条件的配装结果'); + } + }; + + const resultColumns = [ + {title: '套装', dataIndex: 'sets', key: 'sets', render: (v: string) => {v}}, + {title: '攻击', dataIndex: 'atk', key: 'atk'}, + {title: '防御', dataIndex: 'def', key: 'def'}, + {title: '生命', dataIndex: 'hp', key: 'hp'}, + {title: '速度', dataIndex: 'spd', key: 'spd'}, + {title: '暴击', dataIndex: 'cr', key: 'cr'}, + {title: '爆伤', dataIndex: 'cd', key: 'cd'}, + {title: '命中', dataIndex: 'acc', key: 'acc'}, + {title: '抵抗', dataIndex: 'res', key: 'res'}, + ]; + + return ( + <> + + + - {/* 角色头像与神器区 */} - } style={{marginRight: 16}}/> -
-
{hero.name}
-
- 神器: - setEditVisible(true)}> - {/* 假设有artifactUrl则显示图片,否则暂无 */} - {editData.artifact ? 神器 : '暂无'} - -
-
- 专属神器: - setEditVisible(true)}> - {editData.exclusive ? editData.exclusive : '暂无'} - -
+ }/> +
+
英雄
+ - {setOptions.map(opt => {opt.label})} - + +
属性过滤
+
+ {STAT_LABELS.map(stat => ( +
+ {stat.label} + { + setStatFilters(prev => ({ + ...prev, + [stat.key]: {...prev[stat.key], min: value ?? undefined}, + })); + }} + /> + ~ + { + setStatFilters(prev => ({ + ...prev, + [stat.key]: {...prev[stat.key], max: value ?? undefined}, + })); + }} + /> +
+ ))} +
+ + +
+
+
+
+ {renderSetBadge(setFilters.set1)} + +
+
+ {renderSetBadge(setFilters.set2)} + +
+
+ {renderSetBadge(setFilters.set3)} + +
+
+
+
主能力值
+
+ 项链 + +
+
+ 戒指 + +
+
+ 鞋子 + +
+
+
+
+
能力值权重
+
+ + 攻击 + setWeightValues(prev => ({...prev, atk: value as number}))} + /> +
+
+ + 防御 + setWeightValues(prev => ({...prev, def: value as number}))} + /> +
+
+ + 生命 + setWeightValues(prev => ({...prev, hp: value as number}))} + /> +
+
+ + 速度 + setWeightValues(prev => ({...prev, spd: value as number}))} + /> +
+
+ + 暴击 + setWeightValues(prev => ({...prev, cr: value as number}))} + /> +
+
+ + 爆伤 + setWeightValues(prev => ({...prev, cd: value as number}))} + /> +
+
+ + 命中 + setWeightValues(prev => ({...prev, acc: value as number}))} + /> +
+
+ + 抵抗 + setWeightValues(prev => ({...prev, res: value as number}))} + /> +
+
+
+ - {/* 按钮区,绝对定位到Card右下角 */}
- - + +
- {/* 配装结果列表区 */}
-
全部排列组合:123,456 条 | 筛选结果:2
- +
+ 组合数量:{totalCombos.toLocaleString()} | + 结果数量:{results.length.toLocaleString()} +
+
({ + onClick: () => setSelectedResult(record), + })} /> - {/* 单个配装详情区 */} -
-
- -
{hero.name}
+ {selectedResult ? ( +
+
+ }/> +
{selectedHero?.name || '未知英雄'}
+
+ +
+
+ 套装:{selectedResult.sets} +
+
+ 攻击:{selectedResult.atk},防御:{selectedResult.def},生命: + {selectedResult.hp},速度:{selectedResult.spd} +
+
+ 暴击:{selectedResult.cr}% ,爆伤:{selectedResult.cd}% ,命中: + {selectedResult.acc}% ,抵抗:{selectedResult.res}% +
+
- -
-
套装:暴击套
-
攻击:2000,防御:1500,生命:20000,速度:200
-
暴击:100%,爆伤:250%,命中:30%,抵抗:20%
-
- -
- - -
-
+ ) : ( +
暂无配装结果
+ )} - - {/* 角色信息编辑弹窗 */} - setEditVisible(false)} - onOk={() => setEditVisible(false)} - > -
-
- 神器: - setEditData({...editData, artifact: e.target.value})} - placeholder="神器图片URL或名称" - /> -
-
- 神器等级: - setEditData({...editData, artifactLevel: v || 0})} - /> -
-
- 阵型: - setEditData({...editData, formation: e.target.value})} - placeholder="如:前排/后排" - /> -
-
- 专属装备: - setEditData({...editData, exclusive: e.target.value})} - placeholder="如:+9速度" - /> -
-
- 星数: - setEditData({...editData, star: v || 1})} - /> -
-
-
- + + {setPickerOpen && + createPortal( +
setSetPickerOpen(false)} + > +
event.stopPropagation()} + > +
+ {setPickerOptions.map(option => ( + + ))} +
+
+
, + document.body + )} + {mainPickerOpen && + createPortal( +
setMainPickerOpen(false)} + > +
event.stopPropagation()} + > +
+ {getMainStatOptions(mainPickerTarget).map(option => ( + + ))} +
+
+
, + document.body + )} + ); -} \ No newline at end of file +} diff --git a/frontend/src/pages/optimizer.css b/frontend/src/pages/optimizer.css new file mode 100644 index 0000000..c783cdc --- /dev/null +++ b/frontend/src/pages/optimizer.css @@ -0,0 +1,294 @@ +.optimizer-set-panel { + background: linear-gradient(180deg, #1f2c49 0%, #1a233b 100%); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + padding: 6px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.optimizer-panel-header { + background: linear-gradient(90deg, rgba(120, 160, 220, 0.25), rgba(120, 160, 220, 0)); + color: #c9d7f7; + font-weight: 600; + font-size: 12px; + padding: 4px 6px; + border-radius: 6px; + letter-spacing: 0.5px; +} + +.optimizer-set-row { + display: flex; + align-items: center; + gap: 8px; + background: rgba(6, 10, 20, 0.55); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + padding: 6px; +} + +.optimizer-set-row-tight { + padding: 6px 8px; +} + +.optimizer-set-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 34px; + height: 26px; + border-radius: 6px; + background: #ffffff; + color: #1b2744; + font-weight: 600; + font-size: 11px; +} + +.optimizer-set-select { + flex: 1; +} + +.optimizer-set-select-tight .ant-select-selector { + padding-left: 6px !important; +} + +.optimizer-set-select-tight .ant-select-selection-placeholder { + color: #c9d7f7 !important; + opacity: 0.9; +} + +.optimizer-set-button { + flex: 1; + height: 28px; + background: transparent; + border: none; + color: #c9d7f7; + text-align: left; + padding: 0 4px; +} + +.optimizer-set-button:disabled { + color: rgba(201, 215, 247, 0.4); +} + +.optimizer-set-popover-overlay { + position: fixed; + inset: 0; + background: transparent; + z-index: 1000; +} + +.optimizer-set-popover { + position: fixed; + width: 520px; + background: #0b0f16; + border-radius: 12px; + padding: 0; + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.45); +} + +.optimizer-main-popover { + position: fixed; + width: 300px; + background: #0b0f16; + border-radius: 12px; + padding: 0; + box-shadow: 0 16px 32px rgba(0, 0, 0, 0.45); +} + +.optimizer-set-modal-list { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px 12px; + padding: 12px 16px 16px 16px; + max-height: 520px; + overflow: hidden; +} + +.optimizer-main-modal-list { + display: grid; + grid-template-columns: 1fr; + gap: 6px; + padding: 10px 12px 12px 12px; + max-height: 320px; + overflow: hidden; +} + +.optimizer-main-modal-list .optimizer-set-modal-item { + padding: 6px 8px; + gap: 6px; + font-size: 12px; +} + +.optimizer-main-modal-list .optimizer-set-modal-label { + font-size: 14px; + white-space: nowrap; +} + +.optimizer-main-modal-list .optimizer-set-modal-icon { + font-size: 12px; +} + +.optimizer-set-modal-item { + display: grid; + grid-template-columns: 20px 1fr auto; + align-items: center; + gap: 8px; + background: #0f131b; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + padding: 8px 10px; + color: #c9d7f7; + text-align: left; +} + +.optimizer-set-modal-item:hover { + border-color: rgba(123, 180, 255, 0.6); +} + +.optimizer-set-modal-icon { + color: #7bb4ff; +} + +.optimizer-set-modal-count { + color: #7ad15f; + font-weight: 600; +} +.optimizer-set-select .ant-select-selector { + background: transparent !important; + border: none !important; + box-shadow: none !important; + padding: 0 !important; + min-height: 26px !important; +} + +.optimizer-set-select .ant-select-selection-item, +.optimizer-set-select .ant-select-selection-placeholder { + color: #c9d7f7 !important; +} + +.optimizer-set-select .ant-select-selection-overflow { + gap: 6px; +} + +.optimizer-set-select .ant-select-selection-item-content { + color: #c9d7f7; +} + +.optimizer-set-select .ant-select-selection-item-remove { + color: #9fb3e5; +} + +.optimizer-weight-row { + display: grid; + grid-template-columns: 38px 1fr; + align-items: center; + gap: 10px; +} + +.optimizer-weight-label { + color: #c9d7f7; + font-size: 11px; + text-align: right; +} + +.optimizer-left-panels { + display: flex; + flex-direction: column; + gap: 8px; +} + +.optimizer-side-panel { + display: flex; + gap: 8px; + align-items: flex-start; +} + +.optimizer-left-panels { + flex: 1; + min-width: 0; +} + +.optimizer-weight-panel { + flex: 1; + min-width: 0; +} + +.optimizer-weight-panel { + background: linear-gradient(180deg, #1f2c49 0%, #1a233b 100%); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + padding: 6px; + align-self: flex-start; + height: fit-content; +} + +.optimizer-weight-row { + display: grid; + grid-template-columns: 20px 34px 1fr; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.optimizer-weight-row:last-child { + margin-bottom: 0; +} + +.optimizer-weight-row .ant-slider { + margin: 0; +} + +.optimizer-weight-icon { + width: 18px; + height: 18px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.12); + color: #c9d7f7; + font-size: 11px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.optimizer-weight-label { + color: #c9d7f7; + font-size: 12px; + text-align: left; +} + +.optimizer-weight-slider .ant-slider-rail { + background: repeating-linear-gradient( + to right, + rgba(255, 255, 255, 0.25), + rgba(255, 255, 255, 0.25) 2px, + rgba(255, 255, 255, 0) 14px, + rgba(255, 255, 255, 0) 18px + ); + height: 4px; +} + +.optimizer-weight-slider .ant-slider-track { + background-color: #7bb4ff; + height: 4px; +} + +.optimizer-weight-slider .ant-slider-handle { + width: 12px; + height: 12px; + margin-top: -4px; + border-color: #7bb4ff; +} +.optimizer-weight-row .ant-slider-rail { + background-color: rgba(255, 255, 255, 0.15); +} + +.optimizer-weight-row .ant-slider-track { + background-color: #6aa9ff; +} + +.optimizer-weight-row .ant-slider-handle { + border-color: #6aa9ff; + background-color: #ffffff; +} diff --git a/internal/service/parser_service.go b/internal/service/parser_service.go index 93f7ecf..b0756f9 100644 --- a/internal/service/parser_service.go +++ b/internal/service/parser_service.go @@ -9,7 +9,9 @@ import ( "equipment-analyzer/internal/utils" "fmt" "io/ioutil" + "math" "net/http" + "strings" "time" ) @@ -237,6 +239,9 @@ func (ps *ParserService) convertGear(item map[string]interface{}) { if _, exists := item["type"]; !exists { if code, exists := item["code"].(string); exists { baseCode := code + if parts := strings.Split(code, "_"); len(parts) > 0 { + baseCode = parts[0] + } if idx := len(baseCode) - 1; idx >= 0 { gearLetter := string(baseCode[idx]) item["gear"] = gearByGearLetter[gearLetter] @@ -324,7 +329,7 @@ func (ps *ParserService) convertMainStat(item map[string]interface{}) { if ps.isFlat(mainOpType) { mainValue = mainStatValue } else { - mainValue = mainStatValue * 100 + mainValue = ps.round10ths(mainStatValue * 100) } if mainValue == 0 || mainValue != mainValue { // NaN check @@ -367,7 +372,7 @@ func (ps *ParserService) convertSubStats(item map[string]interface{}) { if ps.isFlat(opType) { value = opValue } else { - value = opValue * 100 + value = ps.round10ths(opValue * 100) } if existingStat, exists := statAcc[statType]; exists { @@ -428,6 +433,10 @@ func (ps *ParserService) isFlat(text string) bool { return text == "max_hp" || text == "speed" || text == "att" || text == "def" } +func (ps *ParserService) round10ths(value float64) float64 { + return math.Round(value*10) / 10 +} + // 映射常量 var ( rankByIngameGrade = []string{ @@ -476,6 +485,10 @@ var ( "set_vampire": "LifestealSet", "set_shield": "ProtectionSet", "set_torrent": "TorrentSet", + "set_revenant": "ReversalSet", + "set_riposte": "RiposteSet", + "set_chase": "PursuitSet", + "set_opener": "WarfareSet", } statByIngameStat = map[string]string{