Add NPC creation status tracking and progress reporting in Admin module

- Implemented getNPCsCreationStatus method in AdminController to retrieve the status of NPC creation jobs.
- Enhanced AdminService to manage NPC creation jobs, including job ID generation, progress updates, and error handling.
- Updated frontend CreateNPCView to display progress of NPC creation, including estimated time remaining and job status.
- Added localization strings for progress reporting in both German and English.
- Improved overall user experience by providing real-time feedback during NPC creation processes.
This commit is contained in:
Torsten Schulz (local)
2026-01-07 17:09:54 +01:00
parent b34dcac685
commit c322eb1e5a
8 changed files with 347 additions and 25 deletions

View File

@@ -45,6 +45,7 @@ class AdminController {
this.deleteRegionDistance = this.deleteRegionDistance.bind(this); this.deleteRegionDistance = this.deleteRegionDistance.bind(this);
this.createNPCs = this.createNPCs.bind(this); this.createNPCs = this.createNPCs.bind(this);
this.getTitlesOfNobility = this.getTitlesOfNobility.bind(this); this.getTitlesOfNobility = this.getTitlesOfNobility.bind(this);
this.getNPCsCreationStatus = this.getNPCsCreationStatus.bind(this);
} }
async getOpenInterests(req, res) { async getOpenInterests(req, res) {
@@ -393,6 +394,7 @@ class AdminController {
if (countValue < 1 || countValue > 500) { if (countValue < 1 || countValue > 500) {
return res.status(400).json({ error: 'Count must be between 1 and 500' }); return res.status(400).json({ error: 'Count must be between 1 and 500' });
} }
console.log('[createNPCs] Request received:', { userId, regionIds, minAge, maxAge, minTitleId, maxTitleId, count: countValue });
const result = await AdminService.createNPCs(userId, { const result = await AdminService.createNPCs(userId, {
regionIds: regionIds && regionIds.length > 0 ? regionIds : null, regionIds: regionIds && regionIds.length > 0 ? regionIds : null,
minAge: parseInt(minAge) || 0, minAge: parseInt(minAge) || 0,
@@ -401,11 +403,13 @@ class AdminController {
maxTitleId: parseInt(maxTitleId) || 19, maxTitleId: parseInt(maxTitleId) || 19,
count: countValue count: countValue
}); });
console.log('[createNPCs] Job created:', result);
res.status(200).json(result); res.status(200).json(result);
} catch (error) { } catch (error) {
console.log(error); console.error('[createNPCs] Error:', error);
console.error('[createNPCs] Error stack:', error.stack);
const status = error.message === 'noaccess' ? 403 : 500; const status = error.message === 'noaccess' ? 403 : 500;
res.status(status).json({ error: error.message }); res.status(status).json({ error: error.message || 'Internal server error' });
} }
} }
@@ -421,6 +425,20 @@ class AdminController {
} }
} }
async getNPCsCreationStatus(req, res) {
try {
const { userid: userId } = req.headers;
const { jobId } = req.params;
const status = await AdminService.getNPCsCreationStatus(userId, jobId);
res.status(200).json(status);
} catch (error) {
console.log(error);
const status = error.message === 'noaccess' || error.message === 'Access denied' ? 403 :
error.message === 'Job not found' ? 404 : 500;
res.status(status).json({ error: error.message });
}
}
async getRoomTypes(req, res) { async getRoomTypes(req, res) {
try { try {
const userId = req.headers.userid; const userId = req.headers.userid;

View File

@@ -47,6 +47,7 @@ router.get('/falukant/region-distances', authenticate, adminController.getRegion
router.post('/falukant/region-distances', authenticate, adminController.upsertRegionDistance); router.post('/falukant/region-distances', authenticate, adminController.upsertRegionDistance);
router.delete('/falukant/region-distances/:id', authenticate, adminController.deleteRegionDistance); router.delete('/falukant/region-distances/:id', authenticate, adminController.deleteRegionDistance);
router.post('/falukant/npcs/create', authenticate, adminController.createNPCs); router.post('/falukant/npcs/create', authenticate, adminController.createNPCs);
router.get('/falukant/npcs/status/:jobId', authenticate, adminController.getNPCsCreationStatus);
router.get('/falukant/titles', authenticate, adminController.getTitlesOfNobility); router.get('/falukant/titles', authenticate, adminController.getTitlesOfNobility);
// --- Minigames Admin --- // --- Minigames Admin ---

View File

@@ -26,6 +26,8 @@ import Room from '../models/chat/room.js';
import UserParam from '../models/community/user_param.js'; import UserParam from '../models/community/user_param.js';
import TitleOfNobility from "../models/falukant/type/title_of_nobility.js"; import TitleOfNobility from "../models/falukant/type/title_of_nobility.js";
import { sequelize } from '../utils/sequelize.js'; import { sequelize } from '../utils/sequelize.js';
import npcCreationJobService from './npcCreationJobService.js';
import { v4 as uuidv4 } from 'uuid';
class AdminService { class AdminService {
async hasUserAccess(userId, section) { async hasUserAccess(userId, section) {
@@ -1113,7 +1115,7 @@ class AdminService {
count // Anzahl der zu erstellenden NPCs count // Anzahl der zu erstellenden NPCs
} = options; } = options;
// Hole alle Städte, wenn keine spezifischen Regionen angegeben // Berechne zuerst die Gesamtanzahl, um den Job richtig zu initialisieren
let targetRegions = []; let targetRegions = [];
if (regionIds && regionIds.length > 0) { if (regionIds && regionIds.length > 0) {
targetRegions = await RegionData.findAll({ targetRegions = await RegionData.findAll({
@@ -1140,7 +1142,6 @@ class AdminService {
throw new Error('No cities found'); throw new Error('No cities found');
} }
// Hole alle Titles im Bereich
const titles = await TitleOfNobility.findAll({ const titles = await TitleOfNobility.findAll({
where: { where: {
id: { id: {
@@ -1154,13 +1155,56 @@ class AdminService {
throw new Error('No titles found in specified range'); throw new Error('No titles found in specified range');
} }
const genders = ['male', 'female'];
const createdNPCs = [];
const totalNPCs = targetRegions.length * titles.length * count; const totalNPCs = targetRegions.length * titles.length * count;
// Erstelle NPCs in einer Transaktion // Erstelle Job-ID
// Für jede Stadt-Titel-Kombination wird die angegebene Anzahl erstellt const jobId = uuidv4();
await sequelize.transaction(async (t) => { npcCreationJobService.createJob(userId, jobId);
npcCreationJobService.updateProgress(jobId, 0, totalNPCs);
npcCreationJobService.setStatus(jobId, 'running');
// Starte asynchronen Prozess
this._createNPCsAsync(jobId, userId, {
regionIds,
minAge,
maxAge,
minTitleId,
maxTitleId,
count,
targetRegions,
titles
}).catch(error => {
console.error('Error in _createNPCsAsync:', error);
const errorMessage = error?.message || error?.toString() || 'Unknown error occurred';
npcCreationJobService.setError(jobId, errorMessage);
});
return { jobId };
}
async _createNPCsAsync(jobId, userId, options) {
try {
const {
regionIds,
minAge,
maxAge,
minTitleId,
maxTitleId,
count,
targetRegions,
titles
} = options;
const genders = ['male', 'female'];
const createdNPCs = [];
const totalNPCs = targetRegions.length * titles.length * count;
let currentNPC = 0;
console.log(`[NPC Creation Job ${jobId}] Starting creation of ${totalNPCs} NPCs`);
// Erstelle NPCs in einer Transaktion
// Für jede Stadt-Titel-Kombination wird die angegebene Anzahl erstellt
await sequelize.transaction(async (t) => {
for (const region of targetRegions) { for (const region of targetRegions) {
for (const title of titles) { for (const title of titles) {
// Erstelle 'count' NPCs für diese Stadt-Titel-Kombination // Erstelle 'count' NPCs für diese Stadt-Titel-Kombination
@@ -1218,18 +1262,40 @@ class AdminService {
region: region.name, region: region.name,
title: title.labelTr title: title.labelTr
}); });
// Update Progress
currentNPC++;
npcCreationJobService.updateProgress(jobId, currentNPC, totalNPCs);
} }
} }
} }
}); });
return { console.log(`[NPC Creation Job ${jobId}] Completed: ${createdNPCs.length} NPCs created`);
success: true,
count: createdNPCs.length, // Job abschließen
countPerCombination: count, npcCreationJobService.setResult(jobId, {
totalCombinations: targetRegions.length * titles.length, success: true,
npcs: createdNPCs count: createdNPCs.length,
}; countPerCombination: count,
totalCombinations: targetRegions.length * titles.length,
npcs: createdNPCs
});
} catch (error) {
console.error(`[NPC Creation Job ${jobId}] Error:`, error);
throw error; // Re-throw für den catch-Block in createNPCs
}
}
getNPCsCreationStatus(userId, jobId) {
const job = npcCreationJobService.getJob(jobId);
if (!job) {
throw new Error('Job not found');
}
if (job.userId !== userId) {
throw new Error('Access denied');
}
return job;
} }
} }

View File

@@ -0,0 +1,86 @@
// In-Memory Job-Status-Service für NPC-Erstellung
// Für Produktion sollte man Redis oder eine Datenbank verwenden
const jobs = new Map();
class NPCCreationJobService {
createJob(userId, jobId) {
jobs.set(jobId, {
userId,
status: 'pending',
progress: 0,
total: 0,
current: 0,
startTime: Date.now(),
estimatedTimeRemaining: null,
error: null,
result: null
});
return jobId;
}
getJob(jobId) {
return jobs.get(jobId);
}
updateProgress(jobId, current, total) {
const job = jobs.get(jobId);
if (!job) return;
job.current = current;
job.total = total;
job.progress = total > 0 ? Math.round((current / total) * 100) : 0;
// Berechne verbleibende Zeit basierend auf bisheriger Geschwindigkeit
if (current > 0 && job.progress < 100) {
const elapsed = Date.now() - job.startTime;
const avgTimePerItem = elapsed / current;
const remaining = total - current;
job.estimatedTimeRemaining = Math.round(remaining * avgTimePerItem);
}
}
setStatus(jobId, status) {
const job = jobs.get(jobId);
if (!job) return;
job.status = status;
}
setError(jobId, error) {
const job = jobs.get(jobId);
if (!job) return;
job.status = 'error';
job.error = error;
}
setResult(jobId, result) {
const job = jobs.get(jobId);
if (!job) return;
job.status = 'completed';
job.result = result;
job.progress = 100;
job.estimatedTimeRemaining = 0;
}
deleteJob(jobId) {
jobs.delete(jobId);
}
// Cleanup alte Jobs (älter als 1 Stunde)
cleanupOldJobs() {
const oneHourAgo = Date.now() - (60 * 60 * 1000);
for (const [jobId, job] of jobs.entries()) {
if (job.startTime < oneHourAgo) {
jobs.delete(jobId);
}
}
}
}
// Cleanup alle 10 Minuten
setInterval(() => {
const service = new NPCCreationJobService();
service.cleanupOldJobs();
}, 10 * 60 * 1000);
export default new NPCCreationJobService();

View File

@@ -1,6 +1,8 @@
<template> <template>
<main> <main class="contenthidden">
<router-view></router-view> <div class="contentscroll">
<router-view></router-view>
</div>
</main> </main>
</template> </template>
@@ -12,9 +14,13 @@
<style scoped> <style scoped>
main { main {
padding: 20px; padding: 0;
background-color: #ffffff; background-color: #ffffff;
flex: 1; flex: 1;
} }
.contentscroll {
padding: 20px;
}
</style> </style>

View File

@@ -135,7 +135,13 @@
"errorCreating": "Fehler beim Erstellen der NPCs.", "errorCreating": "Fehler beim Erstellen der NPCs.",
"invalidAgeRange": "Ungültiger Altersbereich.", "invalidAgeRange": "Ungültiger Altersbereich.",
"invalidTitleRange": "Ungültiger Titel-Bereich.", "invalidTitleRange": "Ungültiger Titel-Bereich.",
"invalidCount": "Ungültige Anzahl (1-500)." "invalidCount": "Ungültige Anzahl (1-500).",
"progress": "Fortschritt",
"progressDetails": "{current} von {total} NPCs erstellt",
"timeRemainingSeconds": "Verbleibende Zeit: {seconds} Sekunden",
"timeRemainingMinutes": "Verbleibende Zeit: {minutes} Minuten {seconds} Sekunden",
"almostDone": "Fast fertig...",
"jobNotFound": "Job nicht gefunden oder abgelaufen."
} }
}, },
"chatrooms": { "chatrooms": {

View File

@@ -162,7 +162,13 @@
"errorCreating": "Error creating NPCs.", "errorCreating": "Error creating NPCs.",
"invalidAgeRange": "Invalid age range.", "invalidAgeRange": "Invalid age range.",
"invalidTitleRange": "Invalid title range.", "invalidTitleRange": "Invalid title range.",
"invalidCount": "Invalid count (1-500)." "invalidCount": "Invalid count (1-500).",
"progress": "Progress",
"progressDetails": "{current} of {total} NPCs created",
"timeRemainingSeconds": "Time remaining: {seconds} seconds",
"timeRemainingMinutes": "Time remaining: {minutes} minutes {seconds} seconds",
"almostDone": "Almost done...",
"jobNotFound": "Job not found or expired."
} }
}, },
"chatrooms": { "chatrooms": {

View File

@@ -58,6 +58,26 @@
{{ creating ? $t('admin.falukant.createNPC.creating') : $t('admin.falukant.createNPC.create') }} {{ creating ? $t('admin.falukant.createNPC.creating') : $t('admin.falukant.createNPC.create') }}
</button> </button>
</div> </div>
<!-- Fortschrittsanzeige -->
<div v-if="creating && jobStatus" class="progress-section">
<div class="progress-header">
<h3>{{ $t('admin.falukant.createNPC.progress') }}</h3>
<span class="progress-percentage">{{ jobStatus.progress }}%</span>
</div>
<div class="progress-bar-container">
<div class="progress-bar" :style="{ width: jobStatus.progress + '%' }"></div>
</div>
<div class="progress-details">
<div>{{ $t('admin.falukant.createNPC.progressDetails', {
current: jobStatus.current || 0,
total: jobStatus.total || 0
}) }}</div>
<div v-if="jobStatus.estimatedTimeRemaining" class="time-remaining">
{{ formatTimeRemaining(jobStatus.estimatedTimeRemaining) }}
</div>
</div>
</div>
</div> </div>
<!-- Ergebnis-Anzeige --> <!-- Ergebnis-Anzeige -->
@@ -105,9 +125,17 @@ export default {
count: 1, count: 1,
creating: false, creating: false,
result: null, result: null,
error: null error: null,
jobId: null,
jobStatus: null,
statusPollInterval: null
}; };
}, },
beforeUnmount() {
if (this.statusPollInterval) {
clearInterval(this.statusPollInterval);
}
},
async mounted() { async mounted() {
await this.loadRegions(); await this.loadRegions();
await this.loadTitles(); await this.loadTitles();
@@ -162,6 +190,8 @@ export default {
this.creating = true; this.creating = true;
this.error = null; this.error = null;
this.result = null; this.result = null;
this.jobStatus = null;
this.jobId = null;
try { try {
const response = await apiClient.post('/api/admin/falukant/npcs/create', { const response = await apiClient.post('/api/admin/falukant/npcs/create', {
@@ -173,13 +203,62 @@ export default {
count: this.count count: this.count
}); });
this.result = response.data; this.jobId = response.data.jobId;
this.startStatusPolling();
} catch (error) { } catch (error) {
console.error('Error creating NPCs:', error); console.error('Error creating NPCs:', error);
this.error = error.response?.data?.error || this.$t('admin.falukant.createNPC.errorCreating'); this.error = error.response?.data?.error || this.$t('admin.falukant.createNPC.errorCreating');
} finally {
this.creating = false; this.creating = false;
} }
},
startStatusPolling() {
if (this.statusPollInterval) {
clearInterval(this.statusPollInterval);
}
this.statusPollInterval = setInterval(async () => {
if (!this.jobId) return;
try {
const response = await apiClient.get(`/api/admin/falukant/npcs/status/${this.jobId}`);
this.jobStatus = response.data;
if (this.jobStatus.status === 'completed') {
this.result = this.jobStatus.result;
this.creating = false;
clearInterval(this.statusPollInterval);
this.statusPollInterval = null;
} else if (this.jobStatus.status === 'error') {
this.error = this.jobStatus.error || this.$t('admin.falukant.createNPC.errorCreating');
this.creating = false;
clearInterval(this.statusPollInterval);
this.statusPollInterval = null;
}
} catch (error) {
console.error('Error polling status:', error);
if (error.response?.status === 404) {
// Job nicht gefunden - möglicherweise abgelaufen
this.error = this.$t('admin.falukant.createNPC.jobNotFound');
this.creating = false;
clearInterval(this.statusPollInterval);
this.statusPollInterval = null;
}
}
}, 1000); // Poll alle Sekunde
},
formatTimeRemaining(ms) {
if (!ms || ms <= 0) return this.$t('admin.falukant.createNPC.almostDone');
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes > 0) {
return this.$t('admin.falukant.createNPC.timeRemainingMinutes', {
minutes,
seconds: remainingSeconds
});
}
return this.$t('admin.falukant.createNPC.timeRemainingSeconds', { seconds });
} }
} }
}; };
@@ -311,4 +390,58 @@ export default {
color: #155724; color: #155724;
margin-top: 5px; margin-top: 5px;
} }
.progress-section {
background: #e7f3ff;
padding: 20px;
border-radius: 8px;
margin-top: 20px;
border: 1px solid #b3d9ff;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.progress-header h3 {
margin: 0;
font-size: 1.2em;
}
.progress-percentage {
font-size: 1.5em;
font-weight: bold;
color: #0066cc;
}
.progress-bar-container {
width: 100%;
height: 30px;
background-color: #e0e0e0;
border-radius: 15px;
overflow: hidden;
margin-bottom: 10px;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #28a745, #20c997);
transition: width 0.3s ease;
border-radius: 15px;
}
.progress-details {
display: flex;
justify-content: space-between;
font-size: 0.9em;
color: #666;
}
.time-remaining {
font-weight: bold;
color: #0066cc;
}
</style> </style>