Files
gameno-dashboard/src/views/settings/SettingsView.vue
T

481 lines
18 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- /src/views/settings/SettingsView.vue -->
<template>
<div class="settings-view w-full max-w-4xl mx-auto">
<PageHeader
title="تنظیمات سیستم"
subtitle="قالب‌های پیامک، راه‌اندازی پایگاه داده و وارد کردن داده‌ها"
/>
<div class="surface-card p-4 sm:p-5 border-round-xl border-1 border-color shadow-sm mb-4">
<div class="flex align-items-start gap-3 mb-4">
<div class="w-3rem h-3rem border-round-xl flex align-items-center justify-content-center flex-shrink-0 bg-primary-light text-primary">
<i class="pi pi-send text-xl"></i>
</div>
<div class="flex-grow-1">
<h2 class="text-lg font-bold text-color m-0 mb-1">قالبهای پیامک</h2>
<p class="text-sm text-muted m-0 line-height-3">
شناسه قالب و تعداد و نام متغیرهای sms.ir را بر اساس قالب تعریف شده در پنل پیامک تنظیم کنید. میتوانید هر تعداد متغیر که میخواهید تعریف یا حذف نمایید.
</p>
</div>
</div>
<div v-if="isSettingsLoading" class="flex align-items-center gap-2 text-muted text-sm">
<i class="pi pi-spin pi-spinner"></i>
<span>در حال بارگذاری تنظیمات پیامک</span>
</div>
<div v-else class="flex flex-column gap-4">
<div
v-for="template in smsTemplates"
:key="template.key"
class="p-4 border-round-lg surface-ground border-1 border-color flex flex-column gap-3"
>
<template v-if="templateForm[template.key]">
<div class="flex flex-column sm:flex-row sm:align-items-center justify-content-between gap-2 border-bottom-1 border-color pb-2">
<div class="flex align-items-center gap-2">
<i class="pi pi-bookmark text-primary"></i>
<label class="font-bold text-base text-color" :for="`sms-template-${template.key}`">
{{ template.label }}
</label>
</div>
<span class="text-xs text-muted font-mono">{{ template.key }}</span>
</div>
<div class="flex flex-column sm:flex-row sm:align-items-center gap-2">
<label
class="text-sm font-semibold text-color sm:w-10rem flex-shrink-0"
:for="`sms-template-${template.key}`"
>
شناسه قالب sms.ir:
</label>
<div class="flex-grow-1">
<InputText
:id="`sms-template-${template.key}`"
v-model.trim="templateForm[template.key].templateId"
class="w-full text-sm"
dir="ltr"
inputmode="numeric"
placeholder="مثلاً: 123456"
/>
</div>
</div>
<div class="flex flex-column gap-3 pt-2">
<div class="flex align-items-center justify-content-between">
<div>
<span class="text-sm font-semibold text-color">متغیرهای ارسالی در قالب</span>
<span class="text-xs text-muted block mt-1">متغیرهایی که در پنل sms.ir در متن قالب قرار دادهاید</span>
</div>
<Button
label="افزودن متغیر"
icon="pi pi-plus"
size="small"
outlined
class="text-xs font-semibold"
@click="addVariable(template.key)"
/>
</div>
<div
v-if="!templateForm[template.key].variables || templateForm[template.key].variables.length === 0"
class="p-3 text-center border-1 border-dashed border-round surface-card text-muted text-xs line-height-3"
>
هیچ متغیری برای این قالب تعریف نشده است. پیامک بدون متغیر ارسال خواهد شد.
</div>
<div v-else class="flex flex-column gap-2">
<div
v-for="(variable, vIdx) in templateForm[template.key].variables"
:key="vIdx"
class="flex flex-column sm:flex-row sm:align-items-center gap-2 p-2 border-round surface-card border-1 border-color"
>
<div class="flex align-items-center gap-2 sm:w-14rem flex-shrink-0">
<span class="text-xs text-muted font-bold ml-1">{{ vIdx + 1 }}.</span>
<Select
v-model="variable.slot"
:options="template.availableSlots || []"
optionLabel="label"
optionValue="slot"
placeholder="انتخاب مقدار داده"
class="w-full text-sm"
@change="onSlotChange(template.key, vIdx)"
/>
</div>
<div class="flex-grow-1 flex align-items-center gap-2">
<InputText
v-model.trim="variable.name"
class="w-full text-sm"
dir="ltr"
:placeholder="getSlotDefaultName(template, variable.slot) || 'نام متغیر در sms.ir'"
autocomplete="off"
/>
<Tag
:value="`#${variable.name || getSlotDefaultName(template, variable.slot) || '...'}#`"
severity="secondary"
class="text-xs flex-shrink-0 font-mono"
/>
<Button
icon="pi pi-trash"
severity="danger"
text
rounded
size="small"
class="p-button-sm flex-shrink-0"
title="حذف متغیر"
@click="removeVariable(template.key, vIdx)"
/>
</div>
</div>
</div>
</div>
</template>
</div>
<div class="flex justify-content-end">
<Button
label="ذخیره قالب‌ها"
icon="pi pi-save"
class="font-bold"
:loading="isSavingSettings"
@click="saveSmsTemplates"
/>
</div>
</div>
</div>
<div class="surface-card p-4 sm:p-5 border-round-xl border-1 border-color shadow-sm mb-4">
<div class="flex align-items-start gap-3 mb-4">
<div class="w-3rem h-3rem border-round-xl flex align-items-center justify-content-center flex-shrink-0 bg-primary-light text-primary">
<i class="pi pi-database text-xl"></i>
</div>
<div class="flex-grow-1">
<h2 class="text-lg font-bold text-color m-0 mb-1">راهاندازی پایگاه داده</h2>
<p class="text-sm text-muted m-0 line-height-3">
نقشهای سیستمی و حساب مدیر ارشد را یکبار ایجاد میکند. پس از اجرا، این دکمه برای همیشه مخفی میشود.
</p>
</div>
</div>
<div v-if="isStatusLoading" class="flex align-items-center gap-2 text-muted text-sm">
<i class="pi pi-spin pi-spinner"></i>
<span>در حال بررسی وضعیت راهاندازی</span>
</div>
<div v-else-if="isLocked" class="flex align-items-center gap-2 p-3 border-round surface-ground border-1 border-color">
<i class="pi pi-lock text-primary"></i>
<div class="flex flex-column gap-1">
<span class="font-semibold text-sm text-color">راهاندازی اولیه انجام شده است</span>
<span v-if="lockedAtLabel" class="text-xs text-muted">{{ lockedAtLabel }}</span>
</div>
</div>
<div v-else class="flex flex-column sm:flex-row align-items-stretch sm:align-items-center gap-3">
<Button
label="اجرای راه‌اندازی پایگاه داده"
icon="pi pi-play"
class="font-bold"
:loading="isSeeding"
@click="confirmSeed"
/>
<span class="text-xs text-muted line-height-3">
این عملیات فقط یکبار قابل اجرا است.
</span>
</div>
</div>
<div class="surface-card p-4 sm:p-5 border-round-xl border-1 border-color shadow-sm">
<div class="flex align-items-start gap-3 mb-4">
<div class="w-3rem h-3rem border-round-xl flex align-items-center justify-content-center flex-shrink-0 bg-primary-light text-primary">
<i class="pi pi-upload text-xl"></i>
</div>
<div class="flex-grow-1">
<h2 class="text-lg font-bold text-color m-0 mb-1">وارد کردن دادهها</h2>
<p class="text-sm text-muted m-0 line-height-3">
فایل JSON ساختاریافته دورهها، کلاسها و کاربران را بارگذاری کنید. دادههای قبلی حذف نمیشوند؛ موارد تکراری بهروزرسانی یا به کلاس اضافه میشوند.
</p>
</div>
</div>
<div class="flex flex-column gap-3">
<input
ref="fileInputRef"
type="file"
accept="application/json,.json"
class="hidden"
@change="onFileSelected"
/>
<div class="flex flex-column sm:flex-row align-items-stretch sm:align-items-center gap-3">
<Button
:label="selectedFileName || 'انتخاب فایل JSON'"
:icon="selectedFileName ? 'pi pi-file' : 'pi pi-folder-open'"
severity="secondary"
outlined
class="font-semibold"
@click="fileInputRef?.click()"
/>
<Button
label="بارگذاری و افزودن به پایگاه داده"
icon="pi pi-cloud-upload"
class="font-bold"
:loading="isImporting"
:disabled="!selectedFile"
@click="confirmImport"
/>
</div>
<p class="text-xs text-muted m-0 line-height-3">
نمونه فایل آماده: <code dir="ltr">raw-data/import-data.json</code>
</p>
<div
v-if="importResult"
class="p-3 border-round surface-ground border-1 border-color text-sm"
>
<div class="font-semibold mb-2">نتیجه آخرین بارگذاری</div>
<ul class="m-0 pr-3 line-height-3">
<li>دوره جدید: {{ importResult.stats?.coursesCreated ?? 0 }} بازاستفاده: {{ importResult.stats?.coursesReused ?? 0 }}</li>
<li>کلاس جدید: {{ importResult.stats?.classesCreated ?? 0 }} بازاستفاده: {{ importResult.stats?.classesReused ?? 0 }}</li>
<li>کاربر جدید: {{ importResult.stats?.studentsCreated ?? 0 }} بهروزرسانی: {{ importResult.stats?.studentsUpdated ?? 0 }}</li>
<li>ثبتنام در کلاس: {{ importResult.stats?.enrollmentsAdded ?? 0 }} رد شده: {{ importResult.stats?.studentsSkipped ?? 0 }}</li>
<li v-if="importResult.warnings?.length">هشدارها: {{ importResult.warnings.length }}</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { useConfirm } from 'primevue/useconfirm';
import Button from 'primevue/button';
import InputText from 'primevue/inputtext';
import Select from 'primevue/select';
import Tag from 'primevue/tag';
import PageHeader from '@/components/common/PageHeader.vue';
import { seedApi } from '@/api/seedApi';
import { settingsApi } from '@/api/settingsApi';
import { useToast } from '@/composables/useToast';
const confirm = useConfirm();
const { showSuccess, showError } = useToast();
const isSettingsLoading = ref(true);
const isSavingSettings = ref(false);
const smsTemplates = ref([]);
const templateForm = reactive({});
const isStatusLoading = ref(true);
const isSeeding = ref(false);
const isLocked = ref(false);
const lockedAt = ref(null);
const fileInputRef = ref(null);
const selectedFile = ref(null);
const selectedFileName = ref('');
const isImporting = ref(false);
const importResult = ref(null);
const lockedAtLabel = computed(() => {
if (!lockedAt.value) return '';
const date = new Date(lockedAt.value);
if (Number.isNaN(date.getTime())) return '';
return `قفل شده در ${date.toLocaleString('fa-IR')}`;
});
const getSlotDefaultName = (template, slotKey) => {
const slot = (template?.availableSlots || []).find((s) => s.slot === slotKey);
return slot?.defaultName || '';
};
const onSlotChange = (templateKey, vIdx) => {
const form = templateForm[templateKey];
if (!form || !form.variables[vIdx]) return;
const template = smsTemplates.value.find((t) => t.key === templateKey);
const currentVar = form.variables[vIdx];
if (!currentVar.name) {
currentVar.name = getSlotDefaultName(template, currentVar.slot);
}
};
const addVariable = (templateKey) => {
const form = templateForm[templateKey];
if (!form) return;
const template = smsTemplates.value.find((t) => t.key === templateKey);
const availableSlots = template?.availableSlots || [];
const existingSlots = new Set((form.variables || []).map((v) => v.slot));
const unusedSlot = availableSlots.find((s) => !existingSlots.has(s.slot));
const selectedSlot = unusedSlot || availableSlots[0] || { slot: 'param', defaultName: 'param' };
form.variables.push({
slot: selectedSlot.slot,
name: selectedSlot.defaultName || ''
});
};
const removeVariable = (templateKey, index) => {
const form = templateForm[templateKey];
if (form && form.variables) {
form.variables.splice(index, 1);
}
};
const applyStatus = (payload) => {
const status = payload?.data || payload || {};
isLocked.value = Boolean(status.locked);
lockedAt.value = status.lockedAt || null;
};
const loadStatus = async () => {
isStatusLoading.value = true;
try {
const response = await seedApi.getStatus();
applyStatus(response);
} catch (err) {
showError(err);
} finally {
isStatusLoading.value = false;
}
};
const runSeed = async () => {
isSeeding.value = true;
try {
const response = await seedApi.runSeed();
applyStatus({ locked: true, lockedAt: new Date().toISOString(), ...(response?.data || response) });
isLocked.value = true;
showSuccess('راه‌اندازی پایگاه داده با موفقیت انجام شد.');
} catch (err) {
showError(err);
await loadStatus();
} finally {
isSeeding.value = false;
}
};
const confirmSeed = () => {
confirm.require({
header: 'راه‌اندازی پایگاه داده',
message: 'نقش‌های سیستمی و حساب مدیر ارشد ایجاد یا به‌روز می‌شوند. این دکمه پس از اجرا دیگر نمایش داده نمی‌شود. ادامه می‌دهید؟',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'اجرا',
rejectLabel: 'انصراف',
acceptClass: 'p-button-primary',
accept: () => {
runSeed();
}
});
};
const onFileSelected = (event) => {
const file = event.target.files?.[0] || null;
selectedFile.value = file;
selectedFileName.value = file?.name || '';
importResult.value = null;
};
const runImport = async () => {
if (!selectedFile.value) return;
isImporting.value = true;
try {
const response = await seedApi.importData(selectedFile.value);
const data = response?.data || response;
importResult.value = data;
showSuccess('داده‌ها با موفقیت به پایگاه داده اضافه شدند.');
} catch (err) {
showError(err);
} finally {
isImporting.value = false;
}
};
const confirmImport = () => {
if (!selectedFile.value) return;
confirm.require({
header: 'وارد کردن داده‌ها',
message: 'دوره‌ها، کلاس‌ها و کاربران از فایل JSON به پایگاه داده افزوده می‌شوند. داده‌های موجود حذف نمی‌شوند. ادامه می‌دهید؟',
icon: 'pi pi-upload',
acceptLabel: 'بارگذاری',
rejectLabel: 'انصراف',
acceptClass: 'p-button-primary',
accept: () => {
runImport();
}
});
};
const emptyFormEntry = (template) => ({
templateId: template?.templateId || '',
variables: Array.isArray(template?.variables)
? template.variables.map((variable) => ({
slot: variable.slot,
name: variable.name || ''
}))
: []
});
const applySmsTemplates = (payload) => {
const data = payload?.data || payload || {};
const templates = Array.isArray(data.smsTemplates) ? data.smsTemplates : [];
smsTemplates.value = templates;
Object.keys(templateForm).forEach((key) => {
delete templateForm[key];
});
templates.forEach((template) => {
templateForm[template.key] = emptyFormEntry(template);
});
};
const loadSettings = async () => {
isSettingsLoading.value = true;
try {
const response = await settingsApi.get();
applySmsTemplates(response);
} catch (err) {
showError(err);
} finally {
isSettingsLoading.value = false;
}
};
const saveSmsTemplates = async () => {
isSavingSettings.value = true;
try {
const smsTemplatesPayload = {};
smsTemplates.value.forEach((template) => {
const entry = templateForm[template.key] || emptyFormEntry(template);
smsTemplatesPayload[template.key] = {
templateId: entry.templateId || '',
variables: (entry.variables || [])
.filter((v) => v.slot)
.map((v) => ({
slot: v.slot,
name: v.name || getSlotDefaultName(template, v.slot) || v.slot
}))
};
});
const response = await settingsApi.save({ smsTemplates: smsTemplatesPayload });
applySmsTemplates(response);
showSuccess('قالب‌های پیامک ذخیره شدند.');
} catch (err) {
showError(err);
} finally {
isSavingSettings.value = false;
}
};
onMounted(() => {
loadStatus();
loadSettings();
});
</script>
<style scoped>
.hidden {
display: none;
}
</style>