Files
yourpart3/frontend/src/components/falukant/MessagesDialog.vue
Torsten Schulz (local) 6783faee44 feat(falukant): enhance notification handling and parameter extraction in MessagesDialog
- Introduced a new method `mergeNotificationPayload` to consolidate top-level fields with embedded JSON from notifications, improving data handling.
- Updated `formatBody` and `extractParams` methods to utilize the merged payload, ensuring accurate extraction of parameters from notifications.
- Enhanced parameter extraction logic to handle various notification structures, improving clarity and consistency in displayed information.
2026-04-10 08:41:53 +02:00

440 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<DialogWidget
ref="dlg"
name="falukant-messages"
:title="'falukant.messages.title'"
:isTitleTranslated="true"
icon="falukant/messages24.png"
:modal="false"
:buttons="[
{ text: 'falukant.messages.markAllRead', action: 'markAll' },
{ text: 'message.close', action: 'close' }
]"
width="520px"
height="420px"
>
<ul class="messages">
<li v-for="n in messages" :key="n.id" :class="{ unread: !n.shown }">
<div class="body">
<div v-if="formatBody(n).title" class="notification-title">{{ formatBody(n).title }}</div>
<div class="notification-description">{{ formatBody(n).description || formatBody(n) }}</div>
</div>
<div class="footer">
<span>{{ formatDate(n.createdAt) }}</span>
</div>
</li>
<li v-if="messages.length === 0" class="empty">{{ $t('falukant.messages.empty') }}</li>
</ul>
<div class="pagination" v-if="total > size">
<button @click="firstPage" :disabled="!canPrev">«</button>
<button @click="prevPage" :disabled="!canPrev"></button>
<span>
<input type="number" min="1" :max="totalPages" v-model.number="pageInput" @change="goToPage(pageInput)" />
/ {{ totalPages }}
</span>
<button @click="nextPage" :disabled="!canNext"></button>
<button @click="lastPage" :disabled="!canNext">»</button>
</div>
</DialogWidget>
</template>
<script>
import DialogWidget from '@/components/DialogWidget.vue';
import apiClient from '@/utils/axios.js';
export default {
name: 'FalukantMessagesDialog',
components: { DialogWidget },
data() {
return {
messages: [],
page: 1,
size: 10,
total: 0,
pageInput: 1,
};
},
methods: {
async open() {
this.page = 1;
this.pageInput = 1;
await this.load();
this.$refs.dlg.open();
// mark unread as shown
try { await apiClient.post('/api/falukant/notifications/mark-shown'); } catch {}
},
async markAll() {
try {
await apiClient.post('/api/falukant/notifications/mark-shown');
// reload to update shown flags and unread count
await this.load();
} catch (e) {
// ignore errors silently
}
},
async load() {
try {
const { data } = await apiClient.get('/api/falukant/notifications/all', { params: { page: this.page, size: this.size } });
if (data && Array.isArray(data.items)) {
this.messages = data.items;
this.total = data.total || 0;
} else {
this.messages = Array.isArray(data) ? data : [];
this.total = this.messages.length;
}
} catch (e) {
console.error('Failed to load messages', e);
this.messages = [];
this.total = 0;
}
},
nextPage() {
if (this.page < this.totalPages) {
this.page++;
this.pageInput = this.page;
this.load();
}
},
prevPage() {
if (this.page > 1) {
this.page--;
this.pageInput = this.page;
this.load();
}
},
firstPage() {
if (this.page !== 1) {
this.page = 1;
this.pageInput = 1;
this.load();
}
},
lastPage() {
if (this.page !== this.totalPages) {
this.page = this.totalPages;
this.pageInput = this.page;
this.load();
}
},
goToPage(val) {
let v = Number(val) || 1;
if (v < 1) v = 1;
if (v > this.totalPages) v = this.totalPages;
if (v !== this.page) {
this.page = v;
this.pageInput = v;
this.load();
} else {
this.pageInput = v;
}
},
formatDate(dt) {
try {
return new Date(dt).toLocaleString();
} catch { return dt; }
},
/** Vereinigt Top-Level-Felder mit in `tr` eingebettetem JSON (Daemon liefert effects oft nur dort). */
mergeNotificationPayload(n) {
if (!n || typeof n !== 'object') return n || {};
let merged = { ...n };
const raw = n.tr;
if (typeof raw === 'string') {
const trimmed = raw.trim();
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === 'object') {
merged = { ...merged, ...parsed };
}
} catch {
/* Rohstring bleibt in merged.tr */
}
}
}
return merged;
},
formatBody(n) {
const payload = this.mergeNotificationPayload(n);
// Wenn die Notification bereits title und description hat (z.B. von WebSocket Events)
if (payload.title && payload.description) {
// Parameter aus effects oder anderen Feldern extrahieren
const params = this.extractParams(payload);
return {
title: this.interpolateString(payload.title, params),
description: this.interpolateString(payload.description, params)
};
}
let raw = payload.tr || '';
let key = raw;
let params = {};
// 1) JSON-Format unterstützen: {"tr":"random_event.windfall","amount":1000,"characterName":"Max"}
if (typeof raw === 'string') {
const trimmed = raw.trim();
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
try {
const parsed = JSON.parse(trimmed);
if (parsed && parsed.tr) {
raw = parsed.tr;
key = parsed.tr;
// Alle anderen Felder als Parameter verwenden und formatieren
params = this.formatParams({ ...parsed });
delete params.tr;
// Merge in params extracted from nested structures (effects, character ids)
try {
const extracted = this.extractParams({ ...payload, ...parsed, characterName: parsed.characterName || parsed.character_name || payload.characterName || payload.character_name });
for (const [k, v] of Object.entries(extracted || {})) {
if (!params.hasOwnProperty(k) || params[k] === undefined || params[k] === null || params[k] === '') {
params[k] = v;
}
}
} catch (e) {
// ignore extraction errors
}
}
} catch (e) {
// bei Parse-Fehler einfach weiter unten mit dem Rohwert arbeiten
}
}
}
// 2) Schlüssel normalisieren:
// - wenn bereits ein voller i18n-Key wie "falukant.notifications.production.overproduction",
// dann direkt verwenden
// - sonst in den Namespace "falukant.notifications." hängen
if (typeof key === 'string') {
const trimmedKey = key.trim();
if (trimmedKey.startsWith('falukant.')) {
key = trimmedKey;
} else {
key = 'falukant.notifications.' + trimmedKey;
}
}
// 3) Prüfe, ob es sich um ein random_event handelt mit title/description Struktur
if (key.startsWith('falukant.notifications.random_event.')) {
const eventId = key.replace('falukant.notifications.random_event.', '');
const eventKey = `falukant.notifications.random_event.${eventId}`;
try {
const titleKey = `${eventKey}.title`;
const descKey = `${eventKey}.description`;
// If no params were parsed from JSON, try to extract them from the notification (effects, character_id, etc.)
if ((!params || Object.keys(params).length === 0) && payload) {
params = this.extractParams(payload) || {};
}
if (this.$te(titleKey) && this.$te(descKey)) {
const title = this.$t(titleKey, params);
const description = this.$t(descKey, params);
return { title, description };
}
} catch (e) {
// Fallback zur alten Methode
}
}
// Fallback: Alte Methode für andere Notification-Typen
return this.$t(key, params);
},
formatParams(params) {
const formatted = {};
const locale = this.$i18n?.locale || 'de';
const isGerman = String(locale).startsWith('de');
// Geldbeträge formatieren
if (params.amount !== undefined) {
formatted.amount = this.formatMoney(params.amount);
}
if (params.absolute !== undefined) {
formatted.amount = this.formatMoney(params.absolute);
}
if (params.percent !== undefined) {
formatted.amount = `${params.percent > 0 ? '+' : ''}${params.percent.toFixed(1)}%`;
}
// Gesundheit formatieren
if (params.change !== undefined) {
formatted.healthChange = params.change > 0 ? `+${params.change}` : `${params.change}`;
}
if (params.healthChange !== undefined) {
formatted.healthChange = params.healthChange > 0 ? `+${params.healthChange}` : `${params.healthChange}`;
}
// Schaden formatieren
if (params.inventory_damage_percent !== undefined) {
formatted.damagePercent = ` Lagerbestand beschädigt: ${params.inventory_damage_percent.toFixed(1)}%.`;
}
if (params.storage_destruction_percent !== undefined) {
formatted.destructionPercent = ` Lager zerstört: ${params.storage_destruction_percent.toFixed(1)}%.`;
}
if (params.regionLabel) {
formatted.regionLabel = isGerman
? ` Ort: ${params.regionLabel}.`
: ` Location: ${params.regionLabel}.`;
}
if (params.spouses) {
formatted.spouses = isGerman
? ` Ehepartner: ${params.spouses}.`
: ` Spouses: ${params.spouses}.`;
}
if (params.children) {
formatted.children = isGerman
? ` Kinder: ${params.children}.`
: ` Children: ${params.children}.`;
}
if (params.lovers) {
formatted.lovers = isGerman
? ` Geliebte: ${params.lovers}.`
: ` Lovers: ${params.lovers}.`;
}
// Alle anderen Parameter übernehmen
for (const [key, value] of Object.entries(params)) {
if (!formatted.hasOwnProperty(key) && key !== 'tr') {
formatted[key] = value;
}
}
return formatted;
},
extractParams(n) {
const base = this.mergeNotificationPayload(n);
const params = {};
// Parameter aus effects extrahieren (Daemon: effects oft nur im JSON in tr)
if (base.effects && Array.isArray(base.effects)) {
for (const effect of base.effects) {
if (effect.type === 'money_change') {
if (effect.absolute !== undefined && effect.absolute !== null) {
const absNum = Number(effect.absolute);
if (Number.isFinite(absNum)) {
// Gebuchter Betrag ist bei Verlust negativ; Anzeige „Verlust: …“ erwartet positive Zahl
params.amount = Math.abs(absNum);
}
} else if (effect.percent !== undefined) {
params.percent = effect.percent;
}
} else if (effect.type === 'character_health_change') {
if (effect.character_id) {
// Prefer explicit characterName from notification, otherwise fall back to provided name or use id placeholder
params.character_id = effect.character_id;
params.characterName = params.characterName || base.characterName || `#${effect.character_id}`;
}
if (effect.change !== undefined) {
params.change = effect.change;
}
} else if (effect.type === 'character_death') {
if (effect.character_id) {
params.character_id = effect.character_id;
params.characterName = params.characterName || base.characterName || `#${effect.character_id}`;
}
} else if (effect.type === 'storage_damage') {
if (effect.inventory_damage_percent !== undefined) {
params.inventory_damage_percent = effect.inventory_damage_percent;
}
if (effect.storage_destruction_percent !== undefined) {
params.storage_destruction_percent = effect.storage_destruction_percent;
}
}
}
}
// Weitere Parameter aus der Notification selbst
if (base.region_id && base.regionName) {
params.regionName = base.regionName;
}
if (base.character_id && base.characterName) {
params.characterName = base.characterName;
}
if (base.amount !== undefined) {
params.amount = base.amount;
}
if (base.deceased && typeof base.deceased === 'object') {
if (base.deceased.display_name) {
params.characterName = base.deceased.display_name;
}
if (base.deceased.region_label) {
params.regionLabel = base.deceased.region_label;
}
if (base.deceased.age_years !== undefined && base.deceased.age_years !== null) {
params.ageYears = base.deceased.age_years;
}
}
if (base.linked && typeof base.linked === 'object') {
if (Array.isArray(base.linked.spouses) && base.linked.spouses.length > 0) {
params.spouses = base.linked.spouses.join(', ');
}
if (Array.isArray(base.linked.children) && base.linked.children.length > 0) {
params.children = base.linked.children.join(', ');
}
if (Array.isArray(base.linked.lovers) && base.linked.lovers.length > 0) {
params.lovers = base.linked.lovers.join(', ');
}
}
return this.formatParams(params);
},
interpolateString(str, params) {
if (!str || typeof str !== 'string') return str;
let result = str;
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== '') {
result = result.replace(new RegExp(`\\{${key}\\}`, 'g'), String(value));
} else {
// Entferne Platzhalter, wenn kein Wert vorhanden ist
result = result.replace(new RegExp(`\\s*\\{${key}\\}\\s*`, 'g'), '');
}
}
// Entferne doppelte Leerzeichen und trimme
result = result.replace(/\s+/g, ' ').trim();
return result;
},
formatMoney(amount) {
return new Intl.NumberFormat(navigator.language, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(amount);
}
},
computed: {
totalPages() {
return Math.max(1, Math.ceil(this.total / this.size));
},
canPrev() { return this.page > 1; },
canNext() { return this.page < this.totalPages; }
}
};
</script>
<style scoped>
.messages {
list-style: none;
padding: 0;
margin: 0;
}
.messages > li { border: 1px solid #7BBE55; margin-bottom: .25em; padding: .5em; }
.messages > li.unread { font-weight: bold; }
.messages > li .body { display: flex; flex-direction: column; gap: 0.25em; }
.messages > li .notification-title { font-weight: bold; font-size: 1.05em; }
.messages > li .notification-description { color: #555; }
.messages > li .footer { color: #f9a22c; font-size: .8em; margin-top: .3em; display: flex; }
.messages > li .footer span:first-child { flex: 1; }
.empty { text-align: center; color: #777; padding: 1em; }
.pagination {
display: flex;
justify-content: center;
gap: .5em;
margin-top: .5em;
}
.pagination button { padding: .25em .6em; }
.pagination input[type="number"] { width: 4em; text-align: right; }
</style>