From 99a769d91760cf1ae93fe9639e70650e51f2820d Mon Sep 17 00:00:00 2001 From: root Date: Fri, 19 Jun 2026 16:08:05 +0000 Subject: [PATCH 001/112] Fix: Replace password auth with token auth for factorio.com API --- src/api/mod_portal_handler.go | 4 ++-- src/factorio/mod_portal.go | 19 +++++++++++++++++++ .../AddMod/components/FactorioLogin.jsx | 8 ++++---- ui/api/resources/mods.js | 4 ++-- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/api/mod_portal_handler.go b/src/api/mod_portal_handler.go index 076db45e..ba4cb813 100644 --- a/src/api/mod_portal_handler.go +++ b/src/api/mod_portal_handler.go @@ -105,14 +105,14 @@ func ModPortalLoginHandler(w http.ResponseWriter, r *http.Request) { var data struct { Username string `json:"username"` - Password string `json:"password"` + Token string `json:"token"` } resp, err = ReadFromRequestBody(w, r, &data) if err != nil { return } - err, statusCode := factorio.FactorioLogin(data.Username, data.Password) + err, statusCode := factorio.FactorioLoginWithToken(data.Username, data.Token) w.WriteHeader(statusCode) if err != nil { resp = fmt.Sprintf("Error trying to login into Factorio: %s", err) diff --git a/src/factorio/mod_portal.go b/src/factorio/mod_portal.go index dd3d5deb..37db290c 100644 --- a/src/factorio/mod_portal.go +++ b/src/factorio/mod_portal.go @@ -145,3 +145,22 @@ func FactorioLogin(username string, password string) (error, int) { return nil, http.StatusOK } + +// FactorioLoginWithToken saves credentials directly using a token from factorio.com/profile +func FactorioLoginWithToken(username string, token string) (error, int) { + if username == "" || token == "" { + return errors.New("username and token are required"), http.StatusBadRequest + } + + credentials := Credentials{ + Username: username, + Userkey: token, + } + + err := credentials.Save() + if err != nil { + return err, http.StatusInternalServerError + } + + return nil, http.StatusOK +} diff --git a/ui/App/views/Mods/components/AddMod/components/FactorioLogin.jsx b/ui/App/views/Mods/components/AddMod/components/FactorioLogin.jsx index 7450333e..193d2885 100644 --- a/ui/App/views/Mods/components/AddMod/components/FactorioLogin.jsx +++ b/ui/App/views/Mods/components/AddMod/components/FactorioLogin.jsx @@ -10,9 +10,9 @@ const FactorioLogin = ({setIsFactorioAuthenticated}) => { const {register, handleSubmit} = useForm(); const [isLoading, setIsLoading] = useState(false); - const login = ({username, password}) => { + const login = ({username, token}) => { setIsLoading(true); - modsResource.portal.login(username, password) + modsResource.portal.login(username, token) .then(res => { setIsFactorioAuthenticated(true) }) @@ -28,8 +28,8 @@ const FactorioLogin = ({setIsFactorioAuthenticated}) => {
-
diff --git a/ui/api/resources/mods.js b/ui/api/resources/mods.js index 33d74a78..8d35521d 100644 --- a/ui/api/resources/mods.js +++ b/ui/api/resources/mods.js @@ -34,10 +34,10 @@ const mods = { }, downloadAllURL: '/api/mods/download', portal: { - login: async (username, password) => { + login: async (username, token) => { const response = await client.post('/api/mods/portal/login', { username, - password + token }); return response.data; }, From 437cd94e1b7227910bee3aa6042c5b789f4dde2c Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Sun, 21 Jun 2026 03:15:11 +0000 Subject: [PATCH 002/112] =?UTF-8?q?=E6=96=87=E6=A1=A3:=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20FSM=20=E5=89=8D=E7=AB=AF=E5=9B=BD=E9=99=85=E5=8C=96?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specs/2026-06-21-fsm-i18n-design.md | 735 ++++++++++++++++++ 1 file changed, 735 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-21-fsm-i18n-design.md diff --git a/docs/superpowers/specs/2026-06-21-fsm-i18n-design.md b/docs/superpowers/specs/2026-06-21-fsm-i18n-design.md new file mode 100644 index 00000000..ad0a2e45 --- /dev/null +++ b/docs/superpowers/specs/2026-06-21-fsm-i18n-design.md @@ -0,0 +1,735 @@ +# FSM Frontend Internationalization (i18n) Design Spec + +**Date:** 2026-06-21 +**Project:** factorio-server-manager-joey +**Branch:** feat/fsm-i18n +**Status:** Draft / Approved for Implementation + +--- + +## 1. Overview and Goals + +### 1.1 Problem + +The Factorio Server Manager (FSM) frontend has zero internationalization support. All UI strings are hardcoded English embedded in JSX components. This blocks adoption by non-English-speaking server operators, particularly the Chinese community where Factorio has a large player base. + +### 1.2 Goals + +- Enable the UI to render in Simplified Chinese (zh-CN) as the first translated locale. +- Keep English as the authoritative fallback language (maintain existing strings as the source of truth). +- Introduce a translation architecture that makes adding future locales trivial (single new JSON folder). +- Minimize component refactoring: the `useTranslation()` Hook pattern mirrors existing React functional component style. +- Zero backend changes. Go error messages, log entries, and RCON output are not translated. +- Complete coverage of all ~200 UI strings across 9 view/page areas. + +### 1.3 Non-Goals (see also Section 10) + +- Translating Go backend strings, error messages, or Factorio server log output. +- Runtime locale switching without page reload (nice-to-have, not required for MVP). +- Right-to-left (RTL) language support. +- Pluralization rules or ICU MessageFormat. +- Integration with CI translation services (POEditor, Crowdin, etc.). + +--- + +## 2. Architecture + +### 2.1 Framework: react-i18next + i18next + +Chosen over alternatives (react-intl, LinguiJS) because: + +- `useTranslation()` Hook matches the existing functional component + Hooks pattern perfectly. +- No Redux or global state manager dependency (the app uses only `useState`). +- Backend-agnostic: translations are loaded as static JSON at build time, no XHR needed. +- `i18next-browser-languagedetector` provides zero-config language detection from `navigator.language`. + +### 2.2 Installation + +```bash +npm install i18next react-i18next i18next-browser-languagedetector +``` + +All three packages are runtime dependencies. No additional Webpack plugins or loaders are required (JSON imports are already supported by Webpack 5 with the default `json` module type; the existing config resolves `.json` extensions via `resolve.extensions`). + +### 2.3 Initialization + +A single initialization module is created at `ui/i18n.js`: + +```js +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +import enCommon from './locales/en/common.json'; +import enLayout from './locales/en/layout.json'; +import enControls from './locales/en/controls.json'; +import enMods from './locales/en/mods.json'; +import enSaves from './locales/en/saves.json'; +import enServerSettings from './locales/en/serverSettings.json'; +import enLogs from './locales/en/logs.json'; +import enConsole from './locales/en/console.json'; +import enUserManagement from './locales/en/userManagement.json'; + +import zhCommon from './locales/zh-CN/common.json'; +import zhLayout from './locales/zh-CN/layout.json'; +import zhControls from './locales/zh-CN/controls.json'; +import zhMods from './locales/zh-CN/mods.json'; +import zhSaves from './locales/zh-CN/saves.json'; +import zhServerSettings from './locales/zh-CN/serverSettings.json'; +import zhLogs from './locales/zh-CN/logs.json'; +import zhConsole from './locales/zh-CN/console.json'; +import zhUserManagement from './locales/zh-CN/userManagement.json'; + +const resources = { + en: { + common: enCommon, + layout: enLayout, + controls: enControls, + mods: enMods, + saves: enSaves, + serverSettings: enServerSettings, + logs: enLogs, + console: enConsole, + userManagement: enUserManagement, + }, + 'zh-CN': { + common: zhCommon, + layout: zhLayout, + controls: zhControls, + mods: zhMods, + saves: zhSaves, + serverSettings: zhServerSettings, + logs: zhLogs, + console: zhConsole, + userManagement: zhUserManagement, + }, +}; + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources, + fallbackLng: 'en', + defaultNS: 'common', + ns: [ + 'common', 'layout', 'controls', 'mods', 'saves', + 'serverSettings', 'logs', 'console', 'userManagement', + ], + interpolation: { + escapeValue: false, // React already escapes output + }, + detection: { + // Only check localStorage and navigator.language + order: ['localStorage', 'navigator'], + caches: ['localStorage'], + lookupLocalStorage: 'fsm_lang', + }, + }); + +export default i18n; +``` + +### 2.4 Provider Wrapping + +In `ui/index.js`, import the i18n instance and wrap `` with ``: + +```jsx +import './i18n'; // initialize i18n before rendering +import React, { Suspense } from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App/App.jsx'; + +window.flash = (message, color = 'gray-light') => + Bus.emit('flash', ({ message, color })); + +const root = ReactDOM.createRoot(document.getElementById('app')); +root.render( + Loading...}> + + +); +``` + +`react-i18next` uses `Suspense` internally to delay rendering until translations are loaded. The `fallback` shows a brief loading indicator. For this app with static JSON imports, the Suspense resolves synchronously, so the fallback is a safety measure only. + +### 2.5 Translation File Structure + +``` +ui/locales/ + en/ + common.json + layout.json + controls.json + mods.json + saves.json + serverSettings.json + logs.json + console.json + userManagement.json + zh-CN/ + common.json + layout.json + controls.json + mods.json + saves.json + serverSettings.json + logs.json + console.json + userManagement.json +``` + +Every namespace folder mirrors `en/` exactly. Missing keys in `zh-CN/*.json` automatically fall back to `en/*.json` at runtime (no crash, no missing string). + +--- + +## 3. Component-Level i18n Integration Pattern + +### 3.1 Basic Pattern + +**Before** (hardcoded English): + +```jsx +import React from 'react'; +import Button from '../components/Button'; + +const Controls = ({ serverStatus }) => { + return ( + + ); +}; +``` + +**After** (translated): + +```jsx +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import Button from '../components/Button'; + +const Controls = ({ serverStatus }) => { + const { t } = useTranslation('controls'); + + return ( + + ); +}; +``` + +### 3.2 Component with Multiple Namespaces + +When a view imports strings from `common` and its own namespace: + +```jsx +const { t } = useTranslation(['mods', 'common']); +// Usage: +t('installMod') // looks in 'mods' first +t('save') // looks in 'common' (fallback namespace) +t('common:save') // explicit namespace prefix +``` + +Explicit prefix (`'common:save'`) is preferred for clarity when mixing namespaces, except in trivial cases. + +### 3.3 Interpolation (Dynamic Values) + +For strings that embed variables (save names, usernames, version numbers): + +**Translation JSON** (`en/common.json`): +```json +{ + "saveLastModified": "Last Modified: {{date}}", + "deleteConfirm": "Are you sure you want to delete \"{{name}}\"?", + "versionInfo": "Version {{version}}" +} +``` + +**Component usage:** +```jsx +{t('saveLastModified', { date: save.last_mod })} +{t('deleteConfirm', { name: save.name })} +{t('versionInfo', { version: factorioVersion })} +``` + +### 3.4 No Changes to Pure Presentational Components + +Components like `Button`, `Panel`, `Input`, `Label`, `Modal` do NOT import `useTranslation`. They accept `children` or `text` props. The translation call happens at the call site. + +**Exception:** `ConfirmDialog` receives translated `title`, `content` props from its parent. Its own button labels ("Cancel", "Confirm") are translated inside the component via `useTranslation('common')`. + +### 3.5 Changes Outside JSX + +For the `window.flash()` calls in JavaScript logic (e.g., error toasts): + +```jsx +import { useTranslation } from 'react-i18next'; +const { t } = useTranslation('common'); + +// Before: +window.flash("Login failed. Username or Password wrong.", "red"); + +// After: +window.flash(t('loginFailed'), "red"); +``` + +For non-component files (api resources, utility modules) that display user-facing messages, they must either accept a `t` function as a parameter or the calling component must wrap the message. + +--- + +## 4. Translation Key Naming Convention + +### 4.1 Rules + +- **Flat camelCase.** No dots, no nesting. Each JSON file is a single-level object. +- **VerbPrefix for actions.** `startServer`, `stopServer`, `deleteSave`, `uploadMod`. +- **NounOnly for labels.** `serverStatus`, `factorioVersion`, `lastModified`, `actions`. +- **Status constants in SCREAMING_SNAKE.** `RUNNING`, `STOPPED`, `UNKNOWN`. +- **Error messages:** `errorRequired`, `errorInvalidPort`, `loginFailed`. +- **Confirm dialogs:** `confirmDeleteMod`, `confirmStopServer`. +- **Placeholder text:** `placeholderUsername`, `placeholderPassword`. + +### 4.2 Examples per Category + +``` +# Controls +"startServer" "Start Server" +"saveStopServer" "Save & Stop Server" +"killServer" "Kill Server" +"serverStatus" "Server Status" +"status" "Status" +"ip" "IP" +"port" "Port" +"factorioVersion" "Factorio Version" +"save" "Save" +"RUNNING" "Running" +"STOPPED" "Stopped" +"UNKNOWN" "Unknown" + +# Common +"save" "Save" +"cancel" "Cancel" +"confirm" "Confirm" +"delete" "Delete" +"loading" "Loading..." +"errorOccurred" "An error occurred" +"loginFailed" "Login failed. Username or Password wrong." +"name" "Name" +"actions" "Actions" +``` + +### 4.3 What NOT to Do + +``` +# BAD - deep nesting +"server.controls.start" "Start Server" + +# BAD - inconsistent casing +"start_server" "Start Server" +"stopServer" "Stop Server" + +# BAD - HTML in values +"welcomeMessage" "Welcome to FSM" +``` + +Values are plain strings. No HTML markup inside JSON values. Components use React elements if formatting is needed. + +--- + +## 5. Language Detection and Switching UX + +### 5.1 Auto-Detection + +On first visit, `i18next-browser-languagedetector` checks `navigator.language`: + +| Browser Language | Selected Locale | +|---|---| +| `zh-CN`, `zh-TW`, `zh-HK`, `zh` | `zh-CN` | +| Any other value | `en` | + +The check uses simple prefix matching: if `navigator.language` starts with `zh`, load `zh-CN`. Otherwise, load `en`. + +### 5.2 Persistence + +The selected language is stored in `localStorage` key `fsm_lang`. On subsequent visits, the stored preference takes priority over `navigator.language`. + +Clearing `localStorage` or using a private/incognito window triggers auto-detection again. + +### 5.3 Language Switcher UI + +A language toggle is added to the Layout sidebar, inside the "FSM Administration" section, below the Help link: + +```jsx +// In Layout.jsx, after the Help link: +Users +Help +{/* new */} +
+ +
+``` + +The `` in FSM Administration section: + ```jsx + + ``` + +**Verification:** +- All sidebar strings render correctly +- Language switcher toggles between en/zh-CN immediately +- Language preference persists in localStorage +- `npm run build` succeeds + +--- + +### T7: Controls Page i18n + +**Status:** pending + +**File:** `ui/App/views/Controls.jsx` (MODIFY) + +**Changes:** +- Add `useTranslation('controls')` hook +- Replace panel title, field labels, button texts, error messages with `t()` calls +- Keep dynamic values (bindip, port, savefile) unchanged + +**Verification:** +- All hardcoded English strings replaced +- Dynamic values preserved +- `npm run build` succeeds + +--- + +### T8: Mods Page i18n (7 files) + +**Status:** pending + +**Files (all MODIFY):** +1. `ui/App/views/Mods/Mods.jsx` — panel titles, buttons, warning banner +2. `ui/App/views/Mods/components/UploadMod.jsx` — form labels, buttons +3. `ui/App/views/Mods/components/LoadMods.jsx` — form elements, ConfirmDialog, flash +4. `ui/App/views/Mods/components/ModPack.jsx` — ConfirmDialog usage +5. `ui/App/views/Mods/components/CreateModPack.jsx` — button, Modal title, form labels +6. `ui/App/views/Mods/components/AddMod/components/AddModForm.jsx` — form labels, buttons +7. `ui/App/views/Mods/components/AddMod/components/FactorioLogin.jsx` — form labels, button, flash +8. `ui/App/views/Mods/components/AddMod/components/SelectVersionForm.jsx` — Modal title, table headers + +**Verification:** +- All mods pages render with translated strings +- Tab navigation works +- ConfirmDialog shows translated buttons +- `npm run build` succeeds + +--- + +### T9: Saves Page i18n (3 files) + +**Status:** pending + +**Files (all MODIFY):** +1. `ui/App/views/Saves/Saves.jsx` — panel titles, table headers, disabled state +2. `ui/App/views/Saves/components/CreateSaveForm.jsx` — form labels, button, error +3. `ui/App/views/Saves/components/UploadSaveForm.jsx` — form label, placeholder, error, button + +**Verification:** +- All save pages render with translated strings +- Delete confirmation shows proper strings +- `npm run build` succeeds + +--- + +### T10: Settings + Console + Logs i18n (4 files) + +**Status:** pending + +**Files (all MODIFY):** +1. `ui/App/views/ServerSettings.jsx` — panel title, save button, flash message +2. `ui/App/views/GameSettings.jsx` — panel title +3. `ui/App/views/Console.jsx` — panel title, disabled message, input placeholder +4. `ui/App/views/Logs.jsx` — panel title + +**Verification:** +- All 4 pages render with translated strings +- Flash message uses translated string +- `npm run build` succeeds + +--- + +### T11: User Management + Login + Help i18n (5 files) + +**Status:** pending + +**Files (all MODIFY):** +1. `ui/App/views/UserManagement/UserManagment.jsx` — panel titles, table headers +2. `ui/App/views/UserManagement/components/CreateUserForm.jsx` — labels, placeholders, errors, buttons +3. `ui/App/views/UserManagement/components/ChangePasswordForm.jsx` — labels, placeholders, errors, buttons, flash +4. `ui/App/views/Login.jsx` — panel title, form labels, placeholders, errors, buttons, flash +5. `ui/App/views/Help.jsx` — panel title, section headings, body text + +**Verification:** +- All pages render with translated strings +- Flash messages use translated text +- Form validation errors show translated text +- `npm run build` succeeds + +--- + +### T12: Build Verification + +**Status:** pending + +**Command:** +```bash +npm run build +``` + +**Checks:** +- Build exits with code 0 +- No i18n-related warnings +- Output files exist in `app/` directory + +--- + +### T13: Atomic Commit + +**Status:** pending + +**Commit message:** `功能: 添加 FSM 前端国际化支持(zh-CN)` + +**Files staged:** ~30 files (new i18n config + 18 JSON locale files + ~12 modified components + package.json + package-lock.json) + +--- + +## Success Criteria + +1. `npm run build` exits 0 with no i18n-related warnings +2. All files modified/created as specified +3. English locale loads by default +4. Chinese locale auto-detects when `navigator.language = 'zh-CN'` +5. Language switcher toggles between en/zh-CN immediately +6. Language preference persists across page reload +7. Missing zh-CN keys fall back to English gracefully +8. All pages render without console errors in both languages From 6e452f0edbb2ab6129061ad70e70aa370fde0673 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Sun, 21 Jun 2026 03:22:30 +0000 Subject: [PATCH 004/112] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20i18n=20=E6=A0=B8=E5=BF=83=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E8=AE=BE=E6=96=BD=20+=20=E8=8B=B1=E6=96=87=E7=BF=BB=E8=AF=91?= =?UTF-8?q?=E6=96=87=E4=BB=B6=EF=BC=889=20=E5=91=BD=E5=90=8D=E7=A9=BA?= =?UTF-8?q?=E9=97=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/i18n.js | 69 +++++++++++++++++++++++++++++++ ui/index.js | 9 +++- ui/locales/en/common.json | 42 +++++++++++++++++++ ui/locales/en/console.json | 8 ++++ ui/locales/en/controls.json | 20 +++++++++ ui/locales/en/layout.json | 21 ++++++++++ ui/locales/en/logs.json | 8 ++++ ui/locales/en/mods.json | 32 ++++++++++++++ ui/locales/en/saves.json | 13 ++++++ ui/locales/en/serverSettings.json | 30 ++++++++++++++ ui/locales/en/userManagement.json | 20 +++++++++ 11 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 ui/i18n.js create mode 100644 ui/locales/en/common.json create mode 100644 ui/locales/en/console.json create mode 100644 ui/locales/en/controls.json create mode 100644 ui/locales/en/layout.json create mode 100644 ui/locales/en/logs.json create mode 100644 ui/locales/en/mods.json create mode 100644 ui/locales/en/saves.json create mode 100644 ui/locales/en/serverSettings.json create mode 100644 ui/locales/en/userManagement.json diff --git a/ui/i18n.js b/ui/i18n.js new file mode 100644 index 00000000..d730fa9e --- /dev/null +++ b/ui/i18n.js @@ -0,0 +1,69 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +// English locale files (fallback language) +import enCommon from './locales/en/common.json'; +import enLayout from './locales/en/layout.json'; +import enControls from './locales/en/controls.json'; +import enMods from './locales/en/mods.json'; +import enSaves from './locales/en/saves.json'; +import enServerSettings from './locales/en/serverSettings.json'; +import enLogs from './locales/en/logs.json'; +import enConsole from './locales/en/console.json'; +import enUserManagement from './locales/en/userManagement.json'; + +// Chinese locale files +import zhCommon from './locales/zh-CN/common.json'; +import zhLayout from './locales/zh-CN/layout.json'; +import zhControls from './locales/zh-CN/controls.json'; +import zhMods from './locales/zh-CN/mods.json'; +import zhSaves from './locales/zh-CN/saves.json'; +import zhServerSettings from './locales/zh-CN/serverSettings.json'; +import zhLogs from './locales/zh-CN/logs.json'; +import zhConsole from './locales/zh-CN/console.json'; +import zhUserManagement from './locales/zh-CN/userManagement.json'; + +const resources = { + en: { + common: enCommon, + layout: enLayout, + controls: enControls, + mods: enMods, + saves: enSaves, + serverSettings: enServerSettings, + logs: enLogs, + console: enConsole, + userManagement: enUserManagement, + }, + 'zh-CN': { + common: zhCommon, + layout: zhLayout, + controls: zhControls, + mods: zhMods, + saves: zhSaves, + serverSettings: zhServerSettings, + logs: zhLogs, + console: zhConsole, + userManagement: zhUserManagement, + }, +}; + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources, + fallbackLng: 'en', + defaultNS: 'common', + detection: { + order: ['localStorage', 'navigator'], + lookupLocalStorage: 'fsm_lang', + caches: ['localStorage'], + }, + interpolation: { + escapeValue: false, + }, + }); + +export default i18n; diff --git a/ui/index.js b/ui/index.js index 60afb9cd..27bed460 100644 --- a/ui/index.js +++ b/ui/index.js @@ -1,10 +1,15 @@ import regeneratorRuntime from "regenerator-runtime" +import './i18n'; import Bus from "./notifications" -import React from 'react'; +import React, { Suspense } from 'react'; import ReactDOM from 'react-dom/client'; import App from './App/App.jsx'; window.flash = (message, color="gray-light") => Bus.emit('flash', ({message, color})); const root = ReactDOM.createRoot(document.getElementById('app')); -root.render(); +root.render( + Loading...}> + + +); diff --git a/ui/locales/en/common.json b/ui/locales/en/common.json new file mode 100644 index 00000000..46e48a01 --- /dev/null +++ b/ui/locales/en/common.json @@ -0,0 +1,42 @@ +{ + "save": "Save", + "cancel": "Cancel", + "confirm": "Confirm", + "delete": "Delete", + "loading": "Loading...", + "errorOccurred": "An error occurred", + "loginFailed": "Login failed. Please check your credentials.", + "name": "Name", + "actions": "Actions", + "username": "Username", + "password": "Password", + "email": "Email", + "role": "Role", + "required": "Required", + "signIn": "Sign In", + "logout": "Logout", + "saveSettings": "Save Settings", + "saveLastModified": "Last Modified", + "size": "Size", + "lastModifiedAt": "Last Modified At", + "settingsSaved": "Settings saved.", + "download": "Download", + "upload": "Upload", + "search": "Search", + "close": "Close", + "back": "Back", + "ok": "OK", + "yes": "Yes", + "no": "No", + "enabled": "Enabled", + "disabled": "Disabled", + "unknown": "Unknown", + "none": "None", + "edit": "Edit", + "create": "Create", + "remove": "Remove", + "add": "Add", + "update": "Update", + "refresh": "Refresh", + "status": "Status" +} diff --git a/ui/locales/en/console.json b/ui/locales/en/console.json new file mode 100644 index 00000000..c0a119ac --- /dev/null +++ b/ui/locales/en/console.json @@ -0,0 +1,8 @@ +{ + "console": "Console", + "consoleNotAvailable": "Console is not available when the server is not running.", + "inputPlaceholder": "Enter command...", + "commandSent": "Command sent.", + "rconNotAvailable": "RCON is not available.", + "send": "Send" +} diff --git a/ui/locales/en/controls.json b/ui/locales/en/controls.json new file mode 100644 index 00000000..39cd8602 --- /dev/null +++ b/ui/locales/en/controls.json @@ -0,0 +1,20 @@ +{ + "serverStatus": "Server Status", + "startServer": "Start Server", + "saveStopServer": "Save & Stop Server", + "killServer": "Kill Server", + "status": "Status", + "ip": "IP", + "port": "Port", + "factorioVersion": "Factorio Version", + "save": "Save", + "ipRequired": "IP is required and must be valid.", + "portRequired": "Port is required within range 1-65535", + "saveRequired": "Save is required and must be valid.", + "RUNNING": "Running", + "STOPPED": "Stopped", + "UNKNOWN": "Unknown", + "saveBeforeStop": "Save before stopping?", + "confirmStop": "Are you sure you want to stop the server?", + "confirmKill": "Are you sure you want to kill the server? This may cause data loss." +} diff --git a/ui/locales/en/layout.json b/ui/locales/en/layout.json new file mode 100644 index 00000000..3b744066 --- /dev/null +++ b/ui/locales/en/layout.json @@ -0,0 +1,21 @@ +{ + "appTitle": "Factorio Server Manager", + "serverStatus": "Server Status", + "serverManagement": "Server Management", + "fsmAdministration": "FSM Administration", + "linkControls": "Controls", + "linkSaves": "Saves", + "linkMods": "Mods", + "linkServerSettings": "Server Settings", + "linkGameSettings": "Game Settings", + "linkConsole": "Console", + "linkLogs": "Logs", + "linkUsers": "Users", + "linkHelp": "Help", + "languageLabel": "Language", + "helpTitle": "Help", + "helpBugsAndHelp": "Bugs and Help", + "helpDescription": "For bug reports, feature requests, or help with Factorio Server Manager, please visit the following resources:", + "helpResources": "Resources", + "helpWiki": "Wiki" +} diff --git a/ui/locales/en/logs.json b/ui/locales/en/logs.json new file mode 100644 index 00000000..3b699aeb --- /dev/null +++ b/ui/locales/en/logs.json @@ -0,0 +1,8 @@ +{ + "logs": "Logs", + "noLogsAvailable": "No logs available.", + "factorioLog": "Factorio Log", + "factorioCurrentLog": "Factorio Current Log", + "factorioPreviousLog": "Factorio Previous Log", + "fsmLog": "FSM Log" +} diff --git a/ui/locales/en/mods.json b/ui/locales/en/mods.json new file mode 100644 index 00000000..6c8e3904 --- /dev/null +++ b/ui/locales/en/mods.json @@ -0,0 +1,32 @@ +{ + "mods": "Mods", + "modPacks": "Mod Packs", + "installMod": "Install Mod", + "uploadMod": "Upload Mod", + "loadModsFromSave": "Load Mods from Save", + "changingModsDisabled": "Changing mods is disabled while the server is running. Please stop the server before making changes.", + "deleteAllMods": "Delete all Mods", + "updateAllMods": "Update all Mods", + "downloadAllMods": "Download all Mods", + "confirmDeleteMod": "Are you sure you want to delete this mod?", + "noModsInstalled": "No mods installed.", + "searchMods": "Search mods...", + "install": "Install", + "modPortal": "Mod Portal", + "loadingModList": "Loading mod list...", + "uploadModTitle": "Upload Mod", + "selectModFile": "Select a mod file", + "createModPack": "Create Mod Pack", + "modPackName": "Mod Pack Name", + "modsInThisPack": "Mods in this pack", + "factorioLogin": "Factorio.com Login", + "token": "Token", + "login": "Login", + "selectVersion": "Select Version", + "version": "Version", + "loadMods": "Load Mods", + "loadModsTitle": "Load Mods from Save", + "selectSave": "Select a save file", + "modsLoaded": "Mods loaded from save.", + "deleteExistingMods": "Delete all existing mods before loading? Existing mods will be permanently removed." +} diff --git a/ui/locales/en/saves.json b/ui/locales/en/saves.json new file mode 100644 index 00000000..b3f52b7c --- /dev/null +++ b/ui/locales/en/saves.json @@ -0,0 +1,13 @@ +{ + "createSave": "Create Save", + "uploadSave": "Upload Save", + "saves": "Saves", + "createSaveDisabled": "Creating saves is disabled while the server is running.", + "confirmDeleteSave": "Are you sure you want to delete this save?", + "downloadSave": "Download", + "noSavesFound": "No saves found.", + "savefileName": "Save File Name", + "saveFile": "Save File", + "selectFile": "Select File ...", + "saveNameRequired": "Save name is required." +} diff --git a/ui/locales/en/serverSettings.json b/ui/locales/en/serverSettings.json new file mode 100644 index 00000000..1e85bf1b --- /dev/null +++ b/ui/locales/en/serverSettings.json @@ -0,0 +1,30 @@ +{ + "serverSettings": "Server Settings", + "gameSettings": "Game Settings", + "saveSettings": "Save Settings", + "settingsSaved": "Settings saved.", + "visibility": "Visibility", + "public": "Public", + "lan": "LAN", + "hidden": "Hidden", + "requireUserVerification": "Require User Verification", + "maxPlayers": "Max Players", + "gamePassword": "Game Password", + "description": "Description", + "tags": "Tags", + "autoPause": "Auto Pause", + "onlyAdminsCanPause": "Only admins can pause", + "autosaveInterval": "Autosave Interval (minutes)", + "autosaveSlots": "Autosave Slots", + "afkAutokickInterval": "AFK Auto-kick Interval (minutes)", + "allowCommands": "Allow Commands", + "resourcePatchSize": "Resource Patch Size", + "resourceFrequency": "Resource Frequency", + "resourceRichness": "Resource Richness", + "enemyBaseSize": "Enemy Base Size", + "enemyBaseFrequency": "Enemy Base Frequency", + "startingAreaSize": "Starting Area Size", + "peacefulMode": "Peaceful Mode", + "researchQueue": "Research Queue", + "technologyPriceMultiplier": "Technology Price Multiplier" +} diff --git a/ui/locales/en/userManagement.json b/ui/locales/en/userManagement.json new file mode 100644 index 00000000..8b9b6e83 --- /dev/null +++ b/ui/locales/en/userManagement.json @@ -0,0 +1,20 @@ +{ + "listOfUsers": "List of Users", + "changePassword": "Change Password", + "createUser": "Create User", + "confirmDeleteUser": "Are you sure you want to delete this user?", + "noUsersFound": "No users found.", + "passwordConfirmation": "Password Confirmation", + "oldPassword": "Old Password", + "newPassword": "New Password", + "newPasswordConfirmation": "New Password Confirmation", + "passwordRequired": "Password is required.", + "passwordMismatch": "Passwords do not match.", + "passwordChanged": "Password changed.", + "usernameRequired": "Username is required.", + "roleRequired": "Role is required.", + "emailRequired": "Email is required.", + "userCreated": "User created.", + "userDeleted": "User deleted.", + "change": "Change" +} From af63fe9efc0244b30a24ff2837bf831842d0d722 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Sun, 21 Jun 2026 03:30:20 +0000 Subject: [PATCH 005/112] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20=E5=AE=89?= =?UTF-8?q?=E8=A3=85=20i18n=20=E4=BE=9D=E8=B5=96=20+=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E4=B8=AD=E6=96=87=E7=BF=BB=E8=AF=91=E6=96=87=E4=BB=B6=EF=BC=88?= =?UTF-8?q?9=20=E5=91=BD=E5=90=8D=E7=A9=BA=E9=97=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 172 ++++++++++++++++++++++----- package.json | 3 + ui/locales/zh-CN/common.json | 42 +++++++ ui/locales/zh-CN/console.json | 8 ++ ui/locales/zh-CN/controls.json | 20 ++++ ui/locales/zh-CN/layout.json | 21 ++++ ui/locales/zh-CN/logs.json | 8 ++ ui/locales/zh-CN/mods.json | 32 +++++ ui/locales/zh-CN/saves.json | 13 ++ ui/locales/zh-CN/serverSettings.json | 30 +++++ ui/locales/zh-CN/userManagement.json | 20 ++++ 11 files changed, 341 insertions(+), 28 deletions(-) create mode 100644 ui/locales/zh-CN/common.json create mode 100644 ui/locales/zh-CN/console.json create mode 100644 ui/locales/zh-CN/controls.json create mode 100644 ui/locales/zh-CN/layout.json create mode 100644 ui/locales/zh-CN/logs.json create mode 100644 ui/locales/zh-CN/mods.json create mode 100644 ui/locales/zh-CN/saves.json create mode 100644 ui/locales/zh-CN/serverSettings.json create mode 100644 ui/locales/zh-CN/userManagement.json diff --git a/package-lock.json b/package-lock.json index eae1b4f2..9e09ea4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,10 @@ "@fortawesome/react-fontawesome": "^0.2.0", "axios": "^1.6.0", "fuse.js": "^7.0.0", + "i18next": "^26.3.1", + "i18next-browser-languagedetector": "^8.2.1", "react-hook-form": "^7.47.0", + "react-i18next": "^17.0.8", "regenerator-runtime": "^0.14.0", "semver": "^7.3.7", "tailwindcss": "^3.3.5" @@ -1831,23 +1834,14 @@ "dev": true }, "node_modules/@babel/runtime": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.18.6.tgz", - "integrity": "sha512-t9wi7/AW6XtKahAe20Yw0/mMljKq0B1r2fPdvaAdV/KPDZewFXdaaa6K7lxmZBZ8FBNpCiAT6iHPmd6QO9bKfQ==", - "dev": true, - "dependencies": { - "regenerator-runtime": "^0.13.4" - }, + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/runtime/node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "dev": true - }, "node_modules/@babel/template": { "version": "7.22.15", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", @@ -3923,6 +3917,52 @@ "node": ">= 0.4" } }, + "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": "26.3.1", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz", + "integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/icss-utils": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", @@ -5410,6 +5450,33 @@ "react": "^16.8.0 || ^17 || ^18" } }, + "node_modules/react-i18next": { + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz", + "integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -6258,11 +6325,29 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "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/watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", @@ -7722,21 +7807,9 @@ "dev": true }, "@babel/runtime": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.18.6.tgz", - "integrity": "sha512-t9wi7/AW6XtKahAe20Yw0/mMljKq0B1r2fPdvaAdV/KPDZewFXdaaa6K7lxmZBZ8FBNpCiAT6iHPmd6QO9bKfQ==", - "dev": true, - "requires": { - "regenerator-runtime": "^0.13.4" - }, - "dependencies": { - "regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "dev": true - } - } + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==" }, "@babel/template": { "version": "7.22.15", @@ -9245,6 +9318,28 @@ "function-bind": "^1.1.2" } }, + "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==", + "requires": { + "void-elements": "3.1.0" + } + }, + "i18next": { + "version": "26.3.1", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz", + "integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==", + "requires": {} + }, + "i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "requires": { + "@babel/runtime": "^7.23.2" + } + }, "icss-utils": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", @@ -10228,6 +10323,16 @@ "integrity": "sha512-F/TroLjTICipmHeFlMrLtNLceO2xr1jU3CyiNla5zdwsGUGu2UOxxR4UyJgLlhMwLW/Wzp4cpJ7CPfgJIeKdSg==", "requires": {} }, + "react-i18next": { + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz", + "integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==", + "requires": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + } + }, "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -10807,11 +10912,22 @@ "punycode": "^2.1.0" } }, + "use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "requires": {} + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "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==" + }, "watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", diff --git a/package.json b/package.json index c0abfe46..a47ad41e 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,10 @@ "@fortawesome/react-fontawesome": "^0.2.0", "axios": "^1.6.0", "fuse.js": "^7.0.0", + "i18next": "^26.3.1", + "i18next-browser-languagedetector": "^8.2.1", "react-hook-form": "^7.47.0", + "react-i18next": "^17.0.8", "regenerator-runtime": "^0.14.0", "semver": "^7.3.7", "tailwindcss": "^3.3.5" diff --git a/ui/locales/zh-CN/common.json b/ui/locales/zh-CN/common.json new file mode 100644 index 00000000..b4becf0b --- /dev/null +++ b/ui/locales/zh-CN/common.json @@ -0,0 +1,42 @@ +{ + "save": "保存", + "cancel": "取消", + "confirm": "确认", + "delete": "删除", + "loading": "加载中...", + "errorOccurred": "发生错误", + "loginFailed": "登录失败,请检查凭据。", + "name": "名称", + "actions": "操作", + "username": "用户名", + "password": "密码", + "email": "邮箱", + "role": "角色", + "required": "必填", + "signIn": "登录", + "logout": "退出登录", + "saveSettings": "保存设置", + "saveLastModified": "最后修改", + "size": "大小", + "lastModifiedAt": "最后修改时间", + "settingsSaved": "设置已保存。", + "download": "下载", + "upload": "上传", + "search": "搜索", + "close": "关闭", + "back": "返回", + "ok": "确定", + "yes": "是", + "no": "否", + "enabled": "已启用", + "disabled": "已禁用", + "unknown": "未知", + "none": "无", + "edit": "编辑", + "create": "创建", + "remove": "移除", + "add": "添加", + "update": "更新", + "refresh": "刷新", + "status": "状态" +} diff --git a/ui/locales/zh-CN/console.json b/ui/locales/zh-CN/console.json new file mode 100644 index 00000000..4a43e043 --- /dev/null +++ b/ui/locales/zh-CN/console.json @@ -0,0 +1,8 @@ +{ + "console": "控制台", + "consoleNotAvailable": "服务器未运行时控制台不可用。", + "inputPlaceholder": "输入指令...", + "commandSent": "指令已发送。", + "rconNotAvailable": "RCON 不可用。", + "send": "发送" +} diff --git a/ui/locales/zh-CN/controls.json b/ui/locales/zh-CN/controls.json new file mode 100644 index 00000000..d1420efe --- /dev/null +++ b/ui/locales/zh-CN/controls.json @@ -0,0 +1,20 @@ +{ + "serverStatus": "服务器状态", + "startServer": "启动服务器", + "saveStopServer": "保存并停止服务器", + "killServer": "强制停止服务器", + "status": "状态", + "ip": "IP 地址", + "port": "端口", + "factorioVersion": "Factorio 版本", + "save": "存档", + "ipRequired": "IP 地址为必填项且必须有效。", + "portRequired": "端口为必填项,范围 1-65535", + "saveRequired": "存档为必填项且必须有效。", + "RUNNING": "运行中", + "STOPPED": "已停止", + "UNKNOWN": "未知", + "saveBeforeStop": "停止前保存游戏?", + "confirmStop": "确认停止服务器?", + "confirmKill": "确认强制停止服务器?这可能导致数据丢失。" +} diff --git a/ui/locales/zh-CN/layout.json b/ui/locales/zh-CN/layout.json new file mode 100644 index 00000000..84353f39 --- /dev/null +++ b/ui/locales/zh-CN/layout.json @@ -0,0 +1,21 @@ +{ + "appTitle": "Factorio 服务器管理器", + "serverStatus": "服务器状态", + "serverManagement": "服务器管理", + "fsmAdministration": "FSM 管理", + "linkControls": "控制面板", + "linkSaves": "存档", + "linkMods": "模组", + "linkServerSettings": "服务器设置", + "linkGameSettings": "游戏设置", + "linkConsole": "控制台", + "linkLogs": "日志", + "linkUsers": "用户", + "linkHelp": "帮助", + "languageLabel": "语言", + "helpTitle": "帮助", + "helpBugsAndHelp": "Bug 与帮助", + "helpDescription": "报告 Bug、请求新功能或获取 Factorio 服务器管理器的帮助,请访问以下资源:", + "helpResources": "资源", + "helpWiki": "Wiki" +} diff --git a/ui/locales/zh-CN/logs.json b/ui/locales/zh-CN/logs.json new file mode 100644 index 00000000..9570d675 --- /dev/null +++ b/ui/locales/zh-CN/logs.json @@ -0,0 +1,8 @@ +{ + "logs": "日志", + "noLogsAvailable": "暂无日志。", + "factorioLog": "Factorio 日志", + "factorioCurrentLog": "Factorio 当前日志", + "factorioPreviousLog": "Factorio 历史日志", + "fsmLog": "FSM 日志" +} diff --git a/ui/locales/zh-CN/mods.json b/ui/locales/zh-CN/mods.json new file mode 100644 index 00000000..833e7cd3 --- /dev/null +++ b/ui/locales/zh-CN/mods.json @@ -0,0 +1,32 @@ +{ + "mods": "模组", + "modPacks": "模组合集", + "installMod": "安装模组", + "uploadMod": "上传模组", + "loadModsFromSave": "从存档加载模组", + "changingModsDisabled": "服务器运行中无法更改模组。请先停止服务器。", + "deleteAllMods": "删除所有模组", + "updateAllMods": "更新所有模组", + "downloadAllMods": "下载所有模组", + "confirmDeleteMod": "确认删除此模组?", + "noModsInstalled": "未安装任何模组。", + "searchMods": "搜索模组...", + "install": "安装", + "modPortal": "模组门户", + "loadingModList": "正在加载模组列表...", + "uploadModTitle": "上传模组", + "selectModFile": "选择一个模组文件", + "createModPack": "创建模组合集", + "modPackName": "模组合集名称", + "modsInThisPack": "此合集中的模组", + "factorioLogin": "Factorio.com 登录", + "token": "令牌", + "login": "登录", + "selectVersion": "选择版本", + "version": "版本", + "loadMods": "加载模组", + "loadModsTitle": "从存档加载模组", + "selectSave": "选择一个存档文件", + "modsLoaded": "已从存档加载模组。", + "deleteExistingMods": "加载前删除所有现有模组?现有模组将被永久移除。" +} diff --git a/ui/locales/zh-CN/saves.json b/ui/locales/zh-CN/saves.json new file mode 100644 index 00000000..ea9af1b6 --- /dev/null +++ b/ui/locales/zh-CN/saves.json @@ -0,0 +1,13 @@ +{ + "createSave": "创建存档", + "uploadSave": "上传存档", + "saves": "存档", + "createSaveDisabled": "服务器运行中无法创建存档。", + "confirmDeleteSave": "确认删除此存档?", + "downloadSave": "下载", + "noSavesFound": "未找到存档。", + "savefileName": "存档文件名", + "saveFile": "存档文件", + "selectFile": "选择文件 ...", + "saveNameRequired": "存档名称为必填项。" +} diff --git a/ui/locales/zh-CN/serverSettings.json b/ui/locales/zh-CN/serverSettings.json new file mode 100644 index 00000000..d49ca1f9 --- /dev/null +++ b/ui/locales/zh-CN/serverSettings.json @@ -0,0 +1,30 @@ +{ + "serverSettings": "服务器设置", + "gameSettings": "游戏设置", + "saveSettings": "保存设置", + "settingsSaved": "设置已保存。", + "visibility": "可见性", + "public": "公开", + "lan": "局域网", + "hidden": "隐藏", + "requireUserVerification": "需要用户验证", + "maxPlayers": "最大玩家数", + "gamePassword": "游戏密码", + "description": "描述", + "tags": "标签", + "autoPause": "自动暂停", + "onlyAdminsCanPause": "仅管理员可暂停", + "autosaveInterval": "自动保存间隔(分钟)", + "autosaveSlots": "自动保存槽位", + "afkAutokickInterval": "AFK 自动踢出间隔(分钟)", + "allowCommands": "允许指令", + "resourcePatchSize": "资源矿脉大小", + "resourceFrequency": "资源频率", + "resourceRichness": "资源富饶度", + "enemyBaseSize": "虫巢大小", + "enemyBaseFrequency": "虫巢频率", + "startingAreaSize": "起始区域大小", + "peacefulMode": "和平模式", + "researchQueue": "研究队列", + "technologyPriceMultiplier": "科技价格倍率" +} diff --git a/ui/locales/zh-CN/userManagement.json b/ui/locales/zh-CN/userManagement.json new file mode 100644 index 00000000..23a8b199 --- /dev/null +++ b/ui/locales/zh-CN/userManagement.json @@ -0,0 +1,20 @@ +{ + "listOfUsers": "用户列表", + "changePassword": "修改密码", + "createUser": "创建用户", + "confirmDeleteUser": "确认删除此用户?", + "noUsersFound": "未找到用户。", + "passwordConfirmation": "确认密码", + "oldPassword": "旧密码", + "newPassword": "新密码", + "newPasswordConfirmation": "确认新密码", + "passwordRequired": "密码为必填项。", + "passwordMismatch": "两次输入的密码不一致。", + "passwordChanged": "密码已修改。", + "usernameRequired": "用户名为必填项。", + "roleRequired": "角色为必填项。", + "emailRequired": "邮箱为必填项。", + "userCreated": "用户已创建。", + "userDeleted": "用户已删除。", + "change": "修改" +} From a2b7376fa0fb5c0902f46940698a21ea12d5b492 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Sun, 21 Jun 2026 04:25:39 +0000 Subject: [PATCH 006/112] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20ConfirmDialog=20+?= =?UTF-8?q?=20Layout=20=E5=9B=BD=E9=99=85=E5=8C=96=EF=BC=88=E5=90=AB?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=E5=88=87=E6=8D=A2=E5=99=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/components/ConfirmDialog.jsx | 6 ++-- ui/App/components/Layout.jsx | 48 +++++++++++++++++++---------- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/ui/App/components/ConfirmDialog.jsx b/ui/App/components/ConfirmDialog.jsx index 7f96c9bc..3e902068 100644 --- a/ui/App/components/ConfirmDialog.jsx +++ b/ui/App/components/ConfirmDialog.jsx @@ -1,9 +1,11 @@ import React, {useState} from 'react'; +import { useTranslation } from 'react-i18next'; import Modal from "./Modal"; import Button from "./Button"; function ConfirmDialog({title, content, isOpen, close, onSuccess}) { + const { t } = useTranslation('common'); const [isLoading, setIsLoading] = useState(false); const confirm = () => { @@ -21,8 +23,8 @@ function ConfirmDialog({title, content, isOpen, close, onSuccess}) { content={content} actions={ <> - - + + } isOpen={isOpen} diff --git a/ui/App/components/Layout.jsx b/ui/App/components/Layout.jsx index 2f47a676..87ccd14f 100644 --- a/ui/App/components/Layout.jsx +++ b/ui/App/components/Layout.jsx @@ -1,4 +1,6 @@ import React, {useEffect, useState} from "react"; +import { useTranslation } from 'react-i18next'; +import i18n from '../../i18n'; import {NavLink, Outlet} from "react-router-dom"; import Button from "./Button"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; @@ -7,18 +9,19 @@ import {Flash} from "./Flash"; const Layout = ({handleLogout, serverStatus}) => { + const { t } = useTranslation(['layout', 'common', 'controls']); const [isNavCollapsed, setIsNavCollapsed] = useState(true); const Status = ({info}) => { - let text = 'Unknown'; + let text = t('UNKNOWN', { ns: 'controls' }); let color = 'gray-light'; if (info && info.running) { - text = 'Running'; + text = t('RUNNING', { ns: 'controls' }); color = 'green'; } else if (info && !info.running) { - text = 'Stopped'; + text = t('STOPPED', { ns: 'controls' }); color = 'red'; } @@ -48,7 +51,7 @@ const Layout = ({handleLogout, serverStatus}) => {
- Factorio Server Manager + {t('appTitle', { ns: 'layout' })}
-

Server Status

+

{t('serverStatus', { ns: 'layout' })}

-

Server Management

+

{t('serverManagement', { ns: 'layout' })}

- Controls - Saves - Mods - Server Settings - Game Settings - Console - Logs + {t('linkControls', { ns: 'layout' })} + {t('linkSaves', { ns: 'layout' })} + {t('linkMods', { ns: 'layout' })} + {t('linkServerSettings', { ns: 'layout' })} + {t('linkGameSettings', { ns: 'layout' })} + {t('linkConsole', { ns: 'layout' })} + {t('linkLogs', { ns: 'layout' })}
-

FSM Administration

+

{t('fsmAdministration', { ns: 'layout' })}

- Users - Help + {t('linkUsers', { ns: 'layout' })} + {t('linkHelp', { ns: 'layout' })} +
+
+ +
- +
From b0ee691a90e586f27c517eea4310ed658cbd85f0 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Sun, 21 Jun 2026 04:29:30 +0000 Subject: [PATCH 007/112] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20FSM=20=E5=89=8D=E7=AB=AF=E5=9B=BD=E9=99=85=E5=8C=96?= =?UTF-8?q?=E6=94=AF=E6=8C=81=EF=BC=88zh-CN=20=E4=B8=AD=E6=96=87=E7=BF=BB?= =?UTF-8?q?=E8=AF=91=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/build-release.sh | 0 docker/build.sh | 0 docker/entrypoint.sh | 0 ui/App/views/Console.jsx | 7 +++- ui/App/views/Controls.jsx | 42 ++++++++++--------- ui/App/views/GameSettings.jsx | 5 ++- ui/App/views/Help.jsx | 10 +++-- ui/App/views/Login.jsx | 18 ++++---- ui/App/views/Logs.jsx | 5 ++- ui/App/views/Mods/Mods.jsx | 25 ++++++----- .../AddMod/components/AddModForm.jsx | 14 ++++--- .../AddMod/components/SelectVersionForm.jsx | 13 +++--- .../views/Mods/components/CreateModPack.jsx | 13 +++--- ui/App/views/Mods/components/LoadMods.jsx | 13 +++--- ui/App/views/Mods/components/ModList.jsx | 14 ++++--- ui/App/views/Mods/components/ModPack.jsx | 7 +++- ui/App/views/Mods/components/UploadMod.jsx | 8 ++-- ui/App/views/Saves/Saves.jsx | 20 +++++---- .../views/Saves/components/CreateSaveForm.jsx | 8 ++-- .../views/Saves/components/UploadSaveForm.jsx | 10 +++-- ui/App/views/ServerSettings.jsx | 8 ++-- ui/App/views/UserManagement/UserManagment.jsx | 17 ++++---- .../components/ChangePasswordForm.jsx | 24 ++++++----- .../components/CreateUserForm.jsx | 34 ++++++++------- ui/locales/en/mods.json | 5 ++- ui/locales/zh-CN/mods.json | 5 ++- 26 files changed, 191 insertions(+), 134 deletions(-) mode change 100755 => 100644 docker/build-release.sh mode change 100755 => 100644 docker/build.sh mode change 100755 => 100644 docker/entrypoint.sh diff --git a/docker/build-release.sh b/docker/build-release.sh old mode 100755 new mode 100644 diff --git a/docker/build.sh b/docker/build.sh old mode 100755 new mode 100644 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh old mode 100755 new mode 100644 diff --git a/ui/App/views/Console.jsx b/ui/App/views/Console.jsx index 16d633f4..173fa82b 100644 --- a/ui/App/views/Console.jsx +++ b/ui/App/views/Console.jsx @@ -1,9 +1,12 @@ import Panel from "../components/Panel"; import React, {useEffect, useRef, useState} from "react"; +import { useTranslation } from 'react-i18next'; import socket from "../../api/socket"; const Console = ({serverStatus}) => { + const { t } = useTranslation('console'); + const [logs, setLogs] = useState([]); const consoleInput = useRef(null); @@ -25,7 +28,7 @@ const Console = ({serverStatus}) => { return ( @@ -44,7 +47,7 @@ const Console = ({serverStatus}) => { /> :

- The console is not available, because Factorio is not running. + {t('consoleNotAvailable')}

} /> diff --git a/ui/App/views/Controls.jsx b/ui/App/views/Controls.jsx index 62b31d9e..44a46af2 100644 --- a/ui/App/views/Controls.jsx +++ b/ui/App/views/Controls.jsx @@ -1,4 +1,5 @@ import React, {useEffect, useState} from "react"; +import { useTranslation } from 'react-i18next'; import Panel from "../components/Panel"; import Button from "../components/Button"; import server from "../../api/resources/server"; @@ -10,7 +11,8 @@ import Error from "../components/Error"; const Controls = ({serverStatus}) => { - const factorioVersion = serverStatus.fac_version ? serverStatus.fac_version : 'Unknown'; + const { t } = useTranslation('controls'); + const factorioVersion = serverStatus.fac_version ? serverStatus.fac_version : t('UNKNOWN'); const [saves, setSaves] = useState([]); const [isDisabled, setIsDisabled] = useState(true); const [isStopping, setIsStopping] = useState(false); @@ -48,48 +50,48 @@ const Controls = ({serverStatus}) => { return (
{ serverStatus.running ? <>
-
Status
-
{serverStatus.running ? 'Running' : 'Stopped'}
+
{t('status')}
+
{serverStatus.running ? t('RUNNING') : t('STOPPED')}
-
IP
+
{t('ip')}
{serverStatus.bindip}
-
Port
+
{t('port')}
{serverStatus.port}
-
Factorio Version
+
{t('factorioVersion')}
{factorioVersion}
-
Save
+
{t('save')}
{serverStatus.savefile}
: <>
-
Status
-
{serverStatus.running ? 'Running' : 'Stopped'}
+
{t('status')}
+
{serverStatus.running ? t('RUNNING') : t('STOPPED')}
-
IP
+
{t('ip')}
- +
-
Port
+
{t('port')}
{ disabled={isDisabled} register={register('port',{required: true, min: 1, max: 65535})} /> - +
-
Factorio Version
+
{t('factorioVersion')}
{factorioVersion}
-
Save
+
{t('save')}
- +
-
- +
} diff --git a/ui/App/views/Logs.jsx b/ui/App/views/Logs.jsx index e416402a..5ecd0022 100644 --- a/ui/App/views/Logs.jsx +++ b/ui/App/views/Logs.jsx @@ -1,9 +1,12 @@ import React, {useEffect, useState} from "react"; +import { useTranslation } from 'react-i18next'; import log from "../../api/resources/log"; import Panel from "../components/Panel"; const Logs = () => { + const { t } = useTranslation('logs'); + const [logs, setLogs] = useState([]) useEffect(() => { @@ -15,7 +18,7 @@ const Logs = () => { return ( {logs.map((log,index) => (
  • {log}
  • ))} diff --git a/ui/App/views/Mods/Mods.jsx b/ui/App/views/Mods/Mods.jsx index 6376ae0a..23158e6e 100644 --- a/ui/App/views/Mods/Mods.jsx +++ b/ui/App/views/Mods/Mods.jsx @@ -1,5 +1,6 @@ import Panel from "../../components/Panel"; import React, {useEffect, useState} from "react"; +import { useTranslation } from 'react-i18next'; import modsResource from "../../../api/resources/mods"; import Button from "../../components/Button"; import server from "../../../api/resources/server"; @@ -15,6 +16,8 @@ import ModList from "./components/ModList"; const Mods = ({serverStatus}) => { + const { t } = useTranslation('mods'); + const [installedMods, setInstalledMods] = useState([]); const [modPacks, setModPacks] = useState([]) const [factorioVersion, setFactorioVersion] = useState(null); @@ -110,26 +113,26 @@ const Mods = ({serverStatus}) => { {disabled ? - Changing mods is disabled while the server is running! -
    +
    + {t('changingModsDisabled')} +
    } /> : - + - + - + } { { !disabled && && + onClick={deleteAllMods}>{t('deleteAllMods')} && + onClick={updateAllMods}>{t('updateAllMods')} } Download all Mods + href={modsResource.downloadAllURL}>{t('downloadAllMods')} } /> { - return Mod - Portal + const { t } = useTranslation('mods'); + return {t('modPortal')} } const AddModForm = ({setIsFactorioAuthenticated, fuse, refetchInstalledMods}) => { + const { t } = useTranslation('mods'); const {register, watch, setValue, handleSubmit} = useForm(); const [suggestedMods, setSuggestedMods] = useState([]); const [selectedMod, setSelectedMod] = useState(null); @@ -101,11 +103,11 @@ const AddModForm = ({setIsFactorioAuthenticated, fuse, refetchInstalledMods}) =>
    setIsModalOpen(false)}/>
    -
    - - + + ) diff --git a/ui/App/views/Mods/components/AddMod/components/SelectVersionForm.jsx b/ui/App/views/Mods/components/AddMod/components/SelectVersionForm.jsx index dd2815f9..2569b7a1 100644 --- a/ui/App/views/Mods/components/AddMod/components/SelectVersionForm.jsx +++ b/ui/App/views/Mods/components/AddMod/components/SelectVersionForm.jsx @@ -1,4 +1,5 @@ import React from "react"; +import { useTranslation } from 'react-i18next'; import Modal from "../../../../../components/Modal"; import Button from "../../../../../components/Button"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; @@ -8,6 +9,8 @@ import {faTimes} from "@fortawesome/free-solid-svg-icons/faTimes"; const SelectVersionForm = ({releases, isOpen, close, install}) => { + const { t } = useTranslation(['mods', 'common']); + const download = release => { install(release) close() @@ -16,15 +19,15 @@ const SelectVersionForm = ({releases, isOpen, close, install}) => { return ( - - - + + + @@ -45,7 +48,7 @@ const SelectVersionForm = ({releases, isOpen, close, install}) => { } actions={ - + } /> ) diff --git a/ui/App/views/Mods/components/CreateModPack.jsx b/ui/App/views/Mods/components/CreateModPack.jsx index 94262200..5323ba49 100644 --- a/ui/App/views/Mods/components/CreateModPack.jsx +++ b/ui/App/views/Mods/components/CreateModPack.jsx @@ -1,4 +1,5 @@ import React, {useState} from "react"; +import { useTranslation } from 'react-i18next'; import Button from "../../../components/Button"; import Modal from "../../../components/Modal"; import Label from "../../../components/Label"; @@ -8,6 +9,8 @@ import modsResource from "../../../../api/resources/mods"; const CreateModPack = ({onSuccess}) => { + const { t } = useTranslation(['mods', 'common']); + const [isCreating, setIsCreating] = useState(false); const [isOpen, setIsOpen] = useState(false); @@ -26,18 +29,18 @@ const CreateModPack = ({onSuccess}) => { } return <> - - setIsOpen(true)}>{t('createModPack')} +
    -
    - + } actions={ - + } /> diff --git a/ui/App/views/Mods/components/LoadMods.jsx b/ui/App/views/Mods/components/LoadMods.jsx index 331c3527..06609467 100644 --- a/ui/App/views/Mods/components/LoadMods.jsx +++ b/ui/App/views/Mods/components/LoadMods.jsx @@ -1,4 +1,5 @@ import React, {useEffect, useState} from "react"; +import { useTranslation } from 'react-i18next'; import savesResource from "../../../../api/resources/saves"; import Select from "../../../components/Select"; import Label from "../../../components/Label"; @@ -11,6 +12,8 @@ import ConfirmDialog from "../../../components/ConfirmDialog"; const LoadMods = ({refreshMods}) => { + const { t } = useTranslation(['mods', 'common']); + const [saves, setSaves] = useState([]); const {register, reset, handleSubmit} = useForm(); const [isLoading, setIsLoading] = useState(false); @@ -46,7 +49,7 @@ const LoadMods = ({refreshMods}) => { await modResource.portal.installMultiple(mods) .then(() => { refreshMods(); - window.flash(`Mods are loaded from save file ${data.save}.`, "green"); + window.flash(t('modsLoaded').replace('{save}', data.save), "green"); }).finally(() => { setIsLoading(false); setLoadModsData(undefined); @@ -55,7 +58,7 @@ const LoadMods = ({refreshMods}) => { return isFactorioAuthenticated ?
    -
    VersionCompatibilityActions{t('version', { ns: 'mods' })}{t('compatibility', { ns: 'mods' })}{t('actions', { ns: 'common' })}
    - - - - - + + + + + diff --git a/ui/App/views/Mods/components/ModPack.jsx b/ui/App/views/Mods/components/ModPack.jsx index 1b8bca0a..254f8268 100644 --- a/ui/App/views/Mods/components/ModPack.jsx +++ b/ui/App/views/Mods/components/ModPack.jsx @@ -1,4 +1,5 @@ import React, {useState} from "react"; +import { useTranslation } from 'react-i18next'; import {faSpinner, faTrashAlt, faUpload} from "@fortawesome/free-solid-svg-icons"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import modsResource from "../../../../api/resources/mods"; @@ -7,6 +8,8 @@ import ConfirmDialog from "../../../components/ConfirmDialog"; const ModPack = ({modPack, reloadModPacks, factorioVersion, reloadMods, disabled = false}) => { + const { t } = useTranslation('mods'); + const [isLoading, setIsLoading] = useState(false); const [isLoadModPackDialogOpen, setIsLoadModPackDialogOpen] = useState(false); @@ -63,8 +66,8 @@ const ModPack = ({modPack, reloadModPacks, factorioVersion, reloadMods, disabled icon={isLoading ? faSpinner : faUpload} /> setIsLoadModPackDialogOpen(false)} onSuccess={() => loadModPack(modPack.name)} diff --git a/ui/App/views/Mods/components/UploadMod.jsx b/ui/App/views/Mods/components/UploadMod.jsx index f1083508..c31661db 100644 --- a/ui/App/views/Mods/components/UploadMod.jsx +++ b/ui/App/views/Mods/components/UploadMod.jsx @@ -1,4 +1,5 @@ import React, {useState} from "react"; +import { useTranslation } from 'react-i18next'; import Button from "../../../components/Button"; import Label from "../../../components/Label"; import {useForm} from "react-hook-form"; @@ -6,7 +7,8 @@ import modsResource from "../../../../api/resources/mods"; const UploadMod = ({refetchInstalledMods}) => { - const defaultFileName = 'Select File ...' + const { t } = useTranslation('mods'); + const defaultFileName = t('selectModFile') const [fileName, setFileName] = useState(defaultFileName); const {register, handleSubmit} = useForm(); const [isUploading, setIsUploading] = useState(false); @@ -24,7 +26,7 @@ const UploadMod = ({refetchInstalledMods}) => { return ( -
    NameEnabledCompatibilityMod VersionFactorio Version{t('name', { ns: 'common' })}{t('enabled', { ns: 'common' })}{t('compatibility')}{t('modVersion')}{t('factorioVersion')}
    - - - - + + + + diff --git a/ui/App/views/Saves/components/CreateSaveForm.jsx b/ui/App/views/Saves/components/CreateSaveForm.jsx index 10b75fb9..e550fdc3 100644 --- a/ui/App/views/Saves/components/CreateSaveForm.jsx +++ b/ui/App/views/Saves/components/CreateSaveForm.jsx @@ -1,4 +1,5 @@ import {useForm} from "react-hook-form"; +import { useTranslation } from 'react-i18next'; import Button from "../../../components/Button"; import React, {useState} from "react"; import saves from "../../../../api/resources/saves"; @@ -7,6 +8,7 @@ import Input from "../../../components/Input"; import Error from "../../../components/Error"; const CreateSaveForm = ({onSuccess}) => { + const { t } = useTranslation('saves'); const {register, handleSubmit, formState: {errors}} = useForm(); const [isLoading, setIsLoading] = useState(false); @@ -23,11 +25,11 @@ const CreateSaveForm = ({onSuccess}) => { return (
    -
    - + ) } diff --git a/ui/App/views/Saves/components/UploadSaveForm.jsx b/ui/App/views/Saves/components/UploadSaveForm.jsx index 1db27160..a2fd8f3e 100644 --- a/ui/App/views/Saves/components/UploadSaveForm.jsx +++ b/ui/App/views/Saves/components/UploadSaveForm.jsx @@ -1,13 +1,15 @@ import Button from "../../../components/Button"; import React, {useState} from "react"; +import { useTranslation } from 'react-i18next'; import {useForm} from "react-hook-form"; import saves from "../../../../api/resources/saves"; import Error from "../../../components/Error"; const UploadSaveForm = ({onSuccess}) => { + const { t } = useTranslation('saves'); const {register, handleSubmit, formState: {errors}} = useForm(); - const [fileName, setFileName] = useState('Select File ...'); + const [fileName, setFileName] = useState(t('selectFile')); const onSubmit = (data, e) => { saves.upload(data.savefile[0]).then(_ => { @@ -20,7 +22,7 @@ const UploadSaveForm = ({onSuccess}) => {
    { type="file"/>
    {fileName}
    - +
    - + ) } diff --git a/ui/App/views/ServerSettings.jsx b/ui/App/views/ServerSettings.jsx index 754feef8..a4aad960 100644 --- a/ui/App/views/ServerSettings.jsx +++ b/ui/App/views/ServerSettings.jsx @@ -1,5 +1,6 @@ import Panel from "../components/Panel"; import React, {useEffect, useState} from "react"; +import { useTranslation } from 'react-i18next'; import settingsResource from "../../api/resources/settings"; import Input from "../components/Input"; import Label from "../components/Label"; @@ -10,6 +11,7 @@ import {useForm} from "react-hook-form"; const ServerSettings = () => { + const { t } = useTranslation('serverSettings'); const [settings, setSettings] = useState(); const [numberInputs, setNumberInputs] = useState([]); @@ -36,7 +38,7 @@ const ServerSettings = () => { settingsResource.server.update(data) .then(() => { fetchSettings() - .then(() => window.flash("Settings saved.", "green")) + .then(() => window.flash(t('settingsSaved'), "green")) }); } @@ -124,7 +126,7 @@ const ServerSettings = () => { return ( {settings && Object.keys(settings).map(key => { @@ -150,7 +152,7 @@ const ServerSettings = () => { } actions={ - + } /> diff --git a/ui/App/views/UserManagement/UserManagment.jsx b/ui/App/views/UserManagement/UserManagment.jsx index 34829e52..5f132abb 100644 --- a/ui/App/views/UserManagement/UserManagment.jsx +++ b/ui/App/views/UserManagement/UserManagment.jsx @@ -1,5 +1,6 @@ import Panel from "../../components/Panel"; import React, {useCallback, useEffect, useState} from "react"; +import { useTranslation } from 'react-i18next'; import user from "../../../api/resources/user"; import CreateUserForm from "./components/CreateUserForm"; import ChangePasswordForm from "./components/ChangePasswordForm" @@ -8,6 +9,8 @@ import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; const UserManagement = () => { + const { t } = useTranslation('userManagement'); + const [users, setUsers] = useState([]); const updateList = useCallback(async () => { @@ -29,15 +32,15 @@ const UserManagement = () => { return ( <> - - - - + + + + @@ -57,12 +60,12 @@ const UserManagement = () => { className="mb-4" /> } className="mb-4" /> } className="mb-4" /> diff --git a/ui/App/views/UserManagement/components/ChangePasswordForm.jsx b/ui/App/views/UserManagement/components/ChangePasswordForm.jsx index ca00c4d5..e7306fe1 100644 --- a/ui/App/views/UserManagement/components/ChangePasswordForm.jsx +++ b/ui/App/views/UserManagement/components/ChangePasswordForm.jsx @@ -1,5 +1,6 @@ import {useForm} from "react-hook-form"; import React from "react"; +import { useTranslation } from 'react-i18next'; import user from "../../../../api/resources/user"; import Button from "../../../components/Button"; import Label from "../../../components/Label"; @@ -7,6 +8,7 @@ import Input from "../../../components/Input"; import Error from "../../../components/Error"; const ChangePasswordForm = () => { + const { t } = useTranslation('userManagement'); const {register, handleSubmit, reset, formState: {errors}, watch} = useForm(); const new_password = watch("new_password"); @@ -15,7 +17,7 @@ const ChangePasswordForm = () => { const res = await user.changePassword(data); if (res) { // Update successful - window.flash("Password changed", "green") + window.flash(t('passwordChanged'), "green") reset(); } } @@ -23,30 +25,30 @@ const ChangePasswordForm = () => { return (
    -
    -
    -
    - + ) } diff --git a/ui/App/views/UserManagement/components/CreateUserForm.jsx b/ui/App/views/UserManagement/components/CreateUserForm.jsx index ce3fb369..9e9f27d6 100644 --- a/ui/App/views/UserManagement/components/CreateUserForm.jsx +++ b/ui/App/views/UserManagement/components/CreateUserForm.jsx @@ -1,5 +1,6 @@ import {useForm} from "react-hook-form"; import React from "react"; +import { useTranslation } from 'react-i18next'; import user from "../../../../api/resources/user"; import Button from "../../../components/Button"; import Label from "../../../components/Label"; @@ -7,6 +8,7 @@ import Input from "../../../components/Input"; import Error from "../../../components/Error"; const CreateUserForm = ({updateUserList}) => { + const { t } = useTranslation('userManagement'); const roleValue = "admin"; const { @@ -31,52 +33,52 @@ const CreateUserForm = ({updateUserList}) => { return (
    -
    -
    -
    -
    -
    - + ) } diff --git a/ui/locales/en/mods.json b/ui/locales/en/mods.json index 6c8e3904..88ac9683 100644 --- a/ui/locales/en/mods.json +++ b/ui/locales/en/mods.json @@ -28,5 +28,8 @@ "loadModsTitle": "Load Mods from Save", "selectSave": "Select a save file", "modsLoaded": "Mods loaded from save.", - "deleteExistingMods": "Delete all existing mods before loading? Existing mods will be permanently removed." + "deleteExistingMods": "Delete all existing mods before loading? Existing mods will be permanently removed.", + "compatibility": "Compatibility", + "modVersion": "Mod Version", + "factorioVersion": "Factorio Version" } diff --git a/ui/locales/zh-CN/mods.json b/ui/locales/zh-CN/mods.json index 833e7cd3..30aaef2c 100644 --- a/ui/locales/zh-CN/mods.json +++ b/ui/locales/zh-CN/mods.json @@ -28,5 +28,8 @@ "loadModsTitle": "从存档加载模组", "selectSave": "选择一个存档文件", "modsLoaded": "已从存档加载模组。", - "deleteExistingMods": "加载前删除所有现有模组?现有模组将被永久移除。" + "deleteExistingMods": "加载前删除所有现有模组?现有模组将被永久移除。", + "compatibility": "兼容性", + "modVersion": "模组版本", + "factorioVersion": "Factorio 版本" } From 30f789045c64f893bc0e3291812192d540fc95b6 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Sun, 21 Jun 2026 04:30:44 +0000 Subject: [PATCH 008/112] =?UTF-8?q?=E6=96=87=E6=A1=A3:=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E6=96=B0=E8=AF=AD=E8=A8=80=E9=80=82=E9=85=8D=E6=8C=87?= =?UTF-8?q?=E5=8D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/add-language-guide.md | 143 +++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 docs/add-language-guide.md diff --git a/docs/add-language-guide.md b/docs/add-language-guide.md new file mode 100644 index 00000000..05a54021 --- /dev/null +++ b/docs/add-language-guide.md @@ -0,0 +1,143 @@ +# 添加新语言指南 + +FSM 前端基于 `react-i18next`,添加新语言只需创建翻译文件并注册即可。 + +## 快速上手(以日语 ja 为例) + +### 第 1 步:创建翻译文件 + +```bash +mkdir -p ui/locales/ja +``` + +复制英文文件作为模板: + +```bash +cp ui/locales/en/*.json ui/locales/ja/ +``` + +### 第 2 步:翻译 + +逐个编辑 `ui/locales/ja/` 下的 9 个 JSON 文件,**只改 value,不改 key**。 + +| 文件 | 示例 key | 英文 | 日文翻译 | +|---|---|---|---| +| `common.json` | `save` | Save | 保存 | +| `common.json` | `cancel` | Cancel | キャンセル | +| `common.json` | `confirm` | Confirm | 確認 | +| `controls.json` | `startServer` | Start Server | サーバー起動 | +| `controls.json` | `RUNNING` | Running | 実行中 | +| `layout.json` | `appTitle` | Factorio Server Manager | Factorio サーバーマネージャー | + +### 第 3 步:注册语言 + +编辑 `ui/i18n.js`,在两个地方添加 ja: + +**位置 1 — import 翻译文件:** + +```js +// 在 zh-CN imports 后面添加 +import jaCommon from './locales/ja/common.json'; +import jaLayout from './locales/ja/layout.json'; +import jaControls from './locales/ja/controls.json'; +import jaMods from './locales/ja/mods.json'; +import jaSaves from './locales/ja/saves.json'; +import jaServerSettings from './locales/ja/serverSettings.json'; +import jaLogs from './locales/ja/logs.json'; +import jaConsole from './locales/ja/console.json'; +import jaUserManagement from './locales/ja/userManagement.json'; +``` + +**位置 2 — 添加到 resources 对象:** + +```js +const resources = { + en: { /* 已有 */ }, + 'zh-CN': { /* 已有 */ }, + ja: { + common: jaCommon, + layout: jaLayout, + controls: jaControls, + mods: jaMods, + saves: jaSaves, + serverSettings: jaServerSettings, + logs: jaLogs, + console: jaConsole, + userManagement: jaUserManagement, + }, +}; +``` + +### 第 4 步:添加到语言切换器 + +编辑 `ui/App/components/Layout.jsx`,在 `
    + { !disabled && + { !disabled && + From 4edaa86aa7c5dec985af87e579df037776856638 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Mon, 22 Jun 2026 16:26:10 +0000 Subject: [PATCH 061/112] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20Mod=20=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=AF=8F=205=20=E7=A7=92=E8=87=AA=E5=8A=A8=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E2=80=94=E2=80=94=E6=96=87=E4=BB=B6=E5=8F=98=E5=8A=A8?= =?UTF-8?q?=E5=8D=B3=E6=97=B6=E5=8F=8D=E6=98=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Mods/Mods.jsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ui/App/views/Mods/Mods.jsx b/ui/App/views/Mods/Mods.jsx index e2ab4f5a..2a91fdff 100644 --- a/ui/App/views/Mods/Mods.jsx +++ b/ui/App/views/Mods/Mods.jsx @@ -88,6 +88,12 @@ const Mods = ({serverStatus}) => { })); }); + const interval = setInterval(() => { + fetchInstalledMods(); + fetchModPacks(); + }, 5000); + + return () => clearInterval(interval); }, []); const toggleMod = modName => { From 4d6dc49d3e722ba713172bb8b8f0ea4803877565 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Mon, 22 Jun 2026 16:27:29 +0000 Subject: [PATCH 062/112] =?UTF-8?q?=E8=B0=83=E6=95=B4:=202s=20=E8=BD=AE?= =?UTF-8?q?=E8=AF=A2=20+=20=E5=90=8E=E5=8F=B0=E6=A0=87=E7=AD=BE=E9=A1=B5?= =?UTF-8?q?=E8=B7=B3=E8=BF=87=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Mods/Mods.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/App/views/Mods/Mods.jsx b/ui/App/views/Mods/Mods.jsx index 2a91fdff..0d6d02fc 100644 --- a/ui/App/views/Mods/Mods.jsx +++ b/ui/App/views/Mods/Mods.jsx @@ -89,9 +89,10 @@ const Mods = ({serverStatus}) => { }); const interval = setInterval(() => { + if (document.hidden) return; fetchInstalledMods(); fetchModPacks(); - }, 5000); + }, 2000); return () => clearInterval(interval); }, []); From 4c650ab112d763bd2dc525202ddd63687fa4b583 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Mon, 22 Jun 2026 16:57:53 +0000 Subject: [PATCH 063/112] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20Mod=20=E9=97=A8?= =?UTF-8?q?=E6=88=B7=E5=88=97=E8=A1=A8=E6=87=92=E5=8A=A0=E8=BD=BD=E2=80=94?= =?UTF-8?q?=E2=80=94=E4=BB=85=E5=9C=A8=E7=82=B9=E5=AE=89=E8=A3=85=E6=A8=A1?= =?UTF-8?q?=E7=BB=84=E6=A0=87=E7=AD=BE=E6=97=B6=E6=89=8D=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=206000+=20=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Mods/Mods.jsx | 19 +------------------ .../views/Mods/components/AddMod/AddMod.jsx | 15 +++++++++++---- 2 files changed, 12 insertions(+), 22 deletions(-) diff --git a/ui/App/views/Mods/Mods.jsx b/ui/App/views/Mods/Mods.jsx index 0d6d02fc..8c576560 100644 --- a/ui/App/views/Mods/Mods.jsx +++ b/ui/App/views/Mods/Mods.jsx @@ -70,23 +70,6 @@ const Mods = ({serverStatus}) => { fetchModPacks(); }) - // fetch list of mods - modsResource.portal.list() - .then(res => { - setFuse(new Fuse(res.results, { - keys: [ - { - "name": "name", - weight: 2 - }, - { - "name": "title", - weight: 1 - } - ], - minMatchCharLength: 3 - })); - }); const interval = setInterval(() => { if (document.hidden) return; @@ -130,7 +113,7 @@ const Mods = ({serverStatus}) => { : - + diff --git a/ui/App/views/Mods/components/AddMod/AddMod.jsx b/ui/App/views/Mods/components/AddMod/AddMod.jsx index 9a645566..d0183b89 100644 --- a/ui/App/views/Mods/components/AddMod/AddMod.jsx +++ b/ui/App/views/Mods/components/AddMod/AddMod.jsx @@ -2,10 +2,9 @@ import React, {useEffect, useState} from "react"; import AddModForm from "./components/AddModForm"; import FactorioLogin from "./components/FactorioLogin"; import modResource from "../../../../../api/resources/mods"; +import Fuse from "fuse.js"; - - -const AddMod = ({refetchInstalledMods, fuse}) => { +const AddMod = ({refetchInstalledMods, fuse, setFuse}) => { const [isFactorioAuthenticated, setIsFactorioAuthenticated] = useState(false); @@ -13,6 +12,14 @@ const AddMod = ({refetchInstalledMods, fuse}) => { (async () => { setIsFactorioAuthenticated(await modResource.portal.status()) })(); + if (!fuse) { + modResource.portal.list().then(res => { + setFuse(new Fuse(res.results, { + keys: [{name: "name", weight: 2}, {name: "title", weight: 1}], + minMatchCharLength: 3 + })); + }); + } }, []); return isFactorioAuthenticated @@ -20,4 +27,4 @@ const AddMod = ({refetchInstalledMods, fuse}) => { : } -export default AddMod; \ No newline at end of file +export default AddMod; From bd54b8fe63d76ad60bde9e907144b1899f1238cc Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Mon, 22 Jun 2026 16:59:37 +0000 Subject: [PATCH 064/112] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20=E9=A6=96?= =?UTF-8?q?=E6=AC=A1=E7=82=B9=E5=AE=89=E8=A3=85=E6=A8=A1=E7=BB=84=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E5=8A=A0=E8=BD=BD=E9=97=A8=E6=88=B7=E5=88=97=E8=A1=A8?= =?UTF-8?q?=20+=20TabControl=20=E6=94=AF=E6=8C=81=20onActivate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/components/Tabs/TabControl.jsx | 10 +++++++++- ui/App/views/Mods/Mods.jsx | 17 +++++++++++++++-- ui/App/views/Mods/components/AddMod/AddMod.jsx | 11 +---------- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/ui/App/components/Tabs/TabControl.jsx b/ui/App/components/Tabs/TabControl.jsx index a2a7d94e..05371074 100644 --- a/ui/App/components/Tabs/TabControl.jsx +++ b/ui/App/components/Tabs/TabControl.jsx @@ -4,6 +4,14 @@ import TabTitle from "./TabTitle"; const TabControl = ({children}) => { const [selectedTab, setSelectedTab] = useState(0) + const handleSelect = (index) => { + setSelectedTab(index); + const child = children[index]; + if (child && child.props.onActivate) { + child.props.onActivate(); + } + } + return (
    @@ -13,7 +21,7 @@ const TabControl = ({children}) => { title={item.props.title} index={index} isActive={index === selectedTab} - setSelectedTab={setSelectedTab} + setSelectedTab={handleSelect} /> ))}
    diff --git a/ui/App/views/Mods/Mods.jsx b/ui/App/views/Mods/Mods.jsx index 8c576560..7c7f747f 100644 --- a/ui/App/views/Mods/Mods.jsx +++ b/ui/App/views/Mods/Mods.jsx @@ -23,6 +23,19 @@ const Mods = ({serverStatus}) => { const [modPacks, setModPacks] = useState([]) const [factorioVersion, setFactorioVersion] = useState(null); const [fuse, setFuse] = useState(undefined); + const [portalLoaded, setPortalLoaded] = useState(false); + + const loadPortalList = () => { + if (portalLoaded) return; + setPortalLoaded(true); + modsResource.portal.list() + .then(res => { + setFuse(new Fuse(res.results, { + keys: [{name: "name", weight: 2}, {name: "title", weight: 1}], + minMatchCharLength: 3 + })); + }); + }; const [isDeletingAllMods, setIsDeletingAllMods] = useState(false); const [isUpdatingAllMods, setIsUpdatingAllMods] = useState(false); const [updatableMods, setUpdatableMods] = useState([]); @@ -112,8 +125,8 @@ const Mods = ({serverStatus}) => { /> : - - + + diff --git a/ui/App/views/Mods/components/AddMod/AddMod.jsx b/ui/App/views/Mods/components/AddMod/AddMod.jsx index d0183b89..b85b293b 100644 --- a/ui/App/views/Mods/components/AddMod/AddMod.jsx +++ b/ui/App/views/Mods/components/AddMod/AddMod.jsx @@ -2,9 +2,8 @@ import React, {useEffect, useState} from "react"; import AddModForm from "./components/AddModForm"; import FactorioLogin from "./components/FactorioLogin"; import modResource from "../../../../../api/resources/mods"; -import Fuse from "fuse.js"; -const AddMod = ({refetchInstalledMods, fuse, setFuse}) => { +const AddMod = ({refetchInstalledMods, fuse}) => { const [isFactorioAuthenticated, setIsFactorioAuthenticated] = useState(false); @@ -12,14 +11,6 @@ const AddMod = ({refetchInstalledMods, fuse, setFuse}) => { (async () => { setIsFactorioAuthenticated(await modResource.portal.status()) })(); - if (!fuse) { - modResource.portal.list().then(res => { - setFuse(new Fuse(res.results, { - keys: [{name: "name", weight: 2}, {name: "title", weight: 1}], - minMatchCharLength: 3 - })); - }); - } }, []); return isFactorioAuthenticated From 58a1c85bd51e594c092e6979e3e2d5da76056ac8 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Mon, 22 Jun 2026 17:00:52 +0000 Subject: [PATCH 065/112] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20=E4=BE=A7?= =?UTF-8?q?=E8=BE=B9=E6=A0=8F=E6=96=B0=E5=A2=9E=E6=A3=80=E6=9F=A5=E6=A8=A1?= =?UTF-8?q?=E7=BB=84=E6=9B=B4=E6=96=B0=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/components/Layout.jsx | 4 ++++ ui/App/views/Mods/Mods.jsx | 11 ++++++++++- ui/locales/en/layout.json | 1 + ui/locales/zh-CN/layout.json | 1 + 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/ui/App/components/Layout.jsx b/ui/App/components/Layout.jsx index dccc4443..652819df 100644 --- a/ui/App/components/Layout.jsx +++ b/ui/App/components/Layout.jsx @@ -100,6 +100,10 @@ const Layout = ({handleLogout, serverStatus}) => {
    +
    diff --git a/ui/App/views/Mods/Mods.jsx b/ui/App/views/Mods/Mods.jsx index 7c7f747f..87de02d3 100644 --- a/ui/App/views/Mods/Mods.jsx +++ b/ui/App/views/Mods/Mods.jsx @@ -90,7 +90,16 @@ const Mods = ({serverStatus}) => { fetchModPacks(); }, 2000); - return () => clearInterval(interval); + const handleRefresh = () => { + fetchInstalledMods(); + fetchModPacks(); + }; + window.addEventListener('fsm_refresh_mods', handleRefresh); + + return () => { + clearInterval(interval); + window.removeEventListener('fsm_refresh_mods', handleRefresh); + }; }, []); const toggleMod = modName => { diff --git a/ui/locales/en/layout.json b/ui/locales/en/layout.json index 83a7e7a9..6e673956 100644 --- a/ui/locales/en/layout.json +++ b/ui/locales/en/layout.json @@ -13,6 +13,7 @@ "linkLogs": "Logs", "linkUsers": "Users", "linkHelp": "Help", + "refreshMods": "Check Mod Updates", "languageLabel": "Language", "helpTitle": "Help", "helpBugsAndHelp": "Bugs and Help", diff --git a/ui/locales/zh-CN/layout.json b/ui/locales/zh-CN/layout.json index dcca2e8c..c132450c 100644 --- a/ui/locales/zh-CN/layout.json +++ b/ui/locales/zh-CN/layout.json @@ -13,6 +13,7 @@ "linkLogs": "日志", "linkUsers": "用户", "linkHelp": "帮助", + "refreshMods": "检查模组更新", "languageLabel": "语言", "helpTitle": "帮助", "helpBugsAndHelp": "Bug 与帮助", From e25f5c7b1fe3156a64b1011a54d0fc7c19579b35 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Mon, 22 Jun 2026 17:04:08 +0000 Subject: [PATCH 066/112] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20=E9=97=A8?= =?UTF-8?q?=E6=88=B7=E5=88=97=E8=A1=A8=E7=BC=93=E5=AD=98=201=20=E5=B0=8F?= =?UTF-8?q?=E6=97=B6=E2=80=94=E2=80=94=E9=81=BF=E5=85=8D=E6=AF=8F=E6=AC=A1?= =?UTF-8?q?=E9=83=BD=E8=AF=B7=E6=B1=82=206000+=20=E6=A8=A1=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Mods/Mods.jsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/ui/App/views/Mods/Mods.jsx b/ui/App/views/Mods/Mods.jsx index 87de02d3..9ca304f1 100644 --- a/ui/App/views/Mods/Mods.jsx +++ b/ui/App/views/Mods/Mods.jsx @@ -27,9 +27,27 @@ const Mods = ({serverStatus}) => { const loadPortalList = () => { if (portalLoaded) return; + const cached = sessionStorage.getItem('mod_portal_cache'); + if (cached) { + try { + const {data, time} = JSON.parse(cached); + if (Date.now() - time < 3600000) { + setFuse(new Fuse(data, { + keys: [{name: "name", weight: 2}, {name: "title", weight: 1}], + minMatchCharLength: 3 + })); + setPortalLoaded(true); + return; + } + } catch(e) {} + } setPortalLoaded(true); modsResource.portal.list() .then(res => { + sessionStorage.setItem('mod_portal_cache', JSON.stringify({ + data: res.results, + time: Date.now() + })); setFuse(new Fuse(res.results, { keys: [{name: "name", weight: 2}, {name: "title", weight: 1}], minMatchCharLength: 3 From c137b6bb7e528c38a794f7698060ab68af789a96 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Mon, 22 Jun 2026 17:08:50 +0000 Subject: [PATCH 067/112] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E7=BC=93=E5=AD=98=E9=97=A8=E6=88=B7=E5=88=97=E8=A1=A8?= =?UTF-8?q?=201=20=E5=B0=8F=E6=97=B6=E2=80=94=E2=80=94=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E6=AF=8F=E6=AC=A1=2012MB=20=E4=BC=A0=E8=BE=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/factorio/mod_portal.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/factorio/mod_portal.go b/src/factorio/mod_portal.go index 37db290c..b3b4a846 100644 --- a/src/factorio/mod_portal.go +++ b/src/factorio/mod_portal.go @@ -6,6 +6,7 @@ import ( "io/ioutil" "net/http" "net/url" + "sync" "time" ) @@ -29,7 +30,21 @@ type ModPortalStruct struct { } // get all mods uploaded to the factorio modPortal +var portalCache struct { + data interface{} + expiresAt time.Time + mu sync.RWMutex +} + func ModPortalList() (interface{}, error, int) { + portalCache.mu.RLock() + if portalCache.data != nil && time.Now().Before(portalCache.expiresAt) { + data := portalCache.data + portalCache.mu.RUnlock() + return data, nil, http.StatusOK + } + portalCache.mu.RUnlock() + req, err := http.NewRequest(http.MethodGet, "https://mods.factorio.com/api/mods?page_size=max", nil) if err != nil { return "error", err, http.StatusInternalServerError @@ -56,6 +71,11 @@ func ModPortalList() (interface{}, error, int) { return "error", err, http.StatusInternalServerError } + portalCache.mu.Lock() + portalCache.data = jsonVal + portalCache.expiresAt = time.Now().Add(1 * time.Hour) + portalCache.mu.Unlock() + return jsonVal, nil, resp.StatusCode } From ae9a7578b9d24a7c76d00cb9711919eb3df23e63 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Mon, 22 Jun 2026 17:11:44 +0000 Subject: [PATCH 068/112] =?UTF-8?q?=E5=9B=9E=E9=80=80:=20=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E9=97=A8=E6=88=B7=E5=88=97=E8=A1=A8=E9=A2=84=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E2=80=94=E2=80=94=E5=90=8E=E7=AB=AF=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E5=B7=B2=E5=A4=9F=E5=BF=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Mods/Mods.jsx | 40 ++++++++------------------------------ 1 file changed, 8 insertions(+), 32 deletions(-) diff --git a/ui/App/views/Mods/Mods.jsx b/ui/App/views/Mods/Mods.jsx index 9ca304f1..cd67c0b2 100644 --- a/ui/App/views/Mods/Mods.jsx +++ b/ui/App/views/Mods/Mods.jsx @@ -23,37 +23,6 @@ const Mods = ({serverStatus}) => { const [modPacks, setModPacks] = useState([]) const [factorioVersion, setFactorioVersion] = useState(null); const [fuse, setFuse] = useState(undefined); - const [portalLoaded, setPortalLoaded] = useState(false); - - const loadPortalList = () => { - if (portalLoaded) return; - const cached = sessionStorage.getItem('mod_portal_cache'); - if (cached) { - try { - const {data, time} = JSON.parse(cached); - if (Date.now() - time < 3600000) { - setFuse(new Fuse(data, { - keys: [{name: "name", weight: 2}, {name: "title", weight: 1}], - minMatchCharLength: 3 - })); - setPortalLoaded(true); - return; - } - } catch(e) {} - } - setPortalLoaded(true); - modsResource.portal.list() - .then(res => { - sessionStorage.setItem('mod_portal_cache', JSON.stringify({ - data: res.results, - time: Date.now() - })); - setFuse(new Fuse(res.results, { - keys: [{name: "name", weight: 2}, {name: "title", weight: 1}], - minMatchCharLength: 3 - })); - }); - }; const [isDeletingAllMods, setIsDeletingAllMods] = useState(false); const [isUpdatingAllMods, setIsUpdatingAllMods] = useState(false); const [updatableMods, setUpdatableMods] = useState([]); @@ -101,6 +70,13 @@ const Mods = ({serverStatus}) => { fetchModPacks(); }) + modsResource.portal.list() + .then(res => { + setFuse(new Fuse(res.results, { + keys: [{name: "name", weight: 2}, {name: "title", weight: 1}], + minMatchCharLength: 3 + })); + }); const interval = setInterval(() => { if (document.hidden) return; @@ -152,7 +128,7 @@ const Mods = ({serverStatus}) => { /> : - + From 4242595fb9e984ca0830dc9b2189b52acd84beff Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Mon, 22 Jun 2026 17:13:19 +0000 Subject: [PATCH 069/112] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E6=A8=A1=E7=BB=84=E5=88=97=E8=A1=A8=E6=97=B6=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E8=84=89=E5=86=B2=E8=BF=9B=E5=BA=A6=E6=9D=A1=E2=80=94?= =?UTF-8?q?=E2=80=94=E9=81=BF=E5=85=8D=E4=BD=93=E6=84=9F=E5=8D=A1=E6=AD=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Mods/components/AddMod/components/AddModForm.jsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui/App/views/Mods/components/AddMod/components/AddModForm.jsx b/ui/App/views/Mods/components/AddMod/components/AddModForm.jsx index 3b93a35e..f6d8220a 100644 --- a/ui/App/views/Mods/components/AddMod/components/AddModForm.jsx +++ b/ui/App/views/Mods/components/AddMod/components/AddModForm.jsx @@ -108,6 +108,9 @@ const AddModForm = ({setIsFactorioAuthenticated, fuse, refetchInstalledMods}) => ? :
    {t('loadingModList')} +
    +
    +
    } {suggestedMods.length > 0 && From cc7126997cdbc35bf8567f76a028bf4eac181ff2 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Mon, 22 Jun 2026 17:16:37 +0000 Subject: [PATCH 070/112] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E8=BF=9B=E5=BA=A6=E6=9D=A1=E5=88=86=E6=AE=B5=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E2=80=94=E2=80=941/3=20=E5=AE=BD=E5=BA=A6=E8=84=89?= =?UTF-8?q?=E5=86=B2=E9=81=BF=E5=85=8D=E5=85=A8=E5=AE=BD=E5=8A=A8=E7=94=BB?= =?UTF-8?q?=E6=84=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Mods/Mods.jsx | 6 ++++-- ui/App/views/Mods/components/AddMod/AddMod.jsx | 4 ++-- .../views/Mods/components/AddMod/components/AddModForm.jsx | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/ui/App/views/Mods/Mods.jsx b/ui/App/views/Mods/Mods.jsx index cd67c0b2..eec60b4a 100644 --- a/ui/App/views/Mods/Mods.jsx +++ b/ui/App/views/Mods/Mods.jsx @@ -23,6 +23,7 @@ const Mods = ({serverStatus}) => { const [modPacks, setModPacks] = useState([]) const [factorioVersion, setFactorioVersion] = useState(null); const [fuse, setFuse] = useState(undefined); + const [portalLoading, setPortalLoading] = useState(false); const [isDeletingAllMods, setIsDeletingAllMods] = useState(false); const [isUpdatingAllMods, setIsUpdatingAllMods] = useState(false); const [updatableMods, setUpdatableMods] = useState([]); @@ -70,13 +71,14 @@ const Mods = ({serverStatus}) => { fetchModPacks(); }) + setPortalLoading(true); modsResource.portal.list() .then(res => { setFuse(new Fuse(res.results, { keys: [{name: "name", weight: 2}, {name: "title", weight: 1}], minMatchCharLength: 3 })); - }); + }).finally(() => setPortalLoading(false)); const interval = setInterval(() => { if (document.hidden) return; @@ -129,7 +131,7 @@ const Mods = ({serverStatus}) => { : - + diff --git a/ui/App/views/Mods/components/AddMod/AddMod.jsx b/ui/App/views/Mods/components/AddMod/AddMod.jsx index b85b293b..3f1f524c 100644 --- a/ui/App/views/Mods/components/AddMod/AddMod.jsx +++ b/ui/App/views/Mods/components/AddMod/AddMod.jsx @@ -3,7 +3,7 @@ import AddModForm from "./components/AddModForm"; import FactorioLogin from "./components/FactorioLogin"; import modResource from "../../../../../api/resources/mods"; -const AddMod = ({refetchInstalledMods, fuse}) => { +const AddMod = ({refetchInstalledMods, fuse, loading}) => { const [isFactorioAuthenticated, setIsFactorioAuthenticated] = useState(false); @@ -14,7 +14,7 @@ const AddMod = ({refetchInstalledMods, fuse}) => { }, []); return isFactorioAuthenticated - ? + ? : } diff --git a/ui/App/views/Mods/components/AddMod/components/AddModForm.jsx b/ui/App/views/Mods/components/AddMod/components/AddModForm.jsx index f6d8220a..fb813fe0 100644 --- a/ui/App/views/Mods/components/AddMod/components/AddModForm.jsx +++ b/ui/App/views/Mods/components/AddMod/components/AddModForm.jsx @@ -15,7 +15,7 @@ const LinkModPortal = () => { return {t('modPortal')} } -const AddModForm = ({setIsFactorioAuthenticated, fuse, refetchInstalledMods}) => { +const AddModForm = ({setIsFactorioAuthenticated, fuse, refetchInstalledMods, loading}) => { const { t } = useTranslation('mods'); const {register, watch, setValue, handleSubmit} = useForm(); @@ -109,7 +109,7 @@ const AddModForm = ({setIsFactorioAuthenticated, fuse, refetchInstalledMods}) => :
    {t('loadingModList')}
    -
    +
    } From 0c10a40048fe6fe75317971256dd318bd71ae62b Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Mon, 22 Jun 2026 17:21:14 +0000 Subject: [PATCH 071/112] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E8=99=9A=E5=81=87=E8=BF=9B=E5=BA=A6=E6=9D=A1=E2=80=94?= =?UTF-8?q?=E2=80=94=E5=8A=A0=E8=BD=BD=E6=A8=A1=E7=BB=84=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E4=BB=85=E6=98=BE=E7=A4=BA=E7=9C=9F=E5=AE=9E=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/Mods/components/AddMod/components/AddModForm.jsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/ui/App/views/Mods/components/AddMod/components/AddModForm.jsx b/ui/App/views/Mods/components/AddMod/components/AddModForm.jsx index fb813fe0..c7e057ea 100644 --- a/ui/App/views/Mods/components/AddMod/components/AddModForm.jsx +++ b/ui/App/views/Mods/components/AddMod/components/AddModForm.jsx @@ -107,10 +107,9 @@ const AddModForm = ({setIsFactorioAuthenticated, fuse, refetchInstalledMods, loa { typeof fuse !== "undefined" ? :
    - {t('loadingModList')} -
    -
    -
    + + {loading ? ' ' + t('loadingModList') : ' ' + t('downloadComplete', { ns: 'common' }) + '...'} +
    } {suggestedMods.length > 0 && From 9cf5a39fef3d1e67e15b3b880f25e32500f1d2b2 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Mon, 22 Jun 2026 17:27:26 +0000 Subject: [PATCH 072/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20Factorio=20?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E7=8A=B6=E6=80=81=E5=A4=8D=E7=94=A8=20localS?= =?UTF-8?q?torage=E2=80=94=E2=80=94=E9=81=BF=E5=85=8D=E6=AF=8F=E6=AC=A1?= =?UTF-8?q?=E9=87=8D=E6=9F=A5=20API=20=E5=AF=BC=E8=87=B4=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E8=A1=A8=E5=8D=95=E9=97=AA=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Mods/components/AddMod/AddMod.jsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ui/App/views/Mods/components/AddMod/AddMod.jsx b/ui/App/views/Mods/components/AddMod/AddMod.jsx index 3f1f524c..b3c87782 100644 --- a/ui/App/views/Mods/components/AddMod/AddMod.jsx +++ b/ui/App/views/Mods/components/AddMod/AddMod.jsx @@ -8,8 +8,14 @@ const AddMod = ({refetchInstalledMods, fuse, loading}) => { const [isFactorioAuthenticated, setIsFactorioAuthenticated] = useState(false); useEffect(() => { + const saved = localStorage.getItem('fsm_portal_auth'); + if (saved === 'true') { + setIsFactorioAuthenticated(true); + } (async () => { - setIsFactorioAuthenticated(await modResource.portal.status()) + const status = await modResource.portal.status(); + setIsFactorioAuthenticated(status); + localStorage.setItem('fsm_portal_auth', status ? 'true' : ''); })(); }, []); From 4fd25f40f137f573ec14eee8917cf17f975753e8 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Mon, 22 Jun 2026 17:48:35 +0000 Subject: [PATCH 073/112] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20Worker=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=B8=8B=E8=BD=BD=E8=BF=9B=E5=BA=A6=E6=9D=A1?= =?UTF-8?q?=20+=20=E7=99=BE=E5=88=86=E6=AF=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/mod_modpack_handler.go | 4 +-- src/api/mod_portal_handler.go | 8 +++-- src/factorio/mod_Mods.go | 36 +++++++++++++++++++---- ui/App/views/Mods/components/LoadMods.jsx | 18 +++++++++--- 4 files changed, 52 insertions(+), 14 deletions(-) diff --git a/src/api/mod_modpack_handler.go b/src/api/mod_modpack_handler.go index 402be15e..77c8f227 100644 --- a/src/api/mod_modpack_handler.go +++ b/src/api/mod_modpack_handler.go @@ -454,7 +454,7 @@ func ModPackModPortalInstallHandler(w http.ResponseWriter, r *http.Request) { modList := packMap[packName].Mods - _, err = modList.DownloadMod(data.DownloadURL, data.Filename, data.ModName) + _, err = modList.DownloadMod(data.DownloadURL, data.Filename, data.ModName, nil) if err != nil { resp = fmt.Sprintf("Error downloading a mod: %s", err) log.Println(resp) @@ -505,7 +505,7 @@ func ModPackModPortalInstallMultipleHandler(w http.ResponseWriter, r *http.Reque if release.Version.Equals(datum.Version) { found = true - _, err := modList.DownloadMod(release.DownloadURL, release.FileName, details.Name) + _, err := modList.DownloadMod(release.DownloadURL, release.FileName, details.Name, nil) if err != nil { resp = fmt.Sprintf("Error downloading mod {%s}, error: %s", details.Name, err) log.Println(resp) diff --git a/src/api/mod_portal_handler.go b/src/api/mod_portal_handler.go index 89a6b09d..d9f9967e 100644 --- a/src/api/mod_portal_handler.go +++ b/src/api/mod_portal_handler.go @@ -83,7 +83,7 @@ func ModPortalInstallHandler(w http.ResponseWriter, r *http.Request) { return } - _, err = mods.DownloadMod(data.DownloadURL, data.Filename, data.ModName) + _, err = mods.DownloadMod(data.DownloadURL, data.Filename, data.ModName, nil) if err != nil { resp = fmt.Sprintf("Error downloading a mod: %s", err) log.Println(resp) @@ -229,7 +229,11 @@ func ModPortalInstallMultipleHandler(w http.ResponseWriter, r *http.Request) { found = true size := getContentLength(release.DownloadURL) wsRoom.Send(fmt.Sprintf("{\"type\":\"worker\",\"worker\":%d,\"name\":\"%s\",\"state\":\"downloading\",\"size\":%d}", wid, j.name, size)) - _, dl := modList.DownloadMod(release.DownloadURL, release.FileName, details.Name) + _, dl := modList.DownloadMod(release.DownloadURL, release.FileName, details.Name, func(read, total int64) { + pct := 0 + if total > 0 { pct = int(read * 100 / total) } + wsRoom.Send(fmt.Sprintf("{\"type\":\"worker\",\"worker\":%d,\"name\":\"%s\",\"state\":\"downloading\",\"size\":%d,\"pct\":%d}", wid, j.name, size, pct)) + }) if dl != nil { r.err = dl wsRoom.Send(fmt.Sprintf("{\"type\":\"worker\",\"worker\":%d,\"name\":\"%s\",\"state\":\"error\"}", wid, j.name)) diff --git a/src/factorio/mod_Mods.go b/src/factorio/mod_Mods.go index cd101768..22c8ae5b 100644 --- a/src/factorio/mod_Mods.go +++ b/src/factorio/mod_Mods.go @@ -132,7 +132,7 @@ func (mods *Mods) createMod(modName string, fileName string, fileRc io.Reader) e return nil } -func (mods *Mods) DownloadMod(url string, filename string, modId string) (int64, error) { +func (mods *Mods) DownloadMod(url string, filename string, modId string, progressCb func(int64, int64)) (int64, error) { var err error var credentials Credentials @@ -154,8 +154,6 @@ func (mods *Mods) DownloadMod(url string, filename string, modId string) (int64, return 0, err } - log.Printf("download complete\n StatusCode: %d\n Status: %s", response.StatusCode, response.Status) - defer response.Body.Close() if response.StatusCode != 200 { @@ -163,7 +161,17 @@ func (mods *Mods) DownloadMod(url string, filename string, modId string) (int64, return 0, errors.New("Statuscode not 200: " + fmt.Sprint(response.StatusCode)) } - err = mods.createMod(modId, filename, response.Body) + size := response.ContentLength + var body io.Reader = response.Body + if progressCb != nil && size > 0 { + body = &progressReader{ + Reader: response.Body, + Total: size, + Callback: func(read int64) { progressCb(read, size) }, + } + } + + err = mods.createMod(modId, filename, body) if err != nil { log.Printf("error when creating Mod: %s", err) return 0, err @@ -171,7 +179,23 @@ func (mods *Mods) DownloadMod(url string, filename string, modId string) (int64, log.Printf("completed copying the response.Body") - return response.ContentLength, nil + return size, nil +} + +type progressReader struct { + Reader io.Reader + Total int64 + read int64 + Callback func(int64) +} + +func (pr *progressReader) Read(p []byte) (int, error) { + n, err := pr.Reader.Read(p) + pr.read += int64(n) + if pr.Callback != nil { + pr.Callback(pr.read) + } + return n, err } func (mods *Mods) UploadMod(file multipart.File, header *multipart.FileHeader) error { @@ -213,7 +237,7 @@ func (mods *Mods) UploadMod(file multipart.File, header *multipart.FileHeader) e func (mods *Mods) UpdateMod(modName string, url string, filename string) error { var err error - _, err = mods.DownloadMod(url, filename, modName) + _, err = mods.DownloadMod(url, filename, modName, nil) if err != nil { log.Printf("updateMod ... error when downloading the new Mod: %s", err) return err diff --git a/ui/App/views/Mods/components/LoadMods.jsx b/ui/App/views/Mods/components/LoadMods.jsx index 61799a78..8b32f1d6 100644 --- a/ui/App/views/Mods/components/LoadMods.jsx +++ b/ui/App/views/Mods/components/LoadMods.jsx @@ -72,7 +72,8 @@ const LoadMods = ({refreshMods}) => { [data.worker]: { name: data.name, state: data.state, - size: data.size || 0 + size: data.size || 0, + pct: data.pct || 0 } })); } else if (data.type === 'complete') { @@ -190,10 +191,19 @@ const LoadMods = ({refreshMods}) => { const icon = ws.state === 'done' ? '✓' : ws.state === 'error' ? '✗' : '↓'; const color = ws.state === 'done' ? 'text-green' : ws.state === 'error' ? 'text-red' : 'text-orange'; const sizeText = ws.size > 0 ? ' (' + (ws.size / 1024).toFixed(0) + 'KB)' : ''; + const pct = ws.pct || 0; return ( -
    - {icon} - {ws.name}{sizeText} +
    +
    + {icon} + {ws.name}{sizeText} + {ws.state === 'downloading' && pct > 0 ? {pct}% : null} +
    + {ws.state === 'downloading' ? ( +
    +
    +
    + ) : null}
    ); })} From 94a8b66888399f435c21e0796ffd29f629aa36b5 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Mon, 22 Jun 2026 23:37:48 +0000 Subject: [PATCH 074/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E6=9D=A1=E5=A7=8B=E7=BB=88=E5=8D=A0=E4=BD=8D=E2=80=94?= =?UTF-8?q?=E2=80=94=E4=B8=8D=E5=86=8D=E5=9B=A0=E5=A4=A7=E5=B0=8F=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=E5=BB=B6=E8=BF=9F=E8=80=8C=E8=B7=B3=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Mods/components/LoadMods.jsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/ui/App/views/Mods/components/LoadMods.jsx b/ui/App/views/Mods/components/LoadMods.jsx index 8b32f1d6..ec9417b6 100644 --- a/ui/App/views/Mods/components/LoadMods.jsx +++ b/ui/App/views/Mods/components/LoadMods.jsx @@ -190,20 +190,19 @@ const LoadMods = ({refreshMods}) => { if (!ws) return null; const icon = ws.state === 'done' ? '✓' : ws.state === 'error' ? '✗' : '↓'; const color = ws.state === 'done' ? 'text-green' : ws.state === 'error' ? 'text-red' : 'text-orange'; - const sizeText = ws.size > 0 ? ' (' + (ws.size / 1024).toFixed(0) + 'KB)' : ''; + const sizeText = ws.size > 0 ? ' (' + (ws.size / 1024).toFixed(0) + 'KB)' : ' (...KB)'; const pct = ws.pct || 0; + const showBar = ws.state === 'downloading' || ws.state === 'done' || ws.state === 'error'; return (
    {icon} {ws.name}{sizeText} - {ws.state === 'downloading' && pct > 0 ? {pct}% : null} + {ws.state === 'downloading' ? {pct}% : null} +
    +
    +
    - {ws.state === 'downloading' ? ( -
    -
    -
    - ) : null}
    ); })} From 9a09bf15a33a99fb4b9794fdc0bcb63cf37974a4 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Mon, 22 Jun 2026 23:47:24 +0000 Subject: [PATCH 075/112] =?UTF-8?q?=E5=9B=9E=E9=80=80:=20=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=20localStorage=20=E7=BC=93=E5=AD=98=E2=80=94=E2=80=94?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E5=8E=9F=E5=A7=8B=20AddMod=20=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E6=A3=80=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Mods/components/AddMod/AddMod.jsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/ui/App/views/Mods/components/AddMod/AddMod.jsx b/ui/App/views/Mods/components/AddMod/AddMod.jsx index b3c87782..3f1f524c 100644 --- a/ui/App/views/Mods/components/AddMod/AddMod.jsx +++ b/ui/App/views/Mods/components/AddMod/AddMod.jsx @@ -8,14 +8,8 @@ const AddMod = ({refetchInstalledMods, fuse, loading}) => { const [isFactorioAuthenticated, setIsFactorioAuthenticated] = useState(false); useEffect(() => { - const saved = localStorage.getItem('fsm_portal_auth'); - if (saved === 'true') { - setIsFactorioAuthenticated(true); - } (async () => { - const status = await modResource.portal.status(); - setIsFactorioAuthenticated(status); - localStorage.setItem('fsm_portal_auth', status ? 'true' : ''); + setIsFactorioAuthenticated(await modResource.portal.status()) })(); }, []); From 2f4f6de865fde5b567a6f2eeb176f904706c94e7 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Mon, 22 Jun 2026 23:55:38 +0000 Subject: [PATCH 076/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E5=85=B1?= =?UTF-8?q?=E4=BA=AB=20Factorio=20=E7=99=BB=E5=BD=95=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E5=88=B0=E7=88=B6=E7=BB=84=E4=BB=B6=E2=80=94=E2=80=94AddMod=20?= =?UTF-8?q?=E5=92=8C=20LoadMods=20=E4=B8=8D=E5=86=8D=E5=90=84=E8=87=AA?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Mods/Mods.jsx | 9 +++++++-- ui/App/views/Mods/components/AddMod/AddMod.jsx | 13 ++----------- ui/App/views/Mods/components/LoadMods.jsx | 5 +---- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/ui/App/views/Mods/Mods.jsx b/ui/App/views/Mods/Mods.jsx index eec60b4a..b80ccfb4 100644 --- a/ui/App/views/Mods/Mods.jsx +++ b/ui/App/views/Mods/Mods.jsx @@ -28,6 +28,7 @@ const Mods = ({serverStatus}) => { const [isUpdatingAllMods, setIsUpdatingAllMods] = useState(false); const [updatableMods, setUpdatableMods] = useState([]); const [showDeleteAllConfirm, setShowDeleteAllConfirm] = useState(false); + const [isFactorioAuthenticated, setIsFactorioAuthenticated] = useState(false); const addUpdatableMod = mod => { setUpdatableMods(mods => [...mods, mod]) @@ -64,6 +65,8 @@ const Mods = ({serverStatus}) => { } useEffect(() => { + modsResource.portal.status().then(setIsFactorioAuthenticated); + server.factorioVersion() .then(data => { setFactorioVersion(data.base_mod_version) @@ -131,13 +134,15 @@ const Mods = ({serverStatus}) => { : - + - + } diff --git a/ui/App/views/Mods/components/AddMod/AddMod.jsx b/ui/App/views/Mods/components/AddMod/AddMod.jsx index 3f1f524c..4a0152ca 100644 --- a/ui/App/views/Mods/components/AddMod/AddMod.jsx +++ b/ui/App/views/Mods/components/AddMod/AddMod.jsx @@ -1,17 +1,8 @@ -import React, {useEffect, useState} from "react"; +import React from "react"; import AddModForm from "./components/AddModForm"; import FactorioLogin from "./components/FactorioLogin"; -import modResource from "../../../../../api/resources/mods"; -const AddMod = ({refetchInstalledMods, fuse, loading}) => { - - const [isFactorioAuthenticated, setIsFactorioAuthenticated] = useState(false); - - useEffect(() => { - (async () => { - setIsFactorioAuthenticated(await modResource.portal.status()) - })(); - }, []); +const AddMod = ({refetchInstalledMods, fuse, loading, isFactorioAuthenticated, setIsFactorioAuthenticated}) => { return isFactorioAuthenticated ? diff --git a/ui/App/views/Mods/components/LoadMods.jsx b/ui/App/views/Mods/components/LoadMods.jsx index ec9417b6..bda84095 100644 --- a/ui/App/views/Mods/components/LoadMods.jsx +++ b/ui/App/views/Mods/components/LoadMods.jsx @@ -11,14 +11,13 @@ import FactorioLogin from "./AddMod/components/FactorioLogin"; import Modal from "../../../components/Modal"; import socket from "../../../../api/socket"; -const LoadMods = ({refreshMods}) => { +const LoadMods = ({refreshMods, isFactorioAuthenticated, setIsFactorioAuthenticated}) => { const { t } = useTranslation(['mods', 'common']); const [saves, setSaves] = useState([]); const {register, reset, handleSubmit} = useForm(); const [isLoading, setIsLoading] = useState(false); const [isDisabled, setIsDisabled] = useState(true); - const [isFactorioAuthenticated, setIsFactorioAuthenticated] = useState(false); const [loadModsData, setLoadModsData] = useState(undefined); const [installProgress, setInstallProgress] = useState({current: 0, total: 0}); const [showProgress, setShowProgress] = useState(false); @@ -38,8 +37,6 @@ const LoadMods = ({refreshMods}) => { setActiveCount(0); (async () => { - setIsFactorioAuthenticated(await modResource.portal.status()) - const s = await savesResource.list() setSaves(s); if (s.length > 0) { From ef6f7e75f4510996e8d535f26682be779057d9a4 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Tue, 23 Jun 2026 00:07:12 +0000 Subject: [PATCH 077/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E7=BB=93=E6=9E=9C=E8=BF=94=E5=9B=9E=E5=89=8D=E4=B8=8D?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E6=A0=87=E7=AD=BE=E2=80=94=E2=80=94=E6=B6=88?= =?UTF-8?q?=E9=99=A4=E7=99=BB=E5=BD=95=E8=A1=A8=E5=8D=95=E9=97=AA=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Mods/Mods.jsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ui/App/views/Mods/Mods.jsx b/ui/App/views/Mods/Mods.jsx index b80ccfb4..665a6a9f 100644 --- a/ui/App/views/Mods/Mods.jsx +++ b/ui/App/views/Mods/Mods.jsx @@ -29,6 +29,7 @@ const Mods = ({serverStatus}) => { const [updatableMods, setUpdatableMods] = useState([]); const [showDeleteAllConfirm, setShowDeleteAllConfirm] = useState(false); const [isFactorioAuthenticated, setIsFactorioAuthenticated] = useState(false); + const [authChecked, setAuthChecked] = useState(false); const addUpdatableMod = mod => { setUpdatableMods(mods => [...mods, mod]) @@ -65,8 +66,13 @@ const Mods = ({serverStatus}) => { } useEffect(() => { - modsResource.portal.status().then(setIsFactorioAuthenticated); + modsResource.portal.status().then(auth => { + setIsFactorioAuthenticated(auth); + setAuthChecked(true); + }); + }, []); + useEffect(() => { server.factorioVersion() .then(data => { setFactorioVersion(data.base_mod_version) @@ -132,6 +138,7 @@ const Mods = ({serverStatus}) => { } /> : + !authChecked ? null : Date: Tue, 23 Jun 2026 07:39:49 +0000 Subject: [PATCH 078/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E6=A8=A1=E7=BB=84=E5=88=97=E8=A1=A8=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E2=80=94=E2=80=94=E5=8E=BB=E6=8E=89=20downlo?= =?UTF-8?q?adComplete=20=E9=81=BF=E5=85=8D=E5=8D=A1=E4=BD=8F=E9=94=99?= =?UTF-8?q?=E8=A7=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Mods/components/AddMod/components/AddModForm.jsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ui/App/views/Mods/components/AddMod/components/AddModForm.jsx b/ui/App/views/Mods/components/AddMod/components/AddModForm.jsx index c7e057ea..505c6adb 100644 --- a/ui/App/views/Mods/components/AddMod/components/AddModForm.jsx +++ b/ui/App/views/Mods/components/AddMod/components/AddModForm.jsx @@ -107,9 +107,7 @@ const AddModForm = ({setIsFactorioAuthenticated, fuse, refetchInstalledMods, loa { typeof fuse !== "undefined" ? :
    - - {loading ? ' ' + t('loadingModList') : ' ' + t('downloadComplete', { ns: 'common' }) + '...'} - + {t('loadingModList')}
    } {suggestedMods.length > 0 && From f35dedd1840933becb1d31705239eb9af711b6fd Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Tue, 23 Jun 2026 07:41:58 +0000 Subject: [PATCH 079/112] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E6=A8=A1=E7=BB=84=E5=88=97=E8=A1=A8=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E6=A8=AA=E5=B9=85=E2=80=94=E2=80=94=E5=8A=A0=E8=BD=BD=E4=B8=AD?= =?UTF-8?q?=E4=B8=89=E6=A0=87=E7=AD=BE=E7=81=B0=E8=89=B2=E7=A6=81=E7=94=A8?= =?UTF-8?q?=EF=BC=8C=E5=AE=8C=E6=88=90=E5=90=8E=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Mods/Mods.jsx | 40 +++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/ui/App/views/Mods/Mods.jsx b/ui/App/views/Mods/Mods.jsx index 665a6a9f..2ff85018 100644 --- a/ui/App/views/Mods/Mods.jsx +++ b/ui/App/views/Mods/Mods.jsx @@ -14,6 +14,8 @@ import CreateModPack from "./components/CreateModPack"; import ModPack from "./components/ModPack"; import ModList from "./components/ModList"; import ConfirmDialog from "../../components/ConfirmDialog"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faSpinner } from "@fortawesome/free-solid-svg-icons"; const Mods = ({serverStatus}) => { @@ -139,19 +141,31 @@ const Mods = ({serverStatus}) => { /> : !authChecked ? null : - - - - - - - - - - - +
    + {portalLoading && +
    +
    + + {t('loadingModList')} +
    +
    + } +
    + + + + + + + + + + + +
    +
    } Date: Tue, 23 Jun 2026 12:12:01 +0000 Subject: [PATCH 080/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20Updater=20API=20?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=20stable=20=E6=A0=87=E7=AD=BE=E2=80=94?= =?UTF-8?q?=E2=80=942.1.7=20=E7=89=88=E6=9C=AC=E5=B7=B2=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/factorio/version_manager.go | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/factorio/version_manager.go b/src/factorio/version_manager.go index c40eadc6..2b0444cd 100644 --- a/src/factorio/version_manager.go +++ b/src/factorio/version_manager.go @@ -71,8 +71,9 @@ func (vm *VersionManager) GetAvailableVersions() ([]Release, error) { } type UpdaterResponse map[string][]struct { - From string `json:"from"` - To string `json:"to"` + From string `json:"from"` + To string `json:"to"` + Stable string `json:"stable"` } func (vm *VersionManager) GetFullVersionList() ([]Release, error) { @@ -103,11 +104,15 @@ func (vm *VersionManager) GetFullVersionList() ([]Release, error) { } for _, u := range updates { - if !seen[u.To] { - seen[u.To] = true + ver := u.To + if ver == "" && u.Stable != "" { + ver = u.Stable + } + if ver != "" && !seen[ver] { + seen[ver] = true releases = append(releases, Release{ - Version: u.To, - Stable: isStableVersion(u.To), + Version: ver, + Stable: isStableVersion(ver), Latest: false, }) } From 315b99f3c004cd68c4d1790ff86590e4f3c10c59 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Tue, 23 Jun 2026 12:46:54 +0000 Subject: [PATCH 081/112] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=E8=AE=BE=E7=BD=AE=E6=B7=BB=E5=8A=A0=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E8=AF=B4=E6=98=8E=E5=88=87=E6=8D=A2=E2=80=94=E2=80=94?= =?UTF-8?q?=E9=9D=99=E6=80=81=E7=BF=BB=E8=AF=91=2027=20=E4=B8=AA=20Factori?= =?UTF-8?q?o=20=E8=AE=BE=E7=BD=AE=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/ServerSettings.jsx | 14 ++++++++++++- ui/App/views/settingDescriptions.js | 31 ++++++++++++++++++++++++++++ ui/locales/en/serverSettings.json | 4 +++- ui/locales/zh-CN/serverSettings.json | 4 +++- 4 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 ui/App/views/settingDescriptions.js diff --git a/ui/App/views/ServerSettings.jsx b/ui/App/views/ServerSettings.jsx index a4aad960..5622fa3c 100644 --- a/ui/App/views/ServerSettings.jsx +++ b/ui/App/views/ServerSettings.jsx @@ -8,12 +8,14 @@ import Checkbox from "../components/Checkbox"; import InputPassword from "../components/InputPassword"; import Button from "../components/Button"; import {useForm} from "react-hook-form"; +import settingDescriptions from "./settingDescriptions"; const ServerSettings = () => { const { t } = useTranslation('serverSettings'); const [settings, setSettings] = useState(); const [numberInputs, setNumberInputs] = useState([]); + const [showTranslation, setShowTranslation] = useState(false); const {register, handleSubmit, formState: {errors}, control} = useForm(); @@ -145,6 +147,11 @@ const ServerSettings = () => { return (
    {formTypeField(key, value, label)} + {showTranslation && settingDescriptions[key] && ( +

    + {settingDescriptions[key].zh + (settingDescriptions[key].en ? ' (' + settingDescriptions[key].en + ')' : '')} +

    + )}

    {comment}

    ) @@ -152,7 +159,12 @@ const ServerSettings = () => { } actions={ - +
    + + +
    } /> diff --git a/ui/App/views/settingDescriptions.js b/ui/App/views/settingDescriptions.js new file mode 100644 index 00000000..e80feb4e --- /dev/null +++ b/ui/App/views/settingDescriptions.js @@ -0,0 +1,31 @@ +const settingDescriptions = { + name: { zh: "服务器名称", en: "Server name" }, + description: { zh: "服务器描述", en: "Server description" }, + tags: { zh: "服务器标签(逗号分隔)", en: "Server tags (comma separated)" }, + max_players: { zh: "最大玩家数", en: "Maximum number of players" }, + visibility: { zh: "可见性(公开/局域网/隐藏)", en: "Visibility (public/LAN/hidden)" }, + username: { zh: "Factorio 账号用户名", en: "Factorio account username" }, + password: { zh: "Factorio 账号密码", en: "Factorio account password" }, + token: { zh: "Factorio 认证令牌", en: "Factorio authentication token" }, + game_password: { zh: "游戏房间密码", en: "Game password for joining" }, + require_user_verification: { zh: "要求用户验证", en: "Require user verification" }, + max_upload_in_kilobytes_per_second: { zh: "最大上传速度(KB/s)", en: "Max upload speed in KB/s" }, + max_upload_slots: { zh: "最大上传槽位", en: "Maximum upload slots" }, + minimum_latency_in_ticks: { zh: "最小延迟(tick)", en: "Minimum latency in ticks" }, + ignore_player_limit_for_returning_players: { zh: "返回玩家忽略人数限制", en: "Ignore player limit for returning players" }, + allow_commands: { zh: "允许指令", en: "Allow commands" }, + auto_pause: { zh: "自动暂停", en: "Auto pause when no players" }, + only_admins_can_pause_the_game: { zh: "仅管理员可暂停", en: "Only admins can pause" }, + autosave_interval: { zh: "自动保存间隔(分钟)", en: "Auto-save interval (minutes)" }, + autosave_slots: { zh: "自动保存槽位数", en: "Number of auto-save slots" }, + autosave_only_on_server: { zh: "仅在服务器自动保存", en: "Auto-save only on server" }, + non_blocking_saving: { zh: "非阻塞保存", en: "Non-blocking saving" }, + afk_autokick_interval: { zh: "AFK 自动踢出间隔(分钟)", en: "AFK auto-kick interval (minutes)" }, + admins: { zh: "管理员(逗号分隔)", en: "Admins (comma separated)" }, + minimum_segment_size: { zh: "最小区域大小", en: "Minimum segment size" }, + minimum_segment_size_peer_count: { zh: "最小区域对等数量", en: "Minimum segment size peer count" }, + maximum_segment_size: { zh: "最大区域大小", en: "Maximum segment size" }, + maximum_segment_size_peer_count: { zh: "最大区域对等数量", en: "Maximum segment size peer count" }, +}; + +export default settingDescriptions; diff --git a/ui/locales/en/serverSettings.json b/ui/locales/en/serverSettings.json index 1e85bf1b..256938e9 100644 --- a/ui/locales/en/serverSettings.json +++ b/ui/locales/en/serverSettings.json @@ -26,5 +26,7 @@ "startingAreaSize": "Starting Area Size", "peacefulMode": "Peaceful Mode", "researchQueue": "Research Queue", - "technologyPriceMultiplier": "Technology Price Multiplier" + "technologyPriceMultiplier": "Technology Price Multiplier", + "showTranslation": "Show Description", + "hideTranslation": "Hide Description" } diff --git a/ui/locales/zh-CN/serverSettings.json b/ui/locales/zh-CN/serverSettings.json index d49ca1f9..2382ce7d 100644 --- a/ui/locales/zh-CN/serverSettings.json +++ b/ui/locales/zh-CN/serverSettings.json @@ -26,5 +26,7 @@ "startingAreaSize": "起始区域大小", "peacefulMode": "和平模式", "researchQueue": "研究队列", - "technologyPriceMultiplier": "科技价格倍率" + "technologyPriceMultiplier": "科技价格倍率", + "showTranslation": "显示说明", + "hideTranslation": "隐藏说明" } From 068720e1b9fedbf6f3cbec0bb5985165d94bec96 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Tue, 23 Jun 2026 12:58:00 +0000 Subject: [PATCH 082/112] =?UTF-8?q?=E6=96=87=E6=A1=A3:=20=E5=9F=BA?= =?UTF-8?q?=E4=BA=8E=20Factorio=20Wiki=20=E9=87=8D=E5=86=99=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=E8=AE=BE=E7=BD=AE=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/settingDescriptions.js | 55 ++++++++++++++--------------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/ui/App/views/settingDescriptions.js b/ui/App/views/settingDescriptions.js index e80feb4e..3e5eea99 100644 --- a/ui/App/views/settingDescriptions.js +++ b/ui/App/views/settingDescriptions.js @@ -1,31 +1,30 @@ const settingDescriptions = { - name: { zh: "服务器名称", en: "Server name" }, - description: { zh: "服务器描述", en: "Server description" }, - tags: { zh: "服务器标签(逗号分隔)", en: "Server tags (comma separated)" }, - max_players: { zh: "最大玩家数", en: "Maximum number of players" }, - visibility: { zh: "可见性(公开/局域网/隐藏)", en: "Visibility (public/LAN/hidden)" }, - username: { zh: "Factorio 账号用户名", en: "Factorio account username" }, - password: { zh: "Factorio 账号密码", en: "Factorio account password" }, - token: { zh: "Factorio 认证令牌", en: "Factorio authentication token" }, - game_password: { zh: "游戏房间密码", en: "Game password for joining" }, - require_user_verification: { zh: "要求用户验证", en: "Require user verification" }, - max_upload_in_kilobytes_per_second: { zh: "最大上传速度(KB/s)", en: "Max upload speed in KB/s" }, - max_upload_slots: { zh: "最大上传槽位", en: "Maximum upload slots" }, - minimum_latency_in_ticks: { zh: "最小延迟(tick)", en: "Minimum latency in ticks" }, - ignore_player_limit_for_returning_players: { zh: "返回玩家忽略人数限制", en: "Ignore player limit for returning players" }, - allow_commands: { zh: "允许指令", en: "Allow commands" }, - auto_pause: { zh: "自动暂停", en: "Auto pause when no players" }, - only_admins_can_pause_the_game: { zh: "仅管理员可暂停", en: "Only admins can pause" }, - autosave_interval: { zh: "自动保存间隔(分钟)", en: "Auto-save interval (minutes)" }, - autosave_slots: { zh: "自动保存槽位数", en: "Number of auto-save slots" }, - autosave_only_on_server: { zh: "仅在服务器自动保存", en: "Auto-save only on server" }, - non_blocking_saving: { zh: "非阻塞保存", en: "Non-blocking saving" }, - afk_autokick_interval: { zh: "AFK 自动踢出间隔(分钟)", en: "AFK auto-kick interval (minutes)" }, - admins: { zh: "管理员(逗号分隔)", en: "Admins (comma separated)" }, - minimum_segment_size: { zh: "最小区域大小", en: "Minimum segment size" }, - minimum_segment_size_peer_count: { zh: "最小区域对等数量", en: "Minimum segment size peer count" }, - maximum_segment_size: { zh: "最大区域大小", en: "Maximum segment size" }, - maximum_segment_size_peer_count: { zh: "最大区域对等数量", en: "Maximum segment size peer count" }, + name: { zh: "服务器名称,将在服务器列表中显示", en: "Server name shown in the server browser" }, + description: { zh: "服务器描述,最多 5000 字符", en: "Server description, up to 5000 characters" }, + tags: { zh: "游戏标签列表(逗号分隔)", en: "List of game tags (comma separated)" }, + max_players: { zh: "最大在线玩家数。0 为不限制", en: "Max simultaneous players. 0 = unlimited" }, + visibility: { zh: "服务器可见性:public(公开)/ lan(局域网)/ hidden(隐藏)", en: "Visibility: public / LAN / hidden" }, + username: { zh: "Factorio 账号用户名(公开服务器必填)", en: "Factorio.com username (required for public servers)" }, + password: { zh: "Factorio 密码(建议用 Token 代替)", en: "Factorio.com password (use token instead)" }, + token: { zh: "认证令牌(推荐)。factorio.com/profile 获取", en: "Auth token from factorio.com/profile (recommended)" }, + game_password: { zh: "加入游戏的房间密码,留空则无需密码", en: "Password to join the game. Empty = no password" }, + require_user_verification: { zh: "是否验证玩家 Factorio 账号身份", en: "Verify player identity via Factorio account" }, + max_upload_in_kilobytes_per_second: { zh: "最大上传速度(KB/s),0 为不限制", en: "Max upload speed in KB/s. 0 = unlimited" }, + max_upload_slots: { zh: "最大上传槽位数", en: "Maximum concurrent upload slots" }, + minimum_latency_in_ticks: { zh: "最低延迟(游戏刻),越小响应越快但更吃网络", en: "Minimum latency in ticks. Lower = more responsive" }, + ignore_player_limit_for_returning_players: { zh: "返回玩家是否无视人数上限", en: "Returning players bypass player limit" }, + allow_commands: { zh: "指令权限:true(所有人)/ admins-only / false", en: "Command permission: true (all) / admins-only / false" }, + auto_pause: { zh: "无人在线时自动暂停游戏", en: "Auto-pause when no players connected" }, + only_admins_can_pause_the_game: { zh: "仅允许管理员暂停游戏", en: "Only admins can pause the game" }, + autosave_interval: { zh: "自动保存间隔(分钟)", en: "Auto-save interval in minutes" }, + autosave_slots: { zh: "保留的自动保存槽位数", en: "Number of auto-save slots" }, + autosave_only_on_server: { zh: "仅在服务器端自动保存", en: "Only server performs auto-saves" }, + non_blocking_saving: { zh: "非阻塞保存,保存时游戏不暂停", en: "Non-blocking saving" }, + afk_autokick_interval: { zh: "挂机自动踢出时间(分钟),0 为不禁用", en: "AFK auto-kick in minutes. 0 = never" }, + admins: { zh: "管理员用户名列表(逗号分隔)", en: "Admin usernames (comma separated)" }, + minimum_segment_size: { zh: "最小网络分段大小", en: "Minimum segment size for networking" }, + minimum_segment_size_peer_count: { zh: "最小分段的节点数", en: "Min segment size peer count" }, + maximum_segment_size: { zh: "最大网络分段大小", en: "Maximum segment size for networking" }, + maximum_segment_size_peer_count: { zh: "最大分段的节点数", en: "Max segment size peer count" }, }; - export default settingDescriptions; From d56e36d5108610cc3e9a343c74d91e5ba44c6146 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Tue, 23 Jun 2026 13:45:02 +0000 Subject: [PATCH 083/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20Factorio=202.1=20?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E6=80=A7=E5=88=A4=E6=96=AD=20&=20=E6=A8=A1?= =?UTF-8?q?=E7=BB=84=E7=89=88=E6=9C=AC=E6=98=BE=E7=A4=BA=20&=20=E5=B4=A9?= =?UTF-8?q?=E6=BA=83=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - save.go: 修复 fmt.Errorf 中 %%v 导致编译失败 (20+处) - mod_modInfo.go: 修复依赖解析 parts[2] 越界崩溃 - mod_modInfo.go: 修复 base 依赖覆盖 FactorioVersion 为更低版本 (如 2.0→0.18) - version_compat_test.go: 新增 GEC 兼容性测试 (2.1/2.0/1.0 边界情况) --- src/factorio/mod_modInfo.go | 29 +++++----- src/factorio/save.go | 42 +++++++-------- src/factorio/version_compat_test.go | 82 +++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 33 deletions(-) create mode 100644 src/factorio/version_compat_test.go diff --git a/src/factorio/mod_modInfo.go b/src/factorio/mod_modInfo.go index 7dda8711..940399c1 100644 --- a/src/factorio/mod_modInfo.go +++ b/src/factorio/mod_modInfo.go @@ -97,15 +97,19 @@ func (modInfoList *ModInfoList) listInstalledMods() error { if parts[0] != "base" { continue } - if len(parts) == 1 { - base = modInfo.FactorioVersion - op = ">=" - continue - } + if len(parts) == 1 { + base = modInfo.FactorioVersion + op = ">=" + continue + } + if len(parts) < 3 { + log.Printf("skipping dependency '%s' in '%s': invalid format (expected 'base op version')\n", dep, modInfo.Name) + continue + } - op = parts[1] + op = parts[1] - if err := base.UnmarshalText([]byte(parts[2])); err != nil { + if err := base.UnmarshalText([]byte(parts[2])); err != nil { log.Printf("skipping dependency '%s' in '%s': %v\n", dep, modInfo.Name, err) continue } @@ -115,13 +119,14 @@ func (modInfoList *ModInfoList) listInstalledMods() error { server := GetFactorioServer() - // check both the factorio-version and the base mod dependency - modInfo.Compatibility = server.Version.GEC(modInfo.FactorioVersion) - if modInfo.Compatibility && !base.Equals(NilVersion) { - modInfo.Compatibility = server.Version.Compatible(base, op) + modInfo.Compatibility = server.Version.GEC(modInfo.FactorioVersion) + if modInfo.Compatibility && !base.Equals(NilVersion) { + modInfo.Compatibility = server.Version.Compatible(base, op) + if base.Greater(modInfo.FactorioVersion) || modInfo.FactorioVersion.Equals(NilVersion) { modInfo.FactorioVersion = base - modInfo.DepOp = op } + modInfo.DepOp = op + } modInfoList.Mods = append(modInfoList.Mods, modInfo) } diff --git a/src/factorio/save.go b/src/factorio/save.go index ab974a2d..bc6abb7b 100644 --- a/src/factorio/save.go +++ b/src/factorio/save.go @@ -85,12 +85,12 @@ type Mod struct { func (h *SaveHeader) ReadFrom(r io.Reader) (err error) { data, dataErr := io.ReadAll(r) if dataErr != nil { - return fmt.Errorf("read save data: %%v", dataErr) + return fmt.Errorf("read save data: %v", dataErr) } var fv version64 if err := fv.UnmarshalBinary(data[:8]); err != nil { - return fmt.Errorf("read FactorioVersion: %%%%v", err) + return fmt.Errorf("read FactorioVersion: %v", err) } h.FactorioVersion = Version(fv) @@ -107,64 +107,64 @@ func (h *SaveHeader) ReadFrom(r io.Reader) (err error) { if !h.FactorioVersion.Less(Version{0, 17, 0, 0}) { _, err = buf.Read(scratch[:1]) if err != nil { - return fmt.Errorf("read first random 0.17 byte: %%%%v", err) + return fmt.Errorf("read first random 0.17 byte: %v", err) } } h.Campaign, err = readString(buf, Version(h.FactorioVersion), false) - if err != nil { return fmt.Errorf("read Campaign: %%%%v", err) } + if err != nil { return fmt.Errorf("read Campaign: %v", err) } h.Name, err = readString(buf, Version(h.FactorioVersion), false) - if err != nil { return fmt.Errorf("read Name: %%%%v", err) } + if err != nil { return fmt.Errorf("read Name: %v", err) } h.BaseMod, err = readString(buf, Version(h.FactorioVersion), false) - if err != nil { return fmt.Errorf("read BaseMod: %%%%v", err) } + if err != nil { return fmt.Errorf("read BaseMod: %v", err) } _, err = buf.Read(scratch[:1]) - if err != nil { return fmt.Errorf("read Difficulty: %%%%v", err) } + if err != nil { return fmt.Errorf("read Difficulty: %v", err) } h.Difficulty = scratch[0] _, err = buf.Read(scratch[:1]) - if err != nil { return fmt.Errorf("read Finished: %%%%v", err) } + if err != nil { return fmt.Errorf("read Finished: %v", err) } h.Finished = scratch[0] != 0 _, err = buf.Read(scratch[:1]) - if err != nil { return fmt.Errorf("read PlayerWon: %%%%v", err) } + if err != nil { return fmt.Errorf("read PlayerWon: %v", err) } h.PlayerWon = scratch[0] != 0 h.NextLevel, err = readString(buf, Version(h.FactorioVersion), false) - if err != nil { return fmt.Errorf("read NextLevel: %%%%v", err) } + if err != nil { return fmt.Errorf("read NextLevel: %v", err) } if !h.FactorioVersion.Less(Version{0, 12, 0, 0}) { _, err = buf.Read(scratch[:1]) - if err != nil { return fmt.Errorf("read CanContinue: %%%%v", err) } + if err != nil { return fmt.Errorf("read CanContinue: %v", err) } h.CanContinue = scratch[0] != 0 _, err = buf.Read(scratch[:1]) - if err != nil { return fmt.Errorf("read FinishedButContinuing: %%%%v", err) } + if err != nil { return fmt.Errorf("read FinishedButContinuing: %v", err) } h.FinishedButContinuing = scratch[0] != 0 } _, err = buf.Read(scratch[:1]) - if err != nil { return fmt.Errorf("read SavingReplay: %%%%v", err) } + if err != nil { return fmt.Errorf("read SavingReplay: %v", err) } h.SavingReplay = scratch[0] != 0 if atLeast016 { _, err = buf.Read(scratch[:1]) - if err != nil { return fmt.Errorf("read AllowNonAdmin: %%%%v", err) } + if err != nil { return fmt.Errorf("read AllowNonAdmin: %v", err) } h.AllowNonAdminDebugOptions = scratch[0] != 0 } var loadedFrom version48 err = loadedFrom.ReadFrom(buf, Version(h.FactorioVersion)) - if err != nil { return fmt.Errorf("read LoadedFrom: %%%%v", err) } + if err != nil { return fmt.Errorf("read LoadedFrom: %v", err) } h.LoadedFrom = Version(loadedFrom) _, err = buf.Read(scratch[:2]) - if err != nil { return fmt.Errorf("read LoadedFromBuild: %%%%v", err) } + if err != nil { return fmt.Errorf("read LoadedFromBuild: %v", err) } h.LoadedFromBuild = binary.LittleEndian.Uint16(scratch[:2]) _, err = buf.Read(scratch[:1]) - if err != nil { return fmt.Errorf("read AllowedCommands: %%%%v", err) } + if err != nil { return fmt.Errorf("read AllowedCommands: %v", err) } h.AllowedCommands = scratch[0] if h.FactorioVersion.Less(Version{0, 13, 0, 87}) { if h.AllowedCommands == 0 { h.AllowedCommands = 2 } else { h.AllowedCommands = 1 } @@ -172,23 +172,23 @@ func (h *SaveHeader) ReadFrom(r io.Reader) (err error) { if h.FactorioVersion.Less(Version{0, 13, 0, 42}) { h.Stats, err = h.readStats(buf) - if err != nil { return fmt.Errorf("read Stats: %%%%v", err) } + if err != nil { return fmt.Errorf("read Stats: %v", err) } } var n uint32 if atLeast016 { n, err = readOptimUint(buf, Version(h.FactorioVersion), 32) - if err != nil { return fmt.Errorf("read num mods: %%%%v", err) } + if err != nil { return fmt.Errorf("read num mods: %v", err) } } else { _, err = buf.Read(scratch[:4]) - if err != nil { return fmt.Errorf("read num mods: %%%%v", err) } + if err != nil { return fmt.Errorf("read num mods: %v", err) } n = binary.LittleEndian.Uint32(scratch[:4]) } for i := uint32(0); i < n; i++ { var m Mod if err = (&m).ReadFrom(buf, Version(h.FactorioVersion)); err != nil { - return fmt.Errorf("read mod: %%%%v", err) + return fmt.Errorf("read mod: %v", err) } h.Mods = append(h.Mods, m) } diff --git a/src/factorio/version_compat_test.go b/src/factorio/version_compat_test.go new file mode 100644 index 00000000..11fe7db2 --- /dev/null +++ b/src/factorio/version_compat_test.go @@ -0,0 +1,82 @@ +package factorio + +import "testing" + +func TestGEC_Server2_1(t *testing.T) { + server := &Version{2, 1, 0, 0} + + tests := []struct{ + modVersion Version + expect bool + desc string + }{ + {Version{2, 1, 0, 0}, true, "exact same 2.1.0.0"}, + {Version{2, 1, 5, 0}, false, "mod newer 2.1.5.0"}, + {Version{2, 0, 0, 0}, false, "mod 2.0.0.0 on 2.1 server"}, + {Version{1, 1, 0, 0}, false, "mod 1.1.0.0 on 2.1 server"}, + {Version{0, 18, 0, 0}, false, "mod 0.18.0.0 on 2.1 server"}, + } + + for _, tt := range tests { + got := server.GEC(tt.modVersion) + if got != tt.expect { + t.Errorf("%s: GEC() = %v, want %v", tt.desc, got, tt.expect) + } + } +} + +func TestGEC_Server2_0(t *testing.T) { + server := &Version{2, 0, 0, 0} + + tests := []struct{ + modVersion Version + expect bool + desc string + }{ + {Version{2, 0, 0, 0}, true, "exact match 2.0.0.0"}, + {Version{2, 0, 5, 0}, false, "mod 2.0.5.0 on 2.0.0 server (newer)"}, + {Version{2, 0, 20, 0}, false, "mod 2.0.20.0 on 2.0.0 server (newer)"}, + {Version{1, 1, 0, 0}, false, "mod 1.1.0.0 on 2.0 server"}, + } + + for _, tt := range tests { + got := server.GEC(tt.modVersion) + if got != tt.expect { + t.Errorf("%s: GEC() = %v, want %v", tt.desc, got, tt.expect) + } + } +} + +func TestGEC_Server1_0(t *testing.T) { + server := &Version{1, 0, 0, 0} + + tests := []struct{ + modVersion Version + expect bool + desc string + }{ + {Version{1, 0, 0, 0}, true, "exact match"}, + {Version{0, 18, 0, 0}, true, "0.18 mod on 1.0 server (legacy compat)"}, + } + + for _, tt := range tests { + got := server.GEC(tt.modVersion) + if got != tt.expect { + t.Errorf("%s: GEC() = %v, want %v", tt.desc, got, tt.expect) + } + } +} + +func TestGreaterC_KeyCases(t *testing.T) { + // GreaterC: compatible if same major.minor and server >= mod patch + var s Version = Version{2, 0, 10, 0} + if !s.GreaterC(Version{2, 0, 5, 0}) { + t.Error("2.0.10 should be compatible with 2.0.5") + } + if s.GreaterC(Version{2, 0, 15, 0}) { + t.Error("2.0.10 should NOT be compatible with 2.0.15 (server older)") + } + if s.GreaterC(Version{2, 1, 0, 0}) { + t.Error("2.0.10 should NOT be compatible with 2.1.0 (different minor)") + } +} From fc3f9795550f80d7bf51b7933e60a98633963a6c Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Tue, 23 Jun 2026 14:23:39 +0000 Subject: [PATCH 084/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20Portal=20?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E6=80=A7=E6=94=B9=E7=94=A8=20GEC=20&=20DepOp?= =?UTF-8?q?=20=E4=B8=8D=E5=86=8D=E7=A1=AC=E7=BC=96=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mod_portal.go: 发布版兼容性从 Compatible(>=) 改为 GEC() 修复 2.1 服务器错误标为兼容的 portal 发布版 - mod_Mods.go: DepOp 从硬编码 >= 改为传递 modInfo.DepOp --- src/factorio/mod_Mods.go | 2 +- src/factorio/mod_portal.go | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/factorio/mod_Mods.go b/src/factorio/mod_Mods.go index 22c8ae5b..e99905cc 100644 --- a/src/factorio/mod_Mods.go +++ b/src/factorio/mod_Mods.go @@ -61,7 +61,7 @@ func (mods *Mods) ListInstalledMods() ModsResultList { modsResult.Version = modInfo.Version modsResult.FactorioVersion = modInfo.FactorioVersion modsResult.Compatibility = modInfo.Compatibility - modsResult.DepOp = ">=" + modsResult.DepOp = modInfo.DepOp for _, simpleMod := range mods.ModSimpleList.Mods { if simpleMod.Name == modsResult.Name { diff --git a/src/factorio/mod_portal.go b/src/factorio/mod_portal.go index b3b4a846..ac7decac 100644 --- a/src/factorio/mod_portal.go +++ b/src/factorio/mod_portal.go @@ -110,13 +110,8 @@ func ModPortalModDetails(modId string) (ModPortalStruct, error, int) { server := GetFactorioServer() - installedBaseVersion := Version{} - _ = installedBaseVersion.UnmarshalText([]byte(server.BaseModVersion)) - requiredVersion := NilVersion - for key, release := range mod.Releases { - requiredVersion = release.InfoJSON.FactorioVersion - release.Compatibility = installedBaseVersion.Compatible(requiredVersion, ">=") + release.Compatibility = server.Version.GEC(release.InfoJSON.FactorioVersion) mod.Releases[key] = release } From 3a4203bd606e387b5341890f3c7cfbd8f59810bc Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Tue, 23 Jun 2026 15:02:48 +0000 Subject: [PATCH 085/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E9=97=A8?= =?UTF-8?q?=E6=88=B7=E6=90=9C=E4=B8=8D=E5=88=B0=20mod=20=E6=97=B6=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=20404=20=E8=80=8C=E9=9D=9E=20500=20=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mod_portal_handler.go: Mod not found 时返回 404 + JSON - Mod.jsx: portal.info 失败时 try/catch 静默跳过更新检查 --- src/api/mod_portal_handler.go | 7 +++++++ ui/App/views/Mods/components/Mod.jsx | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/api/mod_portal_handler.go b/src/api/mod_portal_handler.go index d9f9967e..959b4a17 100644 --- a/src/api/mod_portal_handler.go +++ b/src/api/mod_portal_handler.go @@ -48,6 +48,13 @@ func ModPortalModInfoHandler(w http.ResponseWriter, r *http.Request) { resp, err, statusCode = factorio.ModPortalModDetails(modId) if err != nil { + if statusCode == http.StatusNotFound { + w.WriteHeader(http.StatusNotFound) + resp = struct { + Message string `json:"message"` + }{"Mod not found on Factorio portal"} + return + } resp = fmt.Sprintf("Error in getting mod details from mod portal: %s", err) log.Println(resp) w.WriteHeader(http.StatusInternalServerError) diff --git a/ui/App/views/Mods/components/Mod.jsx b/ui/App/views/Mods/components/Mod.jsx index 857420c7..94352045 100644 --- a/ui/App/views/Mods/components/Mod.jsx +++ b/ui/App/views/Mods/components/Mod.jsx @@ -20,7 +20,8 @@ const Mod = ({mod, factorioVersion, toggleMod, deleteMod, updateMod, addUpdatabl useEffect(() => { if (!disabled) { (async () => { - const data = await modsResource.portal.info(mod.name) + try { + const data = await modsResource.portal.info(mod.name) //get newest COMPATIBLE release let newestRelease; @@ -59,6 +60,7 @@ const Mod = ({mod, factorioVersion, toggleMod, deleteMod, updateMod, addUpdatabl setNewVersion(null); } + } catch (e) {} })(); } }, [mod]); From 4a031d31b66ba12657cad9bccc9e358b84c3302b Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Tue, 23 Jun 2026 15:46:59 +0000 Subject: [PATCH 086/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20i18next=20?= =?UTF-8?q?=E4=B8=AD=E6=96=87=E7=BF=BB=E8=AF=91=20=E2=80=94=20common=20?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E7=A9=BA=E9=97=B4=20+=20=E8=AF=AD=E8=A8=80?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - common 命名空间: signIn/username/password 等登录页翻译 - i18n.js: ns 配置 + zh-CN 资源 - zh.json: 67 个顶层键 + 嵌套翻译 (完整镜像 en.json) - ChangeLangDialog: 添加简体中文按钮 - zh-common.json: 独立 common 命名空间翻译 --- ui/App/components/ChangeLangDialog.jsx | 1 + ui/App/i18n.js | 7 + ui/App/locales/en-common.json | 17 +++ ui/App/locales/zh-common.json | 42 ++++++ ui/App/locales/zh.json | 190 +++++++++++++++++++++---- 5 files changed, 231 insertions(+), 26 deletions(-) create mode 100644 ui/App/locales/en-common.json create mode 100644 ui/App/locales/zh-common.json diff --git a/ui/App/components/ChangeLangDialog.jsx b/ui/App/components/ChangeLangDialog.jsx index 58ebae22..eb59147b 100644 --- a/ui/App/components/ChangeLangDialog.jsx +++ b/ui/App/components/ChangeLangDialog.jsx @@ -19,6 +19,7 @@ function ChangeLangDialog({isOpen, close, onSuccess}) { <> + {/* Other languages */} } diff --git a/ui/App/i18n.js b/ui/App/i18n.js index b639a06f..6a21c533 100644 --- a/ui/App/i18n.js +++ b/ui/App/i18n.js @@ -3,22 +3,27 @@ import { initReactI18next } from "react-i18next"; import Backend from 'i18next-http-backend' import LanguageDetector from "i18next-browser-languagedetector"; import enTranslations from "./locales/en.json"; +import enCommonTranslations from "./locales/en-common.json"; import ruTranslations from "./locales/ru.json"; import zhTranslations from "./locales/zh.json"; +import zhCommonTranslations from "./locales/zh-common.json"; const resources = { en: { translation: enTranslations, + common: enCommonTranslations, }, ru: { translation: ruTranslations, + common: enCommonTranslations, }, zh: { translation: zhTranslations, + common: zhCommonTranslations, } }; @@ -27,6 +32,8 @@ i18n .use(LanguageDetector) .use(initReactI18next) .init({ + ns: ['translation', 'common'], + defaultNS: 'translation', resources, fallbackLng: "en", // Default Language // Detecting and caching of language cookies diff --git a/ui/App/locales/en-common.json b/ui/App/locales/en-common.json new file mode 100644 index 00000000..23b2316f --- /dev/null +++ b/ui/App/locales/en-common.json @@ -0,0 +1,17 @@ +{ + "username": "Username", + "password": "Password", + "upload": "Upload", + "name": "Name", + "actions": "Actions", + "role": "Role", + "email": "Email", + "save": "Save", + "logout": "Logout", + "cancel": "Cancel", + "create": "Create", + "confirm": "Confirm", + "change": "Change", + "install": "Install", + "load": "Load" +} \ No newline at end of file diff --git a/ui/App/locales/zh-common.json b/ui/App/locales/zh-common.json new file mode 100644 index 00000000..11cf4a87 --- /dev/null +++ b/ui/App/locales/zh-common.json @@ -0,0 +1,42 @@ +{ + "signIn": "登录", + "username": "用户名", + "password": "密码", + "usernameRequired": "请输入用户名", + "passwordRequired": "请输入密码", + "loginFailed": "登录失败,用户名或密码错误", + "upload": "上传", + "name": "名称", + "lastModifiedAt": "最后修改", + "size": "大小", + "actions": "操作", + "role": "角色", + "email": "邮箱", + "unknown": "未知", + "save": "保存", + "logout": "退出登录", + "cancel": "取消", + "create": "创建", + "confirm": "确认", + "change": "修改", + "delete": "删除", + "search": "搜索", + "install": "安装", + "load": "加载", + "update": "更新", + "download": "下载", + "remove": "删除", + "start": "启动", + "stop": "停止", + "kill": "强制终止", + "close": "关闭", + "back": "返回", + "next": "下一步", + "previous": "上一步", + "submit": "提交", + "reset": "重置", + "enable": "启用", + "disable": "禁用", + "enabled": "已启用", + "disabled": "已禁用" +} \ No newline at end of file diff --git a/ui/App/locales/zh.json b/ui/App/locales/zh.json index 068c0b61..0b3009c0 100644 --- a/ui/App/locales/zh.json +++ b/ui/App/locales/zh.json @@ -10,15 +10,41 @@ "password": "密码", "logout": "退出登录", "install": "安装", + "load": "加载", "actions": "操作", "role": "角色", "email": "邮箱", "change": "修改", - "load": "加载", + "search": "搜索", + "language": "语言", + "UNKNOWN": "未知", + "main_title": "异星工厂服务器管理器", "server_status": "服务器状态", "server_management": "服务器管理", "FSM_administration": "FSM 管理", - "main_title": "异星工厂服务器管理器", + "lang": "语言", + "controls": { + "title": "控制面板", + "unknown": "未知", + "running": "运行中", + "stopped": "已停止", + "factorioVersion": "游戏版本", + "serverRunning": "服务器运行中", + "serverStopped": "服务器已停止", + "start": "启动服务器", + "stop": "停止服务器", + "kill": "强制终止", + "save_game": "保存游戏", + "ip": "IP 地址", + "port": "端口", + "saveFile": "存档", + "loadLatest": "加载最新", + "factorioInstallation": "Factorio 安装", + "installedVersion": "已安装版本", + "availableVersions": "可用版本", + "downloadInstall": "下载并安装", + "removeInstallation": "卸载" + }, "server_settings": { "title": "服务器设置", "admins": "管理员", @@ -36,32 +62,36 @@ "game_password": "游戏密码", "require_user_verification": "验证用户身份", "max_upload": "最大上传速度", - "max_upload_slots": "上传槽位", + "max_upload_slots": "上传槽位数", "minimum_latency": "最低延迟", "ignore_player_limit": "返回玩家无视上限", "allow_commands": "允许指令", "auto_pause": "自动暂停", "only_admins_can_pause": "仅管理员可暂停", "autosave_interval": "自动保存间隔", - "autosave_slots": "自动保存槽位", + "autosave_slots": "自动保存槽位数", "non_blocking_saving": "非阻塞保存", "afk_autokick_interval": "挂机踢出时间", "require_user_verification_label": "需要验证用户身份", - "visibility_label": "服务器可见性" + "visibility_label": "服务器可见性", + "show_descriptions": "显示说明", + "hide_descriptions": "隐藏说明" }, - "controls": { - "title": "服务器控制", - "start": "启动", - "stop": "停止", - "kill": "强制终止", - "save_game": "存档", - "factorioVersion": "游戏版本", - "serverRunning": "运行中", - "serverStopped": "已停止" + "saves": { + "title": "存档管理", + "dl_save": "下载存档", + "upload_save": "上传存档", + "remove_save": "删除存档", + "create_save": "创建存档", + "no_saves": "没有存档", + "delete_confirm": "确认删除存档?", + "name": "存档名称", + "lastModifiedAt": "最后修改", + "size": "大小" }, "mods": { "title": "模组管理", - "installed": "已安装", + "installed": "已安装模组", "portal": "模组门户", "add": "添加模组", "load": "从存档加载", @@ -74,26 +104,134 @@ "compatibility": "兼容性", "mod_version": "模组版本", "file_size": "文件大小", - "search": "搜索..." + "updateAllMods": "更新全部模组", + "downloadAllMods": "下载全部模组", + "deleteAllMods": "删除全部模组", + "confirmDeleteAll": "确认删除所有模组?此操作不可撤销。", + "noModsInstalled": "没有安装模组", + "installMods": "安装模组", + "loadingMods": "加载模组列表...", + "installedTab": "已安装", + "addModTab": "添加模组", + "loadModsTab": "加载模组", + "modPacksTab": "模组包" }, - "saves": { - "title": "存档管理", - "dl_save": "下载存档", - "upload_save": "上传存档", - "remove_save": "删除存档", - "create_save": "创建存档" + "modPacks": "模组包", + "createModPack": "创建模组包", + "modPackName": "模组包名称", + "game_settings": { + "title": "游戏设置" + }, + "console": { + "title": "控制台", + "send": "发送", + "command": "命令", + "placeholder": "输入指令..." }, "logs": { - "title": "日志" + "title": "日志", + "refresh": "刷新", + "autoScroll": "自动滚动" }, - "user_management": { + "users": { "title": "用户管理", "add_user": "添加用户", "remove": "删除" }, + "userManagement": { + "title": "用户管理", + "addUser": "添加用户", + "username": "用户名", + "password": "密码", + "role": "角色", + "email": "邮箱", + "actions": "操作", + "confirmDelete": "确认删除用户?", + "usernameRequired": "请输入用户名", + "passwordRequired": "请输入密码" + }, "help": { "title": "帮助" }, - "language": "语言", - "UNKNOWN": "未知" + "login": { + "title": "登录", + "login": "登录", + "login_failed_message": "登录失败,用户名或密码错误", + "username_error_message": "请输入用户名", + "password_error_message": "请输入密码", + "factorio_login_error_message": "用户名或密码不匹配" + }, + "factorioLogin": { + "title": "Factorio 登录", + "login_status": "登录状态", + "logged_in": "已登录", + "not_logged_in": "未登录", + "login": "登录", + "logout": "退出", + "username": "用户名", + "token": "令牌", + "token_hint": "在 factorio.com/profile 获取", + "login_success": "Factorio 登录成功", + "login_failed": "Factorio 登录失败", + "factorio_login_error_message": "用户名或密码不匹配" + }, + "serverVersion": { + "title": "服务器版本", + "current": "当前版本", + "available": "可用版本", + "stable": "稳定版", + "experimental": "实验版", + "install": "安装", + "installing": "安装中...", + "latestReleases": "最新发布", + "fullVersionList": "完整版本列表", + "noVersionsAvailable": "没有可用版本" + }, + "change_password": { + "title": "修改密码", + "old_password": "旧密码", + "old_password_error": "请输入旧密码", + "new_password": "新密码", + "new_password_error": "请输入新密码", + "new_password_confirmation": "确认新密码", + "new_password_confirmation_error": "请输入确认密码" + }, + "passwordConfirmation": "确认密码", + "passwordMismatch": "两次密码不一致", + "passwordChanged": "密码已修改", + "usernameRequired": "请输入用户名", + "passwordRequired": "请输入密码", + "signIn": "登录", + "loginFailed": "登录失败", + "lastModifiedAt": "最后修改", + "size": "大小", + "delete_confirm": "确认删除?", + "update": "更新", + "download": "下载", + "remove": "删除", + "start": "启动", + "stop": "停止", + "kill": "强制终止", + "close": "关闭", + "back": "返回", + "next": "下一步", + "previous": "上一步", + "submit": "提交", + "reset": "重置", + "enable": "启用", + "disable": "禁用", + "enabled": "已启用", + "disabled": "已禁用", + "updateAllMods": "更新全部模组", + "downloadAllMods": "下载全部模组", + "deleteAllMods": "删除全部模组", + "modPortal": "模组门户", + "installMod": "安装模组", + "uploadMod": "上传模组", + "loadModsFromSave": "从存档加载", + "noModsInstalled": "没有安装模组", + "installedTab": "已安装", + "addModTab": "添加模组", + "loadModsTab": "加载模组", + "modPacksTab": "模组包" } \ No newline at end of file From c257bf5ef06c91046a49ad13b38e33d5d2fc9c7a Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Tue, 23 Jun 2026 16:14:26 +0000 Subject: [PATCH 087/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E4=BE=A7?= =?UTF-8?q?=E8=BE=B9=E6=A0=8F=E6=81=A2=E5=A4=8D=E6=9C=8D=E5=8A=A1=E5=99=A8?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/components/Layout.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/App/components/Layout.jsx b/ui/App/components/Layout.jsx index d5086fea..30bcae2b 100644 --- a/ui/App/components/Layout.jsx +++ b/ui/App/components/Layout.jsx @@ -75,6 +75,7 @@ const Layout = ({handleLogout, serverStatus}) => { {t("controls.title")} {t("saves.title")} {t("mods.title")} + {t("serverVersion.title")} {t("server_settings.title")} {t("game_settings.title")} {t("console.title")} From f701ec72f8ef2bfbc17c64dc5dcf71ab474cbbab Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Wed, 24 Jun 2026 04:37:42 +0000 Subject: [PATCH 088/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E5=85=A8=E9=83=A8=2010=20=E4=B8=AA=20i18n=20=E5=91=BD?= =?UTF-8?q?=E5=90=8D=E7=A9=BA=E9=97=B4=20+=20=E5=88=9B=E5=BB=BA=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E7=BF=BB=E8=AF=91=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并后 Joey 的组件使用独立命名空间 (controls/console/logs/mods/saves/ serverSettings/serverVersion/userManagement/layout) 但 i18n.js 只注册了 translation/common 两个,导致页面显示原始键名。 - 创建 9 个 zh-*.json 命名空间翻译文件 - i18n.js 注册全部 ns + 加载对应资源 - 补全大小写键名 (STOPPED/RUNNING/serverStatus 等) --- ui/App/i18n.js | 20 +++++++++++++- ui/App/locales/zh-console.json | 6 +++++ ui/App/locales/zh-controls.json | 28 +++++++++++++++++++ ui/App/locales/zh-layout.json | 9 +++++++ ui/App/locales/zh-logs.json | 5 ++++ ui/App/locales/zh-mods.json | 39 +++++++++++++++++++++++++++ ui/App/locales/zh-saves.json | 12 +++++++++ ui/App/locales/zh-serverSettings.json | 32 ++++++++++++++++++++++ ui/App/locales/zh-serverVersion.json | 12 +++++++++ ui/App/locales/zh-userManagement.json | 12 +++++++++ 10 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 ui/App/locales/zh-console.json create mode 100644 ui/App/locales/zh-controls.json create mode 100644 ui/App/locales/zh-layout.json create mode 100644 ui/App/locales/zh-logs.json create mode 100644 ui/App/locales/zh-mods.json create mode 100644 ui/App/locales/zh-saves.json create mode 100644 ui/App/locales/zh-serverSettings.json create mode 100644 ui/App/locales/zh-serverVersion.json create mode 100644 ui/App/locales/zh-userManagement.json diff --git a/ui/App/i18n.js b/ui/App/i18n.js index 6a21c533..6de916ad 100644 --- a/ui/App/i18n.js +++ b/ui/App/i18n.js @@ -7,6 +7,15 @@ import enCommonTranslations from "./locales/en-common.json"; import ruTranslations from "./locales/ru.json"; import zhTranslations from "./locales/zh.json"; import zhCommonTranslations from "./locales/zh-common.json"; +import zhControlsTranslations from "./locales/zh-controls.json"; +import zhConsoleTranslations from "./locales/zh-console.json"; +import zhLayoutTranslations from "./locales/zh-layout.json"; +import zhLogsTranslations from "./locales/zh-logs.json"; +import zhModsTranslations from "./locales/zh-mods.json"; +import zhSavesTranslations from "./locales/zh-saves.json"; +import zhServersettingsTranslations from "./locales/zh-serverSettings.json"; +import zhServerversionTranslations from "./locales/zh-serverVersion.json"; +import zhUsermanagementTranslations from "./locales/zh-userManagement.json"; const resources = { @@ -24,6 +33,15 @@ const resources = { translation: zhTranslations, common: zhCommonTranslations, + controls: zhControlsTranslations, + console: zhConsoleTranslations, + layout: zhLayoutTranslations, + logs: zhLogsTranslations, + mods: zhModsTranslations, + saves: zhSavesTranslations, + serverSettings: zhServersettingsTranslations, + serverVersion: zhServerversionTranslations, + userManagement: zhUsermanagementTranslations, } }; @@ -32,7 +50,7 @@ i18n .use(LanguageDetector) .use(initReactI18next) .init({ - ns: ['translation', 'common'], + ns: ["translation", "common", "controls", "console", "layout", "logs", "mods", "saves", "serverSettings", "serverVersion", "userManagement"], defaultNS: 'translation', resources, fallbackLng: "en", // Default Language diff --git a/ui/App/locales/zh-console.json b/ui/App/locales/zh-console.json new file mode 100644 index 00000000..02382cb5 --- /dev/null +++ b/ui/App/locales/zh-console.json @@ -0,0 +1,6 @@ +{ + "title": "控制台", + "send": "发送", + "command": "命令", + "placeholder": "输入指令..." +} \ No newline at end of file diff --git a/ui/App/locales/zh-controls.json b/ui/App/locales/zh-controls.json new file mode 100644 index 00000000..6f42cffb --- /dev/null +++ b/ui/App/locales/zh-controls.json @@ -0,0 +1,28 @@ +{ + "title": "控制面板", + "unknown": "未知", + "running": "运行中", + "stopped": "已停止", + "factorioVersion": "游戏版本", + "serverRunning": "服务器运行中", + "serverStopped": "服务器已停止", + "start": "启动", + "stop": "停止服务器", + "kill": "强制终止", + "save_game": "保存游戏", + "ip": "IP 地址", + "port": "端口", + "saveFile": "存档", + "loadLatest": "加载最新", + "factorioInstallation": "Factorio 安装", + "installedVersion": "已安装版本", + "availableVersions": "可用版本", + "downloadInstall": "下载并安装", + "removeInstallation": "卸载", + "serverStatus": "服务器状态", + "status": "状态", + "RUNNING": "运行中", + "STOPPED": "已停止", + "save": "存档", + "UNKNOWN": "未知" +} \ No newline at end of file diff --git a/ui/App/locales/zh-layout.json b/ui/App/locales/zh-layout.json new file mode 100644 index 00000000..f04237c7 --- /dev/null +++ b/ui/App/locales/zh-layout.json @@ -0,0 +1,9 @@ +{ + "main_title": "异星工厂服务器管理器", + "server_status": "服务器状态", + "server_management": "服务器管理", + "FSM_administration": "FSM 管理", + "lang": "语言", + "logout": "退出登录", + "UNKNOWN": "未知" +} \ No newline at end of file diff --git a/ui/App/locales/zh-logs.json b/ui/App/locales/zh-logs.json new file mode 100644 index 00000000..6034ad3d --- /dev/null +++ b/ui/App/locales/zh-logs.json @@ -0,0 +1,5 @@ +{ + "title": "日志", + "refresh": "刷新", + "autoScroll": "自动滚动" +} \ No newline at end of file diff --git a/ui/App/locales/zh-mods.json b/ui/App/locales/zh-mods.json new file mode 100644 index 00000000..147b50e3 --- /dev/null +++ b/ui/App/locales/zh-mods.json @@ -0,0 +1,39 @@ +{ + "title": "模组管理", + "installed": "已安装模组", + "portal": "模组门户", + "add": "添加模组", + "load": "从存档加载", + "delete_all": "删除全部", + "update_all": "更新全部", + "install_mods": "安装模组", + "loading_mods": "加载模组列表...", + "factorioVersion": "游戏版本", + "enabled": "启用", + "compatibility": "兼容性", + "mod_version": "模组版本", + "file_size": "文件大小", + "updateAllMods": "更新全部模组", + "downloadAllMods": "下载全部模组", + "deleteAllMods": "删除全部模组", + "confirmDeleteAll": "确认删除所有模组?此操作不可撤销。", + "noModsInstalled": "没有安装模组", + "installMods": "安装模组", + "loadingMods": "加载模组列表...", + "installedTab": "已安装", + "addModTab": "添加模组", + "loadModsTab": "加载模组", + "modPacksTab": "模组包", + "modPacks": "模组包", + "createModPack": "创建模组包", + "modPortal": "模组门户", + "installMod": "安装模组", + "uploadMod": "上传模组", + "loadModsFromSave": "从存档加载", + "search": "搜索...", + "Name": "模组名称", + "Enabled": "启用", + "Compatibility": "兼容性", + "Mod Version": "模组版本", + "Factorio Version": "Factorio 版本" +} \ No newline at end of file diff --git a/ui/App/locales/zh-saves.json b/ui/App/locales/zh-saves.json new file mode 100644 index 00000000..cdee8aa3 --- /dev/null +++ b/ui/App/locales/zh-saves.json @@ -0,0 +1,12 @@ +{ + "title": "存档管理", + "dl_save": "下载存档", + "upload_save": "上传存档", + "remove_save": "删除存档", + "create_save": "创建存档", + "no_saves": "没有存档", + "delete_confirm": "确认删除存档?", + "name": "存档名称", + "lastModifiedAt": "最后修改", + "size": "大小" +} \ No newline at end of file diff --git a/ui/App/locales/zh-serverSettings.json b/ui/App/locales/zh-serverSettings.json new file mode 100644 index 00000000..98f17048 --- /dev/null +++ b/ui/App/locales/zh-serverSettings.json @@ -0,0 +1,32 @@ +{ + "title": "服务器设置", + "admins": "管理员", + "name": "服务器名称", + "description": "服务器描述", + "tags": "游戏标签", + "max_players": "最大玩家数", + "visibility": "可见性", + "public": "公开", + "lan": "局域网", + "hidden": "隐藏", + "username": "用户名", + "password": "密码", + "token": "认证令牌", + "game_password": "游戏密码", + "require_user_verification": "验证用户身份", + "max_upload": "最大上传速度", + "max_upload_slots": "上传槽位数", + "minimum_latency": "最低延迟", + "ignore_player_limit": "返回玩家无视上限", + "allow_commands": "允许指令", + "auto_pause": "自动暂停", + "only_admins_can_pause": "仅管理员可暂停", + "autosave_interval": "自动保存间隔", + "autosave_slots": "自动保存槽位数", + "non_blocking_saving": "非阻塞保存", + "afk_autokick_interval": "挂机踢出时间", + "require_user_verification_label": "需要验证用户身份", + "visibility_label": "服务器可见性", + "show_descriptions": "显示说明", + "hide_descriptions": "隐藏说明" +} \ No newline at end of file diff --git a/ui/App/locales/zh-serverVersion.json b/ui/App/locales/zh-serverVersion.json new file mode 100644 index 00000000..0e4c20a7 --- /dev/null +++ b/ui/App/locales/zh-serverVersion.json @@ -0,0 +1,12 @@ +{ + "title": "服务器版本", + "current": "当前版本", + "available": "可用版本", + "stable": "稳定版", + "experimental": "实验版", + "install": "安装", + "installing": "安装中...", + "latestReleases": "最新发布", + "fullVersionList": "完整版本列表", + "noVersionsAvailable": "没有可用版本" +} \ No newline at end of file diff --git a/ui/App/locales/zh-userManagement.json b/ui/App/locales/zh-userManagement.json new file mode 100644 index 00000000..6789e886 --- /dev/null +++ b/ui/App/locales/zh-userManagement.json @@ -0,0 +1,12 @@ +{ + "title": "用户管理", + "addUser": "添加用户", + "username": "用户名", + "password": "密码", + "role": "角色", + "email": "邮箱", + "actions": "操作", + "confirmDelete": "确认删除用户?", + "usernameRequired": "请输入用户名", + "passwordRequired": "请输入密码" +} \ No newline at end of file From 88bfc0b3fb4bfb618ed833255e794404e4716949 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Wed, 24 Jun 2026 04:39:00 +0000 Subject: [PATCH 089/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E6=A3=80=E6=9F=A5=E6=A8=A1=E7=BB=84=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=8C=89=E9=92=AE=20+=20=E6=B8=85=E7=90=86=20LoadMods=20?= =?UTF-8?q?=E6=AD=BB=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Layout.jsx: 恢复 FSM 管理区的刷新模组按钮 (fsm_refresh_mods) - Mods.jsx: 移除 LoadMods 中未使用的共享 auth 属性 (Joey 版本自管 auth) - zh-layout.json / en.json: 添加 refresh 翻译键 --- ui/App/components/Layout.jsx | 5 + ui/App/locales/en.json | 393 +++++++++++++++++----------------- ui/App/locales/zh-layout.json | 3 +- ui/App/views/Mods/Mods.jsx | 3 +- 4 files changed, 205 insertions(+), 199 deletions(-) diff --git a/ui/App/components/Layout.jsx b/ui/App/components/Layout.jsx index 30bcae2b..54bc7a92 100644 --- a/ui/App/components/Layout.jsx +++ b/ui/App/components/Layout.jsx @@ -95,6 +95,11 @@ const Layout = ({handleLogout, serverStatus}) => { />
    +
    +
    + +
    +
    diff --git a/ui/App/locales/en.json b/ui/App/locales/en.json index 9280a472..128a56c4 100644 --- a/ui/App/locales/en.json +++ b/ui/App/locales/en.json @@ -1,207 +1,208 @@ { - "save": "Save", - "upload": "Upload", - "saved": "Settings saved.", - "create": "Create", - "cancel": "Cancel", - "confirm": "Confirm", + "save": "Save", + "upload": "Upload", + "saved": "Settings saved.", + "create": "Create", + "cancel": "Cancel", + "confirm": "Confirm", + "name": "Name", + "username": "Username", + "password": "Password", + "logout": "Logout", + "install": "Install", + "actions": "Actions", + "role": "Role", + "email": "Email", + "change": "Change", + "load": "Load", + "server_status": "Server Status", + "server_management": "Server Management", + "FSM_administration": "FSM Administration", + "main_title": "Factorio Server Manager", + "server_settings": { + "title": "Server settings", + "admins": "Admins", "name": "Name", + "description": "Description", + "tags": "Tags", + "_comment_max_players": "Maximum number of players allowed, admins can join even a full server. 0 means unlimited.", + "max_players": "Max players", + "_comment_visibility": "Public: Game will be published on the official Factorio matching server. Lan: Game will be broadcast on LAN", + "visibility______": { + "public_": "Public", + "lan_": "Lan" + }, + "visibility": "Visibility", + "_comment_credentials": "Your factorio.com login credentials. Required for games with visibility public", "username": "Username", "password": "Password", - "logout": "Logout", - "install": "Install", + "_comment_token": "Authentication token. May be used instead of 'password' above.", + "token": "Token", + "game_password": "Game password", + "_comment_require_user_verification": "When set to true, the server will only allow clients that have a valid Factorio.com account", + "require_user_verification": "Require user verification", + "_comment_max_upload_in_kilobytes_per_second": "Optional, default value is 0. 0 means unlimited.", + "max_upload_in_kilobytes_per_second": "Max upload in kilobytes per second", + "_comment_max_upload_slots": "Optional, default value is 5. 0 means unlimited.", + "max_upload_slots": "Max upload slots", + "_comment_minimum_latency_in_ticks": "Optional one tick is 16ms in default speed, default value is 0. 0 means no minimum.", + "minimum_latency_in_ticks": "Minimum latency in ticks", + "_comment_max_heartbeats_per_second": "Network tick rate. Maximum rate game updates packets are sent at before bundling them together. Minimum value is 6, maximum value is 240.", + "max_heartbeats_per_second": "Max heartbeats per second", + "_comment_ignore_player_limit_for_returning_players": "Players that played on this map already can join even when the max player limit was reached.", + "ignore_player_limit_for_returning_players": "Ignore player limit for returning players", + "_comment_allow_commands": "Possible values are: true, false and admins-only", + "allow_commands": "Allow commands", + "_comment_autosave_interval": "Autosave interval in minutes", + "autosave_interval": "Autosave interval", + "_comment_autosave_slots": "Server autosave slots, it is cycled through when the server autosaves.", + "autosave_slots": "Autosave slots", + "_comment_afk_autokick_interval": "How many minutes until someone is kicked when doing nothing, 0 for never.", + "afk_autokick_interval": "Afk autokick interval", + "_comment_auto_pause": "Whether should the server be paused when no players are present.", + "auto_pause": "Auto pause", + "_comment_auto_pause_when_players_connect": "Whether should the server be paused when someone is connecting to the server.", + "auto_pause_when_players_connect": "Auto pause when players connect", + "only_admins_can_pause_the_game": "Only admins can pause the game", + "_comment_autosave_only_on_server": "Whether autosaves should be saved only on server or also on all connected clients. Default is true.", + "autosave_only_on_server": "Autosave only on server", + "_comment_non_blocking_saving": "Highly experimental feature, enable only at your own risk of losing your saves. On UNIX systems, server will fork itself to create an autosave. Autosaving on connected Windows clients will be disabled regardless of autosave_only_on_server option.", + "non_blocking_saving": "Non blocking saving", + "_comment_segment_sizes": "Long network messages are split into segments that are sent over multiple ticks. Their size depends on the number of peers currently connected. Increasing the segment size will increase upload bandwidth requirement for the server and download bandwidth requirement for clients. This setting only affects server outbound messages. Changing these settings can have a negative impact on connection stability for some clients.", + "minimum_segment_size": "Minimum segment size", + "minimum_segment_size_peer_count": "Minimum segment size peer count", + "maximum_segment_size": "Maximum segment size", + "maximum_segment_size_peer_count": "Maximum segment size peer count" + }, + "saves": { + "create_save": "Create Save", + "upload_save": "Upload Save", + "title": "Saves", + "last_modified": "Last Modified At", + "size": "Size", "actions": "Actions", - "role": "Role", - "email": "Email", - "change": "Change", - "load": "Load", - "server_status": "Server Status", - "server_management": "Server Management", - "FSM_administration": "FSM Administration", - "main_title": "Factorio Server Manager", - "server_settings": { - "title": "Server settings", - "admins": "Admins", - "name": "Name", - "description": "Description", - "tags": "Tags", - "_comment_max_players": "Maximum number of players allowed, admins can join even a full server. 0 means unlimited.", - "max_players": "Max players", - "_comment_visibility": "Public: Game will be published on the official Factorio matching server. Lan: Game will be broadcast on LAN", - "visibility______": { - "public_": "Public", - "lan_": "Lan" - }, - "visibility": "Visibility", - "_comment_credentials": "Your factorio.com login credentials. Required for games with visibility public", - "username": "Username", - "password": "Password", - "_comment_token": "Authentication token. May be used instead of 'password' above.", - "token": "Token", - "game_password": "Game password", - "_comment_require_user_verification": "When set to true, the server will only allow clients that have a valid Factorio.com account", - "require_user_verification": "Require user verification", - "_comment_max_upload_in_kilobytes_per_second": "Optional, default value is 0. 0 means unlimited.", - "max_upload_in_kilobytes_per_second": "Max upload in kilobytes per second", - "_comment_max_upload_slots": "Optional, default value is 5. 0 means unlimited.", - "max_upload_slots": "Max upload slots", - "_comment_minimum_latency_in_ticks": "Optional one tick is 16ms in default speed, default value is 0. 0 means no minimum.", - "minimum_latency_in_ticks": "Minimum latency in ticks", - "_comment_max_heartbeats_per_second": "Network tick rate. Maximum rate game updates packets are sent at before bundling them together. Minimum value is 6, maximum value is 240.", - "max_heartbeats_per_second": "Max heartbeats per second", - "_comment_ignore_player_limit_for_returning_players": "Players that played on this map already can join even when the max player limit was reached.", - "ignore_player_limit_for_returning_players": "Ignore player limit for returning players", - "_comment_allow_commands": "Possible values are: true, false and admins-only", - "allow_commands": "Allow commands", - "_comment_autosave_interval": "Autosave interval in minutes", - "autosave_interval": "Autosave interval", - "_comment_autosave_slots": "Server autosave slots, it is cycled through when the server autosaves.", - "autosave_slots": "Autosave slots", - "_comment_afk_autokick_interval": "How many minutes until someone is kicked when doing nothing, 0 for never.", - "afk_autokick_interval": "Afk autokick interval", - "_comment_auto_pause": "Whether should the server be paused when no players are present.", - "auto_pause": "Auto pause", - "_comment_auto_pause_when_players_connect": "Whether should the server be paused when someone is connecting to the server.", - "auto_pause_when_players_connect": "Auto pause when players connect", - "only_admins_can_pause_the_game": "Only admins can pause the game", - "_comment_autosave_only_on_server": "Whether autosaves should be saved only on server or also on all connected clients. Default is true.", - "autosave_only_on_server": "Autosave only on server", - "_comment_non_blocking_saving": "Highly experimental feature, enable only at your own risk of losing your saves. On UNIX systems, server will fork itself to create an autosave. Autosaving on connected Windows clients will be disabled regardless of autosave_only_on_server option.", - "non_blocking_saving": "Non blocking saving", - "_comment_segment_sizes": "Long network messages are split into segments that are sent over multiple ticks. Their size depends on the number of peers currently connected. Increasing the segment size will increase upload bandwidth requirement for the server and download bandwidth requirement for clients. This setting only affects server outbound messages. Changing these settings can have a negative impact on connection stability for some clients.", - "minimum_segment_size": "Minimum segment size", - "minimum_segment_size_peer_count": "Minimum segment size peer count", - "maximum_segment_size": "Maximum segment size", - "maximum_segment_size_peer_count": "Maximum segment size peer count" - }, - "saves": { - "create_save": "Create Save", - "upload_save": "Upload Save", - "title": "Saves", - "last_modified": "Last Modified At", - "size": "Size", - "actions": "Actions", - "create_new_save_only_when_server_not_running": "Create a new Save is only possible if the Factorio server is not running.", - "save_form": { - "file_name": "Savefile Name", - "save_file_error_message": "Savefile Name is required", - "create_save": "Create Save" - }, - "upload_form": { - "select_file": "Select File ...", - "file_name": "Savefile", - "save_file_error_message": "Savefile is required", - "upload": "Upload" - } - }, - "controls": { - "title": "Server Status", - "status": "Status", - "unknown": "Unknown", - "running": "Running", - "stopped": "Stopped", - "port": "Port", - "IP_error_message": "IP is required and must be valid.", - "port_error_message": "Port is required within range 1-65535", - "save_error_message": "Save is required and must be valid.", - "f_version": "Factorio version", - "save": "Save", - "save&stop": "Save & Stop Server", - "kill_server": "Kill Server", - "start_server": "Start Server" + "create_new_save_only_when_server_not_running": "Create a new Save is only possible if the Factorio server is not running.", + "save_form": { + "file_name": "Savefile Name", + "save_file_error_message": "Savefile Name is required", + "create_save": "Create Save" }, - "mods": { - "change_mods_while_running_error_message": "Changing mods is disabled while the server is running!", - "install_mod": "Install Mod", - "upload_mod": "Upload Mod", - "load_mods": "Load Mods from Save", - "title": "Mods", - "delete_all": "Delete all Mods", - "update_all": "Update all Mods", - "download_all": "Download all Mods", - "mod_packs": "Mod packs", - "select_file": "Select File ...", - "mods_loaded_from_save": "Mods are loaded from save file @@@.", - "load_mods_from_save": "Load Mods from Save", - "load_confirm_dialog": "Loading the Mods from Save @@@ will remove all currently installed Mods.", - "add_modpack_with_current_mods": "Add ModPack with current installed Mods", - "add_mod": { - "loading_mod_list": "Loading List of Mods from", - "version": "Version", - "compatibility": "Compatibility" - }, - "mod_list": { - "enabled": "Enabled", - "compatibility": "Compatibility", - "mod_version": "Mod Version", - "factorio_version": "Factorio Version" - }, - "mod_pack": { - "load_modpack": "Load ModPack", - "load_confirm_dialog": "Loading the ModPack @@@ will remove all installed Mods." - }, - "sync_from_save": "Sync Mods from Save", - "sync_started": "Sync started...", - "sync_downloading": "Downloading", - "sync_done": "Sync complete", - "sync_error": "Sync error", - "sync_warning_vanilla": "Save was created without gameplay mods. Mods may have been added later — sync may be incomplete.", - "status_downloaded": "Downloaded", - "status_already_installed": "Already installed", - "status_builtin": "Built-in / DLC", - "status_not_found": "Not found on portal", - "status_downloading": "Downloading..." - }, - "login": { - "login_failed_message": "Login failed. Username or Password wrong.", - "title": "Login", - "login": "Login", - "username_error_message": "Username is required", - "password_error_message": "Password is required", - "sign_in": "Sign In", - "factorio_login_error_message": "Given username or email and password do not match any account." - }, - "console": { - "title": "Console", - "error": "The console is not available, because Factorio is not running." - }, - "logs": { - "title": "Logs" + "upload_form": { + "select_file": "Select File ...", + "file_name": "Savefile", + "save_file_error_message": "Savefile is required", + "upload": "Upload" + } + }, + "controls": { + "title": "Server Status", + "status": "Status", + "unknown": "Unknown", + "running": "Running", + "stopped": "Stopped", + "port": "Port", + "IP_error_message": "IP is required and must be valid.", + "port_error_message": "Port is required within range 1-65535", + "save_error_message": "Save is required and must be valid.", + "f_version": "Factorio version", + "save": "Save", + "save&stop": "Save & Stop Server", + "kill_server": "Kill Server", + "start_server": "Start Server" + }, + "mods": { + "change_mods_while_running_error_message": "Changing mods is disabled while the server is running!", + "install_mod": "Install Mod", + "upload_mod": "Upload Mod", + "load_mods": "Load Mods from Save", + "title": "Mods", + "delete_all": "Delete all Mods", + "update_all": "Update all Mods", + "download_all": "Download all Mods", + "mod_packs": "Mod packs", + "select_file": "Select File ...", + "mods_loaded_from_save": "Mods are loaded from save file @@@.", + "load_mods_from_save": "Load Mods from Save", + "load_confirm_dialog": "Loading the Mods from Save @@@ will remove all currently installed Mods.", + "add_modpack_with_current_mods": "Add ModPack with current installed Mods", + "add_mod": { + "loading_mod_list": "Loading List of Mods from", + "version": "Version", + "compatibility": "Compatibility" }, - "help": { - "title": "Help", - "fsm": "Factorio Server Manager", - "fsm_content": "The Factorio Server Manager (FSM) is an open source project and is not affiliated to the game Factorio or Wube Software.", - "bugs_help": "Bugs and Help", - "bugs_help_content": "Please use the <0>GitHub repository to report bugs or seek for help.", - "helpful_resources": "Helpful Resources", - "factorio_link_text": "Official Factorio Wiki about Multiplayer" + "mod_list": { + "enabled": "Enabled", + "compatibility": "Compatibility", + "mod_version": "Mod Version", + "factorio_version": "Factorio Version" }, - "users": { - "title": "Users", - "user_list": "List of Users", - "change_password": { - "title": "Change Password", - "update_successful": "Password changed", - "old_password": "Old Password", - "old_password_error": "Old Password is required", - "new_password": "New Password", - "new_password_error": "New Password is required", - "new_password_confirmation": "New Password Confirmation", - "new_password_confirmation_error": "New Password Confirmation is required" - }, - "create_user": { - "title": "Create User", - "username_error": "Username is required", - "role_error": "Role is required", - "email_error": "Email is required", - "password_error": "Password is required", - "password_confirmation": "Password Confirmation", - "password_confirmation_error": "Password Confirmation is required and must match the Password" - } + "mod_pack": { + "load_modpack": "Load ModPack", + "load_confirm_dialog": "Loading the ModPack @@@ will remove all installed Mods." }, - "game_settings": { - "title": "Game Settings" + "sync_from_save": "Sync Mods from Save", + "sync_started": "Sync started...", + "sync_downloading": "Downloading", + "sync_done": "Sync complete", + "sync_error": "Sync error", + "sync_warning_vanilla": "Save was created without gameplay mods. Mods may have been added later \u2014 sync may be incomplete.", + "status_downloaded": "Downloaded", + "status_already_installed": "Already installed", + "status_builtin": "Built-in / DLC", + "status_not_found": "Not found on portal", + "status_downloading": "Downloading..." + }, + "login": { + "login_failed_message": "Login failed. Username or Password wrong.", + "title": "Login", + "login": "Login", + "username_error_message": "Username is required", + "password_error_message": "Password is required", + "sign_in": "Sign In", + "factorio_login_error_message": "Given username or email and password do not match any account." + }, + "console": { + "title": "Console", + "error": "The console is not available, because Factorio is not running." + }, + "logs": { + "title": "Logs" + }, + "help": { + "title": "Help", + "fsm": "Factorio Server Manager", + "fsm_content": "The Factorio Server Manager (FSM) is an open source project and is not affiliated to the game Factorio or Wube Software.", + "bugs_help": "Bugs and Help", + "bugs_help_content": "Please use the <0>GitHub repository to report bugs or seek for help.", + "helpful_resources": "Helpful Resources", + "factorio_link_text": "Official Factorio Wiki about Multiplayer" + }, + "users": { + "title": "Users", + "user_list": "List of Users", + "change_password": { + "title": "Change Password", + "update_successful": "Password changed", + "old_password": "Old Password", + "old_password_error": "Old Password is required", + "new_password": "New Password", + "new_password_error": "New Password is required", + "new_password_confirmation": "New Password Confirmation", + "new_password_confirmation_error": "New Password Confirmation is required" }, - "lang": "Language" + "create_user": { + "title": "Create User", + "username_error": "Username is required", + "role_error": "Role is required", + "email_error": "Email is required", + "password_error": "Password is required", + "password_confirmation": "Password Confirmation", + "password_confirmation_error": "Password Confirmation is required and must match the Password" + } + }, + "game_settings": { + "title": "Game Settings" + }, + "lang": "Language", + "refresh": "Refresh Mods" } \ No newline at end of file diff --git a/ui/App/locales/zh-layout.json b/ui/App/locales/zh-layout.json index f04237c7..1d606408 100644 --- a/ui/App/locales/zh-layout.json +++ b/ui/App/locales/zh-layout.json @@ -5,5 +5,6 @@ "FSM_administration": "FSM 管理", "lang": "语言", "logout": "退出登录", - "UNKNOWN": "未知" + "UNKNOWN": "未知", + "refresh": "刷新模组列表" } \ No newline at end of file diff --git a/ui/App/views/Mods/Mods.jsx b/ui/App/views/Mods/Mods.jsx index 2ff85018..d04a1eb8 100644 --- a/ui/App/views/Mods/Mods.jsx +++ b/ui/App/views/Mods/Mods.jsx @@ -160,8 +160,7 @@ const Mods = ({serverStatus}) => { - +
    From b222041462b80cec1448fc827ab176642c25b49f Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Wed, 24 Jun 2026 07:21:29 +0000 Subject: [PATCH 090/112] =?UTF-8?q?=E7=BF=BB=E8=AF=91:=20=E8=A1=A5?= =?UTF-8?q?=E5=85=A8=209=20=E4=B8=AA=E5=91=BD=E5=90=8D=E7=A9=BA=E9=97=B4?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E7=BF=BB=E8=AF=91=E9=94=AE=20(0=20missing)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - zh-layout.json: 12 keys (main_title, logout, refresh...) - zh-controls.json: 22 keys (RUNNING, STOPPED, saveStopServer...) - zh-console.json: 6 keys - zh-logs.json: 4 keys - zh-mods.json: 49 keys (confirmDeleteAllMods, loadingModList...) - zh-saves.json: 16 keys (createSave, uploadSave...) - zh-serverSettings.json: 32 keys (showTranslation, hideTranslation...) - zh-serverVersion.json: 24 keys (installSuccess, upToDate, fetchFailed...) - zh-userManagement.json: 24 keys (changePassword, passwordMismatch...) - zh.json: 新增 refresh 键 - en.json: 补全 Layout.jsx 引用的嵌套键 --- ui/App/locales/en.json | 5 ++- ui/App/locales/zh-console.json | 4 ++- ui/App/locales/zh-controls.json | 34 +++++++++---------- ui/App/locales/zh-layout.json | 6 +++- ui/App/locales/zh-logs.json | 1 + ui/App/locales/zh-mods.json | 48 +++++++++++++++++---------- ui/App/locales/zh-saves.json | 22 +++++++----- ui/App/locales/zh-serverSettings.json | 12 ++++--- ui/App/locales/zh-serverVersion.json | 20 +++++++++-- ui/App/locales/zh-userManagement.json | 24 +++++++++++--- ui/App/locales/zh.json | 3 +- 11 files changed, 117 insertions(+), 62 deletions(-) diff --git a/ui/App/locales/en.json b/ui/App/locales/en.json index 128a56c4..d05cccf2 100644 --- a/ui/App/locales/en.json +++ b/ui/App/locales/en.json @@ -204,5 +204,8 @@ "title": "Game Settings" }, "lang": "Language", - "refresh": "Refresh Mods" + "refresh": "Refresh Mods", + "serverVersion": { + "title": "Server Version" + } } \ No newline at end of file diff --git a/ui/App/locales/zh-console.json b/ui/App/locales/zh-console.json index 02382cb5..9130943a 100644 --- a/ui/App/locales/zh-console.json +++ b/ui/App/locales/zh-console.json @@ -1,6 +1,8 @@ { "title": "控制台", + "console": "控制台", "send": "发送", - "command": "命令", + "command": "指令", + "consoleNotAvailable": "控制台不可用(服务器未运行)", "placeholder": "输入指令..." } \ No newline at end of file diff --git a/ui/App/locales/zh-controls.json b/ui/App/locales/zh-controls.json index 6f42cffb..164965af 100644 --- a/ui/App/locales/zh-controls.json +++ b/ui/App/locales/zh-controls.json @@ -1,28 +1,24 @@ { "title": "控制面板", - "unknown": "未知", + "serverStatus": "服务器状态", + "status": "状态", + "RUNNING": "运行中", + "STOPPED": "已停止", "running": "运行中", "stopped": "已停止", + "unknown": "未知", "factorioVersion": "游戏版本", - "serverRunning": "服务器运行中", - "serverStopped": "服务器已停止", - "start": "启动", - "stop": "停止服务器", - "kill": "强制终止", - "save_game": "保存游戏", "ip": "IP 地址", "port": "端口", - "saveFile": "存档", - "loadLatest": "加载最新", - "factorioInstallation": "Factorio 安装", - "installedVersion": "已安装版本", - "availableVersions": "可用版本", - "downloadInstall": "下载并安装", - "removeInstallation": "卸载", - "serverStatus": "服务器状态", - "status": "状态", - "RUNNING": "运行中", - "STOPPED": "已停止", "save": "存档", - "UNKNOWN": "未知" + "saveFile": "存档文件", + "saveRequired": "请选择存档", + "ipRequired": "请输入 IP 地址", + "portRequired": "请输入端口", + "killServer": "强制终止", + "startServer": "启动服务器", + "stopServer": "停止服务器", + "saveStopServer": "保存并停止", + "saveSettings": "保存设置", + "settingsSaved": "设置已保存" } \ No newline at end of file diff --git a/ui/App/locales/zh-layout.json b/ui/App/locales/zh-layout.json index 1d606408..a3c5ca51 100644 --- a/ui/App/locales/zh-layout.json +++ b/ui/App/locales/zh-layout.json @@ -6,5 +6,9 @@ "lang": "语言", "logout": "退出登录", "UNKNOWN": "未知", - "refresh": "刷新模组列表" + "refresh": "刷新模组列表", + "appTitle": "Factorio Server Manager", + "helpBugsAndHelp": "反馈 BUG & 获取帮助", + "helpResources": "相关资源", + "helpTitle": "帮助" } \ No newline at end of file diff --git a/ui/App/locales/zh-logs.json b/ui/App/locales/zh-logs.json index 6034ad3d..812ccc1a 100644 --- a/ui/App/locales/zh-logs.json +++ b/ui/App/locales/zh-logs.json @@ -1,5 +1,6 @@ { "title": "日志", + "logs": "日志", "refresh": "刷新", "autoScroll": "自动滚动" } \ No newline at end of file diff --git a/ui/App/locales/zh-mods.json b/ui/App/locales/zh-mods.json index 147b50e3..b0396529 100644 --- a/ui/App/locales/zh-mods.json +++ b/ui/App/locales/zh-mods.json @@ -1,36 +1,48 @@ { "title": "模组管理", - "installed": "已安装模组", + "mods": "模组", + "installed": "已安装", "portal": "模组门户", "add": "添加模组", - "load": "从存档加载", + "load": "加载", "delete_all": "删除全部", "update_all": "更新全部", - "install_mods": "安装模组", - "loading_mods": "加载模组列表...", - "factorioVersion": "游戏版本", - "enabled": "启用", - "compatibility": "兼容性", - "mod_version": "模组版本", - "file_size": "文件大小", + "install": "安装", + "upload": "上传", + "save": "保存", + "cancel": "取消", + "create": "创建", + "name": "名称", + "actions": "操作", + "search": "搜索...", + "logout": "退出登录", "updateAllMods": "更新全部模组", "downloadAllMods": "下载全部模组", "deleteAllMods": "删除全部模组", - "confirmDeleteAll": "确认删除所有模组?此操作不可撤销。", - "noModsInstalled": "没有安装模组", - "installMods": "安装模组", - "loadingMods": "加载模组列表...", - "installedTab": "已安装", - "addModTab": "添加模组", - "loadModsTab": "加载模组", - "modPacksTab": "模组包", "modPacks": "模组包", "createModPack": "创建模组包", "modPortal": "模组门户", "installMod": "安装模组", "uploadMod": "上传模组", "loadModsFromSave": "从存档加载", - "search": "搜索...", + "installedTab": "已安装", + "addModTab": "添加模组", + "loadModsTab": "加载模组", + "modPacksTab": "模组包", + "selectModFile": "选择模组文件", + "selectVersion": "选择版本", + "version": "版本", + "loadModsTitle": "从存档加载模组", + "deleteExistingMods": "删除已有模组", + "loadingModList": "加载模组列表...", + "changingModsDisabled": "模组修改已禁用(服务器运行中)", + "confirmDeleteAllMods": "确认删除所有模组?此操作不可撤销。", + "noModsInstalled": "没有安装模组", + "factorioVersion": "游戏版本", + "enabled": "启用", + "compatibility": "兼容性", + "mod_version": "模组版本", + "file_size": "文件大小", "Name": "模组名称", "Enabled": "启用", "Compatibility": "兼容性", diff --git a/ui/App/locales/zh-saves.json b/ui/App/locales/zh-saves.json index cdee8aa3..7910a342 100644 --- a/ui/App/locales/zh-saves.json +++ b/ui/App/locales/zh-saves.json @@ -1,12 +1,18 @@ { "title": "存档管理", - "dl_save": "下载存档", - "upload_save": "上传存档", - "remove_save": "删除存档", - "create_save": "创建存档", - "no_saves": "没有存档", - "delete_confirm": "确认删除存档?", - "name": "存档名称", + "saves": "存档", + "actions": "操作", + "name": "名称", "lastModifiedAt": "最后修改", - "size": "大小" + "size": "大小", + "upload": "上传", + "uploadSave": "上传存档", + "createSave": "创建存档", + "createSaveDisabled": "创建存档已禁用(服务器运行中)", + "saveFile": "存档文件", + "savefileName": "存档文件名", + "saveNameRequired": "请输入存档名称", + "selectFile": "选择文件", + "no_saves": "没有存档", + "delete_confirm": "确认删除存档?" } \ No newline at end of file diff --git a/ui/App/locales/zh-serverSettings.json b/ui/App/locales/zh-serverSettings.json index 98f17048..6d4cee48 100644 --- a/ui/App/locales/zh-serverSettings.json +++ b/ui/App/locales/zh-serverSettings.json @@ -1,5 +1,11 @@ { "title": "服务器设置", + "serverSettings": "服务器设置", + "gameSettings": "游戏设置", + "saveSettings": "保存设置", + "settingsSaved": "设置已保存", + "showTranslation": "显示说明", + "hideTranslation": "隐藏说明", "admins": "管理员", "name": "服务器名称", "description": "服务器描述", @@ -24,9 +30,5 @@ "autosave_interval": "自动保存间隔", "autosave_slots": "自动保存槽位数", "non_blocking_saving": "非阻塞保存", - "afk_autokick_interval": "挂机踢出时间", - "require_user_verification_label": "需要验证用户身份", - "visibility_label": "服务器可见性", - "show_descriptions": "显示说明", - "hide_descriptions": "隐藏说明" + "afk_autokick_interval": "挂机踢出时间" } \ No newline at end of file diff --git a/ui/App/locales/zh-serverVersion.json b/ui/App/locales/zh-serverVersion.json index 0e4c20a7..768d2702 100644 --- a/ui/App/locales/zh-serverVersion.json +++ b/ui/App/locales/zh-serverVersion.json @@ -1,12 +1,26 @@ { "title": "服务器版本", + "serverVersion": "服务器版本", + "installedVersion": "已安装版本", + "availableVersions": "可用版本", "current": "当前版本", - "available": "可用版本", "stable": "稳定版", "experimental": "实验版", + "latest": "最新", + "showLatest": "显示最新发布", + "showAll": "显示全部版本", + "unknown": "未知", + "upToDate": "已是最新", + "updateAvailable": "有新版本可用", + "updateTo": "更新到", "install": "安装", "installing": "安装中...", - "latestReleases": "最新发布", - "fullVersionList": "完整版本列表", + "downloadProgress": "下载进度", + "installSuccess": "安装成功", + "installFailed": "安装失败", + "checkingVersion": "检查版本...", + "fetchFailed": "获取版本列表失败", + "noInternet": "无法连接 Factorio 服务器", + "serverMustBeStopped": "请先停止服务器", "noVersionsAvailable": "没有可用版本" } \ No newline at end of file diff --git a/ui/App/locales/zh-userManagement.json b/ui/App/locales/zh-userManagement.json index 6789e886..fdcdcf04 100644 --- a/ui/App/locales/zh-userManagement.json +++ b/ui/App/locales/zh-userManagement.json @@ -1,12 +1,26 @@ { "title": "用户管理", - "addUser": "添加用户", - "username": "用户名", - "password": "密码", + "name": "用户名", "role": "角色", "email": "邮箱", "actions": "操作", - "confirmDelete": "确认删除用户?", + "listOfUsers": "用户列表", + "createUser": "添加用户", + "changePassword": "修改密码", + "change": "修改", + "save": "保存", + "remove": "删除", + "username": "用户名", + "password": "密码", "usernameRequired": "请输入用户名", - "passwordRequired": "请输入密码" + "passwordRequired": "请输入密码", + "emailRequired": "请输入邮箱", + "roleRequired": "请选择角色", + "passwordConfirmation": "确认密码", + "passwordMismatch": "两次密码不一致", + "passwordChanged": "密码已修改", + "newPassword": "新密码", + "newPasswordConfirmation": "确认新密码", + "oldPassword": "旧密码", + "confirmDelete": "确认删除用户?" } \ No newline at end of file diff --git a/ui/App/locales/zh.json b/ui/App/locales/zh.json index 0b3009c0..ee89a6c3 100644 --- a/ui/App/locales/zh.json +++ b/ui/App/locales/zh.json @@ -233,5 +233,6 @@ "installedTab": "已安装", "addModTab": "添加模组", "loadModsTab": "加载模组", - "modPacksTab": "模组包" + "modPacksTab": "模组包", + "refresh": "刷新" } \ No newline at end of file From 7a9985df6dc33d05e6e9ce3447945627a1bceb7d Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Wed, 24 Jun 2026 07:29:20 +0000 Subject: [PATCH 091/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E6=A8=A1?= =?UTF-8?q?=E7=BB=84=E9=A1=B5=E9=BB=91=E5=B1=8F=20=E2=80=94=20=E5=8F=96?= =?UTF-8?q?=E6=B6=88=E6=AF=8F=202=20=E7=A7=92=20153=20=E6=AC=A1=20portal?= =?UTF-8?q?=20API=20=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因: Mod 组件 useEffect([mod]) 每次轮询都重新检查更新,导致 153 个 portal.info() 并发请求 + addUpdatableMod 连锁状态更新 → React 组件树崩溃 → 黑屏。 - Mod.jsx: portal.info 仅首次挂载检查一次 (checked flag) - Mods.jsx: addUpdatableMod 去重,避免重复添加触发多余渲染 --- ui/App/views/Mods/Mods.jsx | 5 ++++- ui/App/views/Mods/components/Mod.jsx | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/ui/App/views/Mods/Mods.jsx b/ui/App/views/Mods/Mods.jsx index d04a1eb8..fb0372ae 100644 --- a/ui/App/views/Mods/Mods.jsx +++ b/ui/App/views/Mods/Mods.jsx @@ -34,7 +34,10 @@ const Mods = ({serverStatus}) => { const [authChecked, setAuthChecked] = useState(false); const addUpdatableMod = mod => { - setUpdatableMods(mods => [...mods, mod]) + setUpdatableMods(mods => { + if (mods.some(m => m.modName === mod.modName)) return mods; + return [...mods, mod]; + }); }; const fetchInstalledMods = () => { diff --git a/ui/App/views/Mods/components/Mod.jsx b/ui/App/views/Mods/components/Mod.jsx index 94352045..80893af7 100644 --- a/ui/App/views/Mods/components/Mod.jsx +++ b/ui/App/views/Mods/components/Mod.jsx @@ -16,9 +16,11 @@ const Mod = ({mod, factorioVersion, toggleMod, deleteMod, updateMod, addUpdatabl const [newVersion, setNewVersion] = useState(null) const [icon, setIcon] = useState(faArrowCircleUp) + const [checked, setChecked] = useState(false) useEffect(() => { - if (!disabled) { + if (!disabled && !checked) { + setChecked(true); (async () => { try { const data = await modsResource.portal.info(mod.name) From 848d983c8141062d81d719c9067910f0b3a12fc3 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Wed, 24 Jun 2026 07:34:31 +0000 Subject: [PATCH 092/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20React=20#31=20?= =?UTF-8?q?=E5=B4=A9=E6=BA=83=20=E2=80=94=20axios=20=E6=8B=A6=E6=88=AA?= =?UTF-8?q?=E5=99=A8=20Object=20=E6=B8=B2=E6=9F=93=20+=20404=20=E9=9D=99?= =?UTF-8?q?=E9=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因: portal API 404 返回 {message:...} 对象,axios 拦截器将其传入 window.flash(),Flash 组件渲染 Object → React error #31 → 全局黑屏。 - 404 响应静默跳过(portal 模组不存在是预期行为) - 对象响应安全转为字符串(message 字段或 JSON.stringify) - 仅非预期错误触发 flash 提示 --- ui/api/client.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/ui/api/client.js b/ui/api/client.js index b99da358..572d7542 100644 --- a/ui/api/client.js +++ b/ui/api/client.js @@ -12,15 +12,24 @@ client.interceptors.response.use(res => res, err => { if (window.flash) window.flash("Network error or request timeout", "red"); return Promise.reject(err); } - if(err.response.status === 502) { + const status = err.response.status; + if (status === 502) { if (window.flash) window.flash("Service not available", "red"); - } else if (err.response.status === 401) { + } else if (status === 401) { if (window.location.pathname !== '/login') { window.location.replace('/login'); return new Promise(() => {}); } + } else if (status === 404) { + // 404 is expected for portal "mod not found" — don't flash } else { - if (window.flash) window.flash(err.response.data, "red"); + if (window.flash) { + const data = err.response.data; + const msg = typeof data === 'string' ? data + : (data && data.message) ? data.message + : JSON.stringify(data || ''); + window.flash(msg, "red"); + } } return Promise.reject(err); }); From 6a3725dfa534796798622f66cc75eb72282371ab Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Wed, 24 Jun 2026 07:45:53 +0000 Subject: [PATCH 093/112] =?UTF-8?q?=E6=96=87=E6=A1=A3:=20=E9=81=B5?= =?UTF-8?q?=E5=BE=AA=E4=B8=8A=E6=B8=B8=E8=A7=84=E8=8C=83=20=E2=80=94=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=20CHANGELOG.md=20+=20ROADMAP.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG: 新增 v0.12.0 条目,记录 50+ 项变更 (Added/Changed/Fixed/Removed) - ROADMAP: 标记已完成项 (版本管理器/2.1兼容/i18n中文/并发下载/存档解析...) --- CHANGELOG.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ ROADMAP.md | 12 +++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73bca72a..1c7eb60b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,53 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [0.12.0] - TBD + +### Added +- Server version manager: download, update, and downgrade Factorio server from UI - Thanks to @BAYUNZIYUE + - Full version list from updater.factorio.com (stable/experimental) + - Async install with WebSocket real-time progress +- Mod sync from save: read mod list from level.dat0 with exact versions - Thanks to @joey00797-cell + - Selective sync with checkboxes + - WebSocket download progress for each mod +- DLC mods (elevated-rails, quality, space-age) detected from mod-list.json + - Grouped in UI with single toggle - Thanks to @joey00797-cell +- i18n internationalization via i18next framework with zh-CN translations - Thanks to @joey00797-cell, @BAYUNZIYUE +- Language switcher dialog (EN / RU / 中文) +- 5-thread concurrent mod downloading with per-worker progress bars +- Upload mod multi-file support with progress +- Mod list auto-refresh every 2s with background-tab skip +- TabControl onActivate support for lazy portal list loading +- Server settings Chinese descriptions toggle (27 items based on Factorio Wiki) +- File size display in mod list +- IP/port memory in Controls page (localStorage) +- Cache mod portal list for 1 hour (~60x speedup) +- Token-based Factorio authentication support + +### Changed +- Factorio version display uniformly prefixed with `>=` +- Mod portal releases compatibility now uses Factorio-aware GEC (same major.minor required) +- DepOp no longer hardcoded to `>=` — reflects actual dependency operator +- Portal "Mod not found" returns 404 JSON instead of 500 error +- File lock timeout increased to 30s to prevent deadlocks +- Docker build cache for go mod download and npm install + +### Fixed +- Factorio 2.1 compatibility: mods with different minor version correctly marked incompatible +- save.go: Factorio 2.0 save header parsing (readFromV2, 192-line complete rewrite) +- mod_modInfo.go: base dependency version override (old `base >= 0.18` no longer overwrites info.json factorio_version: 2.0) +- mod_modInfo.go: dependency parsing bounds check (prevents crash on malformed deps) +- Corrupted mod zip files gracefully skipped instead of crashing FSM +- 401 auto-redirect to login page (replace instead of push state) +- React #31 crash: axios interceptor no longer passes Object to Flash component +- Mod page black screen: portal API calls limited to initial mount (was 153 calls every 2s) +- Auth gate prevents login form flash (authChecked before tab render) +- Updater API stable tag compatibility for 2.1.7 detection +- save.go: fmt.Errorf escaped percent signs fixed (20+ instances) + +### Removed +- removeAll on Docker volume replaced with proper mod directory clear - Thanks to @joey00797-cell + ## [0.11.0] - TBD ### Changed - Configuration environment variables are now uppercase and prefixed with FSM diff --git a/ROADMAP.md b/ROADMAP.md index c2adce03..ce5e317b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,8 +11,17 @@ - [x] DLC mods auto-detected and grouped in UI with single toggle - [x] DLC mods visible in installed mods list with enable/disable - [x] Server start blocked while mod sync is in progress -- [x] i18n support (EN/RU) +- [x] i18n support (EN/RU/zh-CN) - [x] Token-based authentication on factorio.com +- [x] Server version manager (download/update/downgrade Factorio from UI) +- [x] 5-thread concurrent mod downloading with progress bars +- [x] Upload mod multi-file support +- [x] Mod portal list caching (1 hour) +- [x] Factorio 2.1 compatibility (GEC version checking) +- [x] Factorio 2.0 save header parser (readFromV2) +- [x] Server settings Chinese descriptions (Factorio Wiki based) +- [x] Auth gate preventing login form flash +- [x] 401 auto-redirect to login page ## 🚧 Planned @@ -20,6 +29,7 @@ - [ ] Auto-resolve mod dependencies when creating a new save - [ ] Portal link icon next to each mod in the list - [ ] Row highlight on hover in mod list +- [ ] Batch update all compatible mods for current server version ### Authentication - [ ] Show logged-in username on mod portal tab From 357d8e9a8e094f52ab0f6ba5467f7d08f9b9629f Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Wed, 24 Jun 2026 08:24:05 +0000 Subject: [PATCH 094/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20LoadMods=20?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E7=88=B6=E7=BB=84=E4=BB=B6=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E7=9A=84=20Factorio=20=E8=AE=A4=E8=AF=81=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并后 Joey 的 LoadMods 自管 isFactorioAuthenticated 状态, 每次切换标签都重查 API,导致加载存档标签重复要求登录。 改为接收父组件共享的认证状态,与 AddMod 标签保持一致。 --- ui/App/views/Mods/Mods.jsx | 4 +++- ui/App/views/Mods/components/LoadMods.jsx | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/ui/App/views/Mods/Mods.jsx b/ui/App/views/Mods/Mods.jsx index fb0372ae..45e4a01c 100644 --- a/ui/App/views/Mods/Mods.jsx +++ b/ui/App/views/Mods/Mods.jsx @@ -163,7 +163,9 @@ const Mods = ({serverStatus}) => { - +
    diff --git a/ui/App/views/Mods/components/LoadMods.jsx b/ui/App/views/Mods/components/LoadMods.jsx index 86936e08..aba0862d 100644 --- a/ui/App/views/Mods/components/LoadMods.jsx +++ b/ui/App/views/Mods/components/LoadMods.jsx @@ -31,14 +31,13 @@ const STATUS_TEXT = { not_found: "Not found on portal", }; -const LoadMods = ({refreshMods}) => { +const LoadMods = ({refreshMods, isFactorioAuthenticated, setIsFactorioAuthenticated}) => { const {t} = useTranslation(); const [saves, setSaves] = useState([]); const [selectedSave, setSelectedSave] = useState(""); const [isLoading, setIsLoading] = useState(false); const [isSyncing, setIsSyncing] = useState(false); const [isDisabled, setIsDisabled] = useState(true); - const [isFactorioAuthenticated, setIsFactorioAuthenticated] = useState(false); const [modRows, setModRows] = useState([]); const [checkedMods, setCheckedMods] = useState({}); const [syncError, setSyncError] = useState(null); @@ -47,7 +46,9 @@ const LoadMods = ({refreshMods}) => { useEffect(() => { (async () => { - setIsFactorioAuthenticated(await modsResource.portal.status()); + if (!isFactorioAuthenticated) { + setIsFactorioAuthenticated(await modsResource.portal.status()); + } const s = await savesResource.list(); setSaves(s); if (s.length > 0) { From c9225b516ec0a80aaa949cf7e22a0c462ec9bfa4 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Wed, 24 Jun 2026 08:27:26 +0000 Subject: [PATCH 095/112] =?UTF-8?q?=E6=96=87=E6=A1=A3:=20CHANGELOG=20?= =?UTF-8?q?=E6=A0=87=E6=B3=A8=E5=85=B1=E4=BA=AB=E8=AE=A4=E8=AF=81=E6=80=81?= =?UTF-8?q?=E9=80=80=E5=8C=96=E4=BF=AE=E5=A4=8D=E5=8F=8A=E5=8E=9F=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并 Joey develop 后 LoadMods 自管认证状态覆盖了我们的共享模式。 PR 提交时需向 Joey 说明此设计决策。 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c7eb60b..f336092f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - React #31 crash: axios interceptor no longer passes Object to Flash component - Mod page black screen: portal API calls limited to initial mount (was 153 calls every 2s) - Auth gate prevents login form flash (authChecked before tab render) +- Shared Factorio auth state restored across all mod tabs (AddMod + LoadMods) — + parent component checks portal login once, children receive via props; + merged Joey's LoadMods rewrite regressed this with local state — fixed via prop drilling - Updater API stable tag compatibility for 2.1.7 detection - save.go: fmt.Errorf escaped percent signs fixed (20+ instances) From be455d5e43a08fbbdfb0c29c4dedcd64f9012692 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Wed, 24 Jun 2026 08:31:17 +0000 Subject: [PATCH 096/112] =?UTF-8?q?=E7=BF=BB=E8=AF=91:=20ModList=20?= =?UTF-8?q?=E8=A1=A8=E5=A4=B4=20+=20LoadMods=20=E5=85=A8=E9=83=A8=E7=A1=AC?= =?UTF-8?q?=E7=BC=96=E7=A0=81=E5=AD=97=E7=AC=A6=E4=B8=B2=E4=B8=AD=E6=96=87?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ModList.jsx: 表头使用 t() 调用 + 新增文件大小列 - LoadMods.jsx: STATUS_TEXT/按钮/标签/错误信息全部 t() 调用 - zh-mods.json: +24 翻译键 (File Size/readModsFromSave/selectMissing...) --- ui/App/locales/zh-mods.json | 24 +++++++++++-- ui/App/views/Mods/components/LoadMods.jsx | 41 ++++++++++++----------- ui/App/views/Mods/components/ModList.jsx | 15 +++++---- 3 files changed, 51 insertions(+), 29 deletions(-) diff --git a/ui/App/locales/zh-mods.json b/ui/App/locales/zh-mods.json index b0396529..6a86592f 100644 --- a/ui/App/locales/zh-mods.json +++ b/ui/App/locales/zh-mods.json @@ -1,7 +1,7 @@ { "title": "模组管理", "mods": "模组", - "installed": "已安装", + "installed": "已安装版本", "portal": "模组门户", "add": "添加模组", "load": "加载", @@ -9,7 +9,7 @@ "update_all": "更新全部", "install": "安装", "upload": "上传", - "save": "保存", + "save": "存档", "cancel": "取消", "create": "创建", "name": "名称", @@ -47,5 +47,23 @@ "Enabled": "启用", "Compatibility": "兼容性", "Mod Version": "模组版本", - "Factorio Version": "Factorio 版本" + "Factorio Version": "Factorio 版本", + "Space Age DLC": "太空时代 DLC", + "File Size": "文件大小", + "readModsFromSave": "从存档读取模组", + "selectMissing": "选择缺失", + "clearSelection": "清除选择", + "syncSelected": "同步选中", + "downloadingStatus": "下载中", + "downloadedStatus": "已下载", + "installedStatus": "已安装", + "wrongVersionStatus": "版本不匹配", + "missingStatus": "缺失", + "builtinStatus": "内置/DLC", + "notFoundStatus": "门户未找到", + "failedToReadSave": "读取存档失败", + "failedToStartSync": "启动同步失败", + "mod": "模组", + "required": "需要版本", + "status": "状态" } \ No newline at end of file diff --git a/ui/App/views/Mods/components/LoadMods.jsx b/ui/App/views/Mods/components/LoadMods.jsx index aba0862d..4d3ff675 100644 --- a/ui/App/views/Mods/components/LoadMods.jsx +++ b/ui/App/views/Mods/components/LoadMods.jsx @@ -3,10 +3,11 @@ import savesResource from "../../../../api/resources/saves"; import Label from "../../../components/Label"; import Button from "../../../components/Button"; import modsResource from "../../../../api/resources/mods"; +import {useTranslation} from "react-i18next"; import FactorioLogin from "./AddMod/components/FactorioLogin"; import socket from "../../../../api/socket"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {useTranslation} from "react-i18next"; + import {faSpinner, faCheck, faTimes, faMinusCircle, faExternalLinkAlt} from "@fortawesome/free-solid-svg-icons"; const DLC_MODS = new Set(['elevated-rails', 'quality', 'space-age']); @@ -22,13 +23,13 @@ const STATUS_ICON = { }; const STATUS_TEXT = { - downloading: "Downloading...", - downloaded: "Downloaded", - installed: "Installed", - wrong_version: "Wrong version", - missing: "Missing", - builtin: "Built-in / DLC", - not_found: "Not found on portal", + downloading: t("downloadingStatus"), + downloaded: t("downloadedStatus"), + installed: t("installedStatus"), + wrong_version: t("wrongVersionStatus"), + missing: t("missingStatus"), + builtin: t("builtinStatus"), + not_found: t("notFoundStatus"), }; const LoadMods = ({refreshMods, isFactorioAuthenticated, setIsFactorioAuthenticated}) => { @@ -110,7 +111,7 @@ const LoadMods = ({refreshMods, isFactorioAuthenticated, setIsFactorioAuthentica }); setCheckedMods(checked); } catch(e) { - setSyncError("Failed to read save: " + e.message); + setSyncError(`${t("failedToReadSave")}: ${e.message}`); } finally { setIsLoading(false); } @@ -129,7 +130,7 @@ const LoadMods = ({refreshMods, isFactorioAuthenticated, setIsFactorioAuthentica await modsResource.syncFromSave(selectedSave, selectedModNames); } catch(e) { setIsSyncing(false); - setSyncError("Failed to start sync: " + e.message); + setSyncError(`${t("failedToStartSync")}: ${e.message}`); } }; @@ -158,7 +159,7 @@ const LoadMods = ({refreshMods, isFactorioAuthenticated, setIsFactorioAuthentica return (
    {/* Выбор сейва */} -
    - - - - + + + + diff --git a/ui/App/views/Mods/components/ModList.jsx b/ui/App/views/Mods/components/ModList.jsx index 98c52d62..3af1bc6c 100644 --- a/ui/App/views/Mods/components/ModList.jsx +++ b/ui/App/views/Mods/components/ModList.jsx @@ -2,11 +2,13 @@ import Mod from "./Mod"; import React from "react"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {faCheck, faTimes, faToggleOff, faToggleOn} from "@fortawesome/free-solid-svg-icons"; +import { useTranslation } from 'react-i18next'; const DLC_MODS = new Set(['elevated-rails', 'quality', 'space-age']); const ModList = ({mods, factorioVersion, updateMod, toggleMod, deleteMod, addUpdatableMod = null, disabled = false}) => { + const { t } = useTranslation('mods'); const dlcMods = mods.filter(m => DLC_MODS.has(m.name)); const regularMods = mods.filter(m => !DLC_MODS.has(m.name)); const dlcEnabled = dlcMods.some(m => m.enabled); @@ -24,11 +26,12 @@ const ModList = ({mods, factorioVersion, updateMod, toggleMod, deleteMod, addUpd
    NameLast Modified AtSizeActions{t('name', { ns: 'common' })}{t('lastModifiedAt', { ns: 'common' })}{t('size', { ns: 'common' })}{t('actions', { ns: 'common' })}
    NameRoleEmailActions{t('name', { ns: 'common' })}{t('role', { ns: 'common' })}{t('email', { ns: 'common' })}{t('actions', { ns: 'common' })}
    {mod.factorio_version}{mod.dep_op ? mod.dep_op + ' ' + mod.factorio_version : mod.factorio_version} From 1298a6a2141aacad562b0dfe3f08beafc6e60efa Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Sun, 21 Jun 2026 11:10:51 +0000 Subject: [PATCH 028/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E6=9C=AA?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=20mod=20=E7=82=B9=E5=87=BB=E5=90=AF=E7=94=A8?= =?UTF-8?q?=E6=97=B6=E8=87=AA=E5=8A=A8=E6=B3=A8=E5=86=8C=E5=88=B0=20mod-li?= =?UTF-8?q?st.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/factorio/mod_modSimple.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/factorio/mod_modSimple.go b/src/factorio/mod_modSimple.go index 52d39bd6..4cbb0359 100644 --- a/src/factorio/mod_modSimple.go +++ b/src/factorio/mod_modSimple.go @@ -154,7 +154,11 @@ func (modSimpleList *ModSimpleList) ToggleMod(modName string) (error, bool) { } if !found { - return errors.New("mod is not installed"), newEnabled + err = modSimpleList.createMod(modName) + if err != nil { + return errors.New("mod is not installed"), newEnabled + } + newEnabled = true } err = modSimpleList.saveModInfoJson() From da412b9f707fb882cde4ecb97ba8ba841cd19d6a Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Sun, 21 Jun 2026 11:13:28 +0000 Subject: [PATCH 029/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E5=88=A0?= =?UTF-8?q?=E9=99=A4=20mod=20=E8=BF=94=E5=9B=9E=E6=98=8E=E7=A1=AE=20JSON?= =?UTF-8?q?=20=E7=8A=B6=E6=80=81=E8=80=8C=E9=9D=9E=E5=8E=9F=E5=A7=8B?= =?UTF-8?q?=E5=AD=97=E7=AC=A6=E4=B8=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/mods_handler.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/api/mods_handler.go b/src/api/mods_handler.go index 49fccbea..fe9527b5 100644 --- a/src/api/mods_handler.go +++ b/src/api/mods_handler.go @@ -127,8 +127,7 @@ func ModDeleteHandler(w http.ResponseWriter, r *http.Request) { log.Println(resp) return } - - resp = data.Name + resp = map[string]string{"status": "ok", "name": data.Name} } func ModDeleteAllHandler(w http.ResponseWriter, r *http.Request) { From db6922ba1d01132146346c64b3bccae2c057fc23 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Sun, 21 Jun 2026 11:16:08 +0000 Subject: [PATCH 030/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E4=BB=8E?= =?UTF-8?q?=E5=AD=98=E6=A1=A3=E5=8A=A0=E8=BD=BD=E6=A8=A1=E7=BB=84=E6=94=B9?= =?UTF-8?q?=E7=94=A8=20try/finally=20=E4=BF=9D=E8=AF=81=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=B8=85=E7=90=86=E2=80=94=E2=80=94=E4=B8=8D=E5=86=8D=E6=97=A0?= =?UTF-8?q?=E9=99=90=E8=BD=AC=E5=9C=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Mods/components/LoadMods.jsx | 29 +++++++++++++---------- ui/locales/en/mods.json | 1 + ui/locales/zh-CN/mods.json | 1 + 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/ui/App/views/Mods/components/LoadMods.jsx b/ui/App/views/Mods/components/LoadMods.jsx index 06609467..c33616e6 100644 --- a/ui/App/views/Mods/components/LoadMods.jsx +++ b/ui/App/views/Mods/components/LoadMods.jsx @@ -40,20 +40,25 @@ const LoadMods = ({refreshMods}) => { } const loadMods = async data => { - await modResource.deleteAll(); - const {mods} = await savesResource.mods(data.save).catch(() => { + try { + await modResource.deleteAll(); + const result = await savesResource.mods(data.save); + const mods = result?.mods || []; + + if (mods.length === 0) { + window.flash(t('noModsFound', { ns: 'mods' }), "green"); + return; + } + + await modResource.portal.installMultiple(mods); + refreshMods(); + window.flash(t('modsLoaded').replace('{save}', data.save), "green"); + } catch (e) { + window.flash(t('errorOccurred', { ns: 'common' }), "red"); + } finally { setIsLoading(false); setLoadModsData(undefined); - }); - - await modResource.portal.installMultiple(mods) - .then(() => { - refreshMods(); - window.flash(t('modsLoaded').replace('{save}', data.save), "green"); - }).finally(() => { - setIsLoading(false); - setLoadModsData(undefined); - }); + } } return isFactorioAuthenticated diff --git a/ui/locales/en/mods.json b/ui/locales/en/mods.json index 88ac9683..c5fd1c6a 100644 --- a/ui/locales/en/mods.json +++ b/ui/locales/en/mods.json @@ -29,6 +29,7 @@ "selectSave": "Select a save file", "modsLoaded": "Mods loaded from save.", "deleteExistingMods": "Delete all existing mods before loading? Existing mods will be permanently removed.", + "noModsFound": "No mods found in this save.", "compatibility": "Compatibility", "modVersion": "Mod Version", "factorioVersion": "Factorio Version" diff --git a/ui/locales/zh-CN/mods.json b/ui/locales/zh-CN/mods.json index 30aaef2c..35e9fb22 100644 --- a/ui/locales/zh-CN/mods.json +++ b/ui/locales/zh-CN/mods.json @@ -29,6 +29,7 @@ "selectSave": "选择一个存档文件", "modsLoaded": "已从存档加载模组。", "deleteExistingMods": "加载前删除所有现有模组?现有模组将被永久移除。", + "noModsFound": "此存档中没有模组。", "compatibility": "兼容性", "modVersion": "模组版本", "factorioVersion": "Factorio 版本" From 42a39e5acd957bc8e03e4e17fb81534adc7c8fa1 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Sun, 21 Jun 2026 11:25:12 +0000 Subject: [PATCH 031/112] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20=E4=BB=8E?= =?UTF-8?q?=E5=AD=98=E6=A1=A3=E5=8A=A0=E8=BD=BD=E6=A8=A1=E7=BB=84=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=20WebSocket=20=E5=AE=9E=E6=97=B6=E8=BF=9B=E5=BA=A6?= =?UTF-8?q?=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/mod_portal_handler.go | 26 +++++++++------- ui/App/views/Mods/components/LoadMods.jsx | 36 ++++++++++++++++++++++- ui/api/socket.js | 16 ++++++++++ ui/locales/en/mods.json | 1 + ui/locales/zh-CN/mods.json | 1 + 5 files changed, 68 insertions(+), 12 deletions(-) diff --git a/src/api/mod_portal_handler.go b/src/api/mod_portal_handler.go index ba4cb813..444fee83 100644 --- a/src/api/mod_portal_handler.go +++ b/src/api/mod_portal_handler.go @@ -5,6 +5,7 @@ import ( "log" "net/http" + "github.com/OpenFactorioServerManager/factorio-server-manager/api/websocket" "github.com/OpenFactorioServerManager/factorio-server-manager/factorio" "github.com/gorilla/mux" ) @@ -185,40 +186,43 @@ func ModPortalInstallMultipleHandler(w http.ResponseWriter, r *http.Request) { return } + wsRoom := websocket.WebsocketHub.GetRoom("mod_install") + total := len(data) + current := 0 + for _, datum := range data { // skip base mod because it is already included in factorio if datum.Name == "base" { + current++ continue } details, err, statusCode := factorio.ModPortalModDetails(datum.Name) if err != nil || statusCode != http.StatusOK { - resp = fmt.Sprintf("Error in getting mod details from mod portal: %s", err) - log.Println(resp) - w.WriteHeader(http.StatusInternalServerError) - return + log.Printf("Error in getting mod details from mod portal: %s", err) + current++ + continue } - //find correct mod-version var found = false for _, release := range details.Releases { if release.Version.Equals(datum.Version) { found = true - err := modList.DownloadMod(release.DownloadURL, release.FileName, details.Name) if err != nil { - resp = fmt.Sprintf("Error downloading mod {%s}, error: %s", details.Name, err) - log.Println(resp) - w.WriteHeader(http.StatusInternalServerError) - return + log.Printf("Error downloading mod {%s}, error: %s", details.Name, err) + break } break } } if !found { log.Printf("Error downloading mod {%s}, error: %s", details.Name, "version not found") - w.WriteHeader(http.StatusInternalServerError) } + current++ + wsRoom.Send(fmt.Sprintf(\`{"type":"progress","current":%d,"total":%d,"name":"%s"}\`, current, total, datum.Name)) } + wsRoom.Send(fmt.Sprintf(\`{"type":"complete","total":%d}\`, total)) + resp = modList.ListInstalledMods() } diff --git a/ui/App/views/Mods/components/LoadMods.jsx b/ui/App/views/Mods/components/LoadMods.jsx index c33616e6..368bcf28 100644 --- a/ui/App/views/Mods/components/LoadMods.jsx +++ b/ui/App/views/Mods/components/LoadMods.jsx @@ -9,17 +9,19 @@ import modsResource from "../../../../api/resources/mods"; import modResource from "../../../../api/resources/mods"; import FactorioLogin from "./AddMod/components/FactorioLogin"; import ConfirmDialog from "../../../components/ConfirmDialog"; +import socket from "../../../../api/socket"; const LoadMods = ({refreshMods}) => { const { t } = useTranslation(['mods', 'common']); - const [saves, setSaves] = useState([]); const {register, reset, handleSubmit} = useForm(); const [isLoading, setIsLoading] = useState(false); const [isDisabled, setIsDisabled] = useState(true); const [isFactorioAuthenticated, setIsFactorioAuthenticated] = useState(false); const [loadModsData, setLoadModsData] = useState(undefined); + const [installProgress, setInstallProgress] = useState({current: 0, total: 0}); + const [showProgress, setShowProgress] = useState(false); useEffect(() => { (async () => { @@ -32,6 +34,24 @@ const LoadMods = ({refreshMods}) => { } reset(); })(); + + const handleProgress = (msg) => { + try { + const data = JSON.parse(typeof msg === 'string' ? msg : JSON.stringify(msg)); + if (data.type === 'progress') { + setInstallProgress({current: data.current, total: data.total}); + setShowProgress(true); + } else if (data.type === 'complete') { + setShowProgress(false); + } + } catch (e) {} + }; + socket.on('mod_install', handleProgress); + socket.emit('mod install subscribe'); + + return () => { + socket.off('mod_install', handleProgress); + }; }, []); const loadModsRequested = data => { @@ -74,6 +94,20 @@ const LoadMods = ({refreshMods}) => { }))} /> + {showProgress && ( +
    +
    + {t('installingMods', { ns: 'mods' })} + {installProgress.current}/{installProgress.total} +
    +
    +
    0 ? (installProgress.current / installProgress.total * 100) : 0}%`}} + /> +
    +
    + )} { diff --git a/ui/locales/en/mods.json b/ui/locales/en/mods.json index c5fd1c6a..ab6c3761 100644 --- a/ui/locales/en/mods.json +++ b/ui/locales/en/mods.json @@ -30,6 +30,7 @@ "modsLoaded": "Mods loaded from save.", "deleteExistingMods": "Delete all existing mods before loading? Existing mods will be permanently removed.", "noModsFound": "No mods found in this save.", + "installingMods": "Installing mods", "compatibility": "Compatibility", "modVersion": "Mod Version", "factorioVersion": "Factorio Version" diff --git a/ui/locales/zh-CN/mods.json b/ui/locales/zh-CN/mods.json index 35e9fb22..e789f612 100644 --- a/ui/locales/zh-CN/mods.json +++ b/ui/locales/zh-CN/mods.json @@ -30,6 +30,7 @@ "modsLoaded": "已从存档加载模组。", "deleteExistingMods": "加载前删除所有现有模组?现有模组将被永久移除。", "noModsFound": "此存档中没有模组。", + "installingMods": "安装模组中", "compatibility": "兼容性", "modVersion": "模组版本", "factorioVersion": "Factorio 版本" From e43d47c06cbaaa583addf19a33c73e13f74ea4b2 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Sun, 21 Jun 2026 11:25:34 +0000 Subject: [PATCH 032/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=20WebSocket=20=E8=BF=9B=E5=BA=A6=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E5=AD=97=E7=AC=A6=E4=B8=B2=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/mod_portal_handler.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/mod_portal_handler.go b/src/api/mod_portal_handler.go index 444fee83..f56d7430 100644 --- a/src/api/mod_portal_handler.go +++ b/src/api/mod_portal_handler.go @@ -219,10 +219,10 @@ func ModPortalInstallMultipleHandler(w http.ResponseWriter, r *http.Request) { log.Printf("Error downloading mod {%s}, error: %s", details.Name, "version not found") } current++ - wsRoom.Send(fmt.Sprintf(\`{"type":"progress","current":%d,"total":%d,"name":"%s"}\`, current, total, datum.Name)) + wsRoom.Send(fmt.Sprintf("{\"type\":\"progress\",\"current\":%d,\"total\":%d,\"name\":\"%s\"}", current, total, datum.Name)) } - wsRoom.Send(fmt.Sprintf(\`{"type":"complete","total":%d}\`, total)) + wsRoom.Send(fmt.Sprintf("{\"type\":\"complete\",\"total\":%d}", total)) resp = modList.ListInstalledMods() } From a9f4105485219ee3c8f661756af6746bbe8f0ded Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Sun, 21 Jun 2026 11:28:48 +0000 Subject: [PATCH 033/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E5=8A=A0=E8=BD=BD=E6=A8=A1=E7=BB=84=E5=90=8E=E7=AB=8B?= =?UTF-8?q?=E5=8D=B3=E5=85=B3=E9=97=AD=E5=BC=B9=E7=AA=97=E2=80=94=E2=80=94?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E6=9D=A1=E4=B8=8D=E8=A2=AB=E9=81=AE=E6=8C=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Mods/components/LoadMods.jsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/App/views/Mods/components/LoadMods.jsx b/ui/App/views/Mods/components/LoadMods.jsx index 368bcf28..254b5bd9 100644 --- a/ui/App/views/Mods/components/LoadMods.jsx +++ b/ui/App/views/Mods/components/LoadMods.jsx @@ -116,7 +116,11 @@ const LoadMods = ({refreshMods}) => { setIsLoading(false); setLoadModsData(undefined); }} - onSuccess={() => loadMods(loadModsData)} + onSuccess={() => { + const data = loadModsData; + setLoadModsData(undefined); + setTimeout(() => loadMods(data), 0); + }} /> : From 38bf0a49b3c4af6088fccf4aea54b5e624b67dd8 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Sun, 21 Jun 2026 11:30:55 +0000 Subject: [PATCH 034/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20ConfirmDialog=20?= =?UTF-8?q?=E7=82=B9=E5=87=BB=E7=A1=AE=E8=AE=A4=E5=90=8E=E7=AB=8B=E5=8D=B3?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E2=80=94=E2=80=94=E8=BF=9B=E5=BA=A6=E6=9D=A1?= =?UTF-8?q?=E4=B8=8D=E8=A2=AB=E9=81=AE=E6=8C=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/components/ConfirmDialog.jsx | 7 ++----- ui/App/views/Mods/components/LoadMods.jsx | 3 +-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/ui/App/components/ConfirmDialog.jsx b/ui/App/components/ConfirmDialog.jsx index 3e902068..10f02a20 100644 --- a/ui/App/components/ConfirmDialog.jsx +++ b/ui/App/components/ConfirmDialog.jsx @@ -10,11 +10,8 @@ function ConfirmDialog({title, content, isOpen, close, onSuccess}) { const confirm = () => { setIsLoading(true); - onSuccess() - .finally(() => { - close(); - setIsLoading(false); - }) + close(); + onSuccess?.(); } return ( diff --git a/ui/App/views/Mods/components/LoadMods.jsx b/ui/App/views/Mods/components/LoadMods.jsx index 254b5bd9..bf1bfdd4 100644 --- a/ui/App/views/Mods/components/LoadMods.jsx +++ b/ui/App/views/Mods/components/LoadMods.jsx @@ -118,8 +118,7 @@ const LoadMods = ({refreshMods}) => { }} onSuccess={() => { const data = loadModsData; - setLoadModsData(undefined); - setTimeout(() => loadMods(data), 0); + loadMods(data); }} /> From 1b6192469dbf346bf132cfb49e402596efcae611 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Sun, 21 Jun 2026 11:34:39 +0000 Subject: [PATCH 035/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20ConfirmDialog=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20closeImmediately=20=E5=B1=9E=E6=80=A7?= =?UTF-8?q?=E2=80=94=E2=80=94=E4=BB=85=E5=85=B3=E9=97=AD=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=E4=B8=8D=E7=AD=89=E5=BE=85=20Promise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/components/ConfirmDialog.jsx | 14 +++++++++++--- ui/App/views/Mods/components/LoadMods.jsx | 1 + 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/ui/App/components/ConfirmDialog.jsx b/ui/App/components/ConfirmDialog.jsx index 10f02a20..124a552d 100644 --- a/ui/App/components/ConfirmDialog.jsx +++ b/ui/App/components/ConfirmDialog.jsx @@ -3,15 +3,23 @@ import { useTranslation } from 'react-i18next'; import Modal from "./Modal"; import Button from "./Button"; -function ConfirmDialog({title, content, isOpen, close, onSuccess}) { +function ConfirmDialog({title, content, isOpen, close, onSuccess, closeImmediately}) { const { t } = useTranslation('common'); const [isLoading, setIsLoading] = useState(false); const confirm = () => { setIsLoading(true); - close(); - onSuccess?.(); + if (closeImmediately) { + close(); + onSuccess?.(); + } else { + onSuccess() + .finally(() => { + close(); + setIsLoading(false); + }) + } } return ( diff --git a/ui/App/views/Mods/components/LoadMods.jsx b/ui/App/views/Mods/components/LoadMods.jsx index bf1bfdd4..905ade9b 100644 --- a/ui/App/views/Mods/components/LoadMods.jsx +++ b/ui/App/views/Mods/components/LoadMods.jsx @@ -120,6 +120,7 @@ const LoadMods = ({refreshMods}) => { const data = loadModsData; loadMods(data); }} + closeImmediately={true} /> : From 24749f0c469f2fb203623d9e168ba1279d4d98d2 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Sun, 21 Jun 2026 11:40:13 +0000 Subject: [PATCH 036/112] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E6=A8=A1=E7=BB=84=E8=BF=9B=E5=BA=A6=E6=9D=A1=E6=97=81?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=8F=96=E6=B6=88=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Mods/components/LoadMods.jsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ui/App/views/Mods/components/LoadMods.jsx b/ui/App/views/Mods/components/LoadMods.jsx index 905ade9b..eb6c0050 100644 --- a/ui/App/views/Mods/components/LoadMods.jsx +++ b/ui/App/views/Mods/components/LoadMods.jsx @@ -93,7 +93,12 @@ const LoadMods = ({refreshMods}) => { value: save.name }))} /> - +
    + + {showProgress && ( + + )} +
    {showProgress && (
    From b26dfaff5985af3a3072d736a52525ef5775870a Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Sun, 21 Jun 2026 11:59:34 +0000 Subject: [PATCH 037/112] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20=E5=AD=98?= =?UTF-8?q?=E6=A1=A3=E5=8A=A0=E8=BD=BD=E6=A8=A1=E7=BB=84=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=20Checklist=20=E5=8B=BE=E9=80=89=E8=A6=81=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E7=9A=84=E6=A8=A1=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Mods/components/LoadMods.jsx | 108 +++++++++++++++++----- ui/locales/en/mods.json | 5 + ui/locales/zh-CN/mods.json | 5 + 3 files changed, 96 insertions(+), 22 deletions(-) diff --git a/ui/App/views/Mods/components/LoadMods.jsx b/ui/App/views/Mods/components/LoadMods.jsx index eb6c0050..ae4f6bbf 100644 --- a/ui/App/views/Mods/components/LoadMods.jsx +++ b/ui/App/views/Mods/components/LoadMods.jsx @@ -8,7 +8,7 @@ import Button from "../../../components/Button"; import modsResource from "../../../../api/resources/mods"; import modResource from "../../../../api/resources/mods"; import FactorioLogin from "./AddMod/components/FactorioLogin"; -import ConfirmDialog from "../../../components/ConfirmDialog"; +import Modal from "../../../components/Modal"; import socket from "../../../../api/socket"; const LoadMods = ({refreshMods}) => { @@ -22,6 +22,9 @@ const LoadMods = ({refreshMods}) => { const [loadModsData, setLoadModsData] = useState(undefined); const [installProgress, setInstallProgress] = useState({current: 0, total: 0}); const [showProgress, setShowProgress] = useState(false); + const [modList, setModList] = useState([]); + const [selectedMods, setSelectedMods] = useState(new Set()); + const [showModList, setShowModList] = useState(false); useEffect(() => { (async () => { @@ -54,30 +57,65 @@ const LoadMods = ({refreshMods}) => { }; }, []); - const loadModsRequested = data => { + const loadModsRequested = async data => { setIsLoading(true); + const result = await savesResource.mods(data.save); + const mods = result?.mods || []; + + if (mods.length === 0) { + window.flash(t('noModsFound', { ns: 'mods' }), "green"); + setIsLoading(false); + return; + } + + setModList(mods.filter(m => m.name !== 'base')); + setSelectedMods(new Set(mods.filter(m => m.name !== 'base').map(m => m.name))); setLoadModsData(data); + setShowModList(true); + setIsLoading(false); + } + + const toggleMod = (name) => { + const next = new Set(selectedMods); + if (next.has(name)) { + next.delete(name); + } else { + next.add(name); + } + setSelectedMods(next); + } + + const selectAll = () => { + setSelectedMods(new Set(modList.map(m => m.name))); } - const loadMods = async data => { + const deselectAll = () => { + setSelectedMods(new Set()); + } + + const loadMods = async () => { + const data = loadModsData; + setShowModList(false); + setLoadModsData(undefined); + setIsLoading(true); + try { await modResource.deleteAll(); - const result = await savesResource.mods(data.save); - const mods = result?.mods || []; + const toInstall = modList.filter(m => selectedMods.has(m.name)); - if (mods.length === 0) { - window.flash(t('noModsFound', { ns: 'mods' }), "green"); + if (toInstall.length === 0) { + window.flash(t('noModsSelected', { ns: 'mods' }), "gray-light"); return; } - await modResource.portal.installMultiple(mods); + await modResource.portal.installMultiple(toInstall); refreshMods(); window.flash(t('modsLoaded').replace('{save}', data.save), "green"); } catch (e) { window.flash(t('errorOccurred', { ns: 'common' }), "red"); } finally { setIsLoading(false); - setLoadModsData(undefined); + setShowProgress(false); } } @@ -94,7 +132,7 @@ const LoadMods = ({refreshMods}) => { }))} />
    - + {showProgress && ( )} @@ -113,19 +151,45 @@ const LoadMods = ({refreshMods}) => {
    )} - { - setIsLoading(false); - setLoadModsData(undefined); - }} - onSuccess={() => { - const data = loadModsData; - loadMods(data); - }} - closeImmediately={true} + isOpen={showModList} + content={ +
    +

    {t('selectModsToInstall', { ns: 'mods' })}

    +
    + + +
    +
    + + + {modList.map(mod => ( + toggleMod(mod.name)}> + + + + + ))} + +
    + + {mod.name}{mod.version}
    +
    +
    + } + actions={ +
    + + +
    + } /> : diff --git a/ui/locales/en/mods.json b/ui/locales/en/mods.json index ab6c3761..a0c1e14b 100644 --- a/ui/locales/en/mods.json +++ b/ui/locales/en/mods.json @@ -31,6 +31,11 @@ "deleteExistingMods": "Delete all existing mods before loading? Existing mods will be permanently removed.", "noModsFound": "No mods found in this save.", "installingMods": "Installing mods", + "selectAll": "Select All", + "deselectAll": "Deselect All", + "selectModsToInstall": "Select mods to install from this save:", + "installSelected": "Install ({count})", + "noModsSelected": "No mods selected.", "compatibility": "Compatibility", "modVersion": "Mod Version", "factorioVersion": "Factorio Version" diff --git a/ui/locales/zh-CN/mods.json b/ui/locales/zh-CN/mods.json index e789f612..3f4404ce 100644 --- a/ui/locales/zh-CN/mods.json +++ b/ui/locales/zh-CN/mods.json @@ -31,6 +31,11 @@ "deleteExistingMods": "加载前删除所有现有模组?现有模组将被永久移除。", "noModsFound": "此存档中没有模组。", "installingMods": "安装模组中", + "selectAll": "全选", + "deselectAll": "取消全选", + "selectModsToInstall": "选择要从此存档安装的模组:", + "installSelected": "安装 ({count})", + "noModsSelected": "未选择任何模组。", "compatibility": "兼容性", "modVersion": "模组版本", "factorioVersion": "Factorio 版本" From c497c95e28b395aca843ce4a4b389735b252d2ce Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Sun, 21 Jun 2026 12:23:19 +0000 Subject: [PATCH 038/112] =?UTF-8?q?=E8=B0=83=E6=95=B4:=20=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E5=AE=BD=E5=BA=A6=E9=80=82=E9=85=8D=E6=89=8B=E6=9C=BA?= =?UTF-8?q?=E2=80=94=E2=80=94=E7=A7=BB=E5=8A=A8=E7=AB=AF=2011/12=20?= =?UTF-8?q?=E5=AE=BD=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/components/Modal.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/App/components/Modal.jsx b/ui/App/components/Modal.jsx index 021ed680..ae381248 100644 --- a/ui/App/components/Modal.jsx +++ b/ui/App/components/Modal.jsx @@ -11,7 +11,7 @@ const Modal = ({title, content, isOpen, actions = null}) => {
    From d1eb10a86221103685052e882d6ee0a404f8dfeb Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Sun, 21 Jun 2026 14:02:14 +0000 Subject: [PATCH 039/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E9=94=81=E5=A2=9E=E5=8A=A0=2030=20=E7=A7=92=E8=B6=85?= =?UTF-8?q?=E6=97=B6=E2=80=94=E2=80=94=E9=98=B2=E6=AD=A2=E6=AD=BB=E9=94=81?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E9=A1=B5=E9=9D=A2=E5=8D=A1=E6=AD=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/factorio/mod_modInfo.go | 1 - src/lockfile/lockfile.go | 25 ++++++++++++++----------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/factorio/mod_modInfo.go b/src/factorio/mod_modInfo.go index 6397aa0a..3e9225c4 100644 --- a/src/factorio/mod_modInfo.go +++ b/src/factorio/mod_modInfo.go @@ -144,7 +144,6 @@ func (modInfoList *ModInfoList) deleteMod(modName string) error { filePath := filepath.Join(modInfoList.Destination, mod.FileName) FileLock.LockW(filePath) - //delete mod err = os.Remove(filePath) FileLock.Unlock(filePath) if err != nil { diff --git a/src/lockfile/lockfile.go b/src/lockfile/lockfile.go index 6e7b653d..7162b076 100644 --- a/src/lockfile/lockfile.go +++ b/src/lockfile/lockfile.go @@ -107,33 +107,36 @@ func (fl *FileLock) RUnlock(filePath string) error { return nil } -func (fl *FileLock) LockW(filePath string) { +func (fl *FileLock) LockW(filePath string) error { + deadline := time.Now().Add(30 * time.Second) for { err := fl.Lock(filePath) if err == ErrorAlreadyLocked { + if time.Now().After(deadline) { + log.Printf("file lock timeout for %s, giving up", filePath) + return errors.New("file lock timeout") + } time.Sleep(time.Second * 2) - log.Println("file locked wait two seconds to access write-lock") } - if err == nil { - break + return nil } } - return } -func (fl *FileLock) RLockW(filePath string) { +func (fl *FileLock) RLockW(filePath string) error { + deadline := time.Now().Add(30 * time.Second) for { err := fl.RLock(filePath) - if err == ErrorAlreadyLocked { + if time.Now().After(deadline) { + log.Printf("file read-lock timeout for %s, skipping", filePath) + return errors.New("file read-lock timeout") + } time.Sleep(time.Second * 2) - log.Println("file locked ... wait two seconds to try to access read-lock") } - if err == nil { - break + return nil } } - return } From c21d203c4c706f0b867bc299a251d40d170b3992 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Sun, 21 Jun 2026 14:48:41 +0000 Subject: [PATCH 040/112] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E9=9D=A2=E6=9D=BF=20IP/=E7=AB=AF=E5=8F=A3=E8=AE=B0?= =?UTF-8?q?=E4=BD=8F=E4=B8=8A=E6=AC=A1=E8=BE=93=E5=85=A5=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Controls.jsx | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/ui/App/views/Controls.jsx b/ui/App/views/Controls.jsx index 44a46af2..38eba33e 100644 --- a/ui/App/views/Controls.jsx +++ b/ui/App/views/Controls.jsx @@ -12,6 +12,8 @@ import Error from "../components/Error"; const Controls = ({serverStatus}) => { const { t } = useTranslation('controls'); + const savedIp = localStorage.getItem('fsm_ip') || '0.0.0.0'; + const savedPort = localStorage.getItem('fsm_port') || '34197'; const factorioVersion = serverStatus.fac_version ? serverStatus.fac_version : t('UNKNOWN'); const [saves, setSaves] = useState([]); const [isDisabled, setIsDisabled] = useState(true); @@ -23,6 +25,8 @@ const Controls = ({serverStatus}) => { const startServer = async (data) => { setIsStarting(true); + localStorage.setItem('fsm_ip', data.ip); + localStorage.setItem('fsm_port', data.port); await server.start(data.ip, parseInt(data.port), data.save); } @@ -83,23 +87,23 @@ const Controls = ({serverStatus}) => {
    {t('ip')}
    - +
    {t('port')}
    - +
    From 443710d77d5d3c15f46da69e0efb492e8e1043e2 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Sun, 21 Jun 2026 15:10:06 +0000 Subject: [PATCH 041/112] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E6=A8=A1=E7=BB=84=E6=94=AF=E6=8C=81=E5=A4=9A=E9=80=89?= =?UTF-8?q?=E6=96=87=E4=BB=B6=20+=20=E8=BF=9B=E5=BA=A6=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Mods/components/UploadMod.jsx | 56 +++++++++++++++++----- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/ui/App/views/Mods/components/UploadMod.jsx b/ui/App/views/Mods/components/UploadMod.jsx index c31661db..d942144f 100644 --- a/ui/App/views/Mods/components/UploadMod.jsx +++ b/ui/App/views/Mods/components/UploadMod.jsx @@ -8,20 +8,31 @@ import modsResource from "../../../../api/resources/mods"; const UploadMod = ({refetchInstalledMods}) => { const { t } = useTranslation('mods'); - const defaultFileName = t('selectModFile') - const [fileName, setFileName] = useState(defaultFileName); + const defaultFileText = t('selectModFile') + const [fileName, setFileName] = useState(defaultFileText); + const [uploadProgress, setUploadProgress] = useState({current: 0, total: 0}); const {register, handleSubmit} = useForm(); const [isUploading, setIsUploading] = useState(false); - const onSubmit = (data, e) => { - setIsUploading(true) - modsResource.upload(data.mod_file[0]) - .then(refetchInstalledMods) - .finally(() => { - e.target.reset() - setFileName(defaultFileName) - setIsUploading(false); - }) + const onSubmit = async (data, e) => { + const files = data.mod_file; + if (!files || files.length === 0) return; + + setIsUploading(true); + setUploadProgress({current: 0, total: files.length}); + + for (let i = 0; i < files.length; i++) { + try { + await modsResource.upload(files[i]); + } catch (err) {} + setUploadProgress({current: i + 1, total: files.length}); + } + + refetchInstalledMods(); + e.target.reset(); + setFileName(defaultFileText); + setUploadProgress({current: 0, total: 0}); + setIsUploading(false); } return ( @@ -31,14 +42,33 @@ const UploadMod = ({refetchInstalledMods}) => { setFileName(e.currentTarget.files[0].name)} + onChange={e => { + const count = e.currentTarget.files.length; + setFileName(count > 1 ? count + ' files selected' : e.currentTarget.files[0]?.name || defaultFileText); + }} id="mod_file" type="file" + multiple accept="application/zip,.zip,.dat,.json" />
    {fileName}
    - + {uploadProgress.total > 1 && ( +
    +
    +
    +
    +

    {uploadProgress.current}/{uploadProgress.total}

    +
    + )} + ) } From 93a92eac890765f027612138df386e74d1886ac9 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 21 Jun 2026 16:01:35 +0000 Subject: [PATCH 042/112] feat: mod sync from save via level.dat0, i18n, DLC grouping, mod list improvements - Parse mods with versions from level.dat0 (zlib compressed, offset 0x2c) - Fix Factorio 2.0 save header parsing (+6 bytes after AllowedCommands) - New SyncModsFromSave with selective download (checkboxes) - New GetModsFromSave endpoint for reading mods without downloading - Fix RemoveAll on Docker volume (clearModsDir) - Add i18n support (EN/RU) via i18next - Group DLC mods (elevated-rails, quality, space-age) in UI - Show DLC mods from mod-list.json in installed mods list - Docker build cache for go mod download and npm install - WebSocket room mods_sync for download progress - Block server start while mods syncing --- decompiled_script.lua | 0 docker/Dockerfile-build | 22 +- package.json | 8 +- src/api/handlers.go | 79 ++++ src/api/mods_handler.go | 74 +++- src/api/routes.go | 30 ++ src/factorio/mod_Mods.go | 23 ++ src/factorio/mod_modpack.go | 18 +- src/factorio/mod_sync.go | 435 ++++++++++++++++++++++ src/factorio/mods.go | 36 +- src/factorio/save.go | 12 + src/factorio/server.go | 10 + ui/App/App.jsx | 1 + ui/App/components/ChangeLangDialog.jsx | 35 ++ ui/App/components/Layout.jsx | 45 ++- ui/App/components/Select.jsx | 6 +- ui/App/i18n.js | 39 ++ ui/App/locales/en.json | 207 ++++++++++ ui/App/locales/ru.json | 206 ++++++++++ ui/App/views/Controls.jsx | 64 ++++ ui/App/views/Mods/components/LoadMods.jsx | 319 +++++++++++++--- ui/App/views/Mods/components/ModList.jsx | 74 +++- ui/api/resources/mods.js | 8 + ui/api/resources/server.js | 12 + ui/api/socket.js | 12 + 25 files changed, 1646 insertions(+), 129 deletions(-) create mode 100644 decompiled_script.lua create mode 100644 src/factorio/mod_sync.go create mode 100644 ui/App/components/ChangeLangDialog.jsx create mode 100644 ui/App/i18n.js create mode 100644 ui/App/locales/en.json create mode 100644 ui/App/locales/ru.json diff --git a/decompiled_script.lua b/decompiled_script.lua new file mode 100644 index 00000000..e69de29b diff --git a/docker/Dockerfile-build b/docker/Dockerfile-build index be131bec..3c888667 100644 --- a/docker/Dockerfile-build +++ b/docker/Dockerfile-build @@ -1,23 +1,29 @@ FROM alpine:latest as build - RUN apk add --no-cache git make musl-dev go nodejs npm zip mingw-w64-gcc - ENV GOROOT /usr/lib/go ENV GOPATH /go ENV PATH /go/bin:$PATH ENV FACTORIO_ROOT /go/src/factorio-server-manager -COPY docker/build-release.sh /usr/local/bin/build-release.sh -COPY ./ $FACTORIO_ROOT - RUN mkdir -p ${GOPATH}/bin -RUN chmod u+x /usr/local/bin/build-release.sh - WORKDIR $FACTORIO_ROOT +# Кешируем Go зависимости отдельно +COPY src/go.mod src/go.sum ./src/ +RUN cd src && go mod download + +# Кешируем npm зависимости отдельно +COPY package.json package-lock.json ./ +RUN npm install + +# Копируем остальное +COPY ./ ./ + +COPY docker/build-release.sh /usr/local/bin/build-release.sh +RUN chmod u+x /usr/local/bin/build-release.sh + VOLUME /build VOLUME $FACTORIO_ROOT - RUN ["/usr/local/bin/build-release.sh"] FROM scratch as output diff --git a/package.json b/package.json index c0abfe46..2c6da62a 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,10 @@ "react-hook-form": "^7.47.0", "regenerator-runtime": "^0.14.0", "semver": "^7.3.7", - "tailwindcss": "^3.3.5" + "tailwindcss": "^3.3.5", + "i18next": "^25.5.2", + "react-i18next": "^15.7.3", + "i18next-http-backend": "^3.0.2", + "i18next-browser-languagedetector": "^8.2.0" } -} +} \ No newline at end of file diff --git a/src/api/handlers.go b/src/api/handlers.go index 053430c7..5ad23cce 100644 --- a/src/api/handlers.go +++ b/src/api/handlers.go @@ -9,6 +9,7 @@ import ( "log" "net/http" "os" + "os/exec" "path/filepath" "strconv" "sync" @@ -796,3 +797,81 @@ func UpdateServerSettings(w http.ResponseWriter, r *http.Request) { resp = fmt.Sprintf("Settings successfully saved") } + + +// AvailableVersions fetches available Factorio versions from factorio.com API +func AvailableVersions(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json;charset=UTF-8") + resp, err := http.Get("https://factorio.com/api/latest-releases") + if err != nil { + http.Error(w, "Failed to fetch versions", http.StatusInternalServerError) + return + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + w.Write(body) +} + +// InstallFactorio downloads and installs a specific Factorio version +func InstallFactorio(w http.ResponseWriter, r *http.Request) { + var resp interface{} + defer func() { WriteResponse(w, resp) }() + + var data struct { + Version string `json:"version"` + } + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &data) + + if data.Version == "" { + data.Version = "stable" + } + + config := bootstrap.GetConfig() + url := fmt.Sprintf("https://www.factorio.com/get-download/%s/headless/linux64", data.Version) + + log.Printf("Downloading Factorio %s from %s", data.Version, url) + + out, err := os.Create("/tmp/factorio_install.tar.xz") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + resp = fmt.Sprintf("Error creating temp file: %s", err) + return + } + defer out.Close() + + dlResp, err := http.Get(url) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + resp = fmt.Sprintf("Error downloading Factorio: %s", err) + return + } + defer dlResp.Body.Close() + io.Copy(out, dlResp.Body) + + cmd := exec.Command("tar", "-xf", "/tmp/factorio_install.tar.xz", "-C", filepath.Dir(config.FactorioDir)) + if err := cmd.Run(); err != nil { + w.WriteHeader(http.StatusInternalServerError) + resp = fmt.Sprintf("Error extracting Factorio: %s", err) + return + } + + os.Remove("/tmp/factorio_install.tar.xz") + resp = fmt.Sprintf("Factorio %s installed successfully", data.Version) + log.Println(resp) +} + +// RemoveFactorio removes the Factorio installation +func RemoveFactorio(w http.ResponseWriter, r *http.Request) { + var resp interface{} + defer func() { WriteResponse(w, resp) }() + + config := bootstrap.GetConfig() + if err := os.RemoveAll(config.FactorioDir); err != nil { + w.WriteHeader(http.StatusInternalServerError) + resp = fmt.Sprintf("Error removing Factorio: %s", err) + return + } + resp = "Factorio installation removed successfully" + log.Println(resp) +} diff --git a/src/api/mods_handler.go b/src/api/mods_handler.go index dcc1508b..3d09dcaf 100644 --- a/src/api/mods_handler.go +++ b/src/api/mods_handler.go @@ -332,7 +332,6 @@ func LoadModsFromSaveHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json;charset=UTF-8") - //Get Data out of the request var saveFileStruct struct { Name string `json:"saveFile"` } @@ -365,3 +364,76 @@ func LoadModsFromSaveHandler(w http.ResponseWriter, r *http.Request) { resp = header } + +// SyncModsFromSaveHandler запускает синк модов из сейва в горутине. +// Прогресс идёт через WebSocket room "mods_sync". +// Сервер не стартует пока идёт синк. +func SyncModsFromSaveHandler(w http.ResponseWriter, r *http.Request) { + var resp interface{} + defer func() { + WriteResponse(w, resp) + }() + w.Header().Set("Content-Type", "application/json;charset=UTF-8") + + var syncRequest struct { + Name string `json:"saveFile"` + ModNames []string `json:"modNames"` + } + + var err error + resp, err = ReadFromRequestBody(w, r, &syncRequest) + if err != nil { + return + } + + if factorio.IsModsSyncing() { + w.WriteHeader(http.StatusConflict) + resp = "mod sync already in progress" + return + } + + config := bootstrap.GetConfig() + savePath := filepath.Join(config.FactorioSavesDir, syncRequest.Name) + + if _, err := os.Stat(savePath); os.IsNotExist(err) { + w.WriteHeader(http.StatusBadRequest) + resp = fmt.Sprintf("save file not found: %s", syncRequest.Name) + return + } + + go factorio.SyncModsFromSave(savePath, syncRequest.ModNames) + resp = map[string]string{"status": "started"} +} + +func GetModsFromSaveHandler(w http.ResponseWriter, r *http.Request) { + var resp interface{} + defer func() { WriteResponse(w, resp) }() + w.Header().Set("Content-Type", "application/json;charset=UTF-8") + + var saveFileStruct struct { + Name string `json:"saveFile"` + } + var err error + resp, err = ReadFromRequestBody(w, r, &saveFileStruct) + if err != nil { + return + } + + config := bootstrap.GetConfig() + savePath := filepath.Join(config.FactorioSavesDir, saveFileStruct.Name) + + if _, err := os.Stat(savePath); os.IsNotExist(err) { + w.WriteHeader(http.StatusBadRequest) + resp = fmt.Sprintf("save file not found: %s", saveFileStruct.Name) + return + } + + mods, err := factorio.GetModsFromSave(savePath) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + resp = fmt.Sprintf("error reading mods from save: %s", err) + return + } + + resp = mods +} diff --git a/src/api/routes.go b/src/api/routes.go index 65650fee..e9b3af65 100644 --- a/src/api/routes.go +++ b/src/api/routes.go @@ -171,6 +171,18 @@ var apiRoutes = Routes{ "/saves/mods", LoadModsFromSaveHandler, true, + }, { + "GetModsFromSave", + "POST", + "/saves/mods/list", + GetModsFromSaveHandler, + true, + }, { + "SyncModsFromSave", + "POST", + "/saves/mods/sync", + SyncModsFromSaveHandler, + true, }, { "LogTail", "GET", @@ -213,6 +225,24 @@ var apiRoutes = Routes{ "/server/facVersion", FactorioVersion, false, + }, { + "AvailableVersions", + "GET", + "/server/availableVersions", + AvailableVersions, + false, + }, { + "InstallFactorio", + "POST", + "/server/install", + InstallFactorio, + true, + }, { + "RemoveFactorio", + "DELETE", + "/server/install", + RemoveFactorio, + true, }, { "LogoutUser", "GET", diff --git a/src/factorio/mod_Mods.go b/src/factorio/mod_Mods.go index c9f32983..6370ba04 100644 --- a/src/factorio/mod_Mods.go +++ b/src/factorio/mod_Mods.go @@ -71,6 +71,29 @@ func (mods *Mods) ListInstalledMods() ModsResultList { result.ModsResult = append(result.ModsResult, modsResult) } + // Добавляем моды из mod-list.json которых нет как zip (DLC моды) + for _, simpleMod := range mods.ModSimpleList.Mods { + if simpleMod.Name == "base" { + continue + } + // Проверяем — есть ли уже в результатах + found := false + for _, r := range result.ModsResult { + if r.Name == simpleMod.Name { + found = true + break + } + } + if !found { + // DLC мод — только в mod-list.json, без zip + var modsResult ModsResult + modsResult.Name = simpleMod.Name + modsResult.Title = simpleMod.Name + modsResult.Enabled = simpleMod.Enabled + modsResult.Compatibility = true + result.ModsResult = append(result.ModsResult, modsResult) + } + } return result } diff --git a/src/factorio/mod_modpack.go b/src/factorio/mod_modpack.go index 2b10f5e2..3ed696b8 100644 --- a/src/factorio/mod_modpack.go +++ b/src/factorio/mod_modpack.go @@ -218,24 +218,10 @@ func (modPackMap *ModPackMap) DeleteModPack(modPackName string) error { func (modPack *ModPack) LoadModPack() error { var err error config := bootstrap.GetConfig() - //get filemode, so it can be restored - fileInfo, err := os.Stat(config.FactorioModsDir) - if err != nil { - log.Printf("error on trying to save folder infos: %s", err) - return err - } - folderMode := fileInfo.Mode() - //clean factorio mod directory - err = os.RemoveAll(config.FactorioModsDir) - if err != nil { - log.Printf("error on removing the factorio mods dir: %s", err) - return err - } - - err = os.Mkdir(config.FactorioModsDir, folderMode) + err = clearModsDir(config.FactorioModsDir) if err != nil { - log.Printf("error on recreating mod dir: %s", err) + log.Printf("error on clearing the factorio mods dir: %s", err) return err } diff --git a/src/factorio/mod_sync.go b/src/factorio/mod_sync.go new file mode 100644 index 00000000..c94339d8 --- /dev/null +++ b/src/factorio/mod_sync.go @@ -0,0 +1,435 @@ +package factorio + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log" + "bytes" + "compress/flate" + "compress/zlib" + "net/http" + "sync/atomic" + + "github.com/OpenFactorioServerManager/factorio-server-manager/api/websocket" + "github.com/OpenFactorioServerManager/factorio-server-manager/bootstrap" +) + +var modsSyncing atomic.Bool + +func IsModsSyncing() bool { + return modsSyncing.Load() +} + +type ModSyncResult struct { + Name string `json:"name"` + Version string `json:"version"` + Status string `json:"status"` // downloaded, already_installed, builtin, not_found +} + +type ModSyncProgress struct { + Type string `json:"type"` + Status string `json:"status"` + Current int `json:"current,omitempty"` + Total int `json:"total,omitempty"` + Mod string `json:"mod,omitempty"` + Message string `json:"message,omitempty"` + Warning string `json:"warning,omitempty"` + Mods []ModSyncResult `json:"mods,omitempty"` +} + +// baseModNames — моды которые есть в любом vanilla + DLC сейве +var baseModNames = map[string]bool{ + "base": true, + "elevated-rails": true, + "quality": true, + "space-age": true, +} + +// isVanillaSave — true если сейв создан без геймплейных модов +func isVanillaSave(mods []Mod) bool { + for _, m := range mods { + if !baseModNames[m.Name] { + return false + } + } + return true +} + +func sendSyncProgress(p ModSyncProgress) { + p.Type = "mods_sync" + data, _ := json.Marshal(p) + room := websocket.WebsocketHub.GetRoom("mods_sync") + room.Send(string(data)) +} + +type portalModRelease struct { + DownloadURL string `json:"download_url"` + FileName string `json:"file_name"` + Version string `json:"version"` +} + +type portalModInfo struct { + Releases []portalModRelease `json:"releases"` +} + +// normalizeVersion убирает четвёртый компонент если он 0 +// version48 читает только 3 части, v[3] всегда 0 +// info.json хранит версию как "1.2.3", поэтому приводим к одному формату +func normalizeVersion(v Version) string { + if v[3] == 0 { + return fmt.Sprintf("%d.%d.%d", v[0], v[1], v[2]) + } + return v.String() +} + +var ErrModNotOnPortal = fmt.Errorf("mod not available on portal (builtin or DLC)") + +// LevelDatMod хранит мод из level.dat0 +type LevelDatMod struct { + Name string + Version Version +} + +// readModsFromLevelDat читает полный список модов с версиями из level.dat0 +// Это работает для всех модов включая добавленные после создания сейва +func readModsFromLevelDat(savePath string) ([]LevelDatMod, error) { + f, err := OpenArchiveFile(savePath, "level.dat0") + if err != nil { + return nil, fmt.Errorf("cannot open level.dat0: %v", err) + } + defer f.Close() + + compressed, err := ioutil.ReadAll(f) + if err != nil { + return nil, fmt.Errorf("cannot read level.dat0: %v", err) + } + + // Распаковываем zlib + data, err := zlibDecompress(compressed) + if err != nil { + return nil, fmt.Errorf("cannot decompress level.dat0: %v", err) + } + + // Таблица модов начинается на смещении 0x2c + if len(data) < 0x2d { + return nil, fmt.Errorf("level.dat0 too small") + } + + pos := 0x2c + n := int(data[pos]); pos++ + + var mods []LevelDatMod + for i := 0; i < n; i++ { + if pos >= len(data) { + break + } + nameLen := int(data[pos]); pos++ + if pos+nameLen+7 > len(data) { + break + } + name := string(data[pos : pos+nameLen]); pos += nameLen + v0, v1, v2 := uint(data[pos]), uint(data[pos+1]), uint(data[pos+2]); pos += 3 + pos += 4 // CRC + mods = append(mods, LevelDatMod{ + Name: name, + Version: Version{v0, v1, v2, 0}, + }) + } + + return mods, nil +} + +// zlibDecompress распаковывает zlib данные +func zlibDecompress(data []byte) ([]byte, error) { + // Пробуем стандартный zlib + r, err := zlib.NewReader(bytes.NewReader(data)) + if err == nil { + defer r.Close() + return ioutil.ReadAll(r) + } + // Пробуем raw deflate + r2 := flate.NewReader(bytes.NewReader(data)) + defer r2.Close() + return ioutil.ReadAll(r2) +} + +func getModRelease(modName string, version string) (portalModRelease, error) { + url := fmt.Sprintf("https://mods.factorio.com/api/mods/%s", modName) + resp, err := http.Get(url) + if err != nil { + return portalModRelease{}, fmt.Errorf("portal request failed: %v", err) + } + defer resp.Body.Close() + + // 404 — мод не на портале (DLC или встроенный) + if resp.StatusCode == 404 { + return portalModRelease{}, ErrModNotOnPortal + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return portalModRelease{}, fmt.Errorf("reading portal response: %v", err) + } + + // category: internal — встроенный DLC мод, не качаем + var fullInfo struct { + Category string `json:"category"` + Releases []portalModRelease `json:"releases"` + } + if err := json.Unmarshal(body, &fullInfo); err == nil { + // no-category — DLC заглушка без релизов + if fullInfo.Category == "no-category" { + return portalModRelease{}, ErrModNotOnPortal + } + // internal без релизов — встроенный DLC (elevated-rails, space-age) + // internal с релизами — обычный мод (flib) + if fullInfo.Category == "internal" && len(fullInfo.Releases) == 0 { + return portalModRelease{}, ErrModNotOnPortal + } + } + + var info portalModInfo + if err := json.Unmarshal(body, &info); err != nil { + return portalModRelease{}, fmt.Errorf("parsing portal response: %v", err) + } + + for _, release := range info.Releases { + if release.Version == version { + return release, nil + } + } + + return portalModRelease{}, fmt.Errorf("version %s not found for mod %s on portal", version, modName) +} + +// SyncModsFromSave читает моды из сейва, сравнивает с установленными, +// качает только недостающие. Прогресс через WebSocket room "mods_sync". +// Пока идёт синк — IsModsSyncing() возвращает true, сервер не стартует. +func SyncModsFromSave(savePath string, modNames []string) { + if !modsSyncing.CompareAndSwap(false, true) { + log.Println("SyncModsFromSave: already syncing, skipping") + sendSyncProgress(ModSyncProgress{Status: "error", Message: "sync already in progress"}) + return + } + defer modsSyncing.Store(false) + + config := bootstrap.GetConfig() + + // 1. Читаем список модов из сейва + f, err := OpenArchiveFile(savePath, "level.dat", "level-init.dat") + if err != nil { + sendSyncProgress(ModSyncProgress{Status: "error", Message: fmt.Sprintf("cannot open save: %v", err)}) + return + } + defer f.Close() + + var header SaveHeader + if err := header.ReadFrom(f); err != nil { + sendSyncProgress(ModSyncProgress{Status: "error", Message: fmt.Sprintf("cannot read save header: %v", err)}) + return + } + + // 1.5. Читаем полный список модов из level.dat0 + levelMods, err := readModsFromLevelDat(savePath) + if err != nil { + log.Printf("SyncModsFromSave: cannot read level.dat0, falling back to header: %v", err) + } else { + header.Mods = nil + for _, lm := range levelMods { + header.Mods = append(header.Mods, Mod{Name: lm.Name, Version: lm.Version}) + } + log.Printf("SyncModsFromSave: loaded %d mods from level.dat0", len(header.Mods)) + } + + // 1.6. Проверяем тип сейва + var vanillaWarning string + if isVanillaSave(header.Mods) { + vanillaWarning = "Сейв создан без геймплейных модов. Моды могли быть добавлены позже — синхронизация может быть неполной." + log.Println("SyncModsFromSave: vanilla save detected, mods may have been added later") + } + + // 2. Читаем установленные моды + mods, err := NewMods(config.FactorioModsDir) + if err != nil { + sendSyncProgress(ModSyncProgress{Status: "error", Message: fmt.Sprintf("cannot read installed mods: %v", err)}) + return + } + + // 3. Map установленных: name -> version (из info.json, формат "1.2.3") + installed := make(map[string]string) + for _, m := range mods.ModInfoList.Mods { + installed[m.Name] = m.Version + } + + // Строим set модов для скачивания если передан список + filterMods := make(map[string]bool) + for _, name := range modNames { + filterMods[name] = true + } + + // 4. Обходим все моды из сейва — собираем результаты + var results []ModSyncResult + var toDownload []Mod + + for _, saveMod := range header.Mods { + if saveMod.Name == "base" { + continue + } + // Если передан список — качаем только выбранные + if len(filterMods) > 0 && !filterMods[saveMod.Name] { + continue + } + wantVersion := normalizeVersion(saveMod.Version) + if gotVersion, ok := installed[saveMod.Name]; ok && gotVersion == wantVersion { + log.Printf("SyncModsFromSave: %s %s already installed, skipping", saveMod.Name, wantVersion) + results = append(results, ModSyncResult{Name: saveMod.Name, Version: wantVersion, Status: "already_installed"}) + continue + } + toDownload = append(toDownload, saveMod) + } + + total := len(toDownload) + log.Printf("SyncModsFromSave: %d mods to download", total) + + if total == 0 { + sendSyncProgress(ModSyncProgress{Status: "done", Total: 0, Mods: results, Warning: vanillaWarning}) + return + } + + // 5. Качаем недостающие + for i, saveMod := range toDownload { + wantVersion := normalizeVersion(saveMod.Version) + + sendSyncProgress(ModSyncProgress{ + Status: "progress", + Current: i + 1, + Total: total, + Mod: saveMod.Name, + }) + + // Сначала проверяем локальный список DLC/базовых модов + if baseModNames[saveMod.Name] { + log.Printf("SyncModsFromSave: %s is builtin/DLC (local list), skipping", saveMod.Name) + results = append(results, ModSyncResult{Name: saveMod.Name, Version: wantVersion, Status: "builtin"}) + continue + } + + release, err := getModRelease(saveMod.Name, wantVersion) + if err == ErrModNotOnPortal { + log.Printf("SyncModsFromSave: %s is builtin/DLC, skipping", saveMod.Name) + results = append(results, ModSyncResult{Name: saveMod.Name, Version: wantVersion, Status: "builtin"}) + continue + } + if err != nil { + results = append(results, ModSyncResult{Name: saveMod.Name, Version: wantVersion, Status: "not_found"}) + log.Printf("SyncModsFromSave: %s not found on portal: %v", saveMod.Name, err) + continue + } + + mods, err = NewMods(config.FactorioModsDir) + if err != nil { + sendSyncProgress(ModSyncProgress{Status: "error", Message: fmt.Sprintf("cannot refresh mods: %v", err)}) + return + } + + if err = mods.DownloadMod(release.DownloadURL, release.FileName, saveMod.Name); err != nil { + results = append(results, ModSyncResult{Name: saveMod.Name, Version: wantVersion, Status: "not_found"}) + log.Printf("SyncModsFromSave: download failed for %s: %v", saveMod.Name, err) + continue + } + + results = append(results, ModSyncResult{Name: saveMod.Name, Version: wantVersion, Status: "downloaded"}) + log.Printf("SyncModsFromSave: downloaded %s %s (%d/%d)", saveMod.Name, wantVersion, i+1, total) + } + + // Обновляем mod-list.json — включаем нужные, выключаем лишние + finalMods, err := NewMods(config.FactorioModsDir) + if err == nil { + // Строим set модов из сейва + saveModSet := make(map[string]bool) + for _, saveMod := range header.Mods { + if saveMod.Name != "base" { + saveModSet[saveMod.Name] = true + } + } + // Включаем моды из сейва, выключаем остальные + for i, m := range finalMods.ModSimpleList.Mods { + if m.Name == "base" { + continue + } + if saveModSet[m.Name] { + finalMods.ModSimpleList.Mods[i].Enabled = true + } else { + finalMods.ModSimpleList.Mods[i].Enabled = false + } + } + if saveErr := finalMods.ModSimpleList.saveModInfoJson(); saveErr != nil { + log.Printf("SyncModsFromSave: error saving mod-list.json: %v", saveErr) + } + } + + sendSyncProgress(ModSyncProgress{Status: "done", Total: total, Mods: results, Warning: vanillaWarning}) +} + +// ModStatus — статус мода при сравнении сейва с установленными +type ModStatus struct { + Name string `json:"name"` + VersionRequired string `json:"version_required"` // версия в сейве + VersionInstalled string `json:"version_installed"` // версия установленная (если есть) + Status string `json:"status"` // missing, installed, wrong_version, builtin + PortalURL string `json:"portal_url"` +} + +// GetModsFromSave читает моды из сейва и сравнивает с установленными +func GetModsFromSave(savePath string) ([]ModStatus, error) { + config := bootstrap.GetConfig() + + // Читаем моды из level.dat0 + levelMods, err := readModsFromLevelDat(savePath) + if err != nil { + return nil, fmt.Errorf("cannot read mods from save: %v", err) + } + + // Читаем установленные моды + mods, err := NewMods(config.FactorioModsDir) + if err != nil { + return nil, fmt.Errorf("cannot read installed mods: %v", err) + } + + installed := make(map[string]string) + for _, m := range mods.ModInfoList.Mods { + installed[m.Name] = m.Version + } + + var result []ModStatus + for _, lm := range levelMods { + if lm.Name == "base" { + continue + } + + wantVersion := normalizeVersion(lm.Version) + status := ModStatus{ + Name: lm.Name, + VersionRequired: wantVersion, + PortalURL: "https://mods.factorio.com/mod/" + lm.Name, + } + + if baseModNames[lm.Name] { + status.Status = "builtin" + } else if gotVersion, ok := installed[lm.Name]; ok { + status.VersionInstalled = gotVersion + if gotVersion == wantVersion { + status.Status = "installed" + } else { + status.Status = "wrong_version" + } + } else { + status.Status = "missing" + } + + result = append(result, status) + } + + return result, nil +} diff --git a/src/factorio/mods.go b/src/factorio/mods.go index 9fc4083e..e7cb44bd 100644 --- a/src/factorio/mods.go +++ b/src/factorio/mods.go @@ -5,6 +5,7 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "io/ioutil" "log" "os" @@ -23,28 +24,12 @@ type LoginSuccessResponse struct { } func DeleteAllMods() error { - var err error config := bootstrap.GetConfig() - modsDirInfo, err := os.Stat(config.FactorioModsDir) + err := clearModsDir(config.FactorioModsDir) if err != nil { - log.Printf("error getting stats of FactorioModsDir: %s", err) + log.Printf("Error deleting all mods: %s", err) return err } - - modsDirPerm := modsDirInfo.Mode().Perm() - - err = os.RemoveAll(config.FactorioModsDir) - if err != nil { - log.Printf("removing FactorioModsDir failed: %s", err) - return err - } - - err = os.Mkdir(config.FactorioModsDir, modsDirPerm) - if err != nil { - log.Printf("error recreating modPackDir: %s", err) - return err - } - return nil } @@ -187,3 +172,18 @@ func ModStartUp() { } } } + +// clearModsDir удаляет содержимое директории, но не саму директорию. +// Используется вместо os.RemoveAll когда директория примонтирована как Docker volume. +func clearModsDir(dir string) error { + entries, err := ioutil.ReadDir(dir) + if err != nil { + return fmt.Errorf("clearModsDir ReadDir: %v", err) + } + for _, entry := range entries { + if err := os.RemoveAll(filepath.Join(dir, entry.Name())); err != nil { + return fmt.Errorf("clearModsDir remove %s: %v", entry.Name(), err) + } + } + return nil +} diff --git a/src/factorio/save.go b/src/factorio/save.go index 3d37b0f4..c48f024f 100644 --- a/src/factorio/save.go +++ b/src/factorio/save.go @@ -210,6 +210,18 @@ func (h *SaveHeader) ReadFrom(r io.Reader) (err error) { } } + // Factorio 2.0 добавил два байта и uint32 перед списком модов + if !h.FactorioVersion.Less(Version{2, 0, 0, 0}) { + _, err = r.Read(scratch[:2]) + if err != nil { + return fmt.Errorf("read 2.0 unknown bytes: %v", err) + } + _, err = r.Read(scratch[:4]) + if err != nil { + return fmt.Errorf("read 2.0 extra uint32: %v", err) + } + } + var n uint32 if atLeast016 { n, err = readOptimUint(r, Version(h.FactorioVersion), 32) diff --git a/src/factorio/server.go b/src/factorio/server.go index a8a80d97..6ca74dcf 100644 --- a/src/factorio/server.go +++ b/src/factorio/server.go @@ -14,6 +14,7 @@ import ( "strconv" "strings" "sync" + "time" "github.com/OpenFactorioServerManager/factorio-server-manager/api/websocket" "github.com/OpenFactorioServerManager/factorio-server-manager/bootstrap" @@ -300,6 +301,15 @@ func (server *Server) Run() error { go server.parseRunningCommand(server.StdOut) go server.parseRunningCommand(server.StdErr) + // Ждём завершения синка модов если он идёт + if IsModsSyncing() { + log.Println("Waiting for mod sync to complete before starting server...") + for IsModsSyncing() { + time.Sleep(1 * time.Second) + } + log.Println("Mod sync complete, starting server") + } + err = server.Cmd.Start() if err != nil { log.Printf("Factorio process failed to start: %s", err) diff --git a/ui/App/App.jsx b/ui/App/App.jsx index 9ed229f0..353698ea 100644 --- a/ui/App/App.jsx +++ b/ui/App/App.jsx @@ -16,6 +16,7 @@ import GameSettings from "./views/GameSettings"; import Console from "./views/Console"; import Help from "./views/Help"; import socket from "../api/socket"; +import "./i18n"; import {Flash} from "./components/Flash"; diff --git a/ui/App/components/ChangeLangDialog.jsx b/ui/App/components/ChangeLangDialog.jsx new file mode 100644 index 00000000..58ebae22 --- /dev/null +++ b/ui/App/components/ChangeLangDialog.jsx @@ -0,0 +1,35 @@ +import React, {useState} from 'react'; +import Modal from "./Modal"; +import Button from "./Button"; +import { useTranslation } from "react-i18next"; + +function ChangeLangDialog({isOpen, close, onSuccess}) { + + const { t, i18n } = useTranslation(); + + const changeLang = (langKey) => { + i18n.changeLanguage(langKey) + close() + } + + return ( + + + + {/* Other languages */} + + } + actions={ + <> + + } + isOpen={isOpen} + onSuccess={onSuccess} + /> + ); +} + +export default ChangeLangDialog; \ No newline at end of file diff --git a/ui/App/components/Layout.jsx b/ui/App/components/Layout.jsx index 2f47a676..d5086fea 100644 --- a/ui/App/components/Layout.jsx +++ b/ui/App/components/Layout.jsx @@ -4,21 +4,26 @@ import Button from "./Button"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {faBars} from "@fortawesome/free-solid-svg-icons"; import {Flash} from "./Flash"; +import ChangeLangDialog from "./ChangeLangDialog"; +import { useTranslation } from "react-i18next"; const Layout = ({handleLogout, serverStatus}) => { + const { t, i18n } = useTranslation(); + const [isNavCollapsed, setIsNavCollapsed] = useState(true); + const [isChangingLang, setIsChangingLang] = useState(false); const Status = ({info}) => { - let text = 'Unknown'; + let text = t("controls.unknown"); let color = 'gray-light'; if (info && info.running) { - text = 'Running'; + text = t("controls.running"); color = 'green'; } else if (info && !info.running) { - text = 'Stopped'; + text = t("controls.stopped"); color = 'red'; } @@ -48,7 +53,7 @@ const Layout = ({handleLogout, serverStatus}) => {
    - Factorio Server Manager + {t("main_title")}
    -

    Server Status

    +

    {t("server_status")}

    -

    Server Management

    +

    {t("server_management")}

    - Controls - Saves - Mods - Server Settings - Game Settings - Console - Logs + {t("controls.title")} + {t("saves.title")} + {t("mods.title")} + {t("server_settings.title")} + {t("game_settings.title")} + {t("console.title")} + {t("logs.title")}
    -

    FSM Administration

    +

    {t("FSM_administration")}

    - Users - Help + {t("users.title")} + + {t("help.title")} + setIsChangingLang(false)} + onSuccess={() => console.log("new lang apply")} + />
    - +
    diff --git a/ui/App/components/Select.jsx b/ui/App/components/Select.jsx index f3de0573..9d93aac8 100644 --- a/ui/App/components/Select.jsx +++ b/ui/App/components/Select.jsx @@ -15,9 +15,11 @@ const Select = ({register, options, className = "", defaultValue = "", disabled diff --git a/ui/App/i18n.js b/ui/App/i18n.js new file mode 100644 index 00000000..8f318336 --- /dev/null +++ b/ui/App/i18n.js @@ -0,0 +1,39 @@ +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; +import Backend from 'i18next-http-backend' +import LanguageDetector from "i18next-browser-languagedetector"; +import enTranslations from "./locales/en.json"; +import ruTranslations from "./locales/ru.json"; + +const resources = +{ + en: + { + translation: enTranslations, + }, + ru: + { + translation: ruTranslations, + } +}; + +i18n + .use(Backend) + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources, + fallbackLng: "en", // Default Language + // Detecting and caching of language cookies + detection: + { + order: ["localStorage", "cookie", "navigator"], + cache: ["localStorage", "cookie"] + }, + interpolation: + { + escapeValue: false // react already safes from xss + } + }); + +export default i18n; \ No newline at end of file diff --git a/ui/App/locales/en.json b/ui/App/locales/en.json new file mode 100644 index 00000000..9280a472 --- /dev/null +++ b/ui/App/locales/en.json @@ -0,0 +1,207 @@ +{ + "save": "Save", + "upload": "Upload", + "saved": "Settings saved.", + "create": "Create", + "cancel": "Cancel", + "confirm": "Confirm", + "name": "Name", + "username": "Username", + "password": "Password", + "logout": "Logout", + "install": "Install", + "actions": "Actions", + "role": "Role", + "email": "Email", + "change": "Change", + "load": "Load", + "server_status": "Server Status", + "server_management": "Server Management", + "FSM_administration": "FSM Administration", + "main_title": "Factorio Server Manager", + "server_settings": { + "title": "Server settings", + "admins": "Admins", + "name": "Name", + "description": "Description", + "tags": "Tags", + "_comment_max_players": "Maximum number of players allowed, admins can join even a full server. 0 means unlimited.", + "max_players": "Max players", + "_comment_visibility": "Public: Game will be published on the official Factorio matching server. Lan: Game will be broadcast on LAN", + "visibility______": { + "public_": "Public", + "lan_": "Lan" + }, + "visibility": "Visibility", + "_comment_credentials": "Your factorio.com login credentials. Required for games with visibility public", + "username": "Username", + "password": "Password", + "_comment_token": "Authentication token. May be used instead of 'password' above.", + "token": "Token", + "game_password": "Game password", + "_comment_require_user_verification": "When set to true, the server will only allow clients that have a valid Factorio.com account", + "require_user_verification": "Require user verification", + "_comment_max_upload_in_kilobytes_per_second": "Optional, default value is 0. 0 means unlimited.", + "max_upload_in_kilobytes_per_second": "Max upload in kilobytes per second", + "_comment_max_upload_slots": "Optional, default value is 5. 0 means unlimited.", + "max_upload_slots": "Max upload slots", + "_comment_minimum_latency_in_ticks": "Optional one tick is 16ms in default speed, default value is 0. 0 means no minimum.", + "minimum_latency_in_ticks": "Minimum latency in ticks", + "_comment_max_heartbeats_per_second": "Network tick rate. Maximum rate game updates packets are sent at before bundling them together. Minimum value is 6, maximum value is 240.", + "max_heartbeats_per_second": "Max heartbeats per second", + "_comment_ignore_player_limit_for_returning_players": "Players that played on this map already can join even when the max player limit was reached.", + "ignore_player_limit_for_returning_players": "Ignore player limit for returning players", + "_comment_allow_commands": "Possible values are: true, false and admins-only", + "allow_commands": "Allow commands", + "_comment_autosave_interval": "Autosave interval in minutes", + "autosave_interval": "Autosave interval", + "_comment_autosave_slots": "Server autosave slots, it is cycled through when the server autosaves.", + "autosave_slots": "Autosave slots", + "_comment_afk_autokick_interval": "How many minutes until someone is kicked when doing nothing, 0 for never.", + "afk_autokick_interval": "Afk autokick interval", + "_comment_auto_pause": "Whether should the server be paused when no players are present.", + "auto_pause": "Auto pause", + "_comment_auto_pause_when_players_connect": "Whether should the server be paused when someone is connecting to the server.", + "auto_pause_when_players_connect": "Auto pause when players connect", + "only_admins_can_pause_the_game": "Only admins can pause the game", + "_comment_autosave_only_on_server": "Whether autosaves should be saved only on server or also on all connected clients. Default is true.", + "autosave_only_on_server": "Autosave only on server", + "_comment_non_blocking_saving": "Highly experimental feature, enable only at your own risk of losing your saves. On UNIX systems, server will fork itself to create an autosave. Autosaving on connected Windows clients will be disabled regardless of autosave_only_on_server option.", + "non_blocking_saving": "Non blocking saving", + "_comment_segment_sizes": "Long network messages are split into segments that are sent over multiple ticks. Their size depends on the number of peers currently connected. Increasing the segment size will increase upload bandwidth requirement for the server and download bandwidth requirement for clients. This setting only affects server outbound messages. Changing these settings can have a negative impact on connection stability for some clients.", + "minimum_segment_size": "Minimum segment size", + "minimum_segment_size_peer_count": "Minimum segment size peer count", + "maximum_segment_size": "Maximum segment size", + "maximum_segment_size_peer_count": "Maximum segment size peer count" + }, + "saves": { + "create_save": "Create Save", + "upload_save": "Upload Save", + "title": "Saves", + "last_modified": "Last Modified At", + "size": "Size", + "actions": "Actions", + "create_new_save_only_when_server_not_running": "Create a new Save is only possible if the Factorio server is not running.", + "save_form": { + "file_name": "Savefile Name", + "save_file_error_message": "Savefile Name is required", + "create_save": "Create Save" + }, + "upload_form": { + "select_file": "Select File ...", + "file_name": "Savefile", + "save_file_error_message": "Savefile is required", + "upload": "Upload" + } + }, + "controls": { + "title": "Server Status", + "status": "Status", + "unknown": "Unknown", + "running": "Running", + "stopped": "Stopped", + "port": "Port", + "IP_error_message": "IP is required and must be valid.", + "port_error_message": "Port is required within range 1-65535", + "save_error_message": "Save is required and must be valid.", + "f_version": "Factorio version", + "save": "Save", + "save&stop": "Save & Stop Server", + "kill_server": "Kill Server", + "start_server": "Start Server" + }, + "mods": { + "change_mods_while_running_error_message": "Changing mods is disabled while the server is running!", + "install_mod": "Install Mod", + "upload_mod": "Upload Mod", + "load_mods": "Load Mods from Save", + "title": "Mods", + "delete_all": "Delete all Mods", + "update_all": "Update all Mods", + "download_all": "Download all Mods", + "mod_packs": "Mod packs", + "select_file": "Select File ...", + "mods_loaded_from_save": "Mods are loaded from save file @@@.", + "load_mods_from_save": "Load Mods from Save", + "load_confirm_dialog": "Loading the Mods from Save @@@ will remove all currently installed Mods.", + "add_modpack_with_current_mods": "Add ModPack with current installed Mods", + "add_mod": { + "loading_mod_list": "Loading List of Mods from", + "version": "Version", + "compatibility": "Compatibility" + }, + "mod_list": { + "enabled": "Enabled", + "compatibility": "Compatibility", + "mod_version": "Mod Version", + "factorio_version": "Factorio Version" + }, + "mod_pack": { + "load_modpack": "Load ModPack", + "load_confirm_dialog": "Loading the ModPack @@@ will remove all installed Mods." + }, + "sync_from_save": "Sync Mods from Save", + "sync_started": "Sync started...", + "sync_downloading": "Downloading", + "sync_done": "Sync complete", + "sync_error": "Sync error", + "sync_warning_vanilla": "Save was created without gameplay mods. Mods may have been added later — sync may be incomplete.", + "status_downloaded": "Downloaded", + "status_already_installed": "Already installed", + "status_builtin": "Built-in / DLC", + "status_not_found": "Not found on portal", + "status_downloading": "Downloading..." + }, + "login": { + "login_failed_message": "Login failed. Username or Password wrong.", + "title": "Login", + "login": "Login", + "username_error_message": "Username is required", + "password_error_message": "Password is required", + "sign_in": "Sign In", + "factorio_login_error_message": "Given username or email and password do not match any account." + }, + "console": { + "title": "Console", + "error": "The console is not available, because Factorio is not running." + }, + "logs": { + "title": "Logs" + }, + "help": { + "title": "Help", + "fsm": "Factorio Server Manager", + "fsm_content": "The Factorio Server Manager (FSM) is an open source project and is not affiliated to the game Factorio or Wube Software.", + "bugs_help": "Bugs and Help", + "bugs_help_content": "Please use the <0>GitHub repository to report bugs or seek for help.", + "helpful_resources": "Helpful Resources", + "factorio_link_text": "Official Factorio Wiki about Multiplayer" + }, + "users": { + "title": "Users", + "user_list": "List of Users", + "change_password": { + "title": "Change Password", + "update_successful": "Password changed", + "old_password": "Old Password", + "old_password_error": "Old Password is required", + "new_password": "New Password", + "new_password_error": "New Password is required", + "new_password_confirmation": "New Password Confirmation", + "new_password_confirmation_error": "New Password Confirmation is required" + }, + "create_user": { + "title": "Create User", + "username_error": "Username is required", + "role_error": "Role is required", + "email_error": "Email is required", + "password_error": "Password is required", + "password_confirmation": "Password Confirmation", + "password_confirmation_error": "Password Confirmation is required and must match the Password" + } + }, + "game_settings": { + "title": "Game Settings" + }, + "lang": "Language" +} \ No newline at end of file diff --git a/ui/App/locales/ru.json b/ui/App/locales/ru.json new file mode 100644 index 00000000..c25555a7 --- /dev/null +++ b/ui/App/locales/ru.json @@ -0,0 +1,206 @@ +{ + "save": "Сохранить", + "upload": "Загрузить", + "saved": "Настройки сохранены.", + "create": "Создать", + "cancel": "Отмена", + "confirm": "Подтвердить", + "name": "Имя", + "username": "Имя пользователя", + "password": "Пароль", + "logout": "Выйти", + "install": "Установить", + "actions": "Действия", + "role": "Роль", + "email": "Email", + "change": "Изменить", + "load": "Загрузить", + "server_status": "Состояние сервера", + "server_management": "Управление сервером", + "FSM_administration": "Администрирование FSM", + "main_title": "Factorio Server Manager", + "server_settings": { + "title": "Настройки сервера", + "admins": "Админы", + "name": "Название", + "description": "Описание", + "tags": "Теги", + "_comment_max_players": "Максимальное количество игроков. Администраторы могут присоединиться даже к полному серверу. 0 означает без ограничений.", + "max_players": "Максимум игроков", + "_comment_visibility": "Public: Игра будет опубликована на официальном сервере подбора Factorio. Lan: Игра будет транслироваться в локальной сети", + "visibility_____": { + "public_": "Публичный", + "lan_": "Локальная сеть" + }, + "visibility": "Видимость", + "_comment_credentials": "Ваши учётные данные для входа на factorio.com. Требуется для игр с публичной видимостью", + "username": "Имя пользователя", + "password": "Пароль", + "_comment_token": "Токен аутентификации. Может использоваться вместо 'пароля' выше.", + "token": "Токен", + "game_password": "Пароль игры", + "_comment_require_user_verification": "Если установлено значение true, сервер будет разрешать подключение только клиентам с действующей учётной записью Factorio.com", + "require_user_verification": "Требуется проверка пользователя", + "_comment_max_upload_in_kilobytes_per_second": "Необязательно, значение по умолчанию — 0. 0 означает без ограничений.", + "max_upload_in_kilobytes_per_second": "Максимальная загрузка в килобайтах в секунду", + "_comment_max_upload_slots": "Необязательно, значение по умолчанию — 5. 0 означает без ограничений.", + "max_upload_slots": "Максимальное количество слотов загрузки", + "_comment_minimum_latency_in_ticks": "Необязательно, один тик — это 16 мс при стандартной скорости, значение по умолчанию — 0. 0 означает отсутствие минимума.", + "minimum_latency_in_ticks": "Минимальная задержка в тиках", + "_comment_max_heartbeats_per_second": "Сетевая частота тиков. Максимальная частота, с которой пакеты обновлений игры отправляются перед их объединением. Минимальное значение — 6, максимальное — 240.", + "max_heartbeats_per_second": "Максимум сердечных сокращений в секунду", + "_comment_ignore_player_limit_for_returning_players": "Игроки, которые уже играли на этой карте, могут присоединиться даже при достижении лимита игроков.", + "ignore_player_limit_for_returning_players": "Игнорировать лимит игроков для возвращающихся игроков", + "_comment_allow_commands": "Возможные значения: true, false и только-админы", + "allow_commands": "Разрешить команды", + "_comment_autosave_interval": "Интервал автосохранения в минутах", + "autosave_interval": "Интервал автосохранения", + "_comment_autosave_slots": "Слоты автосохранения сервера, циклически используемые при автосохранении.", + "autosave_slots": "Слоты автосохранения", + "_comment_afk_autokick_interval": "Сколько минут до кика игрока за бездействие, 0 — никогда.", + "afk_autokick_interval": "Интервал автокика за AFK", + "_comment_auto_pause": "Следует ли ставить игру на паузу, когда нет игроков.", + "auto_pause": "Автопауза", + "_comment_auto_pause_when_players_connect": "Следует ли ставить игру на паузу, когда игрок подключается к серверу.", + "auto_pause_when_players_connect": "Автопауза при подключении игроков", + "only_admins_can_pause_the_game": "Только админы могут ставить игру на паузу", + "_comment_autosave_only_on_server": "Следует ли сохранять автосохранения только на сервере или также на всех подключённых клиентах. По умолчанию — true.", + "autosave_only_on_server": "Автосохранение только на сервере", + "_comment_non_blocking_saving": "Экспериментальная функция, включайте на свой страх и риск потери сохранений. На UNIX-системах сервер будет форкаться для создания автосохранения. Автосохранение на подключённых Windows-клиентах будет отключено независимо от опции autosave_only_on_server.", + "non_blocking_saving": "Неблокирующее сохранение", + "_comment_segment_sizes": "Длинные сетевые сообщения разбиваются на сегменты, которые отправляются за несколько тиков. Их размер зависит от количества подключённых пиров. Увеличение размера сегмента увеличит требования к пропускной способности загрузки для сервера и скачивания для клиентов. Эта настройка влияет только на исходящие сообщения сервера. Изменение этих настроек может негативно сказаться на стабильности соединения для некоторых клиентов.", + "minimum_segment_size": "Минимальный размер сегмента", + "minimum_segment_size_peer_count": "Количество пиров для минимального размера сегмента", + "maximum_segment_size": "Максимальный размер сегмента", + "maximum_segment_size_peer_count": "Количество пиров для максимального размера сегмента" + }, + "saves": { + "create_save": "Создать сохранение", + "upload_save": "Загрузить сохранение", + "title": "Сохранения", + "last_modified": "Последние изменение", + "size": "Вес", + "actions": "Действия", + "create_new_save_only_when_server_not_running": "Создать новый сохранённый файл возможно только если сервер Factorio не запущен.", + "save_form": { + "file_name": "Имя файла", + "save_file_error": "Имя файла обязательное поле", + "create_save": "Создать сохранение" + }, + "upload_form": { + "select_file": "Выберите сохранение ...", + "file_name": "Файл сохранения", + "save_file_error": "Файл сохранения обязателен", + "upload": "Загрузить" + } + }, + "controls": { + "title": "Статус сервера", + "status": "Статус", + "running": "Запущен", + "stopped": "Остановлен", + "port": "Порт", + "IP_error_message": "Требуется IP-адрес, который должен быть действительным.", + "port_error_message": "Требуется порт в диапазоне 1-65535", + "save_error_message": "Сохранение обязательно и должно быть действительным.", + "f_version": "Версия Factorio", + "save": "Сохранить", + "save&stop": "Сохранить и остановить сервер", + "kill_server": "Убить сервер", + "start_server": "Запустить сервер" + }, + "mods": { + "change_mods_while_running_error_message": "Изменение модов отключено, пока сервер работает!", + "install_mod": "Установить мод", + "upload_mod": "Загрузить мод", + "load_mods": "Загрузить моды из сохранения", + "title": "Моды", + "delete_all": "Удалить все моды", + "update_all": "Обновить все моды", + "download_all": "Скачать все моды", + "mod_packs": "Паки модов", + "select_file": "Выбрать файл...", + "mods_loaded_from_save": "Моды загружены из файла сохранения @@@.", + "load_mods_from_save": "Загрузить моды из сохранения", + "load_confirm_dialog": "Загрузка модов из сохранения @@@ удалит все текущие установленные моды.", + "add_modpack_with_current_mods": "Добавить пак модов с текущими установленными модами", + "add_mod": { + "loading_mod_list": "Загрузка списка модов из", + "version": "Версия", + "compatibility": "Совместимость" + }, + "mod_list": { + "enabled": "Включён", + "compatibility": "Совместимость", + "mod_version": "Версия мода", + "factorio_version": "Версия Factorio" + }, + "mod_pack": { + "load_modpack": "Загрузить пак модов", + "load_confirm_dialog": "Загрузка пака модов @@@ удалит все установленные моды." + }, + "sync_from_save": "Синхронизировать моды из сейва", + "sync_started": "Синхронизация запущена...", + "sync_downloading": "Скачивание", + "sync_done": "Синхронизация завершена", + "sync_error": "Ошибка синхронизации", + "sync_warning_vanilla": "Сейв создан без геймплейных модов. Моды могли быть добавлены позже — синхронизация может быть неполной.", + "status_downloaded": "Скачан", + "status_already_installed": "Уже установлен", + "status_builtin": "Встроенный / DLC", + "status_not_found": "Не найден на портале", + "status_downloading": "Скачивается..." + }, + "login": { + "login_failed_message": "Ошибка входа. Неверное имя пользователя или пароль.", + "title": "Вход", + "login": "Вход", + "username_error_message": "Требуется имя пользователя", + "password_error_message": "Требуется пароль", + "sing_in": "Войти", + "factorio_login_error_message": "Указанные имя пользователя, email или пароль не соответствуют ни одному аккаунту." + }, + "console": { + "title": "Консоль", + "error": "Консоль недоступна, так как Factorio не запущен." + }, + "logs": { + "title": "Логи" + }, + "help": { + "title": "Помощь", + "fsm": "Менеджер серверов Factorio", + "fsm_content": "Factorio Server Manager (FSM) — проект с открытым исходным кодом и не связан с игрой Factorio или компанией Wube Software.", + "bugs_help": "Сообщения об ошибках и помощь", + "bugs_help_content": "Пожалуйста, используйте <0>репозиторий GitHub для сообщения об ошибках или запроса помощи.", + "helpful_resources": "Полезные ресурсы", + "factorio_link_text": "Официальная вики Factorio о многопользовательской игре" + }, + "users": { + "title": "Пользователи", + "user_list": "Список пользователей", + "change_password": { + "title": "Изменить пароль", + "update_successful": "Пароль изменён", + "old_password": "Старый пароль", + "old_password_error": "Требуется старый пароль", + "new_password": "Новый пароль", + "new_password_error": "Требуется новый пароль", + "new_password_confirmation": "Подтверждение нового пароля", + "new_password_confirmation_error": "Требуется подтверждение нового пароля" + }, + "create_user": { + "title": "Создать пользователя", + "username_error": "Требуется имя пользователя", + "role_error": "Требуется роль", + "email_error": "Требуется email", + "password_error": "Требуется пароль", + "password_confirmation": "Подтверждение пароля", + "password_confirmation_error": "Требуется подтверждение пароля, и оно должно совпадать с паролем" + } + }, + "game_settings": { + "title": "Настройки игры" + }, + "lang": "Язык" +} \ No newline at end of file diff --git a/ui/App/views/Controls.jsx b/ui/App/views/Controls.jsx index 62b31d9e..f881b25c 100644 --- a/ui/App/views/Controls.jsx +++ b/ui/App/views/Controls.jsx @@ -11,6 +11,27 @@ import Error from "../components/Error"; const Controls = ({serverStatus}) => { const factorioVersion = serverStatus.fac_version ? serverStatus.fac_version : 'Unknown'; + const [availableVersions, setAvailableVersions] = useState({}); + const [isInstalling, setIsInstalling] = useState(false); + const [isRemoving, setIsRemoving] = useState(false); + const [selectedVersion, setSelectedVersion] = useState('stable'); + + useEffect(() => { + server.availableVersions() + .then(res => setAvailableVersions(res)); + }, []); + + const installVersion = async () => { + setIsInstalling(true); + await server.installVersion(selectedVersion); + setIsInstalling(false); + } + + const removeInstallation = async () => { + setIsRemoving(true); + await server.removeInstallation(); + setIsRemoving(false); + } const [saves, setSaves] = useState([]); const [isDisabled, setIsDisabled] = useState(true); const [isStopping, setIsStopping] = useState(false); @@ -135,6 +156,49 @@ const Controls = ({serverStatus}) => {
    } /> + +
    +
    Installed Version
    +
    {factorioVersion}
    +
    +
    +
    Available Versions
    + +
    +
    + } + actions={ +
    + + +
    + } + /> ) }; diff --git a/ui/App/views/Mods/components/LoadMods.jsx b/ui/App/views/Mods/components/LoadMods.jsx index 331c3527..86936e08 100644 --- a/ui/App/views/Mods/components/LoadMods.jsx +++ b/ui/App/views/Mods/components/LoadMods.jsx @@ -1,83 +1,302 @@ import React, {useEffect, useState} from "react"; import savesResource from "../../../../api/resources/saves"; -import Select from "../../../components/Select"; import Label from "../../../components/Label"; -import {useForm} from "react-hook-form"; import Button from "../../../components/Button"; import modsResource from "../../../../api/resources/mods"; -import modResource from "../../../../api/resources/mods"; import FactorioLogin from "./AddMod/components/FactorioLogin"; -import ConfirmDialog from "../../../components/ConfirmDialog"; +import socket from "../../../../api/socket"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {useTranslation} from "react-i18next"; +import {faSpinner, faCheck, faTimes, faMinusCircle, faExternalLinkAlt} from "@fortawesome/free-solid-svg-icons"; -const LoadMods = ({refreshMods}) => { +const DLC_MODS = new Set(['elevated-rails', 'quality', 'space-age']); + +const STATUS_ICON = { + downloading: , + downloaded: , + installed: , + wrong_version: , + missing: , + builtin: , + not_found: , +}; +const STATUS_TEXT = { + downloading: "Downloading...", + downloaded: "Downloaded", + installed: "Installed", + wrong_version: "Wrong version", + missing: "Missing", + builtin: "Built-in / DLC", + not_found: "Not found on portal", +}; + +const LoadMods = ({refreshMods}) => { + const {t} = useTranslation(); const [saves, setSaves] = useState([]); - const {register, reset, handleSubmit} = useForm(); + const [selectedSave, setSelectedSave] = useState(""); const [isLoading, setIsLoading] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); const [isDisabled, setIsDisabled] = useState(true); const [isFactorioAuthenticated, setIsFactorioAuthenticated] = useState(false); - const [loadModsData, setLoadModsData] = useState(undefined); + const [modRows, setModRows] = useState([]); + const [checkedMods, setCheckedMods] = useState({}); + const [syncError, setSyncError] = useState(null); + const [currentMod, setCurrentMod] = useState(null); + const [warning, setWarning] = useState(null); useEffect(() => { (async () => { - setIsFactorioAuthenticated(await modResource.portal.status()) - - const s = await savesResource.list() + setIsFactorioAuthenticated(await modsResource.portal.status()); + const s = await savesResource.list(); setSaves(s); if (s.length > 0) { setIsDisabled(false); + setSelectedSave(s[0].name); } - reset(); })(); }, []); - const loadModsRequested = data => { + useEffect(() => { + const handler = (message) => { + const data = JSON.parse(message); + if (data.status === "progress") { + setCurrentMod(data.mod); + setModRows(rows => rows.map(r => + r.name === data.mod ? {...r, status: "downloading"} : r + )); + } else if (data.status === "done") { + setIsSyncing(false); + setCurrentMod(null); + setWarning(data.warning || null); + if (data.mods) { + setModRows(data.mods); + setCheckedMods({}); + } + refreshMods(); + } else if (data.status === "error") { + setIsSyncing(false); + setCurrentMod(null); + setSyncError(data.message); + } + }; + + socket.on('mods_sync', handler); + socket.emit('mods sync subscribe'); + return () => { + socket.off('mods_sync', handler); + socket.emit('mods sync unsubscribe'); + }; + }, []); + + const onReadSave = async () => { + if (!selectedSave) return; setIsLoading(true); - setLoadModsData(data); - } + setModRows([]); + setCheckedMods({}); + setSyncError(null); + setWarning(null); - const loadMods = async data => { - await modResource.deleteAll(); - const {mods} = await savesResource.mods(data.save).catch(() => { + try { + const mods = await modsResource.getFromSave(selectedSave); + setModRows(mods || []); + // По умолчанию отмечаем missing и wrong_version + const checked = {}; + (mods || []).forEach(m => { + if (m.status === 'missing' || m.status === 'wrong_version') { + checked[m.name] = true; + } + }); + setCheckedMods(checked); + } catch(e) { + setSyncError("Failed to read save: " + e.message); + } finally { setIsLoading(false); - setLoadModsData(undefined); + } + }; + + const onSync = async () => { + const toSync = modRows + .filter(m => checkedMods[m.name]) + .map(m => m.name); + if (toSync.length === 0) return; + + setIsSyncing(true); + setSyncError(null); + try { + const selectedModNames = Object.keys(checkedMods).filter(k => checkedMods[k]); + await modsResource.syncFromSave(selectedSave, selectedModNames); + } catch(e) { + setIsSyncing(false); + setSyncError("Failed to start sync: " + e.message); + } + }; + + const toggleCheck = (name) => { + setCheckedMods(prev => ({...prev, [name]: !prev[name]})); + }; + + const selectAll = () => { + const checked = {}; + modRows.forEach(m => { + if (m.status !== 'builtin' && m.status !== 'installed') { + checked[m.name] = true; + } }); + setCheckedMods(checked); + }; - await modResource.portal.installMultiple(mods) - .then(() => { - refreshMods(); - window.flash(`Mods are loaded from save file ${data.save}.`, "green"); - }).finally(() => { - setIsLoading(false); - setLoadModsData(undefined); - }); + const clearAll = () => setCheckedMods({}); + + const checkedCount = Object.values(checkedMods).filter(Boolean).length; + + if (!isFactorioAuthenticated) { + return ; } - return isFactorioAuthenticated - ?
    + return ( +
    + {/* Выбор сейва */}
    + ); +}; export default LoadMods; diff --git a/ui/App/views/Mods/components/ModList.jsx b/ui/App/views/Mods/components/ModList.jsx index 1cb67270..98c52d62 100644 --- a/ui/App/views/Mods/components/ModList.jsx +++ b/ui/App/views/Mods/components/ModList.jsx @@ -1,24 +1,69 @@ import Mod from "./Mod"; import React from "react"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faCheck, faTimes, faToggleOff, faToggleOn} from "@fortawesome/free-solid-svg-icons"; +const DLC_MODS = new Set(['elevated-rails', 'quality', 'space-age']); const ModList = ({mods, factorioVersion, updateMod, toggleMod, deleteMod, addUpdatableMod = null, disabled = false}) => { + const dlcMods = mods.filter(m => DLC_MODS.has(m.name)); + const regularMods = mods.filter(m => !DLC_MODS.has(m.name)); + const dlcEnabled = dlcMods.some(m => m.enabled); + + const toggleDLC = () => { + dlcMods.forEach(m => { + // Если хотя бы один включён — выключаем все, иначе включаем все + if (dlcEnabled === m.enabled) { + toggleMod(m.name); + } + }); + }; + return ( - - - - - - - + + + + + + + - { - factorioVersion !== null && mods.map( + {/* DLC группа */} + {factorioVersion !== null && dlcMods.length > 0 && ( + + + + + + + + )} + {/* Остальные моды */} + {factorioVersion !== null && regularMods.map( (mod, i) => - ) - } + )}
    NameEnabledCompatibilityMod VersionFactorio Version -
    NameEnabledCompatibilityMod VersionFactorio Version +
    + Space Age DLC + + (elevated-rails, quality, space-age) + + + {disabled + ? dlcEnabled + ? + : + : dlcEnabled + ? + : + } + + + {dlcMods[0]?.version}{dlcMods[0]?.factorio_version} +
    - ) -} + ); +}; -export default ModList; \ No newline at end of file +export default ModList; diff --git a/ui/api/resources/mods.js b/ui/api/resources/mods.js index 8d35521d..f4ba818b 100644 --- a/ui/api/resources/mods.js +++ b/ui/api/resources/mods.js @@ -32,6 +32,14 @@ const mods = { const response = await client.post('/api/mods/delete/all'); return response.data; }, + getFromSave: async saveFile => { + const response = await client.post('/api/saves/mods/list', {saveFile}); + return response.data; + }, + syncFromSave: async (saveFile, modNames) => { + const response = await client.post('/api/saves/mods/sync', {saveFile, modNames}); + return response.data; + }, downloadAllURL: '/api/mods/download', portal: { login: async (username, token) => { diff --git a/ui/api/resources/server.js b/ui/api/resources/server.js index 38d8b548..ee8ac544 100644 --- a/ui/api/resources/server.js +++ b/ui/api/resources/server.js @@ -1,6 +1,10 @@ import client from "../client"; export default { + availableVersions: async () => { + const response = await client.get('/api/server/availableVersions'); + return response.data; + }, factorioVersion: async () => { const response = await client.get('/api/server/facVersion'); return response.data; @@ -21,6 +25,14 @@ export default { }); return response.data; }, + installVersion: async (version) => { + const response = await client.post('/api/server/install', {version}); + return response.data; + }, + removeInstallation: async () => { + const response = await client.delete('/api/server/install'); + return response.data; + }, kill: async () => { const response = await client.get('/api/server/kill'); return response.data; diff --git a/ui/api/socket.js b/ui/api/socket.js index 14b6ea2c..f9f711b2 100644 --- a/ui/api/socket.js +++ b/ui/api/socket.js @@ -63,11 +63,21 @@ function connect() { ); } + function modsSyncSubscribeEvent() { + socket.send(JSON.stringify({room_name: "", controls: {type: "subscribe", value: "mods_sync"}})); + } + + function modsSyncUnsubscribeEvent() { + socket.send(JSON.stringify({room_name: "", controls: {type: "unsubscribe", value: "mods_sync"}})); + } + function registerEventEmitter() { bus.on('log subscribe', logSubscribeEvent); bus.on('log unsubscribe', logUnsubscribeEvent); bus.on('server status subscribe', serverStatusSubscribeEvent); bus.on('command send', commandSendEvent); + bus.on('mods sync subscribe', modsSyncSubscribeEvent); + bus.on('mods sync unsubscribe', modsSyncUnsubscribeEvent); } function unregisterEventEmitter() { @@ -75,6 +85,8 @@ function connect() { bus.off('log unsubscribe', logUnsubscribeEvent); bus.off('server status subscribe', serverStatusSubscribeEvent); bus.off('command send', commandSendEvent); + bus.off('mods sync subscribe', modsSyncSubscribeEvent); + bus.off('mods sync unsubscribe', modsSyncUnsubscribeEvent); } socket.onmessage = e => { From d205fb83600451ffde7634828cd286326c8f9822 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Sun, 21 Jun 2026 16:01:38 +0000 Subject: [PATCH 043/112] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20=E6=A8=A1?= =?UTF-8?q?=E7=BB=84=E4=B8=8B=E8=BD=BD=E6=94=B9=E4=B8=BA=205=20=E7=BA=BF?= =?UTF-8?q?=E7=A8=8B=E5=B9=B6=E5=8F=91=20+=20=E8=BF=9B=E5=BA=A6=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/mod_portal_handler.go | 90 ++++++++++++++++------- ui/App/views/Mods/components/LoadMods.jsx | 37 +++++++++- 2 files changed, 98 insertions(+), 29 deletions(-) diff --git a/src/api/mod_portal_handler.go b/src/api/mod_portal_handler.go index f56d7430..cb38062d 100644 --- a/src/api/mod_portal_handler.go +++ b/src/api/mod_portal_handler.go @@ -188,38 +188,76 @@ func ModPortalInstallMultipleHandler(w http.ResponseWriter, r *http.Request) { wsRoom := websocket.WebsocketHub.GetRoom("mod_install") total := len(data) - current := 0 - for _, datum := range data { - // skip base mod because it is already included in factorio - if datum.Name == "base" { - current++ - continue - } - details, err, statusCode := factorio.ModPortalModDetails(datum.Name) - if err != nil || statusCode != http.StatusOK { - log.Printf("Error in getting mod details from mod portal: %s", err) - current++ - continue - } + type job struct { + index int + name string + ver factorio.Version + } + type result struct { + index int + name string + size int64 + err error + } + + jobs := make(chan job, total) + results := make(chan result, total) + + workers := 5 + if total < workers { + workers = total + } - var found = false - for _, release := range details.Releases { - if release.Version.Equals(datum.Version) { - found = true - err := modList.DownloadMod(release.DownloadURL, release.FileName, details.Name) - if err != nil { - log.Printf("Error downloading mod {%s}, error: %s", details.Name, err) - break + for w := 0; w < workers; w++ { + go func() { + for j := range jobs { + var r result + r.index = j.index + r.name = j.name + details, err, statusCode := factorio.ModPortalModDetails(j.name) + if err != nil || statusCode != http.StatusOK { + r.err = fmt.Errorf("portal lookup failed") + results <- r + continue + } + found := false + for _, release := range details.Releases { + if release.Version.Equals(j.ver) { + found = true + dl := modList.DownloadMod(release.DownloadURL, release.FileName, details.Name) + if dl != nil { + r.err = dl + } + break + } + } + if !found { + r.err = fmt.Errorf("version not found") } - break + results <- r } + }() + } + + for i, datum := range data { + if datum.Name != "base" { + jobs <- job{index: i, name: datum.Name, ver: datum.Version} + } else { + results <- result{index: i, name: "base"} } - if !found { - log.Printf("Error downloading mod {%s}, error: %s", details.Name, "version not found") + } + close(jobs) + + completed := 0 + wsRoom.Send(fmt.Sprintf("{\"type\":\"start\",\"total\":%d}", total)) + for completed < total { + r := <-results + completed++ + if r.err != nil { + log.Printf("Error downloading mod {%s}: %v", r.name, r.err) } - current++ - wsRoom.Send(fmt.Sprintf("{\"type\":\"progress\",\"current\":%d,\"total\":%d,\"name\":\"%s\"}", current, total, datum.Name)) + wsRoom.Send(fmt.Sprintf("{\"type\":\"progress\",\"current\":%d,\"total\":%d,\"name\":\"%s\",\"active\":%d}", completed, total, r.name, workers)) } wsRoom.Send(fmt.Sprintf("{\"type\":\"complete\",\"total\":%d}", total)) diff --git a/ui/App/views/Mods/components/LoadMods.jsx b/ui/App/views/Mods/components/LoadMods.jsx index ae4f6bbf..cc1b6ab9 100644 --- a/ui/App/views/Mods/components/LoadMods.jsx +++ b/ui/App/views/Mods/components/LoadMods.jsx @@ -22,11 +22,24 @@ const LoadMods = ({refreshMods}) => { const [loadModsData, setLoadModsData] = useState(undefined); const [installProgress, setInstallProgress] = useState({current: 0, total: 0}); const [showProgress, setShowProgress] = useState(false); + const [activeCount, setActiveCount] = useState(0); + const [completedNames, setCompletedNames] = useState([]); const [modList, setModList] = useState([]); const [selectedMods, setSelectedMods] = useState(new Set()); const [showModList, setShowModList] = useState(false); useEffect(() => { + const stored = localStorage.getItem('mod_install_pending'); + if (stored) { + try { + const data = JSON.parse(stored); + setInstallProgress({current: 0, total: data.total}); + setShowProgress(true); + const done = JSON.parse(localStorage.getItem('mod_install_done') || '[]'); + setCompletedNames(done); + } catch(e) {} + } + (async () => { setIsFactorioAuthenticated(await modResource.portal.status()) @@ -41,11 +54,26 @@ const LoadMods = ({refreshMods}) => { const handleProgress = (msg) => { try { const data = JSON.parse(typeof msg === 'string' ? msg : JSON.stringify(msg)); - if (data.type === 'progress') { + if (data.type === 'start') { + setInstallProgress({current: 0, total: data.total}); + setShowProgress(true); + setActiveCount(0); + setCompletedNames([]); + localStorage.setItem('mod_install_pending', JSON.stringify(data)); + } else if (data.type === 'progress') { setInstallProgress({current: data.current, total: data.total}); + setActiveCount(data.active || 0); + setCompletedNames(prev => { + const next = [...prev, data.name]; + localStorage.setItem('mod_install_done', JSON.stringify(next)); + return next; + }); setShowProgress(true); } else if (data.type === 'complete') { setShowProgress(false); + setActiveCount(0); + localStorage.removeItem('mod_install_pending'); + localStorage.removeItem('mod_install_done'); } } catch (e) {} }; @@ -140,15 +168,18 @@ const LoadMods = ({refreshMods}) => { {showProgress && (
    - {t('installingMods', { ns: 'mods' })} + {t('installingMods', { ns: 'mods' })} {activeCount > 0 ? `(${activeCount} concurrent)` : ''} {installProgress.current}/{installProgress.total}
    -
    +
    0 ? (installProgress.current / installProgress.total * 100) : 0}%`}} />
    +

    + {completedNames.slice(-5).join(', ')} +

    )} Date: Sun, 21 Jun 2026 16:11:06 +0000 Subject: [PATCH 044/112] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20=E5=A4=9A?= =?UTF-8?q?=E7=BA=BF=E7=A8=8B=E8=BF=9B=E5=BA=A6=E6=9D=A1=20+=20=E6=96=AD?= =?UTF-8?q?=E8=BF=9E=E6=81=A2=E5=A4=8D=E2=80=94=E2=80=94=E9=87=8D=E5=90=AF?= =?UTF-8?q?/=E5=88=B7=E6=96=B0=E5=90=8E=E8=87=AA=E5=8A=A8=E6=B8=85?= =?UTF-8?q?=E9=99=A4=E5=B7=B2=E5=AE=8C=E6=88=90=E8=BF=9B=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/mod_portal_handler.go | 10 ++++- ui/App/views/Mods/components/LoadMods.jsx | 50 +++++++++++++++++------ 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/src/api/mod_portal_handler.go b/src/api/mod_portal_handler.go index cb38062d..2a9e217e 100644 --- a/src/api/mod_portal_handler.go +++ b/src/api/mod_portal_handler.go @@ -210,14 +210,16 @@ func ModPortalInstallMultipleHandler(w http.ResponseWriter, r *http.Request) { } for w := 0; w < workers; w++ { - go func() { + go func(wid int) { for j := range jobs { var r result r.index = j.index r.name = j.name + wsRoom.Send(fmt.Sprintf("{\"type\":\"worker\",\"worker\":%d,\"name\":\"%s\",\"state\":\"start\"}", wid, j.name)) details, err, statusCode := factorio.ModPortalModDetails(j.name) if err != nil || statusCode != http.StatusOK { r.err = fmt.Errorf("portal lookup failed") + wsRoom.Send(fmt.Sprintf("{\"type\":\"worker\",\"worker\":%d,\"name\":\"%s\",\"state\":\"error\"}", wid, j.name)) results <- r continue } @@ -228,16 +230,20 @@ func ModPortalInstallMultipleHandler(w http.ResponseWriter, r *http.Request) { dl := modList.DownloadMod(release.DownloadURL, release.FileName, details.Name) if dl != nil { r.err = dl + wsRoom.Send(fmt.Sprintf("{\"type\":\"worker\",\"worker\":%d,\"name\":\"%s\",\"state\":\"error\"}", wid, j.name)) + } else { + wsRoom.Send(fmt.Sprintf("{\"type\":\"worker\",\"worker\":%d,\"name\":\"%s\",\"state\":\"done\"}", wid, j.name)) } break } } if !found { r.err = fmt.Errorf("version not found") + wsRoom.Send(fmt.Sprintf("{\"type\":\"worker\",\"worker\":%d,\"name\":\"%s\",\"state\":\"error\"}", wid, j.name)) } results <- r } - }() + }(w) } for i, datum := range data { diff --git a/ui/App/views/Mods/components/LoadMods.jsx b/ui/App/views/Mods/components/LoadMods.jsx index cc1b6ab9..8cc6ac39 100644 --- a/ui/App/views/Mods/components/LoadMods.jsx +++ b/ui/App/views/Mods/components/LoadMods.jsx @@ -24,6 +24,7 @@ const LoadMods = ({refreshMods}) => { const [showProgress, setShowProgress] = useState(false); const [activeCount, setActiveCount] = useState(0); const [completedNames, setCompletedNames] = useState([]); + const [workerStates, setWorkerStates] = useState({}); const [modList, setModList] = useState([]); const [selectedMods, setSelectedMods] = useState(new Set()); const [showModList, setShowModList] = useState(false); @@ -32,11 +33,14 @@ const LoadMods = ({refreshMods}) => { const stored = localStorage.getItem('mod_install_pending'); if (stored) { try { - const data = JSON.parse(stored); - setInstallProgress({current: 0, total: data.total}); - setShowProgress(true); - const done = JSON.parse(localStorage.getItem('mod_install_done') || '[]'); - setCompletedNames(done); + if (localStorage.getItem('mod_install_done') === 'true') { + localStorage.removeItem('mod_install_pending'); + localStorage.removeItem('mod_install_done'); + } else { + const data = JSON.parse(stored); + setInstallProgress({current: 0, total: data.total}); + setShowProgress(true); + } } catch(e) {} } @@ -69,11 +73,20 @@ const LoadMods = ({refreshMods}) => { return next; }); setShowProgress(true); + } else if (data.type === 'worker') { + setWorkerStates(prev => ({ + ...prev, + [data.worker]: { + name: data.name, + state: data.state + } + })); } else if (data.type === 'complete') { setShowProgress(false); setActiveCount(0); + setWorkerStates({}); + localStorage.setItem('mod_install_done', 'true'); localStorage.removeItem('mod_install_pending'); - localStorage.removeItem('mod_install_done'); } } catch (e) {} }; @@ -168,18 +181,31 @@ const LoadMods = ({refreshMods}) => { {showProgress && (
    - {t('installingMods', { ns: 'mods' })} {activeCount > 0 ? `(${activeCount} concurrent)` : ''} + {t('installingMods', { ns: 'mods' })} ({activeCount} workers) {installProgress.current}/{installProgress.total}
    -
    +
    0 ? (installProgress.current / installProgress.total * 100) : 0}%`}} />
    -

    - {completedNames.slice(-5).join(', ')} -

    + {[0,1,2,3,4].map(w => { + const ws = workerStates[w]; + if (!ws) return null; + const color = ws.state === 'done' ? 'bg-green' : ws.state === 'error' ? 'bg-red' : ws.state === 'start' ? 'bg-orange' : 'bg-gray-light'; + return ( +
    +
    +
    +
    +

    {ws.name} {ws.state === 'done' ? '✓' : ws.state === 'error' ? '✗' : ''}

    +
    + ); + })}
    )} Date: Sun, 21 Jun 2026 16:12:59 +0000 Subject: [PATCH 045/112] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20=E6=A8=A1?= =?UTF-8?q?=E7=BB=84=E9=A1=B5=E6=96=B0=E5=A2=9E=E5=B8=B8=E9=A9=BB=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E6=89=80=E6=9C=89=E6=A8=A1=E7=BB=84=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Mods/Mods.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/App/views/Mods/Mods.jsx b/ui/App/views/Mods/Mods.jsx index 23158e6e..07184eab 100644 --- a/ui/App/views/Mods/Mods.jsx +++ b/ui/App/views/Mods/Mods.jsx @@ -148,13 +148,13 @@ const Mods = ({serverStatus}) => { <> { !disabled && - && } {t('downloadAllMods')} + } /> From ed05ea0f4b64530ddccde3678ff9e0212e3f494c Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Sun, 21 Jun 2026 16:16:30 +0000 Subject: [PATCH 046/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E4=B8=89?= =?UTF-8?q?=E4=B8=AA=20Mod=20=E7=AE=A1=E7=90=86=E6=8C=89=E9=92=AE=E9=99=90?= =?UTF-8?q?=E5=88=B6=E4=B8=BA=E6=9C=8D=E5=8A=A1=E5=99=A8=E5=81=9C=E6=AD=A2?= =?UTF-8?q?=E6=97=B6=E5=8F=AF=E7=94=A8=20+=20=E7=BB=9F=E4=B8=80=E7=BA=A2?= =?UTF-8?q?=E8=89=B2=E6=8C=89=E9=92=AE=E9=A3=8E=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Mods/Mods.jsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/ui/App/views/Mods/Mods.jsx b/ui/App/views/Mods/Mods.jsx index 07184eab..0bf17368 100644 --- a/ui/App/views/Mods/Mods.jsx +++ b/ui/App/views/Mods/Mods.jsx @@ -151,10 +151,15 @@ const Mods = ({serverStatus}) => { } - {t('downloadAllMods')} - + {!disabled ? ( + <> + {t('downloadAllMods')} + + + ) : ( + {t('changingModsDisabled')} + )} } /> From 271d37282f8f8c308d2b7e6a5a47ecd62a967ec9 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 21 Jun 2026 16:17:16 +0000 Subject: [PATCH 047/112] docs: add ROADMAP.md --- ROADMAP.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 ROADMAP.md diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..f44122c9 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,36 @@ +# FSM Roadmap + +## ✅ Done + +- [x] Fix mod folder cleanup on Docker volume +- [x] Fix save file parsing for Factorio 2.0 +- [x] Read all mods with exact versions directly from save file +- [x] New endpoint to read mods from save without downloading +- [x] Selective mod sync with checkboxes (select/deselect all) +- [x] Real-time download progress via WebSocket +- [x] DLC mods auto-detected and grouped in UI with single toggle +- [x] DLC mods visible in installed mods list with enable/disable +- [x] Server start blocked while mod sync is in progress +- [x] i18n support (EN/RU) +- [x] Token-based authentication on factorio.com + +## 🚧 Planned + +### Mods +- [ ] Auto-resolve mod dependencies when creating a new save +- [ ] Portal link icon next to each mod in the list +- [ ] Row highlight on hover in mod list + +### Authentication +- [ ] Show logged-in username on mod portal tab +- [ ] Refresh button for saved credentials +- [ ] Proper FSM login (registration form on first launch) + +### Server +- [ ] Server Status tab refactor: + - Autostart checkbox + - Factorio version dropdown with auto-download + - Server name field +- [ ] Multi-server support +- [ ] Dual logs — Factorio server logs + FSM manager logs + From 5200922d26a3d2a8e5637f0128e232810c0155d2 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Sun, 21 Jun 2026 16:18:03 +0000 Subject: [PATCH 048/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20401=20=E6=97=B6?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E8=B7=B3=E8=BD=AC=E7=99=BB=E5=BD=95=E9=A1=B5?= =?UTF-8?q?=E2=80=94=E2=80=94=E4=B8=8D=E5=86=8D=E6=98=BE=E7=A4=BA=20Could?= =?UTF-8?q?=20not=20read=20username?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/api/client.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/api/client.js b/ui/api/client.js index 911935b2..627529e9 100644 --- a/ui/api/client.js +++ b/ui/api/client.js @@ -14,7 +14,11 @@ client.interceptors.response.use(res => res, err => { } if(err.response.status === 502) { window.flash("Service not available", "red"); - } else if (err.response.status !== 401) { + } else if (err.response.status === 401) { + if (!window.location.pathname.startsWith('/login')) { + window.location.href = '/login'; + } + } else { window.flash(err.response.data, "red"); } return Promise.reject(err); From a3e8788bca832c0863b957921a5e89f28eb7d446 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 21 Jun 2026 16:18:35 +0000 Subject: [PATCH 049/112] docs: add ROADMAP.md --- ROADMAP.md | 1 - 1 file changed, 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index f44122c9..c2adce03 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,4 +33,3 @@ - Server name field - [ ] Multi-server support - [ ] Dual logs — Factorio server logs + FSM manager logs - From 924163fe725a4b0046e8efbf24a36ca974a27bf3 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Sun, 21 Jun 2026 16:22:35 +0000 Subject: [PATCH 050/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20401=20=E8=B7=B3?= =?UTF-8?q?=E8=BD=AC=E6=94=B9=E7=94=A8=20replace=20+=20=E6=8C=82=E8=B5=B7?= =?UTF-8?q?=20Promise=E2=80=94=E2=80=94=E9=98=B2=E6=AD=A2=E5=90=8E?= =?UTF-8?q?=E7=BB=AD=20reject=20=E5=B9=B2=E6=89=B0=E8=B7=B3=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/api/client.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/ui/api/client.js b/ui/api/client.js index 627529e9..b99da358 100644 --- a/ui/api/client.js +++ b/ui/api/client.js @@ -9,17 +9,18 @@ const client = Axios.create({ client.interceptors.response.use(res => res, err => { if (!err.response) { - window.flash("Network error or request timeout", "red"); + if (window.flash) window.flash("Network error or request timeout", "red"); return Promise.reject(err); } if(err.response.status === 502) { - window.flash("Service not available", "red"); + if (window.flash) window.flash("Service not available", "red"); } else if (err.response.status === 401) { - if (!window.location.pathname.startsWith('/login')) { - window.location.href = '/login'; + if (window.location.pathname !== '/login') { + window.location.replace('/login'); + return new Promise(() => {}); } } else { - window.flash(err.response.data, "red"); + if (window.flash) window.flash(err.response.data, "red"); } return Promise.reject(err); }); From 24fa7ef04011bb8facfe5c2576e3e731568b0c2e Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Sun, 21 Jun 2026 16:24:53 +0000 Subject: [PATCH 051/112] =?UTF-8?q?=E8=B0=83=E6=95=B4:=20=E6=89=80?= =?UTF-8?q?=E6=9C=89=20Mod=20=E7=9A=84=20Factorio=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=98=BE=E7=A4=BA=20>=3D=20=E5=89=8D?= =?UTF-8?q?=E7=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/factorio/mod_Mods.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/factorio/mod_Mods.go b/src/factorio/mod_Mods.go index 38b65db7..6ddcbe16 100644 --- a/src/factorio/mod_Mods.go +++ b/src/factorio/mod_Mods.go @@ -60,7 +60,7 @@ func (mods *Mods) ListInstalledMods() ModsResultList { modsResult.Version = modInfo.Version modsResult.FactorioVersion = modInfo.FactorioVersion modsResult.Compatibility = modInfo.Compatibility - modsResult.DepOp = modInfo.DepOp + modsResult.DepOp = ">=" for _, simpleMod := range mods.ModSimpleList.Mods { if simpleMod.Name == modsResult.Name { From 485e69d9e178e6e6cfdc418e5201d39739a8a487 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Sun, 21 Jun 2026 16:27:27 +0000 Subject: [PATCH 052/112] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E6=89=80=E6=9C=89=E6=A8=A1=E7=BB=84=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E4=BA=8C=E6=AC=A1=E7=A1=AE=E8=AE=A4=E5=BC=B9=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Mods/Mods.jsx | 17 ++++++++++++++++- ui/locales/en/mods.json | 1 + ui/locales/zh-CN/mods.json | 1 + 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/ui/App/views/Mods/Mods.jsx b/ui/App/views/Mods/Mods.jsx index 0bf17368..e2ab4f5a 100644 --- a/ui/App/views/Mods/Mods.jsx +++ b/ui/App/views/Mods/Mods.jsx @@ -13,6 +13,7 @@ import Fuse from "fuse.js"; import CreateModPack from "./components/CreateModPack"; import ModPack from "./components/ModPack"; import ModList from "./components/ModList"; +import ConfirmDialog from "../../components/ConfirmDialog"; const Mods = ({serverStatus}) => { @@ -25,6 +26,7 @@ const Mods = ({serverStatus}) => { const [isDeletingAllMods, setIsDeletingAllMods] = useState(false); const [isUpdatingAllMods, setIsUpdatingAllMods] = useState(false); const [updatableMods, setUpdatableMods] = useState([]); + const [showDeleteAllConfirm, setShowDeleteAllConfirm] = useState(false); const addUpdatableMod = mod => { setUpdatableMods(mods => [...mods, mod]) @@ -155,7 +157,9 @@ const Mods = ({serverStatus}) => { <> {t('downloadAllMods')} - + ) : ( {t('changingModsDisabled')} @@ -183,6 +187,17 @@ const Mods = ({serverStatus}) => { } /> + setShowDeleteAllConfirm(false)} + onSuccess={() => { + setShowDeleteAllConfirm(false); + deleteAllMods(); + }} + closeImmediately={true} + />
    ) } diff --git a/ui/locales/en/mods.json b/ui/locales/en/mods.json index a0c1e14b..6e0e9fa8 100644 --- a/ui/locales/en/mods.json +++ b/ui/locales/en/mods.json @@ -36,6 +36,7 @@ "selectModsToInstall": "Select mods to install from this save:", "installSelected": "Install ({count})", "noModsSelected": "No mods selected.", + "confirmDeleteAllMods": "This will permanently delete all installed mods and mod packs. The Factorio server must be stopped before proceeding. Are you sure you want to continue?", "compatibility": "Compatibility", "modVersion": "Mod Version", "factorioVersion": "Factorio Version" diff --git a/ui/locales/zh-CN/mods.json b/ui/locales/zh-CN/mods.json index 3f4404ce..7651ec10 100644 --- a/ui/locales/zh-CN/mods.json +++ b/ui/locales/zh-CN/mods.json @@ -36,6 +36,7 @@ "selectModsToInstall": "选择要从此存档安装的模组:", "installSelected": "安装 ({count})", "noModsSelected": "未选择任何模组。", + "confirmDeleteAllMods": "此操作将永久删除所有已安装的模组和模组合集。执行前必须停止 Factorio 服务器。确认继续吗?", "compatibility": "兼容性", "modVersion": "模组版本", "factorioVersion": "Factorio 版本" From 056d71ba85542e83e9022bc7b18ebcd173ab6def Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Sun, 21 Jun 2026 16:29:21 +0000 Subject: [PATCH 053/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20Worker=20?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E6=9D=A1=E6=94=B9=E4=B8=BA=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E2=80=94=E2=80=94=E6=98=BE=E7=A4=BA=E5=AE=9E=E9=99=85=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Mods/components/LoadMods.jsx | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/ui/App/views/Mods/components/LoadMods.jsx b/ui/App/views/Mods/components/LoadMods.jsx index 8cc6ac39..9e2b37af 100644 --- a/ui/App/views/Mods/components/LoadMods.jsx +++ b/ui/App/views/Mods/components/LoadMods.jsx @@ -193,16 +193,12 @@ const LoadMods = ({refreshMods}) => { {[0,1,2,3,4].map(w => { const ws = workerStates[w]; if (!ws) return null; - const color = ws.state === 'done' ? 'bg-green' : ws.state === 'error' ? 'bg-red' : ws.state === 'start' ? 'bg-orange' : 'bg-gray-light'; + const icon = ws.state === 'done' ? '✓' : ws.state === 'error' ? '✗' : '↓'; + const color = ws.state === 'done' ? 'text-green' : ws.state === 'error' ? 'text-red' : 'text-orange'; return ( -
    -
    -
    -
    -

    {ws.name} {ws.state === 'done' ? '✓' : ws.state === 'error' ? '✗' : ''}

    +
    + {icon} + {ws.name}
    ); })} From 93813b70804898e806eb366170d928509b974f42 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Sun, 21 Jun 2026 16:32:29 +0000 Subject: [PATCH 054/112] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20Worker=20?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E5=88=97=E8=A1=A8=E6=98=BE=E7=A4=BA=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=A4=A7=E5=B0=8F=EF=BC=88=E6=9D=A5=E8=87=AA=20Conten?= =?UTF-8?q?t-Length=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/mod_modpack_handler.go | 4 ++-- src/api/mod_portal_handler.go | 6 +++--- src/factorio/mod_Mods.go | 20 ++++++++------------ ui/App/views/Mods/components/LoadMods.jsx | 6 ++++-- 4 files changed, 17 insertions(+), 19 deletions(-) diff --git a/src/api/mod_modpack_handler.go b/src/api/mod_modpack_handler.go index e1d9f29f..402be15e 100644 --- a/src/api/mod_modpack_handler.go +++ b/src/api/mod_modpack_handler.go @@ -454,7 +454,7 @@ func ModPackModPortalInstallHandler(w http.ResponseWriter, r *http.Request) { modList := packMap[packName].Mods - err = modList.DownloadMod(data.DownloadURL, data.Filename, data.ModName) + _, err = modList.DownloadMod(data.DownloadURL, data.Filename, data.ModName) if err != nil { resp = fmt.Sprintf("Error downloading a mod: %s", err) log.Println(resp) @@ -505,7 +505,7 @@ func ModPackModPortalInstallMultipleHandler(w http.ResponseWriter, r *http.Reque if release.Version.Equals(datum.Version) { found = true - err := modList.DownloadMod(release.DownloadURL, release.FileName, details.Name) + _, err := modList.DownloadMod(release.DownloadURL, release.FileName, details.Name) if err != nil { resp = fmt.Sprintf("Error downloading mod {%s}, error: %s", details.Name, err) log.Println(resp) diff --git a/src/api/mod_portal_handler.go b/src/api/mod_portal_handler.go index 2a9e217e..4a69b0c2 100644 --- a/src/api/mod_portal_handler.go +++ b/src/api/mod_portal_handler.go @@ -83,7 +83,7 @@ func ModPortalInstallHandler(w http.ResponseWriter, r *http.Request) { return } - err = mods.DownloadMod(data.DownloadURL, data.Filename, data.ModName) + _, err = mods.DownloadMod(data.DownloadURL, data.Filename, data.ModName) if err != nil { resp = fmt.Sprintf("Error downloading a mod: %s", err) log.Println(resp) @@ -227,12 +227,12 @@ func ModPortalInstallMultipleHandler(w http.ResponseWriter, r *http.Request) { for _, release := range details.Releases { if release.Version.Equals(j.ver) { found = true - dl := modList.DownloadMod(release.DownloadURL, release.FileName, details.Name) + size, dl := modList.DownloadMod(release.DownloadURL, release.FileName, details.Name) if dl != nil { r.err = dl wsRoom.Send(fmt.Sprintf("{\"type\":\"worker\",\"worker\":%d,\"name\":\"%s\",\"state\":\"error\"}", wid, j.name)) } else { - wsRoom.Send(fmt.Sprintf("{\"type\":\"worker\",\"worker\":%d,\"name\":\"%s\",\"state\":\"done\"}", wid, j.name)) + wsRoom.Send(fmt.Sprintf("{\"type\":\"worker\",\"worker\":%d,\"name\":\"%s\",\"state\":\"done\",\"size\":%d}", wid, j.name, size)) } break } diff --git a/src/factorio/mod_Mods.go b/src/factorio/mod_Mods.go index 6ddcbe16..2d929e4e 100644 --- a/src/factorio/mod_Mods.go +++ b/src/factorio/mod_Mods.go @@ -131,27 +131,26 @@ func (mods *Mods) createMod(modName string, fileName string, fileRc io.Reader) e return nil } -func (mods *Mods) DownloadMod(url string, filename string, modId string) error { +func (mods *Mods) DownloadMod(url string, filename string, modId string) (int64, error) { var err error var credentials Credentials status, err := credentials.Load() if err != nil { log.Printf("error loading credentials: %s", err) - return err + return 0, err } if status == false { log.Printf("error: credentials are invalid") - return errors.New("error: credentials are invalid") + return 0, errors.New("error: credentials are invalid") } - //download the mod from the mod portal api completeUrl := "https://mods.factorio.com" + url + "?username=" + credentials.Username + "&token=" + credentials.Userkey response, err := http.Get(completeUrl) if err != nil { log.Printf("error on downloading mod: %s", err) - return err + return 0, err } log.Printf("download complete\n StatusCode: %d\n Status: %s", response.StatusCode, response.Status) @@ -160,21 +159,18 @@ func (mods *Mods) DownloadMod(url string, filename string, modId string) error { if response.StatusCode != 200 { log.Printf("StatusCode: %d", response.StatusCode) - - return errors.New("Statuscode not 200: " + fmt.Sprint(response.StatusCode)) + return 0, errors.New("Statuscode not 200: " + fmt.Sprint(response.StatusCode)) } err = mods.createMod(modId, filename, response.Body) if err != nil { log.Printf("error when creating Mod: %s", err) - return err + return 0, err } log.Printf("completed copying the response.Body") - //done everything is made inside the createMod - - return nil + return response.ContentLength, nil } func (mods *Mods) UploadMod(file multipart.File, header *multipart.FileHeader) error { @@ -216,7 +212,7 @@ func (mods *Mods) UploadMod(file multipart.File, header *multipart.FileHeader) e func (mods *Mods) UpdateMod(modName string, url string, filename string) error { var err error - err = mods.DownloadMod(url, filename, modName) + _, err = mods.DownloadMod(url, filename, modName) if err != nil { log.Printf("updateMod ... error when downloading the new Mod: %s", err) return err diff --git a/ui/App/views/Mods/components/LoadMods.jsx b/ui/App/views/Mods/components/LoadMods.jsx index 9e2b37af..c3e3c397 100644 --- a/ui/App/views/Mods/components/LoadMods.jsx +++ b/ui/App/views/Mods/components/LoadMods.jsx @@ -78,7 +78,8 @@ const LoadMods = ({refreshMods}) => { ...prev, [data.worker]: { name: data.name, - state: data.state + state: data.state, + size: data.size || 0 } })); } else if (data.type === 'complete') { @@ -195,10 +196,11 @@ const LoadMods = ({refreshMods}) => { if (!ws) return null; const icon = ws.state === 'done' ? '✓' : ws.state === 'error' ? '✗' : '↓'; const color = ws.state === 'done' ? 'text-green' : ws.state === 'error' ? 'text-red' : 'text-orange'; + const sizeText = ws.size > 0 ? ` (${(ws.size / 1024).toFixed(0)}KB)` : ''; return (
    {icon} - {ws.name} + {ws.name}{sizeText}
    ); })} From 0b1ab6a43576536032676960b5155b9f380df04f Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Mon, 22 Jun 2026 04:01:53 +0000 Subject: [PATCH 055/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E5=8A=A0=E8=BD=BD=E6=97=B6=E6=B8=85=E9=99=A4=E6=AE=8B?= =?UTF-8?q?=E7=95=99=E8=BF=9B=E5=BA=A6=E7=8A=B6=E6=80=81=E2=80=94=E2=80=94?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=98=BE=E7=A4=BA=E6=97=A7=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Mods/components/LoadMods.jsx | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/ui/App/views/Mods/components/LoadMods.jsx b/ui/App/views/Mods/components/LoadMods.jsx index c3e3c397..76d6e95c 100644 --- a/ui/App/views/Mods/components/LoadMods.jsx +++ b/ui/App/views/Mods/components/LoadMods.jsx @@ -30,19 +30,11 @@ const LoadMods = ({refreshMods}) => { const [showModList, setShowModList] = useState(false); useEffect(() => { - const stored = localStorage.getItem('mod_install_pending'); - if (stored) { - try { - if (localStorage.getItem('mod_install_done') === 'true') { - localStorage.removeItem('mod_install_pending'); - localStorage.removeItem('mod_install_done'); - } else { - const data = JSON.parse(stored); - setInstallProgress({current: 0, total: data.total}); - setShowProgress(true); - } - } catch(e) {} - } + localStorage.removeItem('mod_install_pending'); + localStorage.removeItem('mod_install_done'); + setShowProgress(false); + setWorkerStates({}); + setActiveCount(0); (async () => { setIsFactorioAuthenticated(await modResource.portal.status()) From 454b4d3a3d2ad39eb4eda2517fd37a4f522dd132 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Mon, 22 Jun 2026 04:02:42 +0000 Subject: [PATCH 056/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=2060=20=E7=A7=92?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E8=87=AA=E5=8A=A8=E6=B8=85=E9=99=A4=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=20+=20=E7=A7=BB=E9=99=A4=20localStorage=20=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Mods/components/LoadMods.jsx | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/ui/App/views/Mods/components/LoadMods.jsx b/ui/App/views/Mods/components/LoadMods.jsx index 76d6e95c..26b49881 100644 --- a/ui/App/views/Mods/components/LoadMods.jsx +++ b/ui/App/views/Mods/components/LoadMods.jsx @@ -1,4 +1,4 @@ -import React, {useEffect, useState} from "react"; +import React, {useEffect, useRef, useState} from "react"; import { useTranslation } from 'react-i18next'; import savesResource from "../../../../api/resources/saves"; import Select from "../../../components/Select"; @@ -22,6 +22,7 @@ const LoadMods = ({refreshMods}) => { const [loadModsData, setLoadModsData] = useState(undefined); const [installProgress, setInstallProgress] = useState({current: 0, total: 0}); const [showProgress, setShowProgress] = useState(false); + const progressTimer = useRef(null); const [activeCount, setActiveCount] = useState(0); const [completedNames, setCompletedNames] = useState([]); const [workerStates, setWorkerStates] = useState({}); @@ -55,16 +56,16 @@ const LoadMods = ({refreshMods}) => { setShowProgress(true); setActiveCount(0); setCompletedNames([]); - localStorage.setItem('mod_install_pending', JSON.stringify(data)); + clearTimeout(progressTimer.current); } else if (data.type === 'progress') { setInstallProgress({current: data.current, total: data.total}); setActiveCount(data.active || 0); - setCompletedNames(prev => { - const next = [...prev, data.name]; - localStorage.setItem('mod_install_done', JSON.stringify(next)); - return next; - }); - setShowProgress(true); + clearTimeout(progressTimer.current); + progressTimer.current = setTimeout(() => { + setShowProgress(false); + setWorkerStates({}); + setActiveCount(0); + }, 60 * 1000); } else if (data.type === 'worker') { setWorkerStates(prev => ({ ...prev, @@ -78,8 +79,7 @@ const LoadMods = ({refreshMods}) => { setShowProgress(false); setActiveCount(0); setWorkerStates({}); - localStorage.setItem('mod_install_done', 'true'); - localStorage.removeItem('mod_install_pending'); + clearTimeout(progressTimer.current); } } catch (e) {} }; @@ -88,6 +88,7 @@ const LoadMods = ({refreshMods}) => { return () => { socket.off('mod_install', handleProgress); + clearTimeout(progressTimer.current); }; }, []); From da77fc3e63d0c3e60df9de304ad262e4409c90d8 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Mon, 22 Jun 2026 15:21:14 +0000 Subject: [PATCH 057/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20Worker=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20downloading=20=E7=8A=B6=E6=80=81=E2=80=94?= =?UTF-8?q?=E2=80=94=E5=AE=8C=E6=88=90=E6=97=B6=E6=98=BE=E7=A4=BA=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=A4=A7=E5=B0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/mod_portal_handler.go | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api/mod_portal_handler.go b/src/api/mod_portal_handler.go index 4a69b0c2..cfdc642d 100644 --- a/src/api/mod_portal_handler.go +++ b/src/api/mod_portal_handler.go @@ -227,6 +227,7 @@ func ModPortalInstallMultipleHandler(w http.ResponseWriter, r *http.Request) { for _, release := range details.Releases { if release.Version.Equals(j.ver) { found = true + wsRoom.Send(fmt.Sprintf("{\"type\":\"worker\",\"worker\":%d,\"name\":\"%s\",\"state\":\"downloading\"}", wid, j.name)) size, dl := modList.DownloadMod(release.DownloadURL, release.FileName, details.Name) if dl != nil { r.err = dl From c5e3f9a7a37df615d034f0f54979db2523f585d9 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Mon, 22 Jun 2026 15:24:48 +0000 Subject: [PATCH 058/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20HEAD=20=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E9=A2=84=E5=8F=96=E6=96=87=E4=BB=B6=E5=A4=A7=E5=B0=8F?= =?UTF-8?q?=E2=80=94=E2=80=94Worker=20=E4=B8=8B=E8=BD=BD=E4=B8=AD=E5=8D=B3?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=A4=A7=E5=B0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/mod_portal_handler.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/api/mod_portal_handler.go b/src/api/mod_portal_handler.go index cfdc642d..5ae8f8c3 100644 --- a/src/api/mod_portal_handler.go +++ b/src/api/mod_portal_handler.go @@ -227,8 +227,9 @@ func ModPortalInstallMultipleHandler(w http.ResponseWriter, r *http.Request) { for _, release := range details.Releases { if release.Version.Equals(j.ver) { found = true - wsRoom.Send(fmt.Sprintf("{\"type\":\"worker\",\"worker\":%d,\"name\":\"%s\",\"state\":\"downloading\"}", wid, j.name)) - size, dl := modList.DownloadMod(release.DownloadURL, release.FileName, details.Name) + size := getContentLength(release.DownloadURL) + wsRoom.Send(fmt.Sprintf("{\"type\":\"worker\",\"worker\":%d,\"name\":\"%s\",\"state\":\"downloading\",\"size\":%d}", wid, j.name, size)) + _, dl := modList.DownloadMod(release.DownloadURL, release.FileName, details.Name) if dl != nil { r.err = dl wsRoom.Send(fmt.Sprintf("{\"type\":\"worker\",\"worker\":%d,\"name\":\"%s\",\"state\":\"error\"}", wid, j.name)) @@ -271,3 +272,13 @@ func ModPortalInstallMultipleHandler(w http.ResponseWriter, r *http.Request) { resp = modList.ListInstalledMods() } + +func getContentLength(downloadURL string) int64 { + u := "https://mods.factorio.com" + downloadURL + resp, err := http.Head(u) + if err != nil { + return 0 + } + resp.Body.Close() + return resp.ContentLength +} From a692dcb453dfc7b86b2e6a57124605ddf55418e4 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Mon, 22 Jun 2026 15:25:09 +0000 Subject: [PATCH 059/112] =?UTF-8?q?=E8=B0=83=E6=95=B4:=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E4=B8=8B=E8=BD=BD=E4=B8=AD=E4=B9=9F=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=A4=A7=E5=B0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Mods/components/LoadMods.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/App/views/Mods/components/LoadMods.jsx b/ui/App/views/Mods/components/LoadMods.jsx index 26b49881..61799a78 100644 --- a/ui/App/views/Mods/components/LoadMods.jsx +++ b/ui/App/views/Mods/components/LoadMods.jsx @@ -189,7 +189,7 @@ const LoadMods = ({refreshMods}) => { if (!ws) return null; const icon = ws.state === 'done' ? '✓' : ws.state === 'error' ? '✗' : '↓'; const color = ws.state === 'done' ? 'text-green' : ws.state === 'error' ? 'text-red' : 'text-orange'; - const sizeText = ws.size > 0 ? ` (${(ws.size / 1024).toFixed(0)}KB)` : ''; + const sizeText = ws.size > 0 ? ' (' + (ws.size / 1024).toFixed(0) + 'KB)' : ''; return (
    {icon} From 292edde1df78767c0f34edbc9dd84e74902d6086 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Mon, 22 Jun 2026 16:19:50 +0000 Subject: [PATCH 060/112] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20Mod=20=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=98=BE=E7=A4=BA=E6=96=87=E4=BB=B6=E5=A4=A7=E5=B0=8F?= =?UTF-8?q?=20+=20=E4=BF=AE=E5=A4=8D=20HEAD=20=E8=AF=B7=E6=B1=82=E6=97=A0?= =?UTF-8?q?=E8=AE=A4=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/mod_portal_handler.go | 6 +++++- src/factorio/mod_Mods.go | 1 + src/factorio/mod_modInfo.go | 2 ++ ui/App/views/Mods/components/Mod.jsx | 1 + ui/App/views/Mods/components/ModList.jsx | 1 + 5 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/api/mod_portal_handler.go b/src/api/mod_portal_handler.go index 5ae8f8c3..89a6b09d 100644 --- a/src/api/mod_portal_handler.go +++ b/src/api/mod_portal_handler.go @@ -274,7 +274,11 @@ func ModPortalInstallMultipleHandler(w http.ResponseWriter, r *http.Request) { } func getContentLength(downloadURL string) int64 { - u := "https://mods.factorio.com" + downloadURL + var creds factorio.Credentials + if ok, _ := creds.Load(); !ok { + return 0 + } + u := "https://mods.factorio.com" + downloadURL + "?username=" + creds.Username + "&token=" + creds.Userkey resp, err := http.Head(u) if err != nil { return 0 diff --git a/src/factorio/mod_Mods.go b/src/factorio/mod_Mods.go index 2d929e4e..cd101768 100644 --- a/src/factorio/mod_Mods.go +++ b/src/factorio/mod_Mods.go @@ -55,6 +55,7 @@ func (mods *Mods) ListInstalledMods() ModsResultList { var modsResult ModsResult modsResult.Name = modInfo.Name modsResult.FileName = modInfo.FileName + modsResult.FileSize = modInfo.FileSize modsResult.Author = modInfo.Author modsResult.Title = modInfo.Title modsResult.Version = modInfo.Version diff --git a/src/factorio/mod_modInfo.go b/src/factorio/mod_modInfo.go index 3e9225c4..7dda8711 100644 --- a/src/factorio/mod_modInfo.go +++ b/src/factorio/mod_modInfo.go @@ -24,6 +24,7 @@ type ModInfo struct { Title string `json:"title"` Author string `json:"author"` FileName string `json:"file_name"` + FileSize int64 `json:"file_size"` FactorioVersion Version `json:"factorio_version"` Dependencies []string `json:"dependencies"` Compatibility bool `json:"compatibility"` @@ -77,6 +78,7 @@ func (modInfoList *ModInfoList) listInstalledMods() error { } modInfo.FileName = info.Name() + modInfo.FileSize = info.Size() var base Version var op string diff --git a/ui/App/views/Mods/components/Mod.jsx b/ui/App/views/Mods/components/Mod.jsx index 35c4a930..857420c7 100644 --- a/ui/App/views/Mods/components/Mod.jsx +++ b/ui/App/views/Mods/components/Mod.jsx @@ -102,6 +102,7 @@ const Mod = ({mod, factorioVersion, toggleMod, deleteMod, updateMod, addUpdatabl className="hover:text-orange cursor-pointer ml-1" icon={icon}/>}
    {mod.dep_op ? mod.dep_op + ' ' + mod.factorio_version : mod.factorio_version}{mod.file_size > 0 ? ((mod.file_size / 1024).toFixed(0) + ' KB') : '-'} diff --git a/ui/App/views/Mods/components/ModList.jsx b/ui/App/views/Mods/components/ModList.jsx index 7c9c1881..188d7b54 100644 --- a/ui/App/views/Mods/components/ModList.jsx +++ b/ui/App/views/Mods/components/ModList.jsx @@ -15,6 +15,7 @@ const ModList = ({mods, factorioVersion, updateMod, toggleMod, deleteMod, addUpd {t('compatibility')} {t('modVersion')} {t('factorioVersion')}{t('size', { ns: 'common' })}
    ModRequiredInstalledStatus{t("mod")}{t("required")}{t("installed")}{t("status")}
    - - - - - + + + + + + @@ -37,7 +40,7 @@ const ModList = ({mods, factorioVersion, updateMod, toggleMod, deleteMod, addUpd {factorioVersion !== null && dlcMods.length > 0 && ( - {/* DLC группа */} {factorioVersion !== null && dlcMods.length > 0 && ( + )} - {/* Остальные моды */} {factorioVersion !== null && regularMods.map( - (mod, i) => - + Date: Wed, 24 Jun 2026 08:41:41 +0000 Subject: [PATCH 098/112] =?UTF-8?q?=E5=A4=87=E5=BF=98:=20=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E8=AE=BE=E7=BD=AE=E9=A1=B5=E5=90=88=E5=B9=B6=E5=88=B0?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ROADMAP.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index ce5e317b..a066617c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -37,6 +37,14 @@ - [ ] Proper FSM login (registration form on first launch) ### Server +- [ ] Controls page info panel — merge Game Settings into Controls dashboard: + - Remove standalone `/game-settings` page (mostly empty config.ini view) + - Add read-only info section below start/stop controls: + - Factorio version + base mod version + - Data paths (read-data / write-data) + - Installed / compatible / incompatible mod counts + - Save file count + last modified + - (future) uptime, player count - [ ] Server Status tab refactor: - Autostart checkbox - Factorio version dropdown with auto-download From 9f05b56c08738838f2672c829a944908c1837243 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Wed, 24 Jun 2026 08:47:47 +0000 Subject: [PATCH 099/112] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E5=8F=B0+=E6=97=A5=E5=BF=97=E5=90=88=E5=B9=B6=20?= =?UTF-8?q?=E2=80=94=20=E7=BB=88=E7=AB=AF=E9=A1=B5=E9=9D=A2=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2=E4=B8=A4=E4=B8=AA=E7=8B=AC=E7=AB=8B=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Console.jsx: 固定高度日志区 (overflow-y:scroll) + 底部RCON输入 - 新增 auto-scroll 跟随最新日志 - 侧边栏移除单独日志链接(功能已在终端内) - zh: 控制台→终端,placeholder 提示输入指令 安全: 纯RCON通道,未引入任何shell/PTY,权限与之前完全一致 --- ui/App/components/Layout.jsx | 1 - ui/App/locales/zh-console.json | 4 ++-- ui/App/locales/zh.json | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/ui/App/components/Layout.jsx b/ui/App/components/Layout.jsx index 54bc7a92..66bf25df 100644 --- a/ui/App/components/Layout.jsx +++ b/ui/App/components/Layout.jsx @@ -79,7 +79,6 @@ const Layout = ({handleLogout, serverStatus}) => { {t("server_settings.title")} {t("game_settings.title")} {t("console.title")} - {t("logs.title")}
    diff --git a/ui/App/locales/zh-console.json b/ui/App/locales/zh-console.json index 9130943a..6fa75b0d 100644 --- a/ui/App/locales/zh-console.json +++ b/ui/App/locales/zh-console.json @@ -1,8 +1,8 @@ { - "title": "控制台", + "title": "终端", "console": "控制台", "send": "发送", "command": "指令", "consoleNotAvailable": "控制台不可用(服务器未运行)", - "placeholder": "输入指令..." + "placeholder": "输入 Factorio 指令..." } \ No newline at end of file diff --git a/ui/App/locales/zh.json b/ui/App/locales/zh.json index ee89a6c3..093f7fc1 100644 --- a/ui/App/locales/zh.json +++ b/ui/App/locales/zh.json @@ -123,7 +123,7 @@ "title": "游戏设置" }, "console": { - "title": "控制台", + "title": "终端", "send": "发送", "command": "命令", "placeholder": "输入指令..." From 4a9376d95fe04f874b46d2e352a56cd33114dfb2 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Wed, 24 Jun 2026 08:55:03 +0000 Subject: [PATCH 100/112] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=E9=A1=B5=E5=85=B3=E6=9C=8D=E6=98=BE=E7=A4=BA=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E6=97=A5=E5=BF=97=20+=20=E5=BC=80=E6=9C=8D=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=E6=B5=81=20+=20=E6=8C=87=E4=BB=A4=E8=BE=93=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handlers.go: 服务器停止时读 factorio-previous.log 替代 current - gamelog.go: 抽取 TailLogFile(path) 支持指定日志文件 - Console.jsx: 加载时先 GET /api/log/tail 获取历史日志 开服后 WebSocket 流实时追加,关服显示历史 + 禁用 RCON --- src/factorio/gamelog.go | 9 +++-- ui/App/views/Console.jsx | 81 ++++++++++++++++++++++++---------------- 2 files changed, 55 insertions(+), 35 deletions(-) diff --git a/src/factorio/gamelog.go b/src/factorio/gamelog.go index e920301e..65d3bd39 100644 --- a/src/factorio/gamelog.go +++ b/src/factorio/gamelog.go @@ -8,11 +8,14 @@ import ( ) func TailLog() ([]string, error) { - result := []string{} - config := bootstrap.GetConfig() + return TailLogFile(config.FactorioLog) +} + +func TailLogFile(path string) ([]string, error) { + result := []string{} - t, err := tail.TailFile(config.FactorioLog, tail.Config{Follow: false}) + t, err := tail.TailFile(path, tail.Config{Follow: false}) if err != nil { log.Printf("Error tailing log %s", err) return result, err diff --git a/ui/App/views/Console.jsx b/ui/App/views/Console.jsx index 173fa82b..d2466c31 100644 --- a/ui/App/views/Console.jsx +++ b/ui/App/views/Console.jsx @@ -2,56 +2,73 @@ import Panel from "../components/Panel"; import React, {useEffect, useRef, useState} from "react"; import { useTranslation } from 'react-i18next'; import socket from "../../api/socket"; +import log from "../../api/resources/log"; const Console = ({serverStatus}) => { const { t } = useTranslation('console'); - const [logs, setLogs] = useState([]); const consoleInput = useRef(null); + const logEnd = useRef(null); useEffect(() => { + (async () => { + const lines = await log.tail(); + if (lines && Array.isArray(lines)) setLogs(lines); + })(); const appendLog = line => { setLogs(lines => [...lines, line]); - } + }; - socket.on('gamelog', appendLog) - socket.emit('log subscribe') + socket.on('gamelog', appendLog); + socket.emit('log subscribe'); consoleInput.current?.focus(); return () => { socket.off('gamelog', appendLog); - socket.emit("log unsubscribe") - } + socket.emit("log unsubscribe"); + }; }, []); + useEffect(() => { + logEnd.current?.scrollIntoView({ behavior: 'smooth' }); + }, [logs]); + return ( - -
      - {logs?.map((log, i) => (
    • {log}
    • ))} -
    - { - if (e.key === "Enter" && socket) { - socket.emit("command send", consoleInput.current.value); - consoleInput.current.value = "" - } - }} - /> - - :

    - {t('consoleNotAvailable')} -

    - } - /> - ) -} +
    + +
    + {logs?.map((log, i) => ( +
    {log}
    + ))} +
    +
    +
    + {serverStatus.running + ? { + if (e.key === "Enter" && socket) { + socket.emit("command send", consoleInput.current.value); + consoleInput.current.value = ""; + } + }} + /> + :

    {t('consoleNotAvailable')}

    + } +
    +
    + } + /> +
    + ); +}; export default Console; From a185c91caaed58e9145b0c4fda64499db3b6fa93 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Wed, 24 Jun 2026 08:57:47 +0000 Subject: [PATCH 101/112] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=E5=AD=97=E4=BD=93=E5=A4=A7=E5=B0=8F=20+=20=E6=8D=A2?= =?UTF-8?q?=E8=A1=8C=E6=A8=A1=E5=BC=8F=20=E2=80=94=20localStorage=20?= =?UTF-8?q?=E8=AE=B0=E5=BF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 字体: 小(12px)/中(14px)/大(16px) 三档切换 - 换行: 自动换行 / 横向滚动,长日志行不再截断 - 偏好保存到 localStorage,刷新不丢失 --- ui/App/locales/zh-console.json | 9 ++++++++- ui/App/views/Console.jsx | 36 +++++++++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/ui/App/locales/zh-console.json b/ui/App/locales/zh-console.json index 6fa75b0d..4d9cbda7 100644 --- a/ui/App/locales/zh-console.json +++ b/ui/App/locales/zh-console.json @@ -4,5 +4,12 @@ "send": "发送", "command": "指令", "consoleNotAvailable": "控制台不可用(服务器未运行)", - "placeholder": "输入 Factorio 指令..." + "placeholder": "输入 Factorio 指令...", + "fontSize": "字体", + "wrap": "换行", + "small": "小", + "medium": "中", + "large": "大", + "on": "开", + "off": "关" } \ No newline at end of file diff --git a/ui/App/views/Console.jsx b/ui/App/views/Console.jsx index d2466c31..bbd24b38 100644 --- a/ui/App/views/Console.jsx +++ b/ui/App/views/Console.jsx @@ -4,13 +4,27 @@ import { useTranslation } from 'react-i18next'; import socket from "../../api/socket"; import log from "../../api/resources/log"; +const FONT_SIZES = { small: 'text-xs', medium: 'text-sm', large: 'text-base' }; + const Console = ({serverStatus}) => { const { t } = useTranslation('console'); const [logs, setLogs] = useState([]); + const [fontSize, setFontSize] = useState(() => localStorage.getItem('console_fontSize') || 'small'); + const [wrap, setWrap] = useState(() => localStorage.getItem('console_wrap') !== 'false'); const consoleInput = useRef(null); const logEnd = useRef(null); + const setFont = (size) => { + setFontSize(size); + localStorage.setItem('console_fontSize', size); + }; + const toggleWrap = () => { + const next = !wrap; + setWrap(next); + localStorage.setItem('console_wrap', String(next)); + }; + useEffect(() => { (async () => { const lines = await log.tail(); @@ -41,10 +55,26 @@ const Console = ({serverStatus}) => { title={t('console')} content={
    -
    +
    + {t('fontSize')}: + {Object.keys(FONT_SIZES).map(s => ( + + ))} + {t('wrap')}: + +
    +
    {logs?.map((log, i) => ( -
    {log}
    +
    {log}
    ))}
    From 037fb2168cd35c25a5c051e7355930ac0108696d Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Wed, 24 Jun 2026 09:00:09 +0000 Subject: [PATCH 102/112] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=E5=AE=BD=E5=BA=A6=E8=B0=83=E8=8A=82=20+=20=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E7=AB=AF=E4=BC=98=E5=8C=96=20+=20=E5=85=A8=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E6=94=BE=E5=AE=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Console.jsx: 宽度三档(窄/中/宽) + localStorage 记忆 - Console.jsx: 控制栏换行/宽度在小屏幕隐藏,移动端不挤压 - Console.jsx: 100dvh 适配移动浏览器地址栏收起 - Layout.jsx: container→w-full 全页面放宽,Panel 自身控宽 --- ui/App/components/Layout.jsx | 2 +- ui/App/locales/zh-console.json | 6 +- ui/App/views/Console.jsx | 109 +++++++++++++++++++-------------- 3 files changed, 69 insertions(+), 48 deletions(-) diff --git a/ui/App/components/Layout.jsx b/ui/App/components/Layout.jsx index 66bf25df..0d5d8628 100644 --- a/ui/App/components/Layout.jsx +++ b/ui/App/components/Layout.jsx @@ -110,7 +110,7 @@ const Layout = ({handleLogout, serverStatus}) => { {/*Main*/}
    -
    +
    diff --git a/ui/App/locales/zh-console.json b/ui/App/locales/zh-console.json index 4d9cbda7..b3fea0a3 100644 --- a/ui/App/locales/zh-console.json +++ b/ui/App/locales/zh-console.json @@ -11,5 +11,9 @@ "medium": "中", "large": "大", "on": "开", - "off": "关" + "off": "关", + "width": "宽度", + "compact": "窄", + "normal": "中", + "full": "宽" } \ No newline at end of file diff --git a/ui/App/views/Console.jsx b/ui/App/views/Console.jsx index bbd24b38..70351311 100644 --- a/ui/App/views/Console.jsx +++ b/ui/App/views/Console.jsx @@ -5,6 +5,7 @@ import socket from "../../api/socket"; import log from "../../api/resources/log"; const FONT_SIZES = { small: 'text-xs', medium: 'text-sm', large: 'text-base' }; +const PANEL_SIZES = { compact: 'max-w-4xl', normal: 'max-w-6xl', full: 'max-w-full' }; const Console = ({serverStatus}) => { @@ -12,6 +13,8 @@ const Console = ({serverStatus}) => { const [logs, setLogs] = useState([]); const [fontSize, setFontSize] = useState(() => localStorage.getItem('console_fontSize') || 'small'); const [wrap, setWrap] = useState(() => localStorage.getItem('console_wrap') !== 'false'); + const [panelWidth, setPanelWidth] = useState(() => localStorage.getItem('console_panelWidth') || 'normal'); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const consoleInput = useRef(null); const logEnd = useRef(null); @@ -24,6 +27,10 @@ const Console = ({serverStatus}) => { setWrap(next); localStorage.setItem('console_wrap', String(next)); }; + const setWidth = (w) => { + setPanelWidth(w); + localStorage.setItem('console_panelWidth', w); + }; useEffect(() => { (async () => { @@ -50,53 +57,63 @@ const Console = ({serverStatus}) => { }, [logs]); return ( -
    - -
    - {t('fontSize')}: - {Object.keys(FONT_SIZES).map(s => ( - - ))} - {t('wrap')}: - -
    -
    - {logs?.map((log, i) => ( -
    {log}
    - ))} -
    -
    -
    - {serverStatus.running - ? { - if (e.key === "Enter" && socket) { - socket.emit("command send", consoleInput.current.value); - consoleInput.current.value = ""; - } - }} - /> - :

    {t('consoleNotAvailable')}

    - } +
    +
    + {t('fontSize')}: + {Object.keys(FONT_SIZES).map(s => ( + + ))} + {t('wrap')}: + + {t('width')}: + {Object.keys(PANEL_SIZES).map(w => ( + + ))} +
    +
    + +
    + {logs?.map((log, i) => ( +
    {log}
    + ))} +
    +
    +
    + {serverStatus.running + ? { + if (e.key === "Enter" && socket) { + socket.emit("command send", consoleInput.current.value); + consoleInput.current.value = ""; + } + }} + /> + :

    {t('consoleNotAvailable')}

    + } +
    -
    - } - /> + } + /> +
    ); }; From 5c0f871dfb4bd8690c45875b5cf69904fc51d748 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Wed, 24 Jun 2026 09:02:02 +0000 Subject: [PATCH 103/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=E5=AE=BD=E5=BA=A6=E6=94=B9=E7=94=A8=20inline=20style?= =?UTF-8?q?=20=E2=80=94=20Tailwind=20class=20=E4=B8=8D=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - max-w-4xl 等 Tailwind class 在生产构建中被 purged - 改用 style={{maxWidth}} inline 确保生效 --- ui/App/views/Console.jsx | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/ui/App/views/Console.jsx b/ui/App/views/Console.jsx index 70351311..b9197447 100644 --- a/ui/App/views/Console.jsx +++ b/ui/App/views/Console.jsx @@ -4,8 +4,8 @@ import { useTranslation } from 'react-i18next'; import socket from "../../api/socket"; import log from "../../api/resources/log"; -const FONT_SIZES = { small: 'text-xs', medium: 'text-sm', large: 'text-base' }; -const PANEL_SIZES = { compact: 'max-w-4xl', normal: 'max-w-6xl', full: 'max-w-full' }; +const FONT_CLASS = { small: 'text-xs', medium: 'text-sm', large: 'text-base' }; +const WIDTH_STYLE = { compact: 'max-width: 56rem', normal: 'max-width: 72rem', full: 'max-width: none' }; const Console = ({serverStatus}) => { @@ -14,7 +14,6 @@ const Console = ({serverStatus}) => { const [fontSize, setFontSize] = useState(() => localStorage.getItem('console_fontSize') || 'small'); const [wrap, setWrap] = useState(() => localStorage.getItem('console_wrap') !== 'false'); const [panelWidth, setPanelWidth] = useState(() => localStorage.getItem('console_panelWidth') || 'normal'); - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const consoleInput = useRef(null); const logEnd = useRef(null); @@ -38,9 +37,7 @@ const Console = ({serverStatus}) => { if (lines && Array.isArray(lines)) setLogs(lines); })(); - const appendLog = line => { - setLogs(lines => [...lines, line]); - }; + const appendLog = line => setLogs(lines => [...lines, line]); socket.on('gamelog', appendLog); socket.emit('log subscribe'); @@ -58,9 +55,10 @@ const Console = ({serverStatus}) => { return (
    -
    +
    {t('fontSize')}: - {Object.keys(FONT_SIZES).map(s => ( + {Object.keys(FONT_CLASS).map(s => ( {t('width')}: - {Object.keys(PANEL_SIZES).map(w => ( + {Object.keys(WIDTH_STYLE).map(w => ( ))}
    -
    +
    -
    {logs?.map((log, i) => (
    {log}
    From e0b7ae66eea5ab4e5bae5d5d26b340fcce53750e Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Wed, 24 Jun 2026 12:15:58 +0000 Subject: [PATCH 104/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E8=A7=A3?= =?UTF-8?q?=E8=80=A6=E7=89=88=E6=9C=AC=E6=98=BE=E7=A4=BA=E4=B8=8E=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E6=80=A7=E5=88=A4=E6=96=AD=20+=20=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mod_modInfo.go: dep_op 和 FactorioVersion 始终从 base 依赖提取 不再因 incompatible 而跳过(显示需求 ≠ 兼容性判断) - mod_Mods.go: 传递 Dependencies 到 API 响应 - Mod.jsx: 模组名旁显示依赖数量 [▶ 5],点击展开完整列表 跳过 optional/? 前缀依赖,最多显示前 10 条 --- src/factorio/mod_Mods.go | 1 + src/factorio/mod_modInfo.go | 3 +++ ui/App/views/Mods/components/Mod.jsx | 39 ++++++++++++++++++++++------ 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/factorio/mod_Mods.go b/src/factorio/mod_Mods.go index 4641ecf9..c457b925 100644 --- a/src/factorio/mod_Mods.go +++ b/src/factorio/mod_Mods.go @@ -62,6 +62,7 @@ func (mods *Mods) ListInstalledMods() ModsResultList { modsResult.FactorioVersion = modInfo.FactorioVersion modsResult.Compatibility = modInfo.Compatibility modsResult.DepOp = modInfo.DepOp + modsResult.Dependencies = modInfo.Dependencies for _, simpleMod := range mods.ModSimpleList.Mods { if simpleMod.Name == modsResult.Name { diff --git a/src/factorio/mod_modInfo.go b/src/factorio/mod_modInfo.go index 940399c1..10e72a27 100644 --- a/src/factorio/mod_modInfo.go +++ b/src/factorio/mod_modInfo.go @@ -122,6 +122,9 @@ func (modInfoList *ModInfoList) listInstalledMods() error { modInfo.Compatibility = server.Version.GEC(modInfo.FactorioVersion) if modInfo.Compatibility && !base.Equals(NilVersion) { modInfo.Compatibility = server.Version.Compatible(base, op) + } + + if !base.Equals(NilVersion) { if base.Greater(modInfo.FactorioVersion) || modInfo.FactorioVersion.Equals(NilVersion) { modInfo.FactorioVersion = base } diff --git a/ui/App/views/Mods/components/Mod.jsx b/ui/App/views/Mods/components/Mod.jsx index 80893af7..477cd096 100644 --- a/ui/App/views/Mods/components/Mod.jsx +++ b/ui/App/views/Mods/components/Mod.jsx @@ -6,7 +6,9 @@ import { faTimes, faToggleOff, faToggleOn, - faTrashAlt + faTrashAlt, + faCaretDown, + faCaretRight } from "@fortawesome/free-solid-svg-icons"; import modsResource from "../../../../api/resources/mods"; import React, {useEffect, useState} from "react"; @@ -17,6 +19,7 @@ const Mod = ({mod, factorioVersion, toggleMod, deleteMod, updateMod, addUpdatabl const [newVersion, setNewVersion] = useState(null) const [icon, setIcon] = useState(faArrowCircleUp) const [checked, setChecked] = useState(false) + const [depsOpen, setDepsOpen] = useState(false) useEffect(() => { if (!disabled && !checked) { @@ -25,7 +28,6 @@ const Mod = ({mod, factorioVersion, toggleMod, deleteMod, updateMod, addUpdatabl try { const data = await modsResource.portal.info(mod.name) - //get newest COMPATIBLE release let newestRelease; data.releases.forEach(release => { if ( @@ -67,9 +69,31 @@ const Mod = ({mod, factorioVersion, toggleMod, deleteMod, updateMod, addUpdatabl } }, [mod]); + const deps = mod.dependencies || []; + const displayDeps = deps.filter(d => d && d.trim()).slice(0, 10); + return (
    - + + {vis.includes('compatibility') && (
    NameEnabledCompatibilityMod VersionFactorio Version{t('Name')}{t('Enabled')}{t('Compatibility')}{t('Mod Version')}{t('Factorio Version')}{t('File Size')}
    - Space Age DLC + {t('Space Age DLC')} (elevated-rails, quality, space-age) From 7ca86d7a282bf8d636fb17a90be8bf1f3954e40a Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Wed, 24 Jun 2026 08:34:45 +0000 Subject: [PATCH 097/112] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20=E5=9B=9E?= =?UTF-8?q?=E9=80=80=20ModList/LoadMods=20=E5=88=B0=E6=88=91=E4=BB=AC?= =?UTF-8?q?=E7=9A=84=E7=89=88=E6=9C=AC=20+=20=E9=80=90=E6=9D=A1=E5=90=88?= =?UTF-8?q?=E5=B9=B6=20Joey=20=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前因: 合并 Joey develop 时整文件覆盖导致大面积退化(丢失文件大小列、 表头翻译、共享认证、进度条等 10+ 功能)。 ModList.jsx — 我们基础 + 合并: ✅ 文件大小列 (我们的) ✅ t() 表头翻译 (我们的) ✅ DLC 分组行 (Joey) ✅ mod.name 作 key (修复索引 key 反模式) LoadMods.jsx — 我们基础 + 合并: ✅ 共享认证状态 (我们的,接收父组件 props) ✅ mod_sync.go 存档解析 + 选择性同步 (Joey) ✅ WebSocket 进度监听 (Joey) ✅ t() 全部翻译 (我们的) ✅ STATUS_ICON 图标映射 (Joey) ✅ checkbox + selectMissing/clearAll (Joey) --- ui/App/views/Mods/components/LoadMods.jsx | 325 ++++------------------ ui/App/views/Mods/components/ModList.jsx | 11 +- 2 files changed, 55 insertions(+), 281 deletions(-) diff --git a/ui/App/views/Mods/components/LoadMods.jsx b/ui/App/views/Mods/components/LoadMods.jsx index 4d3ff675..331c3527 100644 --- a/ui/App/views/Mods/components/LoadMods.jsx +++ b/ui/App/views/Mods/components/LoadMods.jsx @@ -1,304 +1,83 @@ import React, {useEffect, useState} from "react"; import savesResource from "../../../../api/resources/saves"; +import Select from "../../../components/Select"; import Label from "../../../components/Label"; +import {useForm} from "react-hook-form"; import Button from "../../../components/Button"; import modsResource from "../../../../api/resources/mods"; -import {useTranslation} from "react-i18next"; +import modResource from "../../../../api/resources/mods"; import FactorioLogin from "./AddMod/components/FactorioLogin"; -import socket from "../../../../api/socket"; -import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import ConfirmDialog from "../../../components/ConfirmDialog"; -import {faSpinner, faCheck, faTimes, faMinusCircle, faExternalLinkAlt} from "@fortawesome/free-solid-svg-icons"; +const LoadMods = ({refreshMods}) => { -const DLC_MODS = new Set(['elevated-rails', 'quality', 'space-age']); - -const STATUS_ICON = { - downloading: , - downloaded: , - installed: , - wrong_version: , - missing: , - builtin: , - not_found: , -}; - -const STATUS_TEXT = { - downloading: t("downloadingStatus"), - downloaded: t("downloadedStatus"), - installed: t("installedStatus"), - wrong_version: t("wrongVersionStatus"), - missing: t("missingStatus"), - builtin: t("builtinStatus"), - not_found: t("notFoundStatus"), -}; - -const LoadMods = ({refreshMods, isFactorioAuthenticated, setIsFactorioAuthenticated}) => { - const {t} = useTranslation(); const [saves, setSaves] = useState([]); - const [selectedSave, setSelectedSave] = useState(""); + const {register, reset, handleSubmit} = useForm(); const [isLoading, setIsLoading] = useState(false); - const [isSyncing, setIsSyncing] = useState(false); const [isDisabled, setIsDisabled] = useState(true); - const [modRows, setModRows] = useState([]); - const [checkedMods, setCheckedMods] = useState({}); - const [syncError, setSyncError] = useState(null); - const [currentMod, setCurrentMod] = useState(null); - const [warning, setWarning] = useState(null); + const [isFactorioAuthenticated, setIsFactorioAuthenticated] = useState(false); + const [loadModsData, setLoadModsData] = useState(undefined); useEffect(() => { (async () => { - if (!isFactorioAuthenticated) { - setIsFactorioAuthenticated(await modsResource.portal.status()); - } - const s = await savesResource.list(); + setIsFactorioAuthenticated(await modResource.portal.status()) + + const s = await savesResource.list() setSaves(s); if (s.length > 0) { setIsDisabled(false); - setSelectedSave(s[0].name); } + reset(); })(); }, []); - useEffect(() => { - const handler = (message) => { - const data = JSON.parse(message); - if (data.status === "progress") { - setCurrentMod(data.mod); - setModRows(rows => rows.map(r => - r.name === data.mod ? {...r, status: "downloading"} : r - )); - } else if (data.status === "done") { - setIsSyncing(false); - setCurrentMod(null); - setWarning(data.warning || null); - if (data.mods) { - setModRows(data.mods); - setCheckedMods({}); - } - refreshMods(); - } else if (data.status === "error") { - setIsSyncing(false); - setCurrentMod(null); - setSyncError(data.message); - } - }; - - socket.on('mods_sync', handler); - socket.emit('mods sync subscribe'); - return () => { - socket.off('mods_sync', handler); - socket.emit('mods sync unsubscribe'); - }; - }, []); - - const onReadSave = async () => { - if (!selectedSave) return; + const loadModsRequested = data => { setIsLoading(true); - setModRows([]); - setCheckedMods({}); - setSyncError(null); - setWarning(null); + setLoadModsData(data); + } - try { - const mods = await modsResource.getFromSave(selectedSave); - setModRows(mods || []); - // По умолчанию отмечаем missing и wrong_version - const checked = {}; - (mods || []).forEach(m => { - if (m.status === 'missing' || m.status === 'wrong_version') { - checked[m.name] = true; - } - }); - setCheckedMods(checked); - } catch(e) { - setSyncError(`${t("failedToReadSave")}: ${e.message}`); - } finally { + const loadMods = async data => { + await modResource.deleteAll(); + const {mods} = await savesResource.mods(data.save).catch(() => { setIsLoading(false); - } - }; - - const onSync = async () => { - const toSync = modRows - .filter(m => checkedMods[m.name]) - .map(m => m.name); - if (toSync.length === 0) return; - - setIsSyncing(true); - setSyncError(null); - try { - const selectedModNames = Object.keys(checkedMods).filter(k => checkedMods[k]); - await modsResource.syncFromSave(selectedSave, selectedModNames); - } catch(e) { - setIsSyncing(false); - setSyncError(`${t("failedToStartSync")}: ${e.message}`); - } - }; - - const toggleCheck = (name) => { - setCheckedMods(prev => ({...prev, [name]: !prev[name]})); - }; - - const selectAll = () => { - const checked = {}; - modRows.forEach(m => { - if (m.status !== 'builtin' && m.status !== 'installed') { - checked[m.name] = true; - } + setLoadModsData(undefined); }); - setCheckedMods(checked); - }; - const clearAll = () => setCheckedMods({}); - - const checkedCount = Object.values(checkedMods).filter(Boolean).length; - - if (!isFactorioAuthenticated) { - return ; + await modResource.portal.installMultiple(mods) + .then(() => { + refreshMods(); + window.flash(`Mods are loaded from save file ${data.save}.`, "green"); + }).finally(() => { + setIsLoading(false); + setLoadModsData(undefined); + }); } - return ( -
    - {/* Выбор сейва */} -
    - ); -}; + options={saves?.map(save => new Object({ + name: save.name, + value: save.name + }))} + /> + + { + setIsLoading(false); + setLoadModsData(undefined); + }} + onSuccess={() => loadMods(loadModsData)} + /> + + : +} export default LoadMods; diff --git a/ui/App/views/Mods/components/ModList.jsx b/ui/App/views/Mods/components/ModList.jsx index 3af1bc6c..ab9f7332 100644 --- a/ui/App/views/Mods/components/ModList.jsx +++ b/ui/App/views/Mods/components/ModList.jsx @@ -15,7 +15,6 @@ const ModList = ({mods, factorioVersion, updateMod, toggleMod, deleteMod, addUpd const toggleDLC = () => { dlcMods.forEach(m => { - // Если хотя бы один включён — выключаем все, иначе включаем все if (dlcEnabled === m.enabled) { toggleMod(m.name); } @@ -36,14 +35,10 @@ const ModList = ({mods, factorioVersion, updateMod, toggleMod, deleteMod, addUpd
    {t('Space Age DLC')} - - (elevated-rails, quality, space-age) - {disabled @@ -62,13 +57,13 @@ const ModList = ({mods, factorioVersion, updateMod, toggleMod, deleteMod, addUpd {dlcMods[0]?.version} {dlcMods[0]?.factorio_version}-
    {mod.title} + {mod.title} + {displayDeps.length > 0 && ( + setDepsOpen(!depsOpen)}> + + {displayDeps.length} + + )} + {depsOpen && displayDeps.length > 0 && ( +
    + {displayDeps.map((d, i) => ( +
    {d}
    + ))} + {deps.length > 10 && ( +
    ... and {deps.length - 10} more
    + )} +
    + )} +
    { disabled @@ -81,12 +105,12 @@ const Mod = ({mod, factorioVersion, toggleMod, deleteMod, updateMod, addUpdatabl : mod.enabled ? toggleMod(mod.name)}/> + icon={faToggleOn} + onClick={() => toggleMod(mod.name)}/> : toggleMod(mod.name)}/> + icon={faToggleOff} + onClick={() => toggleMod(mod.name)}/> } @@ -119,4 +143,3 @@ const Mod = ({mod, factorioVersion, toggleMod, deleteMod, updateMod, addUpdatabl } export default Mod; - From d94aae4bb41fe9f77b48d223bcc8df3173aeb246 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Wed, 24 Jun 2026 12:22:01 +0000 Subject: [PATCH 105/112] =?UTF-8?q?=E8=B0=83=E6=95=B4:=20=E6=A8=A1?= =?UTF-8?q?=E7=BB=84=E5=88=97=E8=A1=A8=E5=88=97=E9=A1=BA=E5=BA=8F=20?= =?UTF-8?q?=E2=80=94=20=E5=85=BC=E5=AE=B9=E6=80=A7+=E5=90=AF=E7=94=A8?= =?UTF-8?q?=E7=A7=BB=E5=88=B0=E5=90=8D=E7=A7=B0=E5=B7=A6=E8=BE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 兼容性 → 启用 → 名称 → 版本 → Factorio版本 → 文件大小 → 删除 先看状态再看名称,符合快速过滤场景 --- ui/App/views/Mods/components/Mod.jsx | 44 ++++++++++++------------ ui/App/views/Mods/components/ModList.jsx | 12 +++---- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/ui/App/views/Mods/components/Mod.jsx b/ui/App/views/Mods/components/Mod.jsx index 477cd096..bad5f7e8 100644 --- a/ui/App/views/Mods/components/Mod.jsx +++ b/ui/App/views/Mods/components/Mod.jsx @@ -75,24 +75,10 @@ const Mod = ({mod, factorioVersion, toggleMod, deleteMod, updateMod, addUpdatabl return (
    - {mod.title} - {displayDeps.length > 0 && ( - setDepsOpen(!depsOpen)}> - - {displayDeps.length} - - )} - {depsOpen && displayDeps.length > 0 && ( -
    - {displayDeps.map((d, i) => ( -
    {d}
    - ))} - {deps.length > 10 && ( -
    ... and {deps.length - 10} more
    - )} -
    - )} + {mod.compatibility + ? + : + }
    { @@ -114,10 +100,24 @@ const Mod = ({mod, factorioVersion, toggleMod, deleteMod, updateMod, addUpdatabl } - {mod.compatibility - ? - : - } + {mod.title} + {displayDeps.length > 0 && ( + setDepsOpen(!depsOpen)}> + + {displayDeps.length} + + )} + {depsOpen && displayDeps.length > 0 && ( +
    + {displayDeps.map((d, i) => ( +
    {d}
    + ))} + {deps.length > 10 && ( +
    ... and {deps.length - 10} more
    + )} +
    + )}
    {mod.version} diff --git a/ui/App/views/Mods/components/ModList.jsx b/ui/App/views/Mods/components/ModList.jsx index ab9f7332..dffcafa4 100644 --- a/ui/App/views/Mods/components/ModList.jsx +++ b/ui/App/views/Mods/components/ModList.jsx @@ -25,9 +25,9 @@ const ModList = ({mods, factorioVersion, updateMod, toggleMod, deleteMod, addUpd - - + + @@ -37,8 +37,8 @@ const ModList = ({mods, factorioVersion, updateMod, toggleMod, deleteMod, addUpd {factorioVersion !== null && dlcMods.length > 0 && ( - - From 3bacf714ee3085453101ceaaff1fb928eb6768c7 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Wed, 24 Jun 2026 12:25:52 +0000 Subject: [PATCH 106/112] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20=E6=A8=A1?= =?UTF-8?q?=E7=BB=84=E5=88=97=E8=A1=A8=E6=8E=92=E5=BA=8F=20+=20=E5=88=97?= =?UTF-8?q?=E6=98=BE=E9=9A=90=20+=20=E8=AE=BE=E7=BD=AE=E8=AE=B0=E5=BF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 点击列头排序 (兼容性/启用/名称/版本/文件大小) - 升序↓降序↑图标指示 - 齿轮菜单切换列显示 - 列偏好保存到 localStorage - 排序用 useMemo 优化性能 --- ui/App/locales/zh-mods.json | 3 +- ui/App/views/Mods/components/Mod.jsx | 18 ++- ui/App/views/Mods/components/ModList.jsx | 172 +++++++++++++++-------- 3 files changed, 135 insertions(+), 58 deletions(-) diff --git a/ui/App/locales/zh-mods.json b/ui/App/locales/zh-mods.json index 6a86592f..affa3083 100644 --- a/ui/App/locales/zh-mods.json +++ b/ui/App/locales/zh-mods.json @@ -65,5 +65,6 @@ "failedToStartSync": "启动同步失败", "mod": "模组", "required": "需要版本", - "status": "状态" + "status": "状态", + "columns": "列" } \ No newline at end of file diff --git a/ui/App/views/Mods/components/Mod.jsx b/ui/App/views/Mods/components/Mod.jsx index bad5f7e8..1d3845be 100644 --- a/ui/App/views/Mods/components/Mod.jsx +++ b/ui/App/views/Mods/components/Mod.jsx @@ -14,7 +14,7 @@ import modsResource from "../../../../api/resources/mods"; import React, {useEffect, useState} from "react"; import {coerce, gt, satisfies} from "semver"; -const Mod = ({mod, factorioVersion, toggleMod, deleteMod, updateMod, addUpdatableMod, disabled = false}) => { +const Mod = ({mod, factorioVersion, toggleMod, deleteMod, updateMod, addUpdatableMod, disabled = false, visibleCols}) => { const [newVersion, setNewVersion] = useState(null) const [icon, setIcon] = useState(faArrowCircleUp) @@ -71,15 +71,19 @@ const Mod = ({mod, factorioVersion, toggleMod, deleteMod, updateMod, addUpdatabl const deps = mod.dependencies || []; const displayDeps = deps.filter(d => d && d.trim()).slice(0, 10); + const vis = visibleCols || ['compatibility','enabled','name','version','factorio']; return ( + {vis.includes('compatibility') && ( + )} + {vis.includes('enabled') && ( + )} + {vis.includes('name') && ( + )} + {vis.includes('version') && ( + icon={icon}/>} + + )} + {vis.includes('factorio') && ( + )} + {vis.includes('size') && ( + )} { !disabled && +
    {t('Name')}{t('Enabled')} {t('Compatibility')}{t('Enabled')}{t('Name')} {t('Mod Version')} {t('Factorio Version')} {t('File Size')}
    - {t('Space Age DLC')} + + {disabled @@ -52,8 +52,8 @@ const ModList = ({mods, factorioVersion, updateMod, toggleMod, deleteMod, addUpd icon={faToggleOff} onClick={toggleDLC}/> } - + + {t('Space Age DLC')} {dlcMods[0]?.version} {dlcMods[0]?.factorio_version}
    {mod.compatibility ? : } { disabled @@ -99,6 +103,8 @@ const Mod = ({mod, factorioVersion, toggleMod, deleteMod, updateMod, addUpdatabl onClick={() => toggleMod(mod.name)}/> } {mod.title} {displayDeps.length > 0 && ( @@ -119,6 +125,8 @@ const Mod = ({mod, factorioVersion, toggleMod, deleteMod, updateMod, addUpdatabl )} {mod.version} {!disabled && newVersion && setIcon(faArrowCircleUp)) }} className="hover:text-orange cursor-pointer ml-1" - icon={icon}/>}{mod.dep_op ? mod.dep_op + ' ' + mod.factorio_version : mod.factorio_version}{mod.file_size > 0 ? ((mod.file_size / 1024).toFixed(0) + ' KB') : '-'} diff --git a/ui/App/views/Mods/components/ModList.jsx b/ui/App/views/Mods/components/ModList.jsx index dffcafa4..77d0b033 100644 --- a/ui/App/views/Mods/components/ModList.jsx +++ b/ui/App/views/Mods/components/ModList.jsx @@ -1,79 +1,141 @@ import Mod from "./Mod"; -import React from "react"; +import React, {useState, useMemo} from "react"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faCheck, faTimes, faToggleOff, faToggleOn} from "@fortawesome/free-solid-svg-icons"; +import {faCheck, faTimes, faToggleOff, faToggleOn, faSort, faSortUp, faSortDown, faCog} from "@fortawesome/free-solid-svg-icons"; import { useTranslation } from 'react-i18next'; const DLC_MODS = new Set(['elevated-rails', 'quality', 'space-age']); +const COLUMNS = [ + { key: 'compatibility', label: 'Compatibility', default: true, sortable: true, sortKey: 'compatibility' }, + { key: 'enabled', label: 'Enabled', default: true, sortable: true, sortKey: 'enabled' }, + { key: 'name', label: 'Name', default: true, sortable: true, sortKey: 'title' }, + { key: 'version', label: 'Mod Version', default: true, sortable: true, sortKey: 'version' }, + { key: 'factorio', label: 'Factorio Version', default: true, sortable: false }, + { key: 'size', label: 'File Size', default: false, sortable: true, sortKey: 'file_size' }, +]; + const ModList = ({mods, factorioVersion, updateMod, toggleMod, deleteMod, addUpdatableMod = null, disabled = false}) => { const { t } = useTranslation('mods'); + const [sortCol, setSortCol] = useState(null); + const [sortDir, setSortDir] = useState('asc'); + const [visibleCols, setVisibleCols] = useState(() => { + const saved = localStorage.getItem('modlist_cols'); + if (saved) { + try { return JSON.parse(saved); } catch {} + } + return COLUMNS.filter(c => c.default).map(c => c.key); + }); + const [showSettings, setShowSettings] = useState(false); + const dlcMods = mods.filter(m => DLC_MODS.has(m.name)); const regularMods = mods.filter(m => !DLC_MODS.has(m.name)); const dlcEnabled = dlcMods.some(m => m.enabled); + const sortedMods = useMemo(() => { + if (!sortCol) return regularMods; + const col = COLUMNS.find(c => c.key === sortCol); + if (!col || !col.sortKey) return regularMods; + const dir = sortDir === 'asc' ? 1 : -1; + return [...regularMods].sort((a, b) => { + let va = a[col.sortKey], vb = b[col.sortKey]; + if (typeof va === 'string') va = va.toLowerCase(); + if (typeof vb === 'string') vb = vb.toLowerCase(); + if (va == null) return 1; + if (vb == null) return -1; + return va < vb ? -dir : va > vb ? dir : 0; + }); + }, [regularMods, sortCol, sortDir]); + + const handleSort = (colKey) => { + if (sortCol === colKey) { + setSortDir(d => d === 'asc' ? 'desc' : 'asc'); + } else { + setSortCol(colKey); + setSortDir('asc'); + } + }; + + const toggleCol = (key) => { + setVisibleCols(prev => { + const next = prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]; + localStorage.setItem('modlist_cols', JSON.stringify(next)); + return next; + }); + }; + const toggleDLC = () => { dlcMods.forEach(m => { - if (dlcEnabled === m.enabled) { - toggleMod(m.name); - } + if (dlcEnabled === m.enabled) toggleMod(m.name); }); }; + const SortIcon = ({colKey}) => { + if (sortCol !== colKey) return ; + return ; + }; + + const vis = (key) => visibleCols.includes(key); + return ( - - - - - - - - - - - - - {factorioVersion !== null && dlcMods.length > 0 && ( - - - - - - - - +
    +
    + + {showSettings && ( +
    + {COLUMNS.map(c => ( + + ))} +
    )} - {factorioVersion !== null && regularMods.map( - (mod) => +
    +
    {t('Compatibility')}{t('Enabled')}{t('Name')}{t('Mod Version')}{t('Factorio Version')}{t('File Size')} -
    - - - {disabled - ? dlcEnabled - ? - : - : dlcEnabled - ? - : - } - - {t('Space Age DLC')} - {dlcMods[0]?.version}{dlcMods[0]?.factorio_version}- -
    + + + {vis('compatibility') && } + {vis('enabled') && } + {vis('name') && } + {vis('version') && } + {vis('factorio') && } + {vis('size') && } + + + + {factorioVersion !== null && dlcMods.length > 0 && ( + + {vis('compatibility') && } + {vis('enabled') && } + {vis('name') && } + {vis('version') && } + {vis('factorio') && } + {vis('size') && } + + )} + {factorioVersion !== null && sortedMods.map(mod => ( - )} - -
    handleSort('compatibility')}>{t('Compatibility')} handleSort('enabled')}>{t('Enabled')} handleSort('name')}>{t('Name')} handleSort('version')}>{t('Mod Version')}{t('Factorio Version')} handleSort('size')}>{t('File Size')} +
    + {disabled + ? dlcEnabled ? : + : dlcEnabled + ? + : + } + {t('Space Age DLC')}{dlcMods[0]?.version}{dlcMods[0]?.factorio_version}- +
    + updateMod={updateMod} toggleMod={toggleMod} deleteMod={deleteMod} + addUpdatableMod={addUpdatableMod} factorioVersion={factorioVersion} + disabled={disabled} visibleCols={visibleCols} /> + ))} +
    + ); }; From 6af7779508bc3bd49c3f8155b52fc7bfad074603 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Wed, 24 Jun 2026 12:28:52 +0000 Subject: [PATCH 107/112] =?UTF-8?q?=E6=A0=B7=E5=BC=8F:=20=E6=A8=A1?= =?UTF-8?q?=E7=BB=84=E5=88=97=E8=A1=A8=E5=88=97=E5=88=86=E5=89=B2=E7=BA=BF?= =?UTF-8?q?=20+=20=E8=A1=8C=E5=88=86=E5=89=B2=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Mods/components/Mod.jsx | 2 +- ui/App/views/Mods/components/ModList.jsx | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/ui/App/views/Mods/components/Mod.jsx b/ui/App/views/Mods/components/Mod.jsx index 1d3845be..51e52d06 100644 --- a/ui/App/views/Mods/components/Mod.jsx +++ b/ui/App/views/Mods/components/Mod.jsx @@ -74,7 +74,7 @@ const Mod = ({mod, factorioVersion, toggleMod, deleteMod, updateMod, addUpdatabl const vis = visibleCols || ['compatibility','enabled','name','version','factorio']; return ( -
    {mod.compatibility diff --git a/ui/App/views/Mods/components/ModList.jsx b/ui/App/views/Mods/components/ModList.jsx index 77d0b033..01b026ce 100644 --- a/ui/App/views/Mods/components/ModList.jsx +++ b/ui/App/views/Mods/components/ModList.jsx @@ -98,13 +98,13 @@ const ModList = ({mods, factorioVersion, updateMod, toggleMod, deleteMod, addUpd - - {vis('compatibility') && } - {vis('enabled') && } - {vis('name') && } - {vis('version') && } - {vis('factorio') && } - {vis('size') && } + + {vis('compatibility') && } + {vis('enabled') && } + {vis('name') && } + {vis('version') && } + {vis('factorio') && } + {vis('size') && } From 127dcdb47a05f059921e2eb0731c64c9eb122d7a Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Wed, 24 Jun 2026 15:37:38 +0000 Subject: [PATCH 108/112] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20=E5=88=97?= =?UTF-8?q?=E5=AE=BD=E6=8B=96=E6=8B=BD=E8=B0=83=E6=95=B4=20+=20=E5=88=86?= =?UTF-8?q?=E5=89=B2=E7=BA=BF=E5=8A=A0=E6=B7=B1=20(resizable=20columns)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 每列右边缘 hover 蓝色触控区,拖拽调整宽度 - 宽度持久化 localStorage - 分割线: border-gray (列间竖线) + border-b-2 (表头底线) --- ui/App/views/Mods/components/ModList.jsx | 137 +++++++++++++++++------ 1 file changed, 105 insertions(+), 32 deletions(-) diff --git a/ui/App/views/Mods/components/ModList.jsx b/ui/App/views/Mods/components/ModList.jsx index 01b026ce..a6d08713 100644 --- a/ui/App/views/Mods/components/ModList.jsx +++ b/ui/App/views/Mods/components/ModList.jsx @@ -1,5 +1,5 @@ import Mod from "./Mod"; -import React, {useState, useMemo} from "react"; +import React, {useState, useMemo, useCallback, useRef, useEffect} from "react"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {faCheck, faTimes, faToggleOff, faToggleOn, faSort, faSortUp, faSortDown, faCog} from "@fortawesome/free-solid-svg-icons"; import { useTranslation } from 'react-i18next'; @@ -7,27 +7,32 @@ import { useTranslation } from 'react-i18next'; const DLC_MODS = new Set(['elevated-rails', 'quality', 'space-age']); const COLUMNS = [ - { key: 'compatibility', label: 'Compatibility', default: true, sortable: true, sortKey: 'compatibility' }, - { key: 'enabled', label: 'Enabled', default: true, sortable: true, sortKey: 'enabled' }, - { key: 'name', label: 'Name', default: true, sortable: true, sortKey: 'title' }, - { key: 'version', label: 'Mod Version', default: true, sortable: true, sortKey: 'version' }, - { key: 'factorio', label: 'Factorio Version', default: true, sortable: false }, - { key: 'size', label: 'File Size', default: false, sortable: true, sortKey: 'file_size' }, + { key: 'compatibility', label: 'Compatibility', default: true, sortable: true, sortKey: 'compatibility', width: 80 }, + { key: 'enabled', label: 'Enabled', default: true, sortable: true, sortKey: 'enabled', width: 70 }, + { key: 'name', label: 'Name', default: true, sortable: true, sortKey: 'title', width: 0 }, + { key: 'version', label: 'Mod Version', default: true, sortable: true, sortKey: 'version', width: 100 }, + { key: 'factorio', label: 'Factorio Version', default: true, sortable: false, width: 140 }, + { key: 'size', label: 'File Size', default: false, sortable: true, sortKey: 'file_size', width: 80 }, ]; +const loadWidths = () => { + try { return JSON.parse(localStorage.getItem('modlist_widths') || '{}'); } catch { return {}; } +}; +const saveWidths = (w) => localStorage.setItem('modlist_widths', JSON.stringify(w)); + const ModList = ({mods, factorioVersion, updateMod, toggleMod, deleteMod, addUpdatableMod = null, disabled = false}) => { const { t } = useTranslation('mods'); const [sortCol, setSortCol] = useState(null); const [sortDir, setSortDir] = useState('asc'); + const [colWidths, setColWidths] = useState(loadWidths); const [visibleCols, setVisibleCols] = useState(() => { const saved = localStorage.getItem('modlist_cols'); - if (saved) { - try { return JSON.parse(saved); } catch {} - } + if (saved) { try { return JSON.parse(saved); } catch {} } return COLUMNS.filter(c => c.default).map(c => c.key); }); const [showSettings, setShowSettings] = useState(false); + const [resizing, setResizing] = useState(null); const dlcMods = mods.filter(m => DLC_MODS.has(m.name)); const regularMods = mods.filter(m => !DLC_MODS.has(m.name)); @@ -49,12 +54,8 @@ const ModList = ({mods, factorioVersion, updateMod, toggleMod, deleteMod, addUpd }, [regularMods, sortCol, sortDir]); const handleSort = (colKey) => { - if (sortCol === colKey) { - setSortDir(d => d === 'asc' ? 'desc' : 'asc'); - } else { - setSortCol(colKey); - setSortDir('asc'); - } + if (sortCol === colKey) { setSortDir(d => d === 'asc' ? 'desc' : 'asc'); } + else { setSortCol(colKey); setSortDir('asc'); } }; const toggleCol = (key) => { @@ -65,6 +66,32 @@ const ModList = ({mods, factorioVersion, updateMod, toggleMod, deleteMod, addUpd }); }; + const onResizeStart = useCallback((e, colKey) => { + e.preventDefault(); + const startX = e.clientX; + const startWidth = colWidths[colKey] || COLUMNS.find(c => c.key === colKey)?.width || 100; + setResizing({ colKey, startX, startWidth }); + }, [colWidths]); + + useEffect(() => { + if (!resizing) return; + const onMove = (e) => { + const diff = e.clientX - resizing.startX; + const newWidth = Math.max(40, resizing.startWidth + diff); + setColWidths(prev => ({ ...prev, [resizing.colKey]: newWidth })); + }; + const onUp = () => { + setColWidths(prev => { saveWidths(prev); return prev; }); + setResizing(null); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + return () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + }, [resizing]); + const toggleDLC = () => { dlcMods.forEach(m => { if (dlcEnabled === m.enabled) toggleMod(m.name); @@ -72,10 +99,11 @@ const ModList = ({mods, factorioVersion, updateMod, toggleMod, deleteMod, addUpd }; const SortIcon = ({colKey}) => { - if (sortCol !== colKey) return ; + if (sortCol !== colKey) return ; return ; }; + const getWidth = (col) => colWidths[col.key] || col.width || undefined; const vis = (key) => visibleCols.includes(key); return ( @@ -96,23 +124,68 @@ const ModList = ({mods, factorioVersion, updateMod, toggleMod, deleteMod, addUpd )} -
    handleSort('compatibility')}>{t('Compatibility')} handleSort('enabled')}>{t('Enabled')} handleSort('name')}>{t('Name')} handleSort('version')}>{t('Mod Version')}{t('Factorio Version')} handleSort('size')}>{t('File Size')}
    handleSort('compatibility')}>{t('Compatibility')} handleSort('enabled')}>{t('Enabled')} handleSort('name')}>{t('Name')} handleSort('version')}>{t('Mod Version')}{t('Factorio Version')} handleSort('size')}>{t('File Size')}
    +
    + + {vis('compatibility') && } + {vis('enabled') && } + {vis('name') && } + {vis('version') && } + {vis('factorio') && } + {vis('size') && } + + - - {vis('compatibility') && } - {vis('enabled') && } - {vis('name') && } - {vis('version') && } - {vis('factorio') && } - {vis('size') && } + + {vis('compatibility') && ( + + )} + {vis('enabled') && ( + + )} + {vis('name') && ( + + )} + {vis('version') && ( + + )} + {vis('factorio') && ( + + )} + {vis('size') && ( + + )} {factorioVersion !== null && dlcMods.length > 0 && ( - - {vis('compatibility') && } - {vis('enabled') && + {vis('compatibility') && } + {vis('enabled') && } - {vis('name') && } - {vis('version') && } - {vis('factorio') && } - {vis('size') && } + {vis('name') && } + {vis('version') && } + {vis('factorio') && } + {vis('size') && } )} From 93d6a225e8adfef54930333990fc6396b962c423 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Wed, 24 Jun 2026 15:53:26 +0000 Subject: [PATCH 109/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=E5=AF=BC=E8=87=B4=E9=87=8D=E5=A4=8D=20=E2=80=94=20?= =?UTF-8?q?=E7=A8=B3=E5=AE=9A=E6=8E=92=E5=BA=8F=20+=20=E5=94=AF=E4=B8=80?= =?UTF-8?q?=20React=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sort 相等时回退 name→version 二级排序,避免不稳定排序产生重复 - key 改为 file_name 避免同名 mod 键冲突 --- ui/App/views/Mods/components/ModList.jsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ui/App/views/Mods/components/ModList.jsx b/ui/App/views/Mods/components/ModList.jsx index a6d08713..cbcd9c93 100644 --- a/ui/App/views/Mods/components/ModList.jsx +++ b/ui/App/views/Mods/components/ModList.jsx @@ -49,7 +49,11 @@ const ModList = ({mods, factorioVersion, updateMod, toggleMod, deleteMod, addUpd if (typeof vb === 'string') vb = vb.toLowerCase(); if (va == null) return 1; if (vb == null) return -1; - return va < vb ? -dir : va > vb ? dir : 0; + const cmp = va < vb ? -dir : va > vb ? dir : 0; + if (cmp !== 0) return cmp; + const nameCmp = (a.name || '').localeCompare(b.name || ''); + if (nameCmp !== 0) return nameCmp; + return (a.version || '').localeCompare(b.version || ''); }); }, [regularMods, sortCol, sortDir]); @@ -201,7 +205,7 @@ const ModList = ({mods, factorioVersion, updateMod, toggleMod, deleteMod, addUpd )} {factorioVersion !== null && sortedMods.map(mod => ( - From a0272174377dc555d00e77dd3400c38ea89864d5 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Wed, 24 Jun 2026 15:57:19 +0000 Subject: [PATCH 110/112] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20LoadMods=20?= =?UTF-8?q?=E5=9B=9E=E9=80=80=20=E2=80=94=20=E6=81=A2=E5=A4=8D=20mod=5Fsyn?= =?UTF-8?q?c=20=E9=80=89=E6=8B=A9=E6=80=A7=E5=90=8C=E6=AD=A5=20+=20?= =?UTF-8?q?=E5=85=B1=E4=BA=AB=E8=AE=A4=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/App/views/Mods/components/LoadMods.jsx | 266 +++++++++++++++++----- 1 file changed, 214 insertions(+), 52 deletions(-) diff --git a/ui/App/views/Mods/components/LoadMods.jsx b/ui/App/views/Mods/components/LoadMods.jsx index 331c3527..09afcdb8 100644 --- a/ui/App/views/Mods/components/LoadMods.jsx +++ b/ui/App/views/Mods/components/LoadMods.jsx @@ -1,83 +1,245 @@ import React, {useEffect, useState} from "react"; import savesResource from "../../../../api/resources/saves"; -import Select from "../../../components/Select"; import Label from "../../../components/Label"; -import {useForm} from "react-hook-form"; import Button from "../../../components/Button"; import modsResource from "../../../../api/resources/mods"; -import modResource from "../../../../api/resources/mods"; import FactorioLogin from "./AddMod/components/FactorioLogin"; -import ConfirmDialog from "../../../components/ConfirmDialog"; +import socket from "../../../../api/socket"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {useTranslation} from "react-i18next"; +import {faSpinner, faCheck, faTimes, faMinusCircle} from "@fortawesome/free-solid-svg-icons"; -const LoadMods = ({refreshMods}) => { +const DLC_MODS = new Set(['elevated-rails', 'quality', 'space-age']); +const STATUS_ICON = { + downloading: , + downloaded: , + installed: , + wrong_version: , + missing: , + builtin: , + not_found: , +}; + +const LoadMods = ({refreshMods, isFactorioAuthenticated, setIsFactorioAuthenticated}) => { + const {t} = useTranslation('mods'); const [saves, setSaves] = useState([]); - const {register, reset, handleSubmit} = useForm(); + const [selectedSave, setSelectedSave] = useState(""); const [isLoading, setIsLoading] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); const [isDisabled, setIsDisabled] = useState(true); - const [isFactorioAuthenticated, setIsFactorioAuthenticated] = useState(false); - const [loadModsData, setLoadModsData] = useState(undefined); + const [modRows, setModRows] = useState([]); + const [checkedMods, setCheckedMods] = useState({}); + const [syncError, setSyncError] = useState(null); + const [currentMod, setCurrentMod] = useState(null); + const [warning, setWarning] = useState(null); useEffect(() => { (async () => { - setIsFactorioAuthenticated(await modResource.portal.status()) - - const s = await savesResource.list() + if (!isFactorioAuthenticated) { + setIsFactorioAuthenticated(await modsResource.portal.status()); + } + const s = await savesResource.list(); setSaves(s); if (s.length > 0) { setIsDisabled(false); + setSelectedSave(s[0].name); } - reset(); })(); }, []); - const loadModsRequested = data => { + useEffect(() => { + const handler = (message) => { + const data = JSON.parse(message); + if (data.status === "progress") { + setCurrentMod(data.mod); + setModRows(rows => rows.map(r => + r.name === data.mod ? {...r, status: "downloading"} : r + )); + } else if (data.status === "done") { + setIsSyncing(false); + setCurrentMod(null); + setWarning(data.warning || null); + if (data.mods) { + setModRows(data.mods); + setCheckedMods({}); + } + refreshMods(); + } else if (data.status === "error") { + setIsSyncing(false); + setCurrentMod(null); + setSyncError(data.message); + } + }; + + socket.on('mods_sync', handler); + socket.emit('mods sync subscribe'); + return () => { + socket.off('mods_sync', handler); + socket.emit('mods sync unsubscribe'); + }; + }, []); + + const onReadSave = async () => { + if (!selectedSave) return; setIsLoading(true); - setLoadModsData(data); - } + setModRows([]); + setCheckedMods({}); + setSyncError(null); + setWarning(null); - const loadMods = async data => { - await modResource.deleteAll(); - const {mods} = await savesResource.mods(data.save).catch(() => { + try { + const mods = await modsResource.getFromSave(selectedSave); + setModRows(mods || []); + const checked = {}; + (mods || []).forEach(m => { + if (m.status === 'missing' || m.status === 'wrong_version') { + checked[m.name] = true; + } + }); + setCheckedMods(checked); + } catch(e) { + setSyncError(t('failedToReadSave') + ': ' + e.message); + } finally { setIsLoading(false); - setLoadModsData(undefined); + } + }; + + const onSync = async () => { + const toSync = Object.keys(checkedMods).filter(k => checkedMods[k]); + if (toSync.length === 0) return; + + setIsSyncing(true); + setSyncError(null); + try { + await modsResource.syncFromSave(selectedSave, toSync); + } catch (e) { + setSyncError(t('failedToStartSync') + ': ' + e.message); + setIsSyncing(false); + } + }; + + const selectMissing = () => { + const checked = {}; + modRows.forEach(m => { + if (m.status !== 'builtin' && m.status !== 'installed') { + checked[m.name] = true; + } }); + setCheckedMods(checked); + }; - await modResource.portal.installMultiple(mods) - .then(() => { - refreshMods(); - window.flash(`Mods are loaded from save file ${data.save}.`, "green"); - }).finally(() => { - setIsLoading(false); - setLoadModsData(undefined); - }); + const clearAll = () => setCheckedMods({}); + + const checkedCount = Object.values(checkedMods).filter(Boolean).length; + + if (!isFactorioAuthenticated) { + return ; } - return isFactorioAuthenticated - ?
    -
    handleSort('compatibility')}>{t('Compatibility')} handleSort('enabled')}>{t('Enabled')} handleSort('name')}>{t('Name')} handleSort('version')}>{t('Mod Version')}{t('Factorio Version')} handleSort('size')}>{t('File Size')}
    handleSort('compatibility')}> + {t('Compatibility')} +
    onResizeStart(e, 'compatibility')}/> +
    handleSort('enabled')}> + {t('Enabled')} +
    onResizeStart(e, 'enabled')}/> +
    handleSort('name')}> + {t('Name')} +
    onResizeStart(e, 'name')}/> +
    handleSort('version')}> + {t('Mod Version')} +
    onResizeStart(e, 'version')}/> +
    + {t('Factorio Version')} +
    onResizeStart(e, 'factorio')}/> +
    handleSort('size')}> + {t('File Size')} +
    onResizeStart(e, 'size')}/> +
    +
    {disabled ? dlcEnabled ? : : dlcEnabled @@ -120,10 +193,10 @@ const ModList = ({mods, factorioVersion, updateMod, toggleMod, deleteMod, addUpd : } {t('Space Age DLC')}{dlcMods[0]?.version}{dlcMods[0]?.factorio_version}-{t('Space Age DLC')}{dlcMods[0]?.version}{dlcMods[0]?.factorio_version}-
    + + + + + + + + + + + {modRows.map(mod => ( + + + + + + + + ))} + +
    {t('mod')}{t('required')}{t('installed')}{t('status')}
    + {mod.status === 'builtin' ? null : ( + { + setCheckedMods(prev => ({ + ...prev, + [mod.name]: !prev[mod.name] + })); + }} + /> + )} + + {mod.name} + {DLC_MODS.has(mod.name) && ( + DLC + )} + {mod.version_required || mod.version}{mod.version_installed || '-'} + {STATUS_ICON[mod.status] || mod.status} +
    + + )} + + ); +}; export default LoadMods; From 73326a95c43e78b580139cad275ef09f4d97621e Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Wed, 24 Jun 2026 16:01:16 +0000 Subject: [PATCH 111/112] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20=E6=A8=A1?= =?UTF-8?q?=E7=BB=84=E6=93=8D=E4=BD=9C=E4=BA=92=E6=96=A5=E9=94=81=20?= =?UTF-8?q?=E2=80=94=20=E9=98=B2=E6=AD=A2=E5=86=B2=E7=AA=81=E6=93=8D?= =?UTF-8?q?=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isBusy 状态来自父组件,同步中/删除中/更新中任一为真 - isBusy 状态下禁用全部模组操作(安装/上传/同步/删除/切换) - 开服状态下仍独立阻止模组操作 - LoadMods 同步状态传递到父组件级联锁 --- ui/App/views/Mods/Mods.jsx | 13 ++++++++----- ui/App/views/Mods/components/LoadMods.jsx | 5 ++++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/ui/App/views/Mods/Mods.jsx b/ui/App/views/Mods/Mods.jsx index 45e4a01c..f00ee1bf 100644 --- a/ui/App/views/Mods/Mods.jsx +++ b/ui/App/views/Mods/Mods.jsx @@ -32,6 +32,7 @@ const Mods = ({serverStatus}) => { const [showDeleteAllConfirm, setShowDeleteAllConfirm] = useState(false); const [isFactorioAuthenticated, setIsFactorioAuthenticated] = useState(false); const [authChecked, setAuthChecked] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); const addUpdatableMod = mod => { setUpdatableMods(mods => { @@ -131,10 +132,11 @@ const Mods = ({serverStatus}) => { } let disabled = serverStatus.running + let isBusy = disabled || isDeletingAllMods || isUpdatingAllMods || isSyncing return (
    - {disabled ? + {isBusy ? @@ -165,7 +167,8 @@ const Mods = ({serverStatus}) => { + setIsFactorioAuthenticated={setIsFactorioAuthenticated} + setIsSyncing={setIsSyncing} />
    @@ -186,12 +189,12 @@ const Mods = ({serverStatus}) => { } actions={ <> - { - !disabled && + { + !isBusy && } - {!disabled ? ( + {!isBusy ? ( <> {t('downloadAllMods')} diff --git a/ui/App/views/Mods/components/LoadMods.jsx b/ui/App/views/Mods/components/LoadMods.jsx index 09afcdb8..29512998 100644 --- a/ui/App/views/Mods/components/LoadMods.jsx +++ b/ui/App/views/Mods/components/LoadMods.jsx @@ -21,7 +21,7 @@ const STATUS_ICON = { not_found: , }; -const LoadMods = ({refreshMods, isFactorioAuthenticated, setIsFactorioAuthenticated}) => { +const LoadMods = ({refreshMods, isFactorioAuthenticated, setIsFactorioAuthenticated, setIsSyncing}) => { const {t} = useTranslation('mods'); const [saves, setSaves] = useState([]); const [selectedSave, setSelectedSave] = useState(""); @@ -58,6 +58,7 @@ const LoadMods = ({refreshMods, isFactorioAuthenticated, setIsFactorioAuthentica )); } else if (data.status === "done") { setIsSyncing(false); + if (setIsSyncing) setIsSyncing(false); setCurrentMod(null); setWarning(data.warning || null); if (data.mods) { @@ -67,6 +68,7 @@ const LoadMods = ({refreshMods, isFactorioAuthenticated, setIsFactorioAuthentica refreshMods(); } else if (data.status === "error") { setIsSyncing(false); + if (setIsSyncing) setIsSyncing(false); setCurrentMod(null); setSyncError(data.message); } @@ -110,6 +112,7 @@ const LoadMods = ({refreshMods, isFactorioAuthenticated, setIsFactorioAuthentica if (toSync.length === 0) return; setIsSyncing(true); + if (setIsSyncing) setIsSyncing(true); setSyncError(null); try { await modsResource.syncFromSave(selectedSave, toSync); From c465db554d540b24373f7f0e8f942baaacc6c6e4 Mon Sep 17 00:00:00 2001 From: BAYUNZIYUE Date: Thu, 2 Jul 2026 15:30:58 +0000 Subject: [PATCH 112/112] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20mod=20=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=8F=96=E6=B6=88=20+=20ModList=20=E8=A1=A8=E6=A0=BC?= =?UTF-8?q?=E9=87=8D=E5=86=99=20+=20EventLog=20+=20=E6=B8=B8=E6=88=8F?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E5=90=88=E5=B9=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ROADMAP.md | 27 ++ package-lock.json | 51 ++- package.json | 16 +- src/api/mods_handler.go | 35 +- src/api/routes.go | 8 +- src/factorio/mod_Mods.go | 6 +- src/factorio/mod_modInfo.go | 4 +- src/factorio/mod_modSimple.go | 14 +- src/factorio/mod_sync.go | 242 +++++++---- src/factorio/version_manager.go | 103 +++-- ui/App/App.jsx | 4 +- ui/App/components/ConfirmDialog.jsx | 4 +- ui/App/components/Layout.jsx | 4 +- ui/App/components/Modal.jsx | 18 +- ui/App/components/Panel.jsx | 8 +- ui/App/components/Tabs/TabControl.jsx | 10 +- ui/App/eventLog.js | 46 +++ ui/App/locales/zh-controls.json | 8 +- ui/App/locales/zh-mods.json | 15 +- ui/App/locales/zh-serverSettings.json | 2 +- ui/App/locales/zh-serverVersion.json | 2 +- ui/App/locales/zh.json | 4 +- ui/App/views/Console.jsx | 110 ++--- ui/App/views/Controls.jsx | 260 ++++++++---- ui/App/views/EventLog.jsx | 98 +++++ ui/App/views/GameSettings.jsx | 55 --- ui/App/views/Mods/Mods.jsx | 64 ++- ui/App/views/Mods/components/LoadMods.jsx | 301 +++++++++++++- ui/App/views/Mods/components/Mod.jsx | 289 +++++++------ ui/App/views/Mods/components/ModList.jsx | 477 ++++++++++++++-------- ui/api/client.js | 5 +- ui/api/resources/mods.js | 31 +- ui/api/socket.js | 150 +++---- ui/index.js | 6 +- 34 files changed, 1661 insertions(+), 816 deletions(-) create mode 100644 ui/App/eventLog.js create mode 100644 ui/App/views/EventLog.jsx delete mode 100644 ui/App/views/GameSettings.jsx diff --git a/ROADMAP.md b/ROADMAP.md index a066617c..e265eb50 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,6 +23,33 @@ - [x] Auth gate preventing login form flash - [x] 401 auto-redirect to login page +## 📂 Worktree 隔离开发 + +``` +主目录: /workspace/projects/factorio-server-manager-joey (当前功能) +参考目录: /workspace/projects/factorio-server-manager-develop (joey/develop) + +新功能: + git worktree add ../factorio-server-manager- -b feat/ + cd ../factorio-server-manager- + # 开发、构建、测试... + # 完成后: git push && git worktree remove ../factorio-server-manager- +``` + +## 🧪 测试流程(每次改动必做) + +修改代码后,必须闭环验证以下步骤: + +1. **构建**: `npm run build && go build` +2. **部署**: `cp -r app/* /home/game/fsm/app/ && cp factorio-server-manager /home/game/fsm/` +3. **重启**: `kill $(pgrep factorio-server); cd /home/game/fsm && setsid ./factorio-server-manager ...` +4. **登录**: Playwright 打开 `/login` → 输入凭据 → 确认跳转到主页 +5. **功能页**: 导航到改动的页面,等待 15 秒 +6. **检查**: 控制台 0 个 JS Error,页面正常渲染 +7. **交互**: 点击/拖拽核心功能,确认不崩溃 + +> 每次提交前跑一遍,不允许「改完就提交」。 + ## 🚧 Planned ### Mods diff --git a/package-lock.json b/package-lock.json index 073aa378..f13ea7e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@fortawesome/fontawesome-svg-core": "^6.4.2", "@fortawesome/free-solid-svg-icons": "^6.4.2", "@fortawesome/react-fontawesome": "^0.2.0", + "@tanstack/react-table": "^8.21.3", "axios": "^1.6.0", "fuse.js": "^7.0.0", "i18next": "^25.5.2", @@ -2159,6 +2160,39 @@ "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", "dev": true }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", @@ -5468,7 +5502,6 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", - "dev": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -5837,7 +5870,6 @@ "version": "0.23.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", - "dev": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -8106,6 +8138,19 @@ "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", "dev": true }, + "@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "requires": { + "@tanstack/table-core": "8.21.3" + } + }, + "@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==" + }, "@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", @@ -10391,7 +10436,6 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", - "dev": true, "requires": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -10628,7 +10672,6 @@ "version": "0.23.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", - "dev": true, "requires": { "loose-envify": "^1.1.0" } diff --git a/package.json b/package.json index 1a881193..a6497036 100644 --- a/package.json +++ b/package.json @@ -56,18 +56,16 @@ "@fortawesome/fontawesome-svg-core": "^6.4.2", "@fortawesome/free-solid-svg-icons": "^6.4.2", "@fortawesome/react-fontawesome": "^0.2.0", + "@tanstack/react-table": "^8.21.3", "axios": "^1.6.0", "fuse.js": "^7.0.0", - "i18next": "^26.3.1", - "i18next-browser-languagedetector": "^8.2.1", + "i18next": "^25.5.2", + "i18next-browser-languagedetector": "^8.2.0", + "i18next-http-backend": "^3.0.2", "react-hook-form": "^7.47.0", - "react-i18next": "^17.0.8", + "react-i18next": "^15.7.3", "regenerator-runtime": "^0.14.0", "semver": "^7.3.7", - "tailwindcss": "^3.3.5", - "i18next": "^25.5.2", - "react-i18next": "^15.7.3", - "i18next-http-backend": "^3.0.2", - "i18next-browser-languagedetector": "^8.2.0" + "tailwindcss": "^3.3.5" } -} \ No newline at end of file +} diff --git a/src/api/mods_handler.go b/src/api/mods_handler.go index eb314b44..b6bb0546 100644 --- a/src/api/mods_handler.go +++ b/src/api/mods_handler.go @@ -10,11 +10,18 @@ import ( "os" "path/filepath" + "github.com/OpenFactorioServerManager/factorio-server-manager/api/websocket" "github.com/OpenFactorioServerManager/factorio-server-manager/bootstrap" "github.com/OpenFactorioServerManager/factorio-server-manager/factorio" "github.com/OpenFactorioServerManager/factorio-server-manager/lockfile" ) +func broadcastModEvent(evt interface{}) { + data, _ := json.Marshal(evt) + room := websocket.WebsocketHub.GetRoom("mods_events") + room.Send(string(data)) +} + func CreateNewMods(w http.ResponseWriter) (modList factorio.Mods, resp interface{}, err error) { config := bootstrap.GetConfig() modList, err = factorio.NewMods(config.FactorioModsDir) @@ -127,11 +134,11 @@ func ModDeleteHandler(w http.ResponseWriter, r *http.Request) { log.Println(resp) return } + broadcastModEvent(map[string]string{"type": "mod_deleted", "name": data.Name}) resp = map[string]string{"status": "ok", "name": data.Name} } func ModDeleteAllHandler(w http.ResponseWriter, r *http.Request) { - var err error var resp interface{} defer func() { @@ -140,15 +147,23 @@ func ModDeleteAllHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json;charset=UTF-8") - //delete mods folder - err = factorio.DeleteAllMods() - if err != nil { + config := bootstrap.GetConfig() + + mods, listErr := factorio.NewMods(config.FactorioModsDir) + if listErr == nil { + for _, mod := range mods.ModInfoList.Mods { + broadcastModEvent(map[string]string{"type": "mod_deleted", "name": mod.Name}) + } + } + + if err := factorio.DeleteAllMods(); err != nil { resp = fmt.Sprintf("Error deleting all mods: %s", err) log.Println(resp) w.WriteHeader(http.StatusInternalServerError) return } + broadcastModEvent(map[string]string{"type": "mods_cleared"}) resp = nil } @@ -386,12 +401,6 @@ func SyncModsFromSaveHandler(w http.ResponseWriter, r *http.Request) { return } - if factorio.IsModsSyncing() { - w.WriteHeader(http.StatusConflict) - resp = "mod sync already in progress" - return - } - config := bootstrap.GetConfig() savePath := filepath.Join(config.FactorioSavesDir, syncRequest.Name) @@ -405,6 +414,12 @@ func SyncModsFromSaveHandler(w http.ResponseWriter, r *http.Request) { resp = map[string]string{"status": "started"} } +func CancelModsSyncHandler(w http.ResponseWriter, r *http.Request) { + factorio.CancelModsSync() + resp := map[string]string{"status": "cancelled"} + WriteResponse(w, resp) +} + func GetModsFromSaveHandler(w http.ResponseWriter, r *http.Request) { var resp interface{} defer func() { WriteResponse(w, resp) }() diff --git a/src/api/routes.go b/src/api/routes.go index b70ca4a4..353ba42d 100644 --- a/src/api/routes.go +++ b/src/api/routes.go @@ -181,12 +181,18 @@ var apiRoutes = Routes{ "/saves/mods/list", GetModsFromSaveHandler, true, - }, { + }, { "SyncModsFromSave", "POST", "/saves/mods/sync", SyncModsFromSaveHandler, true, + }, { + "CancelModsSync", + "POST", + "/saves/mods/sync/cancel", + CancelModsSyncHandler, + false, }, { "LogTail", "GET", diff --git a/src/factorio/mod_Mods.go b/src/factorio/mod_Mods.go index c457b925..de1050f5 100644 --- a/src/factorio/mod_Mods.go +++ b/src/factorio/mod_Mods.go @@ -74,12 +74,11 @@ func (mods *Mods) ListInstalledMods() ModsResultList { result.ModsResult = append(result.ModsResult, modsResult) } - // Добавляем моды из mod-list.json которых нет как zip (DLC моды) + dlcNames := map[string]bool{"elevated-rails": true, "quality": true, "space-age": true} for _, simpleMod := range mods.ModSimpleList.Mods { - if simpleMod.Name == "base" { + if !dlcNames[simpleMod.Name] { continue } - // Проверяем — есть ли уже в результатах found := false for _, r := range result.ModsResult { if r.Name == simpleMod.Name { @@ -88,7 +87,6 @@ func (mods *Mods) ListInstalledMods() ModsResultList { } } if !found { - // DLC мод — только в mod-list.json, без zip var modsResult ModsResult modsResult.Name = simpleMod.Name modsResult.Title = simpleMod.Name diff --git a/src/factorio/mod_modInfo.go b/src/factorio/mod_modInfo.go index 10e72a27..221f5afa 100644 --- a/src/factorio/mod_modInfo.go +++ b/src/factorio/mod_modInfo.go @@ -172,8 +172,8 @@ func (modInfoList *ModInfoList) deleteMod(modName string) error { } } - log.Printf("the mod-file for mod %s doesn't exist!", modName) - return errors.New("the mod-file for mod " + modName + " doesn't exist!") + log.Printf("mod %s not found as zip — nothing to delete", modName) + return nil } func (modInfo *ModInfo) getModInfo(reader *zip.Reader) error { diff --git a/src/factorio/mod_modSimple.go b/src/factorio/mod_modSimple.go index 4cbb0359..dd4f439d 100644 --- a/src/factorio/mod_modSimple.go +++ b/src/factorio/mod_modSimple.go @@ -6,8 +6,11 @@ import ( "io/ioutil" "log" "os" + "sync" ) +var modListWriteMu sync.Mutex + type ModSimple struct { Name string `json:"name"` Enabled bool `json:"enabled"` @@ -70,14 +73,15 @@ func (modSimpleList *ModSimpleList) listInstalledMods() error { } func (modSimpleList *ModSimpleList) saveModInfoJson() error { - var err error + modListWriteMu.Lock() + defer modListWriteMu.Unlock() - //build json of current state newJson, _ := json.MarshalIndent(modSimpleList, "", " ") - err = ioutil.WriteFile(modSimpleList.Destination+"/mod-list.json", newJson, 0664) - if err != nil { - log.Printf("error when writing new mod-list: %s", err) + path := modSimpleList.Destination + "/mod-list.json" + + if err := ioutil.WriteFile(path, newJson, 0664); err != nil { + log.Printf("error when writing mod-list: %s", err) return err } diff --git a/src/factorio/mod_sync.go b/src/factorio/mod_sync.go index 84d6fadd..6492182c 100644 --- a/src/factorio/mod_sync.go +++ b/src/factorio/mod_sync.go @@ -1,14 +1,19 @@ package factorio import ( + "bytes" + "compress/flate" + "compress/zlib" "encoding/json" + "errors" "fmt" + "io" "io/ioutil" "log" - "bytes" - "compress/flate" - "compress/zlib" "net/http" + "os" + "path/filepath" + "sync" "sync/atomic" "github.com/OpenFactorioServerManager/factorio-server-manager/api/websocket" @@ -16,11 +21,16 @@ import ( ) var modsSyncing atomic.Bool +var modsSyncCancel atomic.Bool func IsModsSyncing() bool { return modsSyncing.Load() } +func CancelModsSync() { + modsSyncCancel.Store(true) +} + type ModSyncResult struct { Name string `json:"name"` Version string `json:"version"` @@ -28,14 +38,16 @@ type ModSyncResult struct { } type ModSyncProgress struct { - Type string `json:"type"` - Status string `json:"status"` - Current int `json:"current,omitempty"` - Total int `json:"total,omitempty"` - Mod string `json:"mod,omitempty"` - Message string `json:"message,omitempty"` - Warning string `json:"warning,omitempty"` - Mods []ModSyncResult `json:"mods,omitempty"` + Type string `json:"type"` + Status string `json:"status"` + Current int `json:"current,omitempty"` + Total int `json:"total,omitempty"` + Mod string `json:"mod,omitempty"` + Message string `json:"message,omitempty"` + Warning string `json:"warning,omitempty"` + Mods []ModSyncResult `json:"mods,omitempty"` + Downloaded int64 `json:"downloaded,omitempty"` + Size int64 `json:"size,omitempty"` } // baseModNames — моды которые есть в любом vanilla + DLC сейве @@ -84,6 +96,7 @@ func normalizeVersion(v Version) string { } var ErrModNotOnPortal = fmt.Errorf("mod not available on portal (builtin or DLC)") +var ErrVersionNotFound = fmt.Errorf("requested version not found on portal") // LevelDatMod хранит мод из level.dat0 type LevelDatMod struct { @@ -178,12 +191,9 @@ func getModRelease(modName string, version string) (portalModRelease, error) { Releases []portalModRelease `json:"releases"` } if err := json.Unmarshal(body, &fullInfo); err == nil { - // no-category — DLC заглушка без релизов - if fullInfo.Category == "no-category" { + if fullInfo.Category == "no-category" && len(fullInfo.Releases) == 0 { return portalModRelease{}, ErrModNotOnPortal } - // internal без релизов — встроенный DLC (elevated-rails, space-age) - // internal с релизами — обычный мод (flib) if fullInfo.Category == "internal" && len(fullInfo.Releases) == 0 { return portalModRelease{}, ErrModNotOnPortal } @@ -200,6 +210,10 @@ func getModRelease(modName string, version string) (portalModRelease, error) { } } + if len(info.Releases) > 0 { + return info.Releases[0], ErrVersionNotFound + } + return portalModRelease{}, fmt.Errorf("version %s not found for mod %s on portal", version, modName) } @@ -213,6 +227,7 @@ func SyncModsFromSave(savePath string, modNames []string) { return } defer modsSyncing.Store(false) + modsSyncCancel.Store(false) config := bootstrap.GetConfig() @@ -297,78 +312,155 @@ func SyncModsFromSave(savePath string, modNames []string) { return } - // 5. Качаем недостающие - for i, saveMod := range toDownload { - wantVersion := normalizeVersion(saveMod.Version) + sem := make(chan struct{}, 5) + var wg sync.WaitGroup + var resultsMu sync.Mutex + + for _, saveMod := range toDownload { + saveMod := saveMod + wg.Add(1) + go func() { + defer wg.Done() + defer func() { + if r := recover(); r != nil { + log.Printf("PANIC in sync goroutine for %s: %v", saveMod.Name, r) + } + }() + + wantVersion := normalizeVersion(saveMod.Version) + + if baseModNames[saveMod.Name] { + resultsMu.Lock() + results = append(results, ModSyncResult{Name: saveMod.Name, Version: wantVersion, Status: "builtin"}) + resultsMu.Unlock() + sendSyncProgress(ModSyncProgress{Status: "builtin", Mod: saveMod.Name}) + return + } - sendSyncProgress(ModSyncProgress{ - Status: "progress", - Current: i + 1, - Total: total, - Mod: saveMod.Name, - }) + sem <- struct{}{} + defer func() { <-sem }() - // Сначала проверяем локальный список DLC/базовых модов - if baseModNames[saveMod.Name] { - log.Printf("SyncModsFromSave: %s is builtin/DLC (local list), skipping", saveMod.Name) - results = append(results, ModSyncResult{Name: saveMod.Name, Version: wantVersion, Status: "builtin"}) - continue - } - - release, err := getModRelease(saveMod.Name, wantVersion) - if err == ErrModNotOnPortal { - log.Printf("SyncModsFromSave: %s is builtin/DLC, skipping", saveMod.Name) - results = append(results, ModSyncResult{Name: saveMod.Name, Version: wantVersion, Status: "builtin"}) - continue - } - if err != nil { - results = append(results, ModSyncResult{Name: saveMod.Name, Version: wantVersion, Status: "not_found"}) - log.Printf("SyncModsFromSave: %s not found on portal: %v", saveMod.Name, err) - continue - } + if modsSyncCancel.Load() { + return + } - mods, err = NewMods(config.FactorioModsDir) - if err != nil { - sendSyncProgress(ModSyncProgress{Status: "error", Message: fmt.Sprintf("cannot refresh mods: %v", err)}) - return - } + release, err := getModRelease(saveMod.Name, wantVersion) + if err == ErrModNotOnPortal { + resultsMu.Lock() + results = append(results, ModSyncResult{Name: saveMod.Name, Version: wantVersion, Status: "builtin"}) + resultsMu.Unlock() + sendSyncProgress(ModSyncProgress{Status: "builtin", Mod: saveMod.Name}) + return + } + if errors.Is(err, ErrVersionNotFound) { + resultsMu.Lock() + results = append(results, ModSyncResult{Name: saveMod.Name, Version: wantVersion, Status: "version_mismatch"}) + resultsMu.Unlock() + info, _ := json.Marshal(map[string]string{ + "downloadUrl": release.DownloadURL, + "fileName": release.FileName, + "latest": release.Version, + }) + sendSyncProgress(ModSyncProgress{Status: "version_mismatch", Mod: saveMod.Name, Message: string(info)}) + return + } + if err != nil { + resultsMu.Lock() + results = append(results, ModSyncResult{Name: saveMod.Name, Version: wantVersion, Status: "not_found"}) + resultsMu.Unlock() + sendSyncProgress(ModSyncProgress{Status: "not_found", Mod: saveMod.Name}) + return + } - if _, err = mods.DownloadMod(release.DownloadURL, release.FileName, saveMod.Name, nil); err != nil { - results = append(results, ModSyncResult{Name: saveMod.Name, Version: wantVersion, Status: "not_found"}) - log.Printf("SyncModsFromSave: download failed for %s: %v", saveMod.Name, err) - continue - } + sendSyncProgress(ModSyncProgress{Status: "progress", Total: total, Mod: saveMod.Name}) - results = append(results, ModSyncResult{Name: saveMod.Name, Version: wantVersion, Status: "downloaded"}) - log.Printf("SyncModsFromSave: downloaded %s %s (%d/%d)", saveMod.Name, wantVersion, i+1, total) - } + cfg := bootstrap.GetConfig() + creds := Credentials{} + if _, statusErr := creds.Load(); statusErr != nil { + resultsMu.Lock() + results = append(results, ModSyncResult{Name: saveMod.Name, Version: wantVersion, Status: "not_found"}) + resultsMu.Unlock() + sendSyncProgress(ModSyncProgress{Status: "not_found", Mod: saveMod.Name}) + return + } - // Обновляем mod-list.json — включаем нужные, выключаем лишние - finalMods, err := NewMods(config.FactorioModsDir) - if err == nil { - // Строим set модов из сейва - saveModSet := make(map[string]bool) - for _, saveMod := range header.Mods { - if saveMod.Name != "base" { - saveModSet[saveMod.Name] = true + dlURL := "https://mods.factorio.com" + release.DownloadURL + "?username=" + creds.Username + "&token=" + creds.Userkey + dlResp, dlErr := http.Get(dlURL) + if dlErr != nil { + resultsMu.Lock() + results = append(results, ModSyncResult{Name: saveMod.Name, Version: wantVersion, Status: "not_found"}) + resultsMu.Unlock() + sendSyncProgress(ModSyncProgress{Status: "not_found", Mod: saveMod.Name}) + return } - } - // Включаем моды из сейва, выключаем остальные - for i, m := range finalMods.ModSimpleList.Mods { - if m.Name == "base" { - continue + defer dlResp.Body.Close() + + if dlResp.StatusCode != 200 { + resultsMu.Lock() + results = append(results, ModSyncResult{Name: saveMod.Name, Version: wantVersion, Status: "not_found"}) + resultsMu.Unlock() + sendSyncProgress(ModSyncProgress{Status: "not_found", Mod: saveMod.Name}) + return + } + + filePath := filepath.Join(cfg.FactorioModsDir, release.FileName) + FileLock.LockW(filePath) + defer FileLock.Unlock(filePath) + outFile, createErr := os.Create(filePath) + if createErr != nil { + resultsMu.Lock() + results = append(results, ModSyncResult{Name: saveMod.Name, Version: wantVersion, Status: "not_found"}) + resultsMu.Unlock() + sendSyncProgress(ModSyncProgress{Status: "not_found", Mod: saveMod.Name}) + return } - if saveModSet[m.Name] { - finalMods.ModSimpleList.Mods[i].Enabled = true + + totalSize := dlResp.ContentLength + var written int64 + if totalSize > 0 { + buf := make([]byte, 32*1024) + for { + if modsSyncCancel.Load() { + break + } + n, readErr := dlResp.Body.Read(buf) + if n > 0 { + _, writeErr := outFile.Write(buf[:n]) + if writeErr != nil { + break + } + written += int64(n) + sendSyncProgress(ModSyncProgress{ + Status: "progress", + Total: total, + Mod: saveMod.Name, + Downloaded: written, + Size: totalSize, + }) + } + if readErr != nil { + break + } + } } else { - finalMods.ModSimpleList.Mods[i].Enabled = false + written, _ = io.Copy(outFile, dlResp.Body) } - } - if saveErr := finalMods.ModSimpleList.saveModInfoJson(); saveErr != nil { - log.Printf("SyncModsFromSave: error saving mod-list.json: %v", saveErr) - } + outFile.Close() + + resultsMu.Lock() + results = append(results, ModSyncResult{Name: saveMod.Name, Version: wantVersion, Status: "downloaded"}) + resultsMu.Unlock() + log.Printf("SyncModsFromSave: downloaded %s %s", saveMod.Name, wantVersion) + + sendSyncProgress(ModSyncProgress{ + Status: "downloaded", + Mod: saveMod.Name, + }) + }() } + wg.Wait() + sendSyncProgress(ModSyncProgress{Status: "done", Total: total, Mods: results, Warning: vanillaWarning}) } diff --git a/src/factorio/version_manager.go b/src/factorio/version_manager.go index 2b0444cd..3cf6c76f 100644 --- a/src/factorio/version_manager.go +++ b/src/factorio/version_manager.go @@ -37,14 +37,76 @@ type VersionManager struct { Credentials *Credentials } -func (vm *VersionManager) GetCurrentVersion() (string, error) { - cmd := exec.Command(vm.FactorioBinary, "--version") +func checkBinaryVersion(binPath string) (string, error) { + info, err := os.Stat(binPath) + if err != nil { + return "", fmt.Errorf("failed to stat binary: %w", err) + } + src, err := os.Open(binPath) + if err != nil { + return "", fmt.Errorf("failed to open binary: %w", err) + } + defer src.Close() + + tmpFile, err := os.CreateTemp("", "factorio-version-*") + if err != nil { + return "", fmt.Errorf("failed to create temp file: %w", err) + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + + if _, err := io.Copy(tmpFile, src); err != nil { + tmpFile.Close() + return "", fmt.Errorf("failed to copy binary: %w", err) + } + tmpFile.Close() + src.Close() + + if err := os.Chmod(tmpPath, info.Mode()); err != nil { + return "", fmt.Errorf("failed to chmod temp binary: %w", err) + } + + cmd := exec.Command(tmpPath, "--version") output, err := cmd.Output() if err != nil { - return "", fmt.Errorf("failed to get current version: %w", err) + return "", fmt.Errorf("failed to read version: %w", err) } - line := string(output) - return parseVersionLine(line) + return parseVersionLine(string(output)) +} + +func (vm *VersionManager) GetCurrentVersion() (string, error) { + return checkBinaryVersion(vm.FactorioBinary) +} + +func RefreshServerVersion() error { + server := GetFactorioServer() + config := bootstrap.GetConfig() + + ver, err := checkBinaryVersion(config.FactorioBinary) + if err != nil { + return fmt.Errorf("failed to read version: %w", err) + } + + reg := regexp.MustCompile("Version.*?((\\d+\\.)?(\\d+\\.)?(\\*|\\d+)+)") + found := reg.FindStringSubmatch(ver) + if len(found) < 2 { + return fmt.Errorf("could not parse version from: %s", ver) + } + + if err := server.Version.UnmarshalText([]byte(found[1])); err != nil { + return fmt.Errorf("could not parse version: %w", err) + } + + baseModInfoFile := filepath.Join(config.FactorioBaseModDir, "info.json") + bmifBa, err := ioutil.ReadFile(baseModInfoFile) + if err == nil { + var modInfo ModInfo + if err := json.Unmarshal(bmifBa, &modInfo); err == nil { + server.BaseModVersion = modInfo.Version + } + } + + return nil } func (vm *VersionManager) GetAvailableVersions() ([]Release, error) { @@ -207,37 +269,6 @@ func (pr *ProgressReader) Read(p []byte) (int, error) { return n, err } -func RefreshServerVersion() error { - server := GetFactorioServer() - config := bootstrap.GetConfig() - - out, err := exec.Command(config.FactorioBinary, "--version").Output() - if err != nil { - return fmt.Errorf("failed to read version: %w", err) - } - - reg := regexp.MustCompile("Version.*?((\\d+\\.)?(\\d+\\.)?(\\*|\\d+)+)") - found := reg.FindStringSubmatch(string(out)) - if len(found) < 2 { - return fmt.Errorf("could not parse version from: %s", string(out)) - } - - if err := server.Version.UnmarshalText([]byte(found[1])); err != nil { - return fmt.Errorf("could not parse version: %w", err) - } - - baseModInfoFile := filepath.Join(config.FactorioBaseModDir, "info.json") - bmifBa, err := ioutil.ReadFile(baseModInfoFile) - if err == nil { - var modInfo ModInfo - if err := json.Unmarshal(bmifBa, &modInfo); err == nil { - server.BaseModVersion = modInfo.Version - } - } - - return nil -} - func NewVersionManager() *VersionManager { config := bootstrap.GetConfig() var creds Credentials diff --git a/ui/App/App.jsx b/ui/App/App.jsx index e0952e64..e25908d8 100644 --- a/ui/App/App.jsx +++ b/ui/App/App.jsx @@ -12,10 +12,10 @@ import server from "../api/resources/server"; import Mods from "./views/Mods/Mods"; import UserManagement from "./views/UserManagement/UserManagment"; import ServerSettings from "./views/ServerSettings"; -import GameSettings from "./views/GameSettings"; import Console from "./views/Console"; import Help from "./views/Help"; import ServerVersion from "./views/ServerVersion"; +import EventLog from "./views/EventLog"; import socket from "../api/socket"; import "./i18n"; import {Flash} from "./components/Flash"; @@ -66,12 +66,12 @@ const App = () => { }/> }/> }/> - }/> }/> }/> }/> }/> }/> + }/> diff --git a/ui/App/components/ConfirmDialog.jsx b/ui/App/components/ConfirmDialog.jsx index 124a552d..16717579 100644 --- a/ui/App/components/ConfirmDialog.jsx +++ b/ui/App/components/ConfirmDialog.jsx @@ -26,13 +26,13 @@ function ConfirmDialog({title, content, isOpen, close, onSuccess, closeImmediate - + } - isOpen={isOpen} /> ); } diff --git a/ui/App/components/Layout.jsx b/ui/App/components/Layout.jsx index 0d5d8628..96239be0 100644 --- a/ui/App/components/Layout.jsx +++ b/ui/App/components/Layout.jsx @@ -77,7 +77,6 @@ const Layout = ({handleLogout, serverStatus}) => { {t("mods.title")} {t("serverVersion.title")} {t("server_settings.title")} - {t("game_settings.title")} {t("console.title")} @@ -86,7 +85,8 @@ const Layout = ({handleLogout, serverStatus}) => {
    {t("users.title")} - {t("help.title")} + {t("help.title")} + 事件日志 setIsChangingLang(false)} diff --git a/ui/App/components/Modal.jsx b/ui/App/components/Modal.jsx index ae381248..f5f3691d 100644 --- a/ui/App/components/Modal.jsx +++ b/ui/App/components/Modal.jsx @@ -1,4 +1,3 @@ -import Panel from "./Panel"; import React from "react"; import * as ReactDom from "react-dom"; @@ -8,15 +7,16 @@ const Modal = ({title, content, isOpen, actions = null}) => { return ReactDom.createPortal((isOpen &&
    -
    - +
    +
    +
    {title}
    +
    + {content} +
    + {actions &&
    {actions}
    } +
    ), modalRoot) } -export default Modal; \ No newline at end of file +export default Modal; diff --git a/ui/App/components/Panel.jsx b/ui/App/components/Panel.jsx index e0707cfd..6d5028bf 100644 --- a/ui/App/components/Panel.jsx +++ b/ui/App/components/Panel.jsx @@ -6,15 +6,15 @@ const Panel = ({title, content, actions, className}) => {
    {title}
    -
    - {content} -
    {actions - ?
    + ?
    {actions}
    : null } +
    + {content} +
    ) } diff --git a/ui/App/components/Tabs/TabControl.jsx b/ui/App/components/Tabs/TabControl.jsx index 05371074..b4cabf66 100644 --- a/ui/App/components/Tabs/TabControl.jsx +++ b/ui/App/components/Tabs/TabControl.jsx @@ -1,9 +1,15 @@ -import React, {useState} from "react"; +import React, {useState, useEffect} from "react"; import TabTitle from "./TabTitle"; -const TabControl = ({children}) => { +const TabControl = ({children, activeIndex}) => { const [selectedTab, setSelectedTab] = useState(0) + useEffect(() => { + if (activeIndex !== undefined && activeIndex !== selectedTab) { + setSelectedTab(activeIndex); + } + }, [activeIndex]); + const handleSelect = (index) => { setSelectedTab(index); const child = children[index]; diff --git a/ui/App/eventLog.js b/ui/App/eventLog.js new file mode 100644 index 00000000..0b060d22 --- /dev/null +++ b/ui/App/eventLog.js @@ -0,0 +1,46 @@ +const MAX_EVENTS = 500; + +let listeners = []; +let events = []; + +export const eventLog = { + add(event) { + const entry = { + id: Date.now() + '_' + Math.random().toString(36).slice(2, 6), + time: new Date().toISOString(), + type: event.type || 'info', + message: event.message || '', + source: event.source || 'app', + }; + events.push(entry); + if (events.length > MAX_EVENTS) events = events.slice(-MAX_EVENTS); + listeners.forEach(fn => fn(entry)); + return entry; + }, + + getAll() { + return [...events]; + }, + + clear() { + events = []; + listeners.forEach(fn => fn({type: 'system', message: 'log cleared', source: 'system'})); + }, + + subscribe(fn) { + listeners.push(fn); + return () => { listeners = listeners.filter(f => f !== fn); }; + }, + + error(msg, source) { + return this.add({type: 'error', message: String(msg), source: source || 'app'}); + }, + + warn(msg, source) { + return this.add({type: 'warn', message: String(msg), source: source || 'app'}); + }, + + info(msg, source) { + return this.add({type: 'info', message: String(msg), source: source || 'app'}); + }, +}; diff --git a/ui/App/locales/zh-controls.json b/ui/App/locales/zh-controls.json index 164965af..5ba05c88 100644 --- a/ui/App/locales/zh-controls.json +++ b/ui/App/locales/zh-controls.json @@ -20,5 +20,11 @@ "stopServer": "停止服务器", "saveStopServer": "保存并停止", "saveSettings": "保存设置", - "settingsSaved": "设置已保存" + "settingsSaved": "设置已保存", + "players": "在线玩家", + "uptime": "运行时长", + "compatible": "兼容", + "incompatible": "不兼容", + "chat": "聊天", + "noChatYet": "暂无聊天消息" } \ No newline at end of file diff --git a/ui/App/locales/zh-mods.json b/ui/App/locales/zh-mods.json index affa3083..de29face 100644 --- a/ui/App/locales/zh-mods.json +++ b/ui/App/locales/zh-mods.json @@ -47,7 +47,6 @@ "Enabled": "启用", "Compatibility": "兼容性", "Mod Version": "模组版本", - "Factorio Version": "Factorio 版本", "Space Age DLC": "太空时代 DLC", "File Size": "文件大小", "readModsFromSave": "从存档读取模组", @@ -66,5 +65,17 @@ "mod": "模组", "required": "需要版本", "status": "状态", - "columns": "列" + "columns": "列", + "syncingInProgress": "模组同步中,部分操作暂时禁用", + "Game Ver": "游戏版本", + "Other Actions": "其他操作", + "active": "进行中", + "completed": "已完成", + "pending": "等待中", + "failed": "失败", + "downloading": "下载中", + "expandAll": "展开全部", + "collapseAll": "收起全部", + "enableAllMods": "启用全部模组", + "disableAllMods": "禁用全部模组" } \ No newline at end of file diff --git a/ui/App/locales/zh-serverSettings.json b/ui/App/locales/zh-serverSettings.json index 6d4cee48..b46fcbbb 100644 --- a/ui/App/locales/zh-serverSettings.json +++ b/ui/App/locales/zh-serverSettings.json @@ -1,5 +1,5 @@ { - "title": "服务器设置", + "title": "配置管理", "serverSettings": "服务器设置", "gameSettings": "游戏设置", "saveSettings": "保存设置", diff --git a/ui/App/locales/zh-serverVersion.json b/ui/App/locales/zh-serverVersion.json index 768d2702..d1138924 100644 --- a/ui/App/locales/zh-serverVersion.json +++ b/ui/App/locales/zh-serverVersion.json @@ -1,5 +1,5 @@ { - "title": "服务器版本", + "title": "版本管理", "serverVersion": "服务器版本", "installedVersion": "已安装版本", "availableVersions": "可用版本", diff --git a/ui/App/locales/zh.json b/ui/App/locales/zh.json index 093f7fc1..8641460d 100644 --- a/ui/App/locales/zh.json +++ b/ui/App/locales/zh.json @@ -46,7 +46,7 @@ "removeInstallation": "卸载" }, "server_settings": { - "title": "服务器设置", + "title": "配置管理", "admins": "管理员", "name": "服务器名称", "description": "服务器描述", @@ -176,7 +176,7 @@ "factorio_login_error_message": "用户名或密码不匹配" }, "serverVersion": { - "title": "服务器版本", + "title": "版本管理", "current": "当前版本", "available": "可用版本", "stable": "稳定版", diff --git a/ui/App/views/Console.jsx b/ui/App/views/Console.jsx index b9197447..ecf3f430 100644 --- a/ui/App/views/Console.jsx +++ b/ui/App/views/Console.jsx @@ -5,112 +5,72 @@ import socket from "../../api/socket"; import log from "../../api/resources/log"; const FONT_CLASS = { small: 'text-xs', medium: 'text-sm', large: 'text-base' }; -const WIDTH_STYLE = { compact: 'max-width: 56rem', normal: 'max-width: 72rem', full: 'max-width: none' }; +const WIDTH = { compact: {maxWidth: '56rem'}, normal: {maxWidth: '72rem'}, full: {maxWidth: 'none'} }; const Console = ({serverStatus}) => { const { t } = useTranslation('console'); const [logs, setLogs] = useState([]); - const [fontSize, setFontSize] = useState(() => localStorage.getItem('console_fontSize') || 'small'); - const [wrap, setWrap] = useState(() => localStorage.getItem('console_wrap') !== 'false'); - const [panelWidth, setPanelWidth] = useState(() => localStorage.getItem('console_panelWidth') || 'normal'); + const [fontSize, setFontSize] = useState(() => { try { return localStorage.getItem('console_fontSize') || 'small'; } catch { return 'small'; } }); + const [wrap, setWrap] = useState(() => { try { return localStorage.getItem('console_wrap') !== 'false'; } catch { return true; } }); + const [panelWidth, setPanelWidth] = useState(() => { try { return localStorage.getItem('console_panelWidth') || 'normal'; } catch { return 'normal'; } }); const consoleInput = useRef(null); const logEnd = useRef(null); - const setFont = (size) => { - setFontSize(size); - localStorage.setItem('console_fontSize', size); - }; - const toggleWrap = () => { - const next = !wrap; - setWrap(next); - localStorage.setItem('console_wrap', String(next)); - }; - const setWidth = (w) => { - setPanelWidth(w); - localStorage.setItem('console_panelWidth', w); - }; + const setFont = (size) => { setFontSize(size); try { localStorage.setItem('console_fontSize', size); } catch {} }; + const toggleWrap = () => { const n = !wrap; setWrap(n); try { localStorage.setItem('console_wrap', String(n)); } catch {} }; + const setW = (w) => { setPanelWidth(w); try { localStorage.setItem('console_panelWidth', w); } catch {} }; useEffect(() => { (async () => { - const lines = await log.tail(); - if (lines && Array.isArray(lines)) setLogs(lines); + try { const lines = await log.tail(); if (lines && Array.isArray(lines)) setLogs(lines); } catch {} })(); - const appendLog = line => setLogs(lines => [...lines, line]); - socket.on('gamelog', appendLog); socket.emit('log subscribe'); consoleInput.current?.focus(); - - return () => { - socket.off('gamelog', appendLog); - socket.emit("log unsubscribe"); - }; + return () => { socket.off('gamelog', appendLog); socket.emit("log unsubscribe"); }; }, []); - useEffect(() => { - logEnd.current?.scrollIntoView({ behavior: 'smooth' }); - }, [logs]); + useEffect(() => { logEnd.current?.scrollIntoView({ behavior: 'smooth' }); }, [logs]); return ( -
    +
    + style={{ ...WIDTH[panelWidth], margin: '0 auto', width: '100%' }}> {t('fontSize')}: {Object.keys(FONT_CLASS).map(s => ( - + ))} {t('wrap')}: - + {t('width')}: - {Object.keys(WIDTH_STYLE).map(w => ( - + {Object.keys(WIDTH).map(w => ( + ))}
    -
    - -
    - {logs?.map((log, i) => ( -
    {log}
    - ))} +
    + +
    +
    + {logs?.map((l, i) => (
    {l}
    ))}
    -
    - {serverStatus.running - ? { - if (e.key === "Enter" && socket) { - socket.emit("command send", consoleInput.current.value); - consoleInput.current.value = ""; - } - }} - /> - :

    {t('consoleNotAvailable')}

    - } -
    - } - /> +
    + {serverStatus.running + ? { if (e.key === "Enter" && socket) { socket.emit("command send", consoleInput.current.value); consoleInput.current.value = ""; } }}/> + :

    {t('consoleNotAvailable')}

    } +
    +
    + }/>
    ); diff --git a/ui/App/views/Controls.jsx b/ui/App/views/Controls.jsx index 38eba33e..f070b888 100644 --- a/ui/App/views/Controls.jsx +++ b/ui/App/views/Controls.jsx @@ -1,13 +1,17 @@ -import React, {useEffect, useState} from "react"; +import React, {useEffect, useState, useRef} from "react"; import { useTranslation } from 'react-i18next'; import Panel from "../components/Panel"; import Button from "../components/Button"; import server from "../../api/resources/server"; import savesResource from "../../api/resources/saves"; +import modsResource from "../../api/resources/mods"; import {useForm} from "react-hook-form"; import Select from "../components/Select"; import Input from "../components/Input"; import Error from "../components/Error"; +import socket from "../../api/socket"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faUsers, faClock, faMicrochip, faMemory, faComments} from "@fortawesome/free-solid-svg-icons"; const Controls = ({serverStatus}) => { @@ -20,6 +24,11 @@ const Controls = ({serverStatus}) => { const [isStopping, setIsStopping] = useState(false); const [isStarting, setIsStarting] = useState(false); const [isKilling, setIsKilling] = useState(false); + const [chatMessages, setChatMessages] = useState([]); + const [modStats, setModStats] = useState({total: 0, compat: 0, incompat: 0}); + const [uptime, setUptime] = useState(0); + const [players, setPlayers] = useState([]); + const chatEnd = useRef(null); const { handleSubmit, reset, register, formState: {errors} } = useForm(); @@ -44,105 +53,178 @@ const Controls = ({serverStatus}) => { savesResource.list(true) .then(res => { setSaves(res); - if (res.length > 0) { - setIsDisabled(undefined); - } + if (res.length > 0) setIsDisabled(undefined); reset(); }); - }, []) + modsResource.installed().then(mods => { + setModStats({ + total: mods.length, + compat: mods.filter(m => m.compatibility).length, + incompat: mods.filter(m => !m.compatibility).length + }); + }); + }, []); + + useEffect(() => { + const handler = (msg) => { + const text = String(msg); + if (text.includes('[CHAT]')) { + setChatMessages(prev => [...prev.slice(-49), text.replace(/^.*\[CHAT\]\s*/, '')]); + } + if (text.includes('joined the game')) { + const name = text.match(/\[JOIN\]\s*(\S+)/)?.[1] || ''; + if (name) setPlayers(prev => prev.includes(name) ? prev : [...prev, name]); + } + if (text.includes('left the game')) { + const name = text.match(/\[LEAVE\]\s*(\S+)/)?.[1] || ''; + if (name) setPlayers(prev => prev.filter(p => p !== name)); + } + }; + socket.on('gamelog', handler); + return () => socket.off('gamelog', handler); + }, []); + + useEffect(() => { + if (!serverStatus.running) return; + setUptime(0); + const timer = setInterval(() => setUptime(u => u + 1), 1000); + return () => clearInterval(timer); + }, [serverStatus.running]); + + useEffect(() => { chatEnd.current?.scrollIntoView({behavior:'smooth'}); }, [chatMessages]); + + const fmtTime = (s) => { + const h = Math.floor(s/3600), m = Math.floor(s%3600/60), sec = s%60; + return `${h}h ${m}m ${sec}s`; + }; return ( -
    - - { serverStatus.running - ? <> -
    -
    {t('status')}
    -
    {serverStatus.running ? t('RUNNING') : t('STOPPED')}
    -
    -
    -
    {t('ip')}
    -
    {serverStatus.bindip}
    -
    -
    -
    {t('port')}
    -
    {serverStatus.port}
    -
    -
    -
    {t('factorioVersion')}
    -
    {factorioVersion}
    -
    -
    -
    {t('save')}
    -
    {serverStatus.savefile}
    -
    - - : <> -
    -
    {t('status')}
    -
    {serverStatus.running ? t('RUNNING') : t('STOPPED')}
    -
    -
    -
    {t('ip')}
    - - -
    -
    -
    {t('port')}
    - - -
    -
    -
    {t('factorioVersion')}
    -
    {factorioVersion}
    +
    + {/* Status Banner */} + {serverStatus.running ? ( +
    +
    +
    + {t('RUNNING')} + {fmtTime(uptime)} +
    +
    + {players.length} {t('players') || 'players'} + {serverStatus.bindip}:{serverStatus.port} +
    +
    + ) : ( +
    +
    + {t('STOPPED')} +
    + )} + + {/* Info Cards */} +
    +
    + +
    {factorioVersion}
    +
    {t('factorioVersion')}
    +
    +
    + +
    {players.length}
    +
    {t('players') || 'players'}
    +
    +
    + +
    {serverStatus.running ? fmtTime(uptime) : '--'}
    +
    {t('uptime') || 'uptime'}
    +
    +
    +
    {modStats.total}
    +
    {modStats.compat} {t('compatible') || 'compatible'}
    +
    {modStats.incompat} {t('incompatible') || 'incompatible'}
    +
    +
    + + {/* Players List */} + {serverStatus.running && players.length > 0 && ( + + {players.map(p => ( + {p} + ))} +
    + }/> + )} + + {/* Server Controls */} + + +
    {t('ip')}: {serverStatus.bindip}
    +
    {t('port')}: {serverStatus.port}
    +
    {t('factorioVersion')}: {factorioVersion}
    +
    {t('save')}: {serverStatus.savefile}
    -
    -
    {t('save')}
    -
    - + +
    +
    +
    {t('port')}
    + + +
    +
    +
    {t('factorioVersion')}
    +
    {factorioVersion}
    +
    +
    +
    {t('save')}
    + - - {settings && (Object.keys(settings).length > 0 && Object.keys(settings).map(key => { - return ( - - - - - ) - })) || - - } - -
    {key}{settings[key]}
    --
    -
    - ) - })} - - } - /> - ) -} - -export default GameSettings; \ No newline at end of file diff --git a/ui/App/views/Mods/Mods.jsx b/ui/App/views/Mods/Mods.jsx index f00ee1bf..6dd0cb74 100644 --- a/ui/App/views/Mods/Mods.jsx +++ b/ui/App/views/Mods/Mods.jsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import modsResource from "../../../api/resources/mods"; import Button from "../../components/Button"; import server from "../../../api/resources/server"; +import socket from "../../../api/socket"; import TabControl from "../../components/Tabs/TabControl"; import Tab from "../../components/Tabs/Tab"; import AddMod from "./components/AddMod/AddMod"; @@ -42,12 +43,12 @@ const Mods = ({serverStatus}) => { }; const fetchInstalledMods = () => { - modsResource.installed() + return modsResource.installed() .then(setInstalledMods); }; const fetchModPacks = () => { - modsResource.packs.list() + return modsResource.packs.list() .then(setModPacks) } @@ -97,18 +98,31 @@ const Mods = ({serverStatus}) => { const interval = setInterval(() => { if (document.hidden) return; - fetchInstalledMods(); - fetchModPacks(); + fetchInstalledMods().catch(() => {}); + fetchModPacks().catch(() => {}); }, 2000); + const handleModEvent = (message) => { + const data = JSON.parse(message); + if (data.type === "mod_deleted") { + setInstalledMods(prev => prev.filter(m => m.name !== data.name)); + } else if (data.type === "mods_cleared") { + setInstalledMods([]); + } + }; + socket.on('mods_events', handleModEvent); + socket.emit('mods events subscribe'); + const handleRefresh = () => { - fetchInstalledMods(); - fetchModPacks(); + fetchInstalledMods().catch(() => {}); + fetchModPacks().catch(() => {}); }; window.addEventListener('fsm_refresh_mods', handleRefresh); return () => { clearInterval(interval); + socket.off('mods_events', handleModEvent); + socket.emit('mods events unsubscribe'); window.removeEventListener('fsm_refresh_mods', handleRefresh); }; }, []); @@ -131,12 +145,26 @@ const Mods = ({serverStatus}) => { .then(fetchInstalledMods) } + const enableAllMods = () => { + const toEnable = installedMods.filter(m => !m.enabled && m.name !== 'base'); + Promise.all(toEnable.map(m => toggleMod(m.name))) + .then(fetchInstalledMods) + .catch(() => fetchInstalledMods()); + } + + const disableAllMods = () => { + const toDisable = installedMods.filter(m => m.enabled && m.name !== 'base'); + Promise.all(toDisable.map(m => toggleMod(m.name))) + .then(fetchInstalledMods) + .catch(() => fetchInstalledMods()); + } + let disabled = serverStatus.running let isBusy = disabled || isDeletingAllMods || isUpdatingAllMods || isSyncing return (
    - {isBusy ? + {disabled && !isSyncing && @@ -144,8 +172,22 @@ const Mods = ({serverStatus}) => {
    } /> - : - !authChecked ? null : + } + {isSyncing && + +
    + {t('syncingInProgress')} +
    + +
    + } + /> + } + {!authChecked ? null :
    {portalLoading &&
    @@ -168,7 +210,7 @@ const Mods = ({serverStatus}) => { + onSyncingChange={setIsSyncing} />
    @@ -198,6 +240,8 @@ const Mods = ({serverStatus}) => { <> {t('downloadAllMods')} + + diff --git a/ui/App/views/Mods/components/LoadMods.jsx b/ui/App/views/Mods/components/LoadMods.jsx index 29512998..08e6fba4 100644 --- a/ui/App/views/Mods/components/LoadMods.jsx +++ b/ui/App/views/Mods/components/LoadMods.jsx @@ -1,4 +1,4 @@ -import React, {useEffect, useState} from "react"; +import React, {useEffect, useState, useRef} from "react"; import savesResource from "../../../../api/resources/saves"; import Label from "../../../components/Label"; import Button from "../../../components/Button"; @@ -7,7 +7,7 @@ import FactorioLogin from "./AddMod/components/FactorioLogin"; import socket from "../../../../api/socket"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {useTranslation} from "react-i18next"; -import {faSpinner, faCheck, faTimes, faMinusCircle} from "@fortawesome/free-solid-svg-icons"; +import {faSpinner, faCheck, faTimes, faMinusCircle, faChevronDown, faChevronUp, faClock} from "@fortawesome/free-solid-svg-icons"; const DLC_MODS = new Set(['elevated-rails', 'quality', 'space-age']); @@ -21,7 +21,22 @@ const STATUS_ICON = { not_found: , }; -const LoadMods = ({refreshMods, isFactorioAuthenticated, setIsFactorioAuthenticated, setIsSyncing}) => { +const formatSize = (bytes) => { + if (!bytes || bytes < 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + let i = 0, v = bytes; + while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } + return `${v.toFixed(i > 0 ? 1 : 0)} ${units[i]}`; +}; + +const formatTime = (seconds) => { + if (!seconds || seconds < 0 || !isFinite(seconds)) return '--:--'; + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; +}; + +const LoadMods = ({refreshMods, isFactorioAuthenticated, setIsFactorioAuthenticated, onSyncingChange}) => { const {t} = useTranslation('mods'); const [saves, setSaves] = useState([]); const [selectedSave, setSelectedSave] = useState(""); @@ -31,8 +46,20 @@ const LoadMods = ({refreshMods, isFactorioAuthenticated, setIsFactorioAuthentica const [modRows, setModRows] = useState([]); const [checkedMods, setCheckedMods] = useState({}); const [syncError, setSyncError] = useState(null); - const [currentMod, setCurrentMod] = useState(null); const [warning, setWarning] = useState(null); + const [modProgress, setModProgress] = useState({}); + const [expanded, setExpanded] = useState(false); + const [tick, setTick] = useState(0); + const recoveringRef = useRef(false); + const modRowsRef = useRef(modRows); + modRowsRef.current = modRows; + const delayTimers = useRef({}); + + useEffect(() => { + if (!isSyncing) return; + const id = setInterval(() => setTick(t => t + 1), 500); + return () => clearInterval(id); + }, [isSyncing]); useEffect(() => { (async () => { @@ -51,16 +78,83 @@ const LoadMods = ({refreshMods, isFactorioAuthenticated, setIsFactorioAuthentica useEffect(() => { const handler = (message) => { const data = JSON.parse(message); + if (modRowsRef.current.length === 0 && data.status && data.status !== "done" && data.status !== "error") { + if (recoveringRef.current) return; + recoveringRef.current = true; + const saved = sessionStorage.getItem('fsm_sync_save'); + if (saved && selectedSave !== saved) { + setSelectedSave(saved); + } + setIsSyncing(true); + if (onSyncingChange) onSyncingChange(true); + if (saved) { + modsResource.getFromSave(saved).then(mods => { + setModRows(prev => { + if (prev.length > 0) return prev; + return mods || []; + }); + }).catch(() => {}).finally(() => { + recoveringRef.current = false; + }); + } else { + recoveringRef.current = false; + } + } if (data.status === "progress") { - setCurrentMod(data.mod); setModRows(rows => rows.map(r => r.name === data.mod ? {...r, status: "downloading"} : r )); + + const now = Date.now(); + const bytes = data.downloaded || 0; + + setModProgress(prev => { + const existing = prev[data.mod] || {}; + const startedAt = existing.startedAt || now; + const elapsed = (now - startedAt) / 1000; + const speed = elapsed > 0.1 ? Math.round(bytes / elapsed) : 0; + + return { + ...prev, + [data.mod]: { + downloaded: bytes, + size: data.size || existing.size || 0, + speed, + startedAt, + } + }; + }); + } else if (data.status === "downloaded") { + setModRows(rows => rows.map(r => + r.name === data.mod ? {...r, status: "downloaded"} : r + )); + if (delayTimers.current[data.mod]) clearTimeout(delayTimers.current[data.mod]); + delayTimers.current[data.mod] = setTimeout(() => { + setModRows(rows => rows.map(r => + r.name === data.mod && r.status === "downloaded" ? {...r, status: "done_delayed"} : r + )); + delete delayTimers.current[data.mod]; + }, 2000); + } else if (data.status === "not_found") { + setModRows(rows => rows.map(r => + r.name === data.mod ? {...r, status: "not_found"} : r + )); + } else if (data.status === "builtin") { + setModRows(rows => rows.map(r => + r.name === data.mod ? {...r, status: "builtin"} : r + )); + } else if (data.status === "version_mismatch") { + let latestInfo = {latest: '?', downloadUrl: '', fileName: ''}; + try { latestInfo = JSON.parse(data.message || '{}'); } catch(e) {} + setModRows(rows => rows.map(r => + r.name === data.mod ? {...r, status: "version_mismatch", latestInfo} : r + )); } else if (data.status === "done") { setIsSyncing(false); - if (setIsSyncing) setIsSyncing(false); - setCurrentMod(null); + if (onSyncingChange) onSyncingChange(false); + sessionStorage.removeItem('fsm_sync_save'); setWarning(data.warning || null); + setExpanded(false); if (data.mods) { setModRows(data.mods); setCheckedMods({}); @@ -68,9 +162,9 @@ const LoadMods = ({refreshMods, isFactorioAuthenticated, setIsFactorioAuthentica refreshMods(); } else if (data.status === "error") { setIsSyncing(false); - if (setIsSyncing) setIsSyncing(false); - setCurrentMod(null); + if (onSyncingChange) onSyncingChange(false); setSyncError(data.message); + setExpanded(false); } }; @@ -79,6 +173,10 @@ const LoadMods = ({refreshMods, isFactorioAuthenticated, setIsFactorioAuthentica return () => { socket.off('mods_sync', handler); socket.emit('mods sync unsubscribe'); + recoveringRef.current = false; + Object.values(delayTimers.current).forEach(clearTimeout); + delayTimers.current = {}; + if (onSyncingChange) onSyncingChange(false); }; }, []); @@ -89,6 +187,8 @@ const LoadMods = ({refreshMods, isFactorioAuthenticated, setIsFactorioAuthentica setCheckedMods({}); setSyncError(null); setWarning(null); + setModProgress({}); + setExpanded(false); try { const mods = await modsResource.getFromSave(selectedSave); @@ -112,13 +212,16 @@ const LoadMods = ({refreshMods, isFactorioAuthenticated, setIsFactorioAuthentica if (toSync.length === 0) return; setIsSyncing(true); - if (setIsSyncing) setIsSyncing(true); + if (onSyncingChange) onSyncingChange(true); + sessionStorage.setItem('fsm_sync_save', selectedSave); setSyncError(null); + setExpanded(false); try { await modsResource.syncFromSave(selectedSave, toSync); } catch (e) { setSyncError(t('failedToStartSync') + ': ' + e.message); setIsSyncing(false); + if (onSyncingChange) onSyncingChange(false); } }; @@ -140,6 +243,154 @@ const LoadMods = ({refreshMods, isFactorioAuthenticated, setIsFactorioAuthentica return ; } + if (isSyncing && modRows.length > 0) { + const now = Date.now(); + const downloading = modRows.filter(r => r.status === 'downloading'); + const downloaded = modRows.filter(r => r.status === 'downloaded'); + const failed = modRows.filter(r => r.status === 'not_found'); + const total = modRows.length; + const pending = total - downloading.length - downloaded.length - failed.length; + + const sorted = [...modRows].sort((a, b) => { + const o = {downloading: 0, downloaded: 1}; + return (o[a.status] ?? 2) - (o[b.status] ?? 2); + }); + + return ( +
    +