Add public API keys, outbound webhooks, and bulk SMS campaigns with settings UI and org modules.
This commit is contained in:
@@ -54,7 +54,8 @@ const supportActive = computed(() => pathIn(['/tickets']));
|
||||
const automationsActive = computed(
|
||||
() =>
|
||||
route.path.startsWith('/settings/automations') ||
|
||||
route.path.startsWith('/settings/message-automations'),
|
||||
route.path.startsWith('/settings/message-automations') ||
|
||||
route.path.startsWith('/settings/sms-campaigns'),
|
||||
);
|
||||
const toolsActive = computed(() =>
|
||||
pathIn(['/settings/lead-forms', '/projects', '/project-cards', '/inventory', '/cheques', '/attachments']),
|
||||
@@ -62,6 +63,8 @@ const toolsActive = computed(() =>
|
||||
const settingsActive = computed(() =>
|
||||
pathIn([
|
||||
'/settings/team',
|
||||
'/settings/audit-logs',
|
||||
'/settings/public-api',
|
||||
'/settings/custom-fields',
|
||||
'/settings/currencies',
|
||||
'/settings/tahesab',
|
||||
|
||||
@@ -227,6 +227,12 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
icon: 'send',
|
||||
module: 'message_automations',
|
||||
},
|
||||
{
|
||||
to: '/settings/sms-campaigns',
|
||||
title: 'پیامک گروهی',
|
||||
icon: 'send',
|
||||
module: 'sms_campaigns',
|
||||
},
|
||||
],
|
||||
cards: [
|
||||
{
|
||||
@@ -247,6 +253,15 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
admin: true,
|
||||
module: 'message_automations',
|
||||
},
|
||||
{
|
||||
to: '/settings/sms-campaigns',
|
||||
title: 'پیامک گروهی',
|
||||
desc: 'ارسال انبوه به مخاطبان انتخابشده',
|
||||
icon: 'send',
|
||||
tint: 'mint',
|
||||
admin: true,
|
||||
module: 'sms_campaigns',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -344,6 +359,15 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
module: 'audit_logs',
|
||||
admin: true,
|
||||
},
|
||||
{
|
||||
to: '/settings/public-api',
|
||||
title: 'API و وبهوک',
|
||||
desc: 'کلید عمومی و ارسال رویداد به سرور شما',
|
||||
icon: 'apps-plus',
|
||||
tint: 'sky',
|
||||
module: 'public_api',
|
||||
admin: true,
|
||||
},
|
||||
{
|
||||
to: '/settings/custom-fields',
|
||||
title: 'فیلدهای سفارشی',
|
||||
|
||||
@@ -258,6 +258,18 @@ export const router = createRouter({
|
||||
component: () => import('@/views/AuditLogsView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'settings/public-api',
|
||||
name: 'public-api',
|
||||
component: () => import('@/views/PublicApiSettingsView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'settings/sms-campaigns',
|
||||
name: 'sms-campaigns',
|
||||
component: () => import('@/views/SmsCampaignsView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'settings/custom-fields',
|
||||
name: 'custom-fields',
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type {
|
||||
CreateOrganizationApiKeyResponse,
|
||||
CreateWebhookEndpointResponse,
|
||||
OrganizationApiKeyDto,
|
||||
WebhookDeliveryDto,
|
||||
WebhookEndpointDto,
|
||||
WebhookEvent,
|
||||
} from '@mento/shared';
|
||||
import { WEBHOOK_EVENTS } from '@mento/shared';
|
||||
import {
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiEmptyState,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const ok = ref('');
|
||||
|
||||
const keys = ref<OrganizationApiKeyDto[]>([]);
|
||||
const endpoints = ref<WebhookEndpointDto[]>([]);
|
||||
const deliveries = ref<WebhookDeliveryDto[]>([]);
|
||||
const newKeyName = ref('');
|
||||
const createdRawKey = ref('');
|
||||
const createdWebhookSecret = ref('');
|
||||
|
||||
const whName = ref('');
|
||||
const whUrl = ref('');
|
||||
const whEvents = ref<string[]>(['person.created', 'deal.created']);
|
||||
|
||||
const EVENT_LABELS: Record<WebhookEvent, string> = {
|
||||
'person.created': 'ایجاد شخص',
|
||||
'person.updated': 'ویرایش شخص',
|
||||
'deal.created': 'ایجاد معامله',
|
||||
'deal.updated': 'ویرایش معامله',
|
||||
'deal.won': 'برد معامله',
|
||||
'deal.lost': 'شکست معامله',
|
||||
'invoice.paid': 'پرداخت فاکتور',
|
||||
'activity.created': 'ایجاد فعالیت',
|
||||
};
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const [keysRes, epRes, delRes] = await Promise.all([
|
||||
api.get<OrganizationApiKeyDto[]>('/api-keys'),
|
||||
api.get<WebhookEndpointDto[]>('/webhooks'),
|
||||
api.get<WebhookDeliveryDto[]>('/webhooks/deliveries'),
|
||||
]);
|
||||
keys.value = keysRes.data;
|
||||
endpoints.value = epRes.data;
|
||||
deliveries.value = delRes.data;
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 403) {
|
||||
error.value = 'ماژول API و وبهوک برای این محیط فعال نیست.';
|
||||
} else {
|
||||
error.value = 'بارگذاری تنظیمات ناموفق بود.';
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createKey() {
|
||||
if (!newKeyName.value.trim()) {
|
||||
error.value = 'نام کلید را وارد کنید.';
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
createdRawKey.value = '';
|
||||
try {
|
||||
const { data } = await api.post<CreateOrganizationApiKeyResponse>(
|
||||
'/api-keys',
|
||||
{ name: newKeyName.value.trim() },
|
||||
);
|
||||
createdRawKey.value = data.rawKey;
|
||||
newKeyName.value = '';
|
||||
ok.value = 'کلید ساخته شد. همین الان کپی کنید؛ دوباره نمایش داده نمیشود.';
|
||||
await load();
|
||||
} catch {
|
||||
error.value = 'ساخت کلید ناموفق بود.';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeKey(id: string) {
|
||||
if (!confirm('این کلید باطل شود؟')) return;
|
||||
try {
|
||||
await api.delete(`/api-keys/${id}`);
|
||||
await load();
|
||||
} catch {
|
||||
error.value = 'باطلسازی کلید ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleEvent(ev: string) {
|
||||
if (whEvents.value.includes(ev)) {
|
||||
whEvents.value = whEvents.value.filter((e) => e !== ev);
|
||||
} else {
|
||||
whEvents.value = [...whEvents.value, ev];
|
||||
}
|
||||
}
|
||||
|
||||
async function createWebhook() {
|
||||
if (!whName.value.trim() || !whUrl.value.trim() || !whEvents.value.length) {
|
||||
error.value = 'نام، آدرس و حداقل یک رویداد لازم است.';
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
createdWebhookSecret.value = '';
|
||||
try {
|
||||
const { data } = await api.post<CreateWebhookEndpointResponse>('/webhooks', {
|
||||
name: whName.value.trim(),
|
||||
url: whUrl.value.trim(),
|
||||
events: whEvents.value,
|
||||
});
|
||||
whName.value = '';
|
||||
whUrl.value = '';
|
||||
createdWebhookSecret.value = data.secret;
|
||||
ok.value = 'وبهوک ذخیره شد. secret را همین الان کپی کنید.';
|
||||
await load();
|
||||
} catch {
|
||||
error.value = 'ذخیره وبهوک ناموفق بود.';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeWebhook(id: string) {
|
||||
if (!confirm('وبهوک حذف شود؟')) return;
|
||||
try {
|
||||
await api.delete(`/webhooks/${id}`);
|
||||
await load();
|
||||
} catch {
|
||||
error.value = 'حذف وبهوک ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
ok.value = 'کپی شد.';
|
||||
} catch {
|
||||
error.value = 'کپی در کلیپبورد ممکن نشد.';
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<UiPageHeader
|
||||
title="API و وبهوک"
|
||||
description="کلید عمومی برای یکپارچهسازی و ارسال رویداد به سرور شما"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="secondary" :disabled="loading" @click="load">
|
||||
<UiIcon name="refresh" />
|
||||
بروزرسانی
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
|
||||
<UiCard title="کلیدهای API">
|
||||
<p class="hint">
|
||||
هدر درخواست:
|
||||
<code>X-Api-Key: mk_…</code>
|
||||
یا
|
||||
<code>Authorization: Bearer mk_…</code>
|
||||
— مسیرها:
|
||||
<code>/api/public/v1/people</code>
|
||||
و
|
||||
<code>/api/public/v1/deals</code>
|
||||
</p>
|
||||
<div class="row">
|
||||
<UiField label="نام کلید">
|
||||
<UiInput v-model="newKeyName" placeholder="مثلاً اتصال سایت" />
|
||||
</UiField>
|
||||
<UiButton :loading="saving" @click="createKey">ساخت کلید</UiButton>
|
||||
</div>
|
||||
<div v-if="createdRawKey" class="raw-key">
|
||||
<code>{{ createdRawKey }}</code>
|
||||
<UiButton variant="secondary" @click="copyText(createdRawKey)">کپی</UiButton>
|
||||
</div>
|
||||
<UiEmptyState
|
||||
v-if="!loading && !keys.length"
|
||||
title="کلیدی نیست"
|
||||
description="برای دسترسی خارجی یک کلید بسازید."
|
||||
/>
|
||||
<div v-else class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>نام</th>
|
||||
<th>پیشوند</th>
|
||||
<th>آخرین استفاده</th>
|
||||
<th>وضعیت</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="k in keys" :key="k.id">
|
||||
<td>{{ k.name }}</td>
|
||||
<td><code>{{ k.prefix }}…</code></td>
|
||||
<td>
|
||||
{{
|
||||
k.lastUsedAt
|
||||
? formatJalaliNumeric(new Date(k.lastUsedAt))
|
||||
: '—'
|
||||
}}
|
||||
</td>
|
||||
<td>{{ k.revokedAt ? 'باطل' : 'فعال' }}</td>
|
||||
<td>
|
||||
<UiButton
|
||||
v-if="!k.revokedAt"
|
||||
variant="secondary"
|
||||
@click="revokeKey(k.id)"
|
||||
>
|
||||
باطلسازی
|
||||
</UiButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<UiCard title="وبهوکهای خروجی">
|
||||
<div class="form-grid">
|
||||
<UiField label="نام">
|
||||
<UiInput v-model="whName" placeholder="مثلاً n8n" />
|
||||
</UiField>
|
||||
<UiField label="آدرس URL">
|
||||
<UiInput v-model="whUrl" placeholder="https://example.com/hook" />
|
||||
</UiField>
|
||||
</div>
|
||||
<div class="events">
|
||||
<button
|
||||
v-for="ev in WEBHOOK_EVENTS"
|
||||
:key="ev"
|
||||
type="button"
|
||||
class="chip"
|
||||
:class="{ on: whEvents.includes(ev) }"
|
||||
@click="toggleEvent(ev)"
|
||||
>
|
||||
{{ EVENT_LABELS[ev] }}
|
||||
</button>
|
||||
</div>
|
||||
<UiButton :loading="saving" @click="createWebhook">افزودن وبهوک</UiButton>
|
||||
<div v-if="createdWebhookSecret" class="raw-key">
|
||||
<span>HMAC secret:</span>
|
||||
<code>{{ createdWebhookSecret }}</code>
|
||||
<UiButton variant="secondary" @click="copyText(createdWebhookSecret)">کپی</UiButton>
|
||||
</div>
|
||||
|
||||
<UiEmptyState
|
||||
v-if="!loading && !endpoints.length"
|
||||
title="وبهوکی نیست"
|
||||
description="رویدادهای CRM به آدرس شما POST میشوند."
|
||||
/>
|
||||
<div v-else class="list">
|
||||
<article v-for="ep in endpoints" :key="ep.id" class="item">
|
||||
<div>
|
||||
<strong>{{ ep.name }}</strong>
|
||||
<p class="muted">{{ ep.url }}</p>
|
||||
<p class="muted">
|
||||
{{ ep.events.map((e) => EVENT_LABELS[e as WebhookEvent] ?? e).join('، ') }}
|
||||
</p>
|
||||
</div>
|
||||
<UiButton variant="secondary" @click="removeWebhook(ep.id)">حذف</UiButton>
|
||||
</article>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<UiCard title="آخرین ارسالهای وبهوک">
|
||||
<UiEmptyState
|
||||
v-if="!deliveries.length"
|
||||
title="ارسالی ثبت نشده"
|
||||
description="پس از وقوع رویدادها اینجا دیده میشوند."
|
||||
/>
|
||||
<div v-else class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>زمان</th>
|
||||
<th>رویداد</th>
|
||||
<th>وضعیت</th>
|
||||
<th>کد</th>
|
||||
<th>تلاش</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="d in deliveries" :key="d.id">
|
||||
<td>{{ formatJalaliNumeric(new Date(d.createdAt)) }}</td>
|
||||
<td>{{ EVENT_LABELS[d.event as WebhookEvent] ?? d.event }}</td>
|
||||
<td>{{ d.status }}</td>
|
||||
<td>{{ d.statusCode ?? '—' }}</td>
|
||||
<td>{{ toFaDigits(d.attempts) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</UiCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.hint {
|
||||
color: #64748b;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.hint code {
|
||||
background: #f1f5f9;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
.raw-key {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.75rem;
|
||||
background: #f0fdfa;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.raw-key code {
|
||||
word-break: break-all;
|
||||
font-weight: 700;
|
||||
color: #0f766e;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.4fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.events {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.chip {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.75rem;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
color: #334155;
|
||||
}
|
||||
.chip.on {
|
||||
background: #f0fdfa;
|
||||
border-color: #99f6e4;
|
||||
color: #0f766e;
|
||||
font-weight: 700;
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.muted {
|
||||
color: #64748b;
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ok {
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
text-align: right;
|
||||
padding: 0.55rem;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
}
|
||||
th {
|
||||
color: #64748b;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.row,
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,350 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type {
|
||||
PaginatedResponse,
|
||||
PersonDto,
|
||||
SmsCampaignDto,
|
||||
SmsCampaignListResponse,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiEmptyState,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
UiTextarea,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const ok = ref('');
|
||||
const campaigns = ref<SmsCampaignDto[]>([]);
|
||||
const people = ref<PersonDto[]>([]);
|
||||
const selectedIds = ref<string[]>([]);
|
||||
const name = ref('');
|
||||
const body = ref('');
|
||||
const queueNow = ref(true);
|
||||
const peopleQ = ref('');
|
||||
|
||||
const filteredPeople = computed(() => {
|
||||
const q = peopleQ.value.trim();
|
||||
if (!q) return people.value.slice(0, 80);
|
||||
return people.value
|
||||
.filter((p) => {
|
||||
const hay = `${p.fullName} ${p.mobile ?? ''} ${p.email ?? ''}`.toLowerCase();
|
||||
return hay.includes(q.toLowerCase());
|
||||
})
|
||||
.slice(0, 80);
|
||||
});
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
draft: 'پیشنویس',
|
||||
queued: 'در صف',
|
||||
sending: 'در حال ارسال',
|
||||
done: 'تمامشده',
|
||||
cancelled: 'لغو شده',
|
||||
};
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const [campRes, peopleRes] = await Promise.all([
|
||||
api.get<SmsCampaignListResponse>('/sms-campaigns'),
|
||||
api.get<PaginatedResponse<PersonDto>>('/people', {
|
||||
params: { page: 1, limit: 200 },
|
||||
}),
|
||||
]);
|
||||
campaigns.value = campRes.data.items;
|
||||
people.value = peopleRes.data.items;
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 403) {
|
||||
error.value = 'ماژول پیامک گروهی برای این محیط فعال نیست.';
|
||||
} else {
|
||||
error.value = 'بارگذاری ناموفق بود.';
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function togglePerson(id: string) {
|
||||
if (selectedIds.value.includes(id)) {
|
||||
selectedIds.value = selectedIds.value.filter((x) => x !== id);
|
||||
} else {
|
||||
selectedIds.value = [...selectedIds.value, id];
|
||||
}
|
||||
}
|
||||
|
||||
function selectVisible() {
|
||||
const ids = filteredPeople.value.map((p) => p.id);
|
||||
selectedIds.value = [...new Set([...selectedIds.value, ...ids])];
|
||||
}
|
||||
|
||||
async function createCampaign() {
|
||||
if (!name.value.trim() || body.value.trim().length < 2) {
|
||||
error.value = 'نام و متن پیامک را کامل کنید.';
|
||||
return;
|
||||
}
|
||||
if (!selectedIds.value.length) {
|
||||
error.value = 'حداقل یک مخاطب انتخاب کنید.';
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
await api.post('/sms-campaigns', {
|
||||
name: name.value.trim(),
|
||||
body: body.value.trim(),
|
||||
personIds: selectedIds.value,
|
||||
queueNow: queueNow.value,
|
||||
});
|
||||
name.value = '';
|
||||
body.value = '';
|
||||
selectedIds.value = [];
|
||||
ok.value = queueNow.value
|
||||
? 'کمپین در صف ارسال قرار گرفت.'
|
||||
: 'کمپین بهصورت پیشنویس ذخیره شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ساخت کمپین ناموفق بود.');
|
||||
} else {
|
||||
error.value = 'ساخت کمپین ناموفق بود.';
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function queue(id: string) {
|
||||
try {
|
||||
await api.post(`/sms-campaigns/${id}/queue`);
|
||||
await load();
|
||||
} catch {
|
||||
error.value = 'صفبندی ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel(id: string) {
|
||||
try {
|
||||
await api.post(`/sms-campaigns/${id}/cancel`);
|
||||
await load();
|
||||
} catch {
|
||||
error.value = 'لغو ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<UiPageHeader
|
||||
title="پیامک گروهی"
|
||||
description="ارسال انبوه پیامک به مخاطبان انتخابشده"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="secondary" :disabled="loading" @click="load">
|
||||
<UiIcon name="refresh" />
|
||||
بروزرسانی
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
|
||||
<UiCard title="کمپین جدید">
|
||||
<div class="form-grid">
|
||||
<UiField label="نام کمپین">
|
||||
<UiInput v-model="name" placeholder="مثلاً اطلاعرسانی جشنواره" />
|
||||
</UiField>
|
||||
<label class="check">
|
||||
<input v-model="queueNow" type="checkbox" />
|
||||
ارسال فوری پس از ذخیره
|
||||
</label>
|
||||
</div>
|
||||
<UiField label="متن پیامک">
|
||||
<UiTextarea
|
||||
v-model="body"
|
||||
:rows="4"
|
||||
placeholder="متن پیامک را بنویسید…"
|
||||
/>
|
||||
</UiField>
|
||||
|
||||
<div class="people-head">
|
||||
<UiField label="جستجوی مخاطب">
|
||||
<UiInput v-model="peopleQ" placeholder="نام یا موبایل…" />
|
||||
</UiField>
|
||||
<UiButton variant="secondary" @click="selectVisible">
|
||||
انتخاب نتایج
|
||||
</UiButton>
|
||||
</div>
|
||||
<p class="muted">
|
||||
انتخابشده: {{ toFaDigits(selectedIds.length) }} نفر
|
||||
</p>
|
||||
<div class="people-list">
|
||||
<label v-for="p in filteredPeople" :key="p.id" class="person">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedIds.includes(p.id)"
|
||||
@change="togglePerson(p.id)"
|
||||
/>
|
||||
<span>
|
||||
<strong>{{ p.fullName }}</strong>
|
||||
<small>{{ p.mobile || 'بدون موبایل' }}</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<UiButton :loading="saving" @click="createCampaign">ساخت کمپین</UiButton>
|
||||
</UiCard>
|
||||
|
||||
<UiCard title="کمپینها">
|
||||
<UiEmptyState
|
||||
v-if="!loading && !campaigns.length"
|
||||
title="کمپینی نیست"
|
||||
description="اولین کمپین پیامک گروهی را بسازید."
|
||||
/>
|
||||
<div v-else class="campaigns">
|
||||
<article v-for="c in campaigns" :key="c.id" class="campaign">
|
||||
<div>
|
||||
<strong>{{ c.name }}</strong>
|
||||
<p class="muted">{{ c.body }}</p>
|
||||
<p class="meta">
|
||||
{{ statusLabel[c.status] ?? c.status }}
|
||||
· کل {{ toFaDigits(c.totalCount) }}
|
||||
· ارسال {{ toFaDigits(c.sentCount) }}
|
||||
· ناموفق {{ toFaDigits(c.failedCount) }}
|
||||
· ردشده {{ toFaDigits(c.skippedCount) }}
|
||||
· {{ formatJalaliNumeric(new Date(c.createdAt)) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<UiButton
|
||||
v-if="c.status === 'draft' || c.status === 'cancelled'"
|
||||
variant="secondary"
|
||||
@click="queue(c.id)"
|
||||
>
|
||||
صف ارسال
|
||||
</UiButton>
|
||||
<UiButton
|
||||
v-if="c.status === 'queued' || c.status === 'sending' || c.status === 'draft'"
|
||||
variant="secondary"
|
||||
@click="cancel(c.id)"
|
||||
>
|
||||
لغو
|
||||
</UiButton>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</UiCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
min-height: var(--control-h);
|
||||
}
|
||||
.people-head {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.people-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 0.45rem;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
margin: 0.75rem 0 1rem;
|
||||
padding: 0.35rem;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.person {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
align-items: flex-start;
|
||||
padding: 0.45rem;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
}
|
||||
.person:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
.person span {
|
||||
display: grid;
|
||||
}
|
||||
.person small {
|
||||
color: #64748b;
|
||||
}
|
||||
.campaigns {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.campaign {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: start;
|
||||
}
|
||||
.muted,
|
||||
.meta {
|
||||
color: #64748b;
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ok {
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.form-grid,
|
||||
.people-head,
|
||||
.campaign {
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user