Implement login page proxy and CAPTCHA handling in MyTischtennisClient and Controller. Enhance login process with CAPTCHA token extraction and error handling. Update frontend to support iframe-based login and improve user experience with loading indicators.
This commit is contained in:
@@ -2,74 +2,75 @@
|
||||
<div class="modal-overlay" @click.self="$emit('close')">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ account ? $t('myTischtennisDialog.editAccount') : $t('myTischtennisDialog.linkAccount') }}</h3>
|
||||
<h3>{{ loginMode ? $t('myTischtennisDialog.login') : (account ? $t('myTischtennisDialog.editAccount') : $t('myTischtennisDialog.linkAccount')) }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="mtt-email">{{ $t('myTischtennisDialog.email') }}:</label>
|
||||
<input
|
||||
type="email"
|
||||
id="mtt-email"
|
||||
v-model="formData.email"
|
||||
:placeholder="$t('myTischtennisDialog.emailPlaceholder')"
|
||||
required
|
||||
/>
|
||||
<!-- Im Login-Modus: Zeige MyTischtennis-Login-Formular in iframe -->
|
||||
<div v-if="loginMode" class="login-iframe-container">
|
||||
<iframe
|
||||
ref="loginIframe"
|
||||
:src="loginUrl"
|
||||
class="login-iframe"
|
||||
@load="onIframeLoad"
|
||||
></iframe>
|
||||
<div v-if="loading" class="iframe-loading">
|
||||
{{ $t('myTischtennisDialog.loadingLoginForm') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="mtt-password">{{ $t('myTischtennisDialog.password') }}:</label>
|
||||
<input
|
||||
type="password"
|
||||
id="mtt-password"
|
||||
v-model="formData.password"
|
||||
:placeholder="account && account.savePassword ? $t('myTischtennisDialog.passwordPlaceholderKeep') : $t('myTischtennisDialog.passwordPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox-group">
|
||||
<label>
|
||||
<!-- Im Bearbeiten-Modus: Zeige normales Formular -->
|
||||
<template v-else>
|
||||
<div class="form-group">
|
||||
<label for="mtt-email">{{ $t('myTischtennisDialog.email') }}:</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="formData.savePassword"
|
||||
type="email"
|
||||
id="mtt-email"
|
||||
v-model="formData.email"
|
||||
:placeholder="$t('myTischtennisDialog.emailPlaceholder')"
|
||||
required
|
||||
/>
|
||||
<span>{{ $t('myTischtennisDialog.savePassword') }}</span>
|
||||
</label>
|
||||
<p class="hint">
|
||||
{{ $t('myTischtennisDialog.savePasswordHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox-group">
|
||||
<label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="mtt-password">{{ $t('myTischtennisDialog.password') }}:</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="formData.autoUpdateRatings"
|
||||
:disabled="!formData.savePassword"
|
||||
type="password"
|
||||
id="mtt-password"
|
||||
v-model="formData.password"
|
||||
:placeholder="account && account.savePassword ? $t('myTischtennisDialog.passwordPlaceholderKeep') : $t('myTischtennisDialog.passwordPlaceholder')"
|
||||
/>
|
||||
<span>{{ $t('myTischtennisDialog.autoUpdateRatings') }}</span>
|
||||
</label>
|
||||
<p class="hint">
|
||||
{{ $t('myTischtennisDialog.autoUpdateRatingsHint') }}
|
||||
</p>
|
||||
<p v-if="!formData.savePassword" class="warning">
|
||||
⚠️ {{ $t('myTischtennisDialog.autoUpdateWarning') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group" v-if="formData.password">
|
||||
<label for="app-password">{{ $t('myTischtennisDialog.appPassword') }}:</label>
|
||||
<input
|
||||
type="password"
|
||||
id="app-password"
|
||||
v-model="formData.userPassword"
|
||||
:placeholder="$t('myTischtennisDialog.appPasswordPlaceholder')"
|
||||
required
|
||||
/>
|
||||
<p class="hint">
|
||||
{{ $t('myTischtennisDialog.appPasswordHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="formData.savePassword"
|
||||
/>
|
||||
<span>{{ $t('myTischtennisDialog.savePassword') }}</span>
|
||||
</label>
|
||||
<p class="hint">
|
||||
{{ $t('myTischtennisDialog.savePasswordHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Auto-Update-Checkbox entfernt - automatische Abrufe wurden deaktiviert -->
|
||||
|
||||
<div class="form-group" v-if="formData.password">
|
||||
<label for="app-password">{{ $t('myTischtennisDialog.appPassword') }}:</label>
|
||||
<input
|
||||
type="password"
|
||||
id="app-password"
|
||||
v-model="formData.userPassword"
|
||||
:placeholder="$t('myTischtennisDialog.appPasswordPlaceholder')"
|
||||
required
|
||||
/>
|
||||
<p class="hint">
|
||||
{{ $t('myTischtennisDialog.appPasswordHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
@@ -80,7 +81,7 @@
|
||||
<button class="btn-secondary" @click="$emit('close')" :disabled="saving">
|
||||
{{ $t('myTischtennisDialog.cancel') }}
|
||||
</button>
|
||||
<button class="btn-primary" @click="saveAccount" :disabled="!canSave || saving">
|
||||
<button v-if="!loginMode" class="btn-primary" @click="saveAccount()" :disabled="!canSave || saving">
|
||||
{{ saving ? $t('myTischtennisDialog.saving') : $t('myTischtennisDialog.save') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -97,6 +98,10 @@ export default {
|
||||
account: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
loginMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -105,34 +110,104 @@ export default {
|
||||
email: this.account?.email || '',
|
||||
password: '',
|
||||
savePassword: this.account?.savePassword || false,
|
||||
autoUpdateRatings: this.account?.autoUpdateRatings || false,
|
||||
autoUpdateRatings: false, // Automatische Updates deaktiviert
|
||||
userPassword: ''
|
||||
},
|
||||
saving: false,
|
||||
error: null
|
||||
loading: false,
|
||||
error: null,
|
||||
urlCheckInterval: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
loginUrl() {
|
||||
// Verwende Backend-Proxy für Login-Seite, damit Cookies im Backend-Kontext bleiben
|
||||
// Verwende absolute URL für iframe
|
||||
const baseUrl = import.meta.env.VITE_BACKEND || window.location.origin;
|
||||
// Füge Token als Query-Parameter hinzu, damit Backend userId extrahieren kann
|
||||
const token = this.$store.state.token;
|
||||
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||
return `${baseUrl}/api/mytischtennis/login-page${tokenParam}`;
|
||||
},
|
||||
canSave() {
|
||||
// E-Mail ist erforderlich
|
||||
if (!this.formData.email.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Im Login-Modus: Passwort ist erforderlich
|
||||
if (this.loginMode) {
|
||||
return !!this.formData.password;
|
||||
}
|
||||
|
||||
// Wenn ein Passwort eingegeben wurde, muss auch das App-Passwort eingegeben werden
|
||||
if (this.formData.password && !this.formData.userPassword) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Automatische Updates erfordern gespeichertes Passwort
|
||||
if (this.formData.autoUpdateRatings && !this.formData.savePassword) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.loginMode) {
|
||||
this.loading = true;
|
||||
// URL-Überwachung wird erst gestartet, nachdem das iframe geladen wurde
|
||||
}
|
||||
},
|
||||
beforeUnmount() {
|
||||
// URL-Überwachung stoppen
|
||||
if (this.urlCheckInterval) {
|
||||
clearInterval(this.urlCheckInterval);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onIframeLoad() {
|
||||
this.loading = false;
|
||||
console.log('[MyTischtennisDialog] Iframe geladen');
|
||||
|
||||
// Starte URL-Überwachung erst NACH dem Laden des iframes
|
||||
// Warte 3 Sekunden, damit der Benutzer Zeit hat, sich einzuloggen
|
||||
setTimeout(() => {
|
||||
this.startUrlMonitoring();
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
checkIframeUrl() {
|
||||
try {
|
||||
const iframe = this.$refs.loginIframe;
|
||||
if (!iframe || !iframe.contentWindow) return;
|
||||
|
||||
// Versuche, die URL zu lesen (funktioniert nur bei gleicher Origin)
|
||||
// Da mytischtennis.de eine andere Origin ist, können wir die URL nicht direkt lesen
|
||||
// Stattdessen überwachen wir über PostMessage oder Polling
|
||||
} catch (error) {
|
||||
// Cross-Origin-Zugriff nicht möglich - das ist normal
|
||||
console.log('[MyTischtennisDialog] Cross-Origin-Zugriff nicht möglich (erwartet)');
|
||||
}
|
||||
},
|
||||
|
||||
startUrlMonitoring() {
|
||||
// Überwache, ob der Login erfolgreich war
|
||||
// Prüfe, ob bereits eine gültige Session existiert
|
||||
this.urlCheckInterval = setInterval(async () => {
|
||||
try {
|
||||
// Prüfe, ob bereits eine gültige Session existiert
|
||||
// Nach erfolgreichem Login im iframe sollte submitLogin die Session gespeichert haben
|
||||
const sessionResponse = await apiClient.get('/mytischtennis/session');
|
||||
if (sessionResponse.data && sessionResponse.data.session && sessionResponse.data.session.accessToken) {
|
||||
// Session vorhanden - Login erfolgreich!
|
||||
clearInterval(this.urlCheckInterval);
|
||||
this.urlCheckInterval = null;
|
||||
this.$emit('logged-in');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// Noch nicht eingeloggt oder Fehler - ignorieren
|
||||
// (wird alle 3 Sekunden wieder versucht)
|
||||
}
|
||||
}, 3000); // Alle 3 Sekunden prüfen
|
||||
},
|
||||
|
||||
async saveAccount() {
|
||||
if (!this.canSave) return;
|
||||
|
||||
@@ -143,7 +218,7 @@ export default {
|
||||
const payload = {
|
||||
email: this.formData.email,
|
||||
savePassword: this.formData.savePassword,
|
||||
autoUpdateRatings: this.formData.autoUpdateRatings
|
||||
autoUpdateRatings: false // Automatische Updates immer deaktiviert
|
||||
};
|
||||
|
||||
// Nur password und userPassword hinzufügen, wenn ein Passwort eingegeben wurde
|
||||
@@ -191,6 +266,33 @@ export default {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.login-iframe-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
min-height: 600px;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.iframe-loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 4px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
|
||||
@@ -1155,7 +1155,11 @@
|
||||
"cancel": "Abbrechen",
|
||||
"saving": "Speichere...",
|
||||
"save": "Speichern",
|
||||
"errorSaving": "Fehler beim Speichern des Accounts"
|
||||
"errorSaving": "Fehler beim Speichern des Accounts",
|
||||
"login": "Einloggen",
|
||||
"loggingIn": "Logge ein...",
|
||||
"errorLogin": "Fehler beim Einloggen",
|
||||
"loadingLoginForm": "Lade Login-Formular..."
|
||||
},
|
||||
"trainingDetails": {
|
||||
"title": "Trainings-Details",
|
||||
@@ -1536,6 +1540,7 @@
|
||||
"ERROR_MYTISCHTENNIS_PASSWORD_NOT_SAVED": "Kein Passwort gespeichert. Bitte geben Sie Ihr Passwort ein.",
|
||||
"ERROR_MYTISCHTENNIS_SESSION_EXPIRED": "Session abgelaufen. Bitte erneut einloggen.",
|
||||
"ERROR_MYTISCHTENNIS_NO_PASSWORD_SAVED": "Kein Passwort gespeichert und Session abgelaufen. Bitte Passwort eingeben.",
|
||||
"ERROR_MYTISCHTENNIS_CAPTCHA_REQUIRED": "CAPTCHA erforderlich. MyTischtennis verwendet jetzt ein CAPTCHA beim Login. Bitte loggen Sie sich einmal direkt auf mytischtennis.de ein, um das CAPTCHA zu lösen, oder kontaktieren Sie den Support.",
|
||||
"ERROR_MEMBER_NOT_FOUND": "Mitglied nicht gefunden.",
|
||||
"ERROR_MEMBER_ALREADY_EXISTS": "Mitglied existiert bereits.",
|
||||
"ERROR_MEMBER_FIRSTNAME_REQUIRED": "Vorname ist erforderlich.",
|
||||
|
||||
@@ -34,75 +34,12 @@
|
||||
<span>{{ formatDate(account.lastLoginAttempt) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row" v-if="account.lastUpdateRatings">
|
||||
<label>{{ $t('myTischtennisAccount.lastFetch') }}</label>
|
||||
<span>{{ formatDate(account.lastUpdateRatings) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row" v-if="account.autoUpdateRatings !== undefined">
|
||||
<label>{{ $t('myTischtennisAccount.autoUpdates') }}</label>
|
||||
<span>{{ account.autoUpdateRatings ? $t('myTischtennisAccount.enabled') : $t('myTischtennisAccount.disabled') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button class="btn-primary" @click="openEditDialog">{{ $t('myTischtennisAccount.editAccount') }}</button>
|
||||
<button type="button" class="btn-secondary" @click="testConnection">{{ $t('myTischtennisAccount.loginAgain') }}</button>
|
||||
<button class="btn-info" @click="openHistoryDialog" v-if="account">{{ $t('myTischtennisAccount.updateHistory') }}</button>
|
||||
<button class="btn-danger" @click="deleteAccount">{{ $t('myTischtennisAccount.unlinkAccount') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fetch Statistics Section -->
|
||||
<div class="info-section fetch-stats-section" v-if="account">
|
||||
<h2>{{ $t('myTischtennisAccount.fetchStatistics') }}</h2>
|
||||
|
||||
<div v-if="loadingStats" class="loading-stats">{{ $t('myTischtennisAccount.loadingStatistics') }}</div>
|
||||
|
||||
<div v-else-if="latestFetches" class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📊</div>
|
||||
<div class="stat-content">
|
||||
<h3>{{ $t('myTischtennisAccount.playerRatings') }}</h3>
|
||||
<div v-if="latestFetches.ratings">
|
||||
<p class="stat-date">{{ formatDateRelative(latestFetches.ratings.lastFetch) }}</p>
|
||||
<p class="stat-detail">{{ latestFetches.ratings.recordsProcessed }} {{ $t('myTischtennisAccount.playersUpdated') }}</p>
|
||||
<p class="stat-time" v-if="latestFetches.ratings.executionTime">{{ latestFetches.ratings.executionTime }}ms</p>
|
||||
</div>
|
||||
<p v-else class="stat-never">{{ $t('myTischtennisAccount.neverFetched') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">🏓</div>
|
||||
<div class="stat-content">
|
||||
<h3>{{ $t('myTischtennisAccount.matchResults') }}</h3>
|
||||
<div v-if="latestFetches.match_results">
|
||||
<p class="stat-date">{{ formatDateRelative(latestFetches.match_results.lastFetch) }}</p>
|
||||
<p class="stat-detail">{{ latestFetches.match_results.recordsProcessed }} {{ $t('myTischtennisAccount.results') }}</p>
|
||||
<p class="stat-time" v-if="latestFetches.match_results.executionTime">{{ latestFetches.match_results.executionTime }}ms</p>
|
||||
</div>
|
||||
<p v-else class="stat-never">{{ $t('myTischtennisAccount.neverFetched') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📋</div>
|
||||
<div class="stat-content">
|
||||
<h3>{{ $t('myTischtennisAccount.leagueTables') }}</h3>
|
||||
<div v-if="latestFetches.league_table">
|
||||
<p class="stat-date">{{ formatDateRelative(latestFetches.league_table.lastFetch) }}</p>
|
||||
<p class="stat-detail">{{ latestFetches.league_table.recordsProcessed }} {{ $t('myTischtennisAccount.teams') }}</p>
|
||||
<p class="stat-time" v-if="latestFetches.league_table.executionTime">{{ latestFetches.league_table.executionTime }}ms</p>
|
||||
</div>
|
||||
<p v-else class="stat-never">{{ $t('myTischtennisAccount.neverFetched') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn-secondary refresh-stats-btn" @click="loadLatestFetches">
|
||||
🔄 {{ $t('myTischtennisAccount.refreshStatistics') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="no-account">
|
||||
@@ -126,15 +63,12 @@
|
||||
<MyTischtennisDialog
|
||||
v-if="showDialog"
|
||||
:account="account"
|
||||
:login-mode="loginMode"
|
||||
@close="closeDialog"
|
||||
@saved="onAccountSaved"
|
||||
@logged-in="onLoggedIn"
|
||||
/>
|
||||
|
||||
<!-- History Dialog -->
|
||||
<MyTischtennisHistoryDialog
|
||||
v-if="showHistoryDialog"
|
||||
@close="closeHistoryDialog"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -163,7 +97,6 @@
|
||||
import apiClient from '../apiClient.js';
|
||||
import { getSafeErrorMessage } from '../utils/errorMessages.js';
|
||||
import MyTischtennisDialog from '../components/MyTischtennisDialog.vue';
|
||||
import MyTischtennisHistoryDialog from '../components/MyTischtennisHistoryDialog.vue';
|
||||
|
||||
import InfoDialog from '../components/InfoDialog.vue';
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||
@@ -171,7 +104,6 @@ export default {
|
||||
name: 'MyTischtennisAccount',
|
||||
components: {
|
||||
MyTischtennisDialog,
|
||||
MyTischtennisHistoryDialog,
|
||||
InfoDialog,
|
||||
ConfirmDialog
|
||||
},
|
||||
@@ -194,17 +126,14 @@ export default {
|
||||
resolveCallback: null
|
||||
},
|
||||
loading: true,
|
||||
loadingStats: false,
|
||||
account: null,
|
||||
accountStatus: null,
|
||||
latestFetches: null,
|
||||
showDialog: false,
|
||||
showHistoryDialog: false
|
||||
loginMode: false
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.loadAccount();
|
||||
this.loadLatestFetches();
|
||||
},
|
||||
methods: {
|
||||
// Dialog Helper Methods
|
||||
@@ -262,19 +191,13 @@ export default {
|
||||
},
|
||||
|
||||
openEditDialog() {
|
||||
this.loginMode = false;
|
||||
this.showDialog = true;
|
||||
},
|
||||
|
||||
closeDialog() {
|
||||
this.showDialog = false;
|
||||
},
|
||||
|
||||
openHistoryDialog() {
|
||||
this.showHistoryDialog = true;
|
||||
},
|
||||
|
||||
closeHistoryDialog() {
|
||||
this.showHistoryDialog = false;
|
||||
this.loginMode = false;
|
||||
},
|
||||
|
||||
async onAccountSaved() {
|
||||
@@ -286,33 +209,19 @@ export default {
|
||||
});
|
||||
},
|
||||
|
||||
async onLoggedIn() {
|
||||
this.closeDialog();
|
||||
await this.loadAccount();
|
||||
this.$store.dispatch('showMessage', {
|
||||
text: this.$t('myTischtennisAccount.loginSuccessful'),
|
||||
type: 'success'
|
||||
});
|
||||
},
|
||||
|
||||
async testConnection() {
|
||||
console.log('[testConnection] Starting connection test...');
|
||||
try {
|
||||
const response = await apiClient.post('/mytischtennis/verify');
|
||||
console.log('[testConnection] Response:', response);
|
||||
this.$store.dispatch('showMessage', {
|
||||
text: this.$t('myTischtennisAccount.loginSuccessful'),
|
||||
type: 'success'
|
||||
});
|
||||
await this.loadAccount(); // Aktualisiere Account-Daten inkl. clubId, fedNickname
|
||||
} catch (error) {
|
||||
console.error('[testConnection] Error:', error);
|
||||
const message = getSafeErrorMessage(error, 'Login fehlgeschlagen');
|
||||
|
||||
if (error.response?.status === 400 && message.includes('Kein Passwort gespeichert')) {
|
||||
// Passwort-Dialog öffnen
|
||||
this.showDialog = true;
|
||||
this.showInfo(this.$t('myTischtennisAccount.passwordRequired'), message, '', 'warning');
|
||||
} else {
|
||||
this.showInfo(this.$t('myTischtennisAccount.loginFailed'), message, '', 'error');
|
||||
}
|
||||
|
||||
this.$store.dispatch('showMessage', {
|
||||
text: message,
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
// Öffne das Login-Dialog mit vorausgefüllter E-Mail
|
||||
this.showDialog = true;
|
||||
this.loginMode = true;
|
||||
},
|
||||
|
||||
async deleteAccount() {
|
||||
@@ -336,18 +245,6 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
async loadLatestFetches() {
|
||||
this.loadingStats = true;
|
||||
try {
|
||||
const response = await apiClient.get('/mytischtennis/latest-fetches');
|
||||
this.latestFetches = response.data.latestFetches;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Fetch-Statistiken:', error);
|
||||
} finally {
|
||||
this.loadingStats = false;
|
||||
}
|
||||
},
|
||||
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
|
||||
Reference in New Issue
Block a user