Files
yourpart3/frontend/src/components/falukant/MessagesDialog.vue
Torsten Schulz (local) 0e572f8cbe
All checks were successful
Deploy to production / deploy (push) Successful in 2m3s
feat(FalukantService, MessagesDialog): enhance character data handling in notifications
- Added mappings for character titles and genders in the FalukantService to enrich notifications with additional character information.
- Updated the MessagesDialog component to utilize the new title and gender data, improving the display of director names based on their titles and genders.
- Ensured that character names are displayed correctly, enhancing the overall user experience in notifications.
2026-05-08 08:25:36 +02:00

592 lines
22 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 (raw && typeof raw === 'object') {
merged = { ...merged, ...raw };
}
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 */
}
}
}
if (merged.character_name && merged.characterName == null) {
merged.characterName = merged.character_name;
}
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 = {};
// Fallback: Manche Daemon-Events liefern event_id/event_type, aber kein tr.
if ((!raw || !String(raw).trim()) && payload?.event_id) {
raw = payload.event_type === 'random_event'
? `random_event.${payload.event_id}`
: String(payload.event_id);
key = raw;
}
// 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`;
const extracted = this.extractParams(payload) || {};
params = { ...extracted, ...params };
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
}
}
// Immer Parameter aus dem vollständigen Payload (nach merge), z. B. director.resignation_risk_high
const extractedAll = this.extractParams(payload) || {};
params = { ...extractedAll, ...params };
// 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 = {};
const locale = this.$i18n?.locale || 'de';
const isGerman = String(locale).startsWith('de');
if (base.characterName) {
params.characterName = base.characterName;
} else if (base.character_name) {
params.characterName = base.character_name;
}
// 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 === 'price_change') {
if (effect.percent !== undefined && effect.percent !== null) {
const p = Number(effect.percent);
if (Number.isFinite(p)) {
params.priceChangePercent = `${p > 0 ? '+' : ''}${p.toFixed(1)}`;
}
}
} else if (effect.type === 'production_quality_change') {
if (effect.change !== undefined && effect.change !== null) {
const v = Number(effect.change);
if (Number.isFinite(v)) {
params.productionQualityChange = (v > 0 ? '+' : '') + (Number.isInteger(v) ? String(v) : v.toFixed(1));
}
}
} else if (effect.type === 'weather_change') {
params.hasWeatherChange = true;
} 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 || base.character_name || `#${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 != null) {
if (base.regionName) {
params.regionName = base.regionName;
} else if (base.region_name) {
params.regionName = base.region_name;
}
}
// Daemon: Meta-Objekte (auch wenn effects[] schon Werte liefert — fehlende Werte ergänzen)
if (params.priceChangePercent == null && base.price_change && typeof base.price_change === 'object') {
const applied = base.price_change.applied !== false;
const pct = base.price_change.percent;
if (applied && pct != null && Number.isFinite(Number(pct))) {
const p = Number(pct);
params.priceChangePercent = `${p > 0 ? '+' : ''}${p.toFixed(1)}`;
}
}
if (params.productionQualityChange == null && base.production_quality && typeof base.production_quality === 'object') {
const applied = base.production_quality.applied !== false;
const ch = base.production_quality.change;
if (applied && ch != null && Number.isFinite(Number(ch))) {
const v = Number(ch);
params.productionQualityChange = (v > 0 ? '+' : '') + (Number.isInteger(v) ? String(v) : v.toFixed(1));
}
}
params.priceEffectLine = this.buildRegionalFestivalEffectsLine(params, isGerman);
if (base.character_id && base.characterName) {
params.characterName = base.characterName;
}
if (base.amount !== undefined) {
params.amount = base.amount;
}
if (base.risk_percent !== undefined) {
params.risk_percent = base.risk_percent;
}
if (base.satisfaction !== undefined) {
params.satisfaction = base.satisfaction;
}
if (base.threshold_percent !== undefined) {
params.threshold_percent = base.threshold_percent;
}
const isDirectorResign = base.event === 'director_resignation_risk_high'
|| base.event === 'director_resigned'
|| (typeof base.tr === 'string' && (base.tr.includes('director.resignation') || base.tr.includes('director.resigned')));
if (isDirectorResign) {
const baseName = params.characterName
|| base.character_name
|| (base.director_character_id != null ? `#${base.director_character_id}` : '');
const titleTr = base.director_title_tr || base.character_title_tr || null;
const gender = base.director_gender || base.character_gender || null;
if (titleTr && gender) {
const k = `falukant.titles.${gender}.${titleTr}`;
const translatedTitle = this.$t(k);
params.directorName = translatedTitle !== k ? `${translatedTitle} ${baseName}`.trim() : `${titleTr} ${baseName}`.trim();
} else {
params.directorName = baseName;
}
}
if (base.director_id !== undefined) {
params.director_id = base.director_id;
}
if (base.director_character_id !== undefined) {
params.director_character_id = base.director_character_id;
}
if (base.affection !== undefined) {
params.affection = base.affection;
}
if (base.visibility !== undefined) {
params.visibility = base.visibility;
}
if (base.discretion !== undefined) {
params.discretion = base.discretion;
}
if (base.months_underfunded !== undefined) {
params.months_underfunded = base.months_underfunded;
}
if (base.relationship_id !== undefined) {
params.relationship_id = base.relationship_id;
}
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);
},
/**
* Zeilen für regionale Events (Fest o. ä.): Wetter, Preis, Produktionsqualität — aus effects[] und/oder price_change / production_quality.
*/
buildRegionalFestivalEffectsLine(params, isGerman) {
const parts = [];
const kw = 'falukant.notifications.random_event.effects.festival_weather_line';
const kp = 'falukant.notifications.random_event.effects.festival_price_line';
const kq = 'falukant.notifications.random_event.effects.festival_quality_line';
if (params.hasWeatherChange) {
if (this.$te(kw)) parts.push(String(this.$t(kw)));
else parts.push(isGerman ? 'Wetter in der Region schlägt um.' : 'The regional weather shifts.');
}
if (params.priceChangePercent != null) {
if (this.$te(kp)) {
parts.push(String(this.$t(kp, { percent: params.priceChangePercent })));
} else {
const legacy = 'falukant.notifications.random_event.effects.price_effect_suffix';
parts.push(
this.$te(legacy)
? String(this.$t(legacy, { percent: params.priceChangePercent }))
: (isGerman
? `Warenpreise etwa ${params.priceChangePercent} %.`
: `Goods prices about ${params.priceChangePercent}%.`)
);
}
}
if (params.productionQualityChange != null) {
if (this.$te(kq)) {
parts.push(String(this.$t(kq, { change: params.productionQualityChange })));
} else {
parts.push(
isGerman
? `Produktionsqualität etwa ${params.productionQualityChange}.`
: `Production quality about ${params.productionQualityChange}.`
);
}
}
if (!parts.length) return '';
return ` ${parts.join(' ')}`;
},
},
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>