481 lines
18 KiB
Vue
481 lines
18 KiB
Vue
<!-- /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>
|