Files
frontend/src/views/admin/ModpacksView.vue

599 lines
19 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>
<div>
<h1>Управление модпаками</h1>
<div class="import-section">
<h2>Импорт нового модпака</h2>
<form @submit.prevent="handleImport" class="import-form">
<div class="form-group">
<label>Тип сборки</label>
<select v-model="form.importerType">
<option value="simple">Простой ZIP</option>
<option value="curseforge">CurseForge</option>
<option value="modrinth">Modrinth (.mrpack)</option>
</select>
</div>
<div class="form-group">
<label>Метод импорта</label>
<div class="radio-group">
<label><input type="radio" v-model="form.importMethod" value="file" /> Файл</label>
<label
><input type="radio" v-model="form.importMethod" value="url" :disabled="form.importerType !== 'curseforge'" />
URL</label
>
</div>
</div>
<!-- Поля для метаданных -->
<div class="form-group">
<label for="name">Системное имя (латиница, без пробелов)</label>
<input id="name" v-model="form.name" type="text" required pattern="^[a-zA-Z0-9_]+$" />
</div>
<div class="form-group">
<label for="displayName">Отображаемое имя</label>
<input id="displayName" v-model="form.displayName" type="text" required />
</div>
<div class="form-group">
<label for="mcVersion">Версия Minecraft</label>
<input id="mcVersion" v-model="form.mcVersion" type="text" required />
</div>
<!-- Поля для источника -->
<div v-if="form.importMethod === 'file'" class="form-group">
<label for="modpack-file">Файл модпака (.zip, .mrpack)</label>
<input id="modpack-file" type="file" @change="onFileSelected" :required="form.importMethod === 'file'" accept=".zip,.mrpack" />
</div>
<div v-if="form.importMethod === 'url'" class="form-group">
<label for="sourceUrl">URL страницы модпака на CurseForge</label>
<input id="sourceUrl" v-model="form.sourceUrl" type="url" :required="form.importMethod === 'url'" />
</div>
<div v-if="importJob.jobId" class="job-status">
<h3>Статус импорта</h3>
<div class="progress-bar">
<div class="progress" :style="{ width: importJob.progress + '%' }"></div>
</div>
<p>{{ importJob.message }} ({{ importJob.progress }}%)</p>
<div v-if="importJob.status === 'failed'" class="error-message">Ошибка: {{ importJob.message }}</div>
<div v-if="importJob.status === 'completed'" class="success-message">Импорт успешно завершен!</div>
</div>
<div v-if="error && !importJob.jobId" class="error-message">{{ error }}</div>
<!-- <div v-if="successMessage" class="success-message">{{ successMessage }}</div> -->
<button type="submit" :disabled="isLoading || (importJob.jobId !== null && importJob.status !== 'completed' && importJob.status !== 'failed')">
{{ isLoading ? "Запуск..." : "Импортировать" }}
</button>
</form>
</div>
<!-- СПИСОК МОДПАКОВ -->
<div class="modpacks-section">
<h2>Существующие модпаки</h2>
<div v-if="modpacksLoading">Загрузка...</div>
<table v-else-if="modpacks.length > 0" class="modpacks-table">
<thead>
<tr>
<th>Имя</th>
<th>Отображаемое имя</th>
<th>MC Version</th>
<th>Обновлено</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<tr v-for="m in modpacks" :key="m.id">
<td>{{ m.name }}</td>
<td>{{ m.display_name }}</td>
<td>{{ m.minecraft_version }}</td>
<td>{{ formatDate(m.updated_at) }}</td>
<td>
<button @click="openUpdateModal(m)" class="btn-small">Обновить</button>
</td>
</tr>
</tbody>
</table>
<p v-else>Нет модпаков</p>
</div>
<!-- МОДАЛЬНОЕ ОКНО ОБНОВЛЕНИЯ -->
<div v-if="updateModal.show" class="modal-overlay" @click.self="closeUpdateModal">
<div class="modal-content">
<h3>Обновить модпак: {{ updateModal.modpack?.name }}</h3>
<form @submit.prevent="handleUpdate" class="import-form">
<div class="form-group">
<label>Тип сборки</label>
<select v-model="updateForm.importerType">
<option value="simple">Простой ZIP</option>
<option value="curseforge">CurseForge</option>
<option value="modrinth">Modrinth (.mrpack)</option>
</select>
</div>
<div class="form-group">
<label>Метод импорта</label>
<div class="radio-group">
<label><input type="radio" v-model="updateForm.importMethod" value="file" /> Файл</label>
<label><input type="radio" v-model="updateForm.importMethod" value="url" :disabled="updateForm.importerType !== 'curseforge'" /> URL</label>
</div>
</div>
<div class="form-group">
<label>Версия Minecraft (опционально)</label>
<input v-model="updateForm.mcVersion" type="text" :placeholder="updateModal.modpack?.minecraft_version" />
</div>
<div v-if="updateForm.importMethod === 'file'" class="form-group">
<label>Файл модпака</label>
<input type="file" @change="onUpdateFileSelected" accept=".zip,.mrpack" />
</div>
<div v-if="updateForm.importMethod === 'url'" class="form-group">
<label>URL страницы модпака</label>
<input v-model="updateForm.sourceUrl" type="url" />
</div>
<div v-if="updateJob.jobId" class="job-status">
<div class="progress-bar">
<div class="progress" :style="{ width: updateJob.progress + '%' }"></div>
</div>
<p>{{ updateJob.message }} ({{ updateJob.progress }}%)</p>
<div v-if="updateJob.status === 'failed'" class="error-message">Ошибка: {{ updateJob.message }}</div>
<div v-if="updateJob.status === 'completed'" class="success-message">Обновление завершено!</div>
</div>
<div class="modal-actions">
<button type="submit" :disabled="updateLoading || (updateJob.jobId !== null && updateJob.status !== 'completed' && updateJob.status !== 'failed')">
{{ updateLoading ? "Запуск..." : "Обновить" }}
</button>
<button type="button" @click="closeUpdateModal" class="btn-secondary">Отмена</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, watch, onMounted, onUnmounted } from "vue";
import apiClient from "@/api/axios";
import { useAuthStore } from "@/stores/auth";
const authStore = useAuthStore();
// ===== Интерфейсы =====
interface Modpack {
id: number;
name: string;
display_name: string;
minecraft_version: string;
is_active: boolean;
created_at: string;
updated_at: string;
}
// ===== Состояние списка модпаков =====
const modpacks = ref<Modpack[]>([]);
const modpacksLoading = ref(true);
// ===== Форма импорта =====
const form = reactive({
name: "",
displayName: "",
mcVersion: "",
file: null as File | null,
importerType: "simple",
importMethod: "file",
sourceUrl: "",
});
const isLoading = ref(false);
const error = ref<string | null>(null);
const successMessage = ref<string | null>(null);
// Состояние задачи импорта
const importJob = reactive({
jobId: null as number | null,
status: "" as string,
progress: 0,
message: "" as string,
});
// ===== Модальное окно обновления =====
const updateModal = reactive({
show: false,
modpack: null as Modpack | null,
});
const updateForm = reactive({
importerType: "simple",
importMethod: "file",
mcVersion: "",
sourceUrl: "",
file: null as File | null,
});
const updateLoading = ref(false);
const updateJob = reactive({
jobId: null as number | null,
status: "" as string,
progress: 0,
message: "" as string,
});
let ws: WebSocket | null = null;
// ===== Watchers =====
watch(
() => form.importerType,
(newType) => {
if (newType !== "curseforge" && form.importMethod === "url") {
form.importMethod = "file";
}
},
);
watch(
() => updateForm.importerType,
(newType) => {
if (newType !== "curseforge" && updateForm.importMethod === "url") {
updateForm.importMethod = "file";
}
},
);
// ===== Helpers =====
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString();
};
const getStatusMessage = (status: string) => {
switch (status) {
case 'pending': return 'Ожидание очереди...';
case 'downloading': return 'Скачивание файлов...';
case 'processing': return 'Обработка...';
case 'completed': return 'Готово';
case 'failed': return 'Ошибка';
default: return status;
}
};
// ===== Fetching =====
const fetchModpacks = async () => {
modpacksLoading.value = true;
try {
const response = await apiClient.get<Modpack[]>("/admin/modpacks");
modpacks.value = response.data || [];
} catch (e) {
console.error("Failed to fetch modpacks", e);
} finally {
modpacksLoading.value = false;
}
};
// ===== File handlers =====
const onFileSelected = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files[0]) {
form.file = target.files[0];
}
};
const onUpdateFileSelected = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files[0]) {
updateForm.file = target.files[0];
}
};
// ===== WebSocket =====
const connectWebSocket = (targetJob: { jobId: number | null; status: string; progress: number; message: string }, onComplete?: () => void) => {
if (ws) {
ws.close();
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const host = window.location.host;
const url = `${protocol}//${host}/api/admin/ws/jobs?token=${authStore.token}`;
ws = new WebSocket(url);
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.job_id === targetJob.jobId) {
targetJob.status = data.status;
targetJob.progress = data.progress;
targetJob.message = data.error_message || getStatusMessage(data.status);
if (data.status === 'completed') {
isLoading.value = false;
updateLoading.value = false;
if (onComplete) onComplete();
} else if (data.status === 'failed') {
isLoading.value = false;
updateLoading.value = false;
}
}
} catch (e) {
console.error("WS parse error", e);
}
};
ws.onclose = () => {
console.log("WS closed");
};
};
// ===== Modal handlers =====
const openUpdateModal = (modpack: Modpack) => {
updateModal.show = true;
updateModal.modpack = modpack;
updateForm.importerType = "simple";
updateForm.importMethod = "file";
updateForm.mcVersion = "";
updateForm.sourceUrl = "";
updateForm.file = null;
updateJob.jobId = null;
updateJob.status = "";
updateJob.progress = 0;
updateJob.message = "";
};
const closeUpdateModal = () => {
updateModal.show = false;
updateModal.modpack = null;
};
// ===== Import handler =====
const handleImport = async () => {
if (form.importMethod === "file" && !form.file) {
error.value = "Пожалуйста, выберите файл для импорта.";
return;
}
if (form.importMethod === "url" && !form.sourceUrl) {
error.value = "Пожалуйста, введите URL.";
return;
}
isLoading.value = true;
error.value = null;
successMessage.value = null;
importJob.jobId = null;
importJob.status = "";
importJob.progress = 0;
importJob.message = "";
const formData = new FormData();
formData.append("name", form.name);
formData.append("displayName", form.displayName);
formData.append("mcVersion", form.mcVersion);
formData.append("importerType", form.importerType);
formData.append("importMethod", form.importMethod);
if (form.importMethod === "file" && form.file) {
formData.append("file", form.file);
} else if (form.importMethod === "url") {
formData.append("sourceUrl", form.sourceUrl);
}
try {
const response = await apiClient.post("/admin/modpacks/import", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
if (response.status === 202 && response.data.job_id) {
importJob.jobId = response.data.job_id;
importJob.status = 'pending';
importJob.message = 'Задача создана...';
connectWebSocket(importJob, fetchModpacks);
} else {
successMessage.value = response.data;
isLoading.value = false;
fetchModpacks();
}
} catch (e: any) {
error.value = e.response?.data || "Произошла ошибка при импорте.";
isLoading.value = false;
}
};
// ===== Update handler =====
const handleUpdate = async () => {
if (!updateModal.modpack) return;
if (updateForm.importMethod === "file" && !updateForm.file) {
return;
}
if (updateForm.importMethod === "url" && !updateForm.sourceUrl) {
return;
}
updateLoading.value = true;
updateJob.jobId = null;
updateJob.status = "";
updateJob.progress = 0;
updateJob.message = "";
const formData = new FormData();
formData.append("modpackName", updateModal.modpack.name);
formData.append("importerType", updateForm.importerType);
formData.append("importMethod", updateForm.importMethod);
if (updateForm.mcVersion) {
formData.append("mcVersion", updateForm.mcVersion);
}
if (updateForm.importMethod === "file" && updateForm.file) {
formData.append("file", updateForm.file);
} else if (updateForm.importMethod === "url") {
formData.append("sourceUrl", updateForm.sourceUrl);
}
try {
const response = await apiClient.post("/admin/modpacks/update", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
if (response.status === 202 && response.data.job_id) {
updateJob.jobId = response.data.job_id;
updateJob.status = 'pending';
updateJob.message = 'Задача создана...';
connectWebSocket(updateJob, () => {
fetchModpacks();
});
} else {
updateLoading.value = false;
fetchModpacks();
}
} catch (e: any) {
console.error("Update error", e);
updateLoading.value = false;
}
};
// ===== Lifecycle =====
onMounted(() => {
fetchModpacks();
});
onUnmounted(() => {
if (ws) ws.close();
});
</script>
<style scoped>
.import-section {
max-width: 600px;
margin-top: 2rem;
padding: 2rem;
border: 1px solid #ddd;
border-radius: 8px;
}
.import-form .form-group {
margin-bottom: 1rem;
}
.import-form label {
display: block;
margin-bottom: 0.5rem;
}
.import-form input,
.import-form select {
width: 100%;
padding: 0.5rem;
box-sizing: border-box;
}
.radio-group label {
display: inline-block;
margin-right: 1rem;
}
.error-message,
.success-message {
margin-top: 1rem;
padding: 0.75rem;
border-radius: 4px;
text-align: center;
}
.error-message {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.success-message {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
button {
padding: 0.75rem 1.5rem;
background-color: #42b983;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:disabled {
background-color: #ccc;
}
/* Modpacks List Section */
.modpacks-section {
margin-top: 2rem;
}
.modpacks-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.modpacks-table th,
.modpacks-table td {
border: 1px solid #ddd;
padding: 0.75rem;
text-align: left;
}
.modpacks-table th {
background-color: #f5f5f5;
}
.btn-small {
padding: 0.4rem 0.8rem;
font-size: 0.9em;
}
/* Modal Overlay */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 2rem;
border-radius: 8px;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal-actions {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
.btn-secondary {
background-color: #6c757d;
}
.btn-secondary:hover {
background-color: #5a6268;
}
/* Progress bar */
.progress-bar {
height: 20px;
background: #eee;
border-radius: 4px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.progress {
height: 100%;
background: #42b983;
transition: width 0.3s;
}
.job-status {
margin-top: 1rem;
padding: 1rem;
border: 1px solid #ddd;
border-radius: 4px;
}
</style>