fix: make the user activity log readable and show Montact
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -23,6 +23,7 @@ import type {
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canReassignRecords } from '@/lib/roles';
|
||||
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
|
||||
import { brand } from '@mento/shared';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -290,7 +291,7 @@ onMounted(() => {
|
||||
<RouterLink class="icon-btn back" :to="{ name: 'activities' }" aria-label="بازگشت">
|
||||
<UiIcon name="chevron" :size="18" />
|
||||
</RouterLink>
|
||||
<span class="brand">mento</span>
|
||||
<span class="brand">{{ brand.name }}</span>
|
||||
<div class="more" @click.stop>
|
||||
<button class="icon-btn" type="button" aria-label="منوی بیشتر" @click="moreOpen = !moreOpen">
|
||||
<UiIcon name="dots-v" :size="18" />
|
||||
|
||||
@@ -1,65 +1,311 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AuditLogListResponse } from '@mento/shared';
|
||||
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import type {
|
||||
AuditLogDto,
|
||||
AuditLogListResponse,
|
||||
DeletionArchiveDto,
|
||||
DeletionArchiveListResponse,
|
||||
DeletionRetention,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiEmptyState,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiModal,
|
||||
UiPageHeader,
|
||||
UiPagination,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import { formatClockFa, formatJalaliNumeric } from '@/lib/jalali';
|
||||
|
||||
type LogTab = 'all' | 'creates' | 'edits' | 'deletes';
|
||||
|
||||
const TABS: Array<{ key: LogTab; label: string; action?: string }> = [
|
||||
{ key: 'all', label: 'همه' },
|
||||
{ key: 'creates', label: 'ایجاد', action: 'ایجاد' },
|
||||
{ key: 'edits', label: 'ویرایش', action: 'ویرایش' },
|
||||
{ key: 'deletes', label: 'حذف', action: 'حذف' },
|
||||
];
|
||||
|
||||
const ENTITY_LABELS: Record<string, string> = {
|
||||
auth: 'حساب کاربری',
|
||||
people: 'مشتری',
|
||||
companies: 'شرکت',
|
||||
leads: 'لید',
|
||||
deals: 'معامله',
|
||||
activities: 'فعالیت',
|
||||
notes: 'یادداشت',
|
||||
tickets: 'تیکت',
|
||||
quotes: 'پیشفاکتور',
|
||||
invoices: 'فاکتور',
|
||||
products: 'محصول',
|
||||
users: 'کاربر',
|
||||
teams: 'تیم',
|
||||
};
|
||||
|
||||
const RETENTION_OPTIONS: Array<{ value: DeletionRetention; label: string }> = [
|
||||
{ value: 'month', label: 'یک ماه' },
|
||||
{ value: 'two_months', label: 'دو ماه' },
|
||||
{ value: 'three_months', label: 'سه ماه' },
|
||||
{ value: 'six_months', label: 'شش ماه' },
|
||||
{ value: 'year', label: 'یک سال' },
|
||||
{ value: 'never', label: 'هرگز' },
|
||||
];
|
||||
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const isOwner = computed(() => auth.user?.role === 'owner');
|
||||
|
||||
const loading = ref(false);
|
||||
const savingRetention = ref(false);
|
||||
const busyId = ref('');
|
||||
const error = ref('');
|
||||
const okMsg = ref('');
|
||||
const q = ref('');
|
||||
const entityType = ref('');
|
||||
const actorId = ref('');
|
||||
const actors = ref<Array<{ id: string; fullName: string }>>([]);
|
||||
const tab = ref<LogTab>('all');
|
||||
const page = ref(1);
|
||||
const pageSize = usePageSize('audit-logs');
|
||||
const data = ref<AuditLogListResponse | null>(null);
|
||||
const deletions = ref<DeletionArchiveListResponse | null>(null);
|
||||
const retention = ref<DeletionRetention>('never');
|
||||
const purgeId = ref<string | null>(null);
|
||||
|
||||
const showingArchive = computed(() => tab.value === 'deletes' && isOwner.value);
|
||||
|
||||
const listTotal = computed(() =>
|
||||
showingArchive.value ? deletions.value?.total : data.value?.total,
|
||||
);
|
||||
|
||||
const totalPages = computed(() => {
|
||||
if (!data.value) return 1;
|
||||
return Math.max(1, Math.ceil(data.value.total / data.value.limit));
|
||||
const total = listTotal.value;
|
||||
if (total == null) return 1;
|
||||
return Math.max(1, Math.ceil(total / pageSize.value));
|
||||
});
|
||||
|
||||
const purgeItem = computed(
|
||||
() => deletions.value?.items.find((item) => item.id === purgeId.value) ?? null,
|
||||
);
|
||||
|
||||
function entityLabel(entityType: string | null) {
|
||||
if (!entityType) return 'مورد';
|
||||
return ENTITY_LABELS[entityType] ?? 'مورد';
|
||||
}
|
||||
|
||||
function rowName(row: AuditLogDto) {
|
||||
const label = row.label?.trim() ?? '';
|
||||
const actor = row.actor?.fullName?.trim() ?? '';
|
||||
if (!label || label === actor || label.includes('/api/')) return '';
|
||||
return label;
|
||||
}
|
||||
|
||||
function actionDone(action: string) {
|
||||
if (action === 'ایجاد') return 'ایجاد شد';
|
||||
if (action === 'حذف') return 'حذف شد';
|
||||
return 'ویرایش شد';
|
||||
}
|
||||
|
||||
function rowStory(row: AuditLogDto) {
|
||||
const kind = entityLabel(row.entityType);
|
||||
const name = rowName(row);
|
||||
const verb = actionDone(row.action);
|
||||
return name ? `${kind} «${name}» ${verb}` : `${kind} ${verb}`;
|
||||
}
|
||||
|
||||
function deletionStory(item: DeletionArchiveDto) {
|
||||
const kind = item.modelLabel?.trim() || 'مورد';
|
||||
const name = item.label?.trim() ?? '';
|
||||
return name && name !== kind ? `${kind} «${name}» حذف شد` : `${kind} حذف شد`;
|
||||
}
|
||||
|
||||
function looksLikeId(id: string | null | undefined) {
|
||||
return !!id && /^c[a-z0-9]{20,}$/i.test(id);
|
||||
}
|
||||
|
||||
function recordPath(row: AuditLogDto) {
|
||||
const parts = row.path.replace(/^\/api\/?/, '').split('?')[0]?.split('/').filter(Boolean) ?? [];
|
||||
const settings: Record<string, string> = {
|
||||
tahesab: '/settings/tahesab',
|
||||
woocommerce: '/settings/woocommerce',
|
||||
montify: '/settings/montify',
|
||||
voip: '/settings/voip',
|
||||
};
|
||||
if (parts[0] === 'integrations' && settings[parts[1] ?? '']) return settings[parts[1] ?? ''];
|
||||
if (parts[0] === 'automations') return '/settings/automations';
|
||||
if (parts[0] === 'message-automations') return '/settings/message-automations';
|
||||
if (parts[0] === 'custom-fields') return '/settings/custom-fields';
|
||||
if (parts[0] === 'faq') return '/settings/faq';
|
||||
if (parts[0] === 'pipelines') return '/settings/pipelines';
|
||||
if (parts[0] === 'users' || parts[0] === 'teams' || parts[0] === 'org-roles') {
|
||||
return '/settings/team';
|
||||
}
|
||||
if (parts[0] === 'auth') return '/profile';
|
||||
const entityId = looksLikeId(parts[1]) ? parts[1] : looksLikeId(row.entityId) ? row.entityId : null;
|
||||
if (!entityId || !row.entityType) return null;
|
||||
const routes: Record<string, string> = {
|
||||
people: `/contacts/people/${entityId}`,
|
||||
companies: `/contacts/companies/${entityId}`,
|
||||
leads: `/leads/${entityId}`,
|
||||
deals: `/deals/${entityId}`,
|
||||
activities: `/activities/${entityId}`,
|
||||
tickets: `/tickets/${entityId}`,
|
||||
quotes: `/quotes/${entityId}`,
|
||||
invoices: `/invoices/${entityId}`,
|
||||
products: `/settings/products/${entityId}/edit`,
|
||||
'project-cards': `/project-cards/${entityId}`,
|
||||
routines: `/routines/manage/${entityId}/edit`,
|
||||
'lead-forms': `/settings/lead-forms/${entityId}`,
|
||||
};
|
||||
return routes[row.entityType] ?? null;
|
||||
}
|
||||
|
||||
function openRecord(row: AuditLogDto) {
|
||||
const path = recordPath(row);
|
||||
if (path) void router.push(path);
|
||||
}
|
||||
|
||||
function apiError(e: unknown, fallback: string) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
if (Array.isArray(msg)) return msg.join('، ');
|
||||
if (typeof msg === 'string' && msg) return msg;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function loadActors() {
|
||||
try {
|
||||
const { data: rows } = await api.get<Array<{ id: string; fullName: string; isActive?: boolean }>>(
|
||||
'/users',
|
||||
);
|
||||
actors.value = rows
|
||||
.filter((row) => row.fullName && row.isActive !== false)
|
||||
.sort((a, b) => a.fullName.localeCompare(b.fullName, 'fa'));
|
||||
} catch {
|
||||
actors.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function load(resetPage = false) {
|
||||
if (resetPage) page.value = 1;
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
const current = tab.value;
|
||||
const archive = current === 'deletes' && isOwner.value;
|
||||
try {
|
||||
const { data: res } = await api.get<AuditLogListResponse>('/audit-logs', {
|
||||
params: {
|
||||
q: q.value.trim() || undefined,
|
||||
entityType: entityType.value.trim() || undefined,
|
||||
page: page.value,
|
||||
limit: 30,
|
||||
},
|
||||
});
|
||||
data.value = res;
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 403) {
|
||||
error.value = 'دسترسی به لاگ فعالیت برای این محیط فعال نیست.';
|
||||
} else if (axios.isAxiosError(e) && !e.response) {
|
||||
error.value = 'اتصال به API برقرار نشد.';
|
||||
if (archive) {
|
||||
const { data: res } = await api.get<DeletionArchiveListResponse>('/deletions', {
|
||||
params: {
|
||||
q: q.value.trim() || undefined,
|
||||
deletedById: actorId.value || undefined,
|
||||
page: page.value,
|
||||
limit: pageSize.value,
|
||||
},
|
||||
});
|
||||
if (tab.value !== current) return;
|
||||
deletions.value = res;
|
||||
data.value = null;
|
||||
retention.value = res.retention;
|
||||
} else {
|
||||
error.value = 'بارگذاری لاگ ناموفق بود.';
|
||||
const action = TABS.find((item) => item.key === current)?.action;
|
||||
const { data: res } = await api.get<AuditLogListResponse>('/audit-logs', {
|
||||
params: {
|
||||
q: q.value.trim() || undefined,
|
||||
action: action || undefined,
|
||||
actorUserId: actorId.value || undefined,
|
||||
page: page.value,
|
||||
limit: pageSize.value,
|
||||
},
|
||||
});
|
||||
if (tab.value !== current) return;
|
||||
data.value = res;
|
||||
deletions.value = null;
|
||||
}
|
||||
} catch (e) {
|
||||
if (tab.value !== current) return;
|
||||
error.value = apiError(e, archive ? 'بارگذاری حذفها ناموفق بود.' : 'بارگذاری لاگ ناموفق بود.');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (tab.value === current) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function go(delta: number) {
|
||||
page.value = Math.min(totalPages.value, Math.max(1, page.value + delta));
|
||||
function selectTab(next: LogTab) {
|
||||
if (tab.value === next) return;
|
||||
tab.value = next;
|
||||
okMsg.value = '';
|
||||
data.value = null;
|
||||
deletions.value = null;
|
||||
void load(true);
|
||||
}
|
||||
|
||||
function onPage(next: number) {
|
||||
page.value = next;
|
||||
void load();
|
||||
}
|
||||
|
||||
function onPageSize(size: number) {
|
||||
pageSize.value = size;
|
||||
if (page.value === 1) void load();
|
||||
}
|
||||
|
||||
async function saveRetention() {
|
||||
savingRetention.value = true;
|
||||
error.value = '';
|
||||
okMsg.value = '';
|
||||
try {
|
||||
await api.patch('/deletions/settings', { retention: retention.value });
|
||||
okMsg.value = 'مدت نگهداری ذخیره شد.';
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ذخیره مدت نگهداری ناموفق بود.');
|
||||
} finally {
|
||||
savingRetention.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function restore(item: DeletionArchiveDto) {
|
||||
busyId.value = item.id;
|
||||
error.value = '';
|
||||
okMsg.value = '';
|
||||
try {
|
||||
await api.post(`/deletions/${item.id}/restore`);
|
||||
okMsg.value = 'مورد برگردانده شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بازگردانی ناموفق بود.');
|
||||
} finally {
|
||||
busyId.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmPurge() {
|
||||
const id = purgeId.value;
|
||||
if (!id) return;
|
||||
busyId.value = id;
|
||||
error.value = '';
|
||||
okMsg.value = '';
|
||||
try {
|
||||
await api.delete(`/deletions/${id}`);
|
||||
purgeId.value = null;
|
||||
okMsg.value = 'این مورد برای همیشه پاک شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'حذف کامل ناموفق بود.');
|
||||
} finally {
|
||||
busyId.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadActors();
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
@@ -78,40 +324,119 @@ onMounted(() => {
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<UiCard class="filters">
|
||||
<div class="filters-grid">
|
||||
<UiField label="جستجو">
|
||||
<UiInput
|
||||
v-model="q"
|
||||
placeholder="خلاصه، مسیر یا نوع عملیات…"
|
||||
@keydown.enter="load(true)"
|
||||
/>
|
||||
</UiField>
|
||||
<UiField label="نوع منبع">
|
||||
<UiInput
|
||||
v-model="entityType"
|
||||
placeholder="مثلاً deals یا people"
|
||||
@keydown.enter="load(true)"
|
||||
/>
|
||||
</UiField>
|
||||
<div class="filters-actions">
|
||||
<UiButton :disabled="loading" @click="load(true)">اعمال فیلتر</UiButton>
|
||||
</div>
|
||||
<nav class="tabs" aria-label="نوع رویداد">
|
||||
<button
|
||||
v-for="item in TABS"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
:class="{ on: tab === item.key }"
|
||||
@click="selectTab(item.key)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<UiCard v-if="showingArchive">
|
||||
<div class="retention">
|
||||
<label for="audit-retention">حذف خودکار از این فهرست</label>
|
||||
<select id="audit-retention" v-model="retention">
|
||||
<option v-for="option in RETENTION_OPTIONS" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<UiButton size="sm" :loading="savingRetention" @click="saveRetention">ذخیره</UiButton>
|
||||
</div>
|
||||
<p class="hint">
|
||||
تا پایان این مدت میتوان مورد را برگرداند. «هرگز» یعنی فقط با حذف کامل از بین میرود.
|
||||
</p>
|
||||
</UiCard>
|
||||
|
||||
<UiCard class="filters">
|
||||
<form class="filters-grid" @submit.prevent="load(true)">
|
||||
<UiField label="جستجو">
|
||||
<UiInput v-model="q" placeholder="نام، عنوان یا شرح تغییر…" />
|
||||
</UiField>
|
||||
<UiField label="انجامدهنده">
|
||||
<select v-model="actorId" class="actor" @change="load(true)">
|
||||
<option value="">همه</option>
|
||||
<option v-for="user in actors" :key="user.id" :value="user.id">{{ user.fullName }}</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<div class="filters-actions">
|
||||
<UiButton type="submit" :disabled="loading">اعمال فیلتر</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
</UiCard>
|
||||
|
||||
<p v-if="okMsg" class="ok">{{ okMsg }}</p>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<UiCard v-if="loading && !data" title="در حال بارگذاری…">
|
||||
<UiCard v-if="loading && !data && !deletions" title="در حال بارگذاری…">
|
||||
<p class="muted">لطفاً صبر کنید.</p>
|
||||
</UiCard>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="!loading && data && !data.items.length"
|
||||
title="لاگی ثبت نشده"
|
||||
description="پس از ایجاد یا ویرایش رکوردها، رویدادها اینجا دیده میشوند."
|
||||
v-else-if="showingArchive && !loading && deletions && !deletions.items.length"
|
||||
title="حذفی در این فهرست نیست"
|
||||
description="حذفها با نام انجامدهنده اینجا میمانند و تا پایان مدت نگهداری قابل بازگشتاند."
|
||||
/>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="!showingArchive && !loading && data && !data.items.length"
|
||||
title="رویدادی در این بخش نیست"
|
||||
description="با انجام کار در سامانه، ایجاد و ویرایش اینجا دیده میشود."
|
||||
/>
|
||||
|
||||
<UiCard v-else-if="showingArchive && deletions?.items.length" title="حذفها">
|
||||
<div class="table-wrap">
|
||||
<table data-no-rownum>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="when-col">زمان</th>
|
||||
<th>انجامدهنده</th>
|
||||
<th>نوع</th>
|
||||
<th>عنوان</th>
|
||||
<th>شرح</th>
|
||||
<th class="actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in deletions.items" :key="item.id">
|
||||
<td class="when" data-label="زمان">
|
||||
<span class="clock">{{ formatClockFa(new Date(item.deletedAt)) }}</span>
|
||||
<span>{{ formatJalaliNumeric(new Date(item.deletedAt)) }}</span>
|
||||
</td>
|
||||
<td data-label="انجامدهنده">{{ item.deletedByName || 'سیستم' }}</td>
|
||||
<td data-label="نوع">{{ item.modelLabel }}</td>
|
||||
<td data-label="عنوان">
|
||||
<span class="clip" :title="item.label">{{ item.label }}</span>
|
||||
</td>
|
||||
<td data-label="شرح">
|
||||
<span class="clip" :title="deletionStory(item)">{{ deletionStory(item) }}</span>
|
||||
</td>
|
||||
<td class="ops" data-label="عملیات">
|
||||
<UiButton size="sm" variant="secondary" :disabled="busyId === item.id" @click="restore(item)">
|
||||
بازگردانی
|
||||
</UiButton>
|
||||
<UiButton size="sm" variant="danger" :disabled="busyId === item.id" @click="purgeId = item.id">
|
||||
حذف کامل
|
||||
</UiButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<UiPagination
|
||||
:page="page"
|
||||
:total-pages="totalPages"
|
||||
:total-items="deletions.total"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="PAGE_SIZE_OPTIONS"
|
||||
@update:page="onPage"
|
||||
@update:page-size="onPageSize"
|
||||
/>
|
||||
</UiCard>
|
||||
|
||||
<UiCard v-else-if="data?.items.length" title="رویدادها">
|
||||
<div class="tc-row">
|
||||
<TableColumnsMenu table-key="audit-logs" align="end" />
|
||||
@@ -119,52 +444,76 @@ onMounted(() => {
|
||||
<div class="table-wrap">
|
||||
<table
|
||||
v-table-cols="'audit-logs'"
|
||||
:style="{ '--row-start': (page - 1) * (data?.limit ?? 30) }"
|
||||
data-no-rownum
|
||||
:style="{ '--row-start': (page - 1) * pageSize }"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>زمان</th>
|
||||
<th class="when-col">زمان</th>
|
||||
<th>کاربر</th>
|
||||
<th>عملیات</th>
|
||||
<th>منبع</th>
|
||||
<th>خلاصه</th>
|
||||
<th>موضوع</th>
|
||||
<th>شرح</th>
|
||||
<th class="actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in data.items" :key="row.id">
|
||||
<td class="nowrap">{{ formatJalaliNumeric(new Date(row.createdAt)) }}</td>
|
||||
<td>{{ row.actor?.fullName ?? '—' }}</td>
|
||||
<td>
|
||||
<tr
|
||||
v-for="row in data.items"
|
||||
:key="row.id"
|
||||
:class="{ link: !!recordPath(row) }"
|
||||
@click="openRecord(row)"
|
||||
>
|
||||
<td class="when" data-label="زمان">
|
||||
<span class="clock">{{ formatClockFa(new Date(row.createdAt)) }}</span>
|
||||
<span>{{ formatJalaliNumeric(new Date(row.createdAt)) }}</span>
|
||||
</td>
|
||||
<td data-label="کاربر">{{ row.actor?.fullName ?? '—' }}</td>
|
||||
<td data-label="عملیات">
|
||||
<span class="pill">{{ row.action }}</span>
|
||||
</td>
|
||||
<td class="muted">{{ row.entityType ?? '—' }}</td>
|
||||
<td>{{ row.summary }}</td>
|
||||
<td data-label="موضوع">
|
||||
<span class="clip" :title="rowName(row) || entityLabel(row.entityType)">
|
||||
{{ rowName(row) || entityLabel(row.entityType) }}
|
||||
</span>
|
||||
</td>
|
||||
<td data-label="شرح">
|
||||
<span class="clip" :title="rowStory(row)">{{ rowStory(row) }}</span>
|
||||
</td>
|
||||
<td class="ops">
|
||||
<button
|
||||
v-if="recordPath(row)"
|
||||
type="button"
|
||||
class="view"
|
||||
@click.stop="openRecord(row)"
|
||||
>
|
||||
مشاهده
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pager">
|
||||
<UiButton
|
||||
variant="secondary"
|
||||
:disabled="loading || page <= 1"
|
||||
@click="go(-1)"
|
||||
>
|
||||
قبلی
|
||||
</UiButton>
|
||||
<span class="muted">
|
||||
صفحه {{ toFaDigits(page) }} از {{ toFaDigits(totalPages) }}
|
||||
· {{ toFaDigits(data.total) }} مورد
|
||||
</span>
|
||||
<UiButton
|
||||
variant="secondary"
|
||||
:disabled="loading || page >= totalPages"
|
||||
@click="go(1)"
|
||||
>
|
||||
بعدی
|
||||
</UiButton>
|
||||
</div>
|
||||
<UiPagination
|
||||
:page="page"
|
||||
:total-pages="totalPages"
|
||||
:total-items="data.total"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="PAGE_SIZE_OPTIONS"
|
||||
@update:page="onPage"
|
||||
@update:page-size="onPageSize"
|
||||
/>
|
||||
</UiCard>
|
||||
|
||||
<UiModal :open="!!purgeItem" title="حذف کامل" @close="purgeId = null">
|
||||
<p v-if="purgeItem" class="confirm">
|
||||
«{{ purgeItem.label }}» برای همیشه پاک شود؟ بعد از این دیگر قابل بازگشت نیست.
|
||||
</p>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" :disabled="!!busyId" @click="purgeId = null">انصراف</UiButton>
|
||||
<UiButton variant="danger" :loading="!!busyId" @click="confirmPurge">حذف کامل</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -173,49 +522,149 @@ onMounted(() => {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.tabs button {
|
||||
flex: 1 1 0;
|
||||
min-width: 4.5rem;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0.55rem 0.35rem 0.7rem;
|
||||
color: #64748b;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 -2px 0 transparent;
|
||||
}
|
||||
.tabs button.on {
|
||||
color: #0f766e;
|
||||
box-shadow: inset 0 -2px 0 #14b8a6;
|
||||
}
|
||||
.retention {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.retention label {
|
||||
font-weight: 700;
|
||||
}
|
||||
.retention select,
|
||||
.actor {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
padding: 0.45rem 0.7rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.retention select {
|
||||
width: auto;
|
||||
min-width: 8.5rem;
|
||||
}
|
||||
.actor {
|
||||
width: 100%;
|
||||
}
|
||||
.hint,
|
||||
.muted {
|
||||
margin: 0.55rem 0 0;
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.filters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr auto;
|
||||
gap: 0.75rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.filters-grid :deep(.ui-field) {
|
||||
flex: 1 1 14rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.filters-grid :deep(.ui-field:nth-child(2)) {
|
||||
flex: 0 1 16rem;
|
||||
max-width: 16rem;
|
||||
}
|
||||
.filters-actions {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
}
|
||||
.filters-actions :deep(button) {
|
||||
min-height: 2.4rem;
|
||||
}
|
||||
.ok {
|
||||
margin: 0;
|
||||
color: #0f766e;
|
||||
font-weight: 700;
|
||||
}
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.muted {
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 46rem;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
table-layout: fixed;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
text-align: right;
|
||||
padding: 0.65rem 0.55rem;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
vertical-align: top;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.when-col,
|
||||
.when {
|
||||
width: 6.2rem;
|
||||
}
|
||||
.when span {
|
||||
display: block;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.clock {
|
||||
font-weight: 800;
|
||||
}
|
||||
.actions,
|
||||
td.ops {
|
||||
width: 11rem;
|
||||
text-align: left;
|
||||
}
|
||||
.clip {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
th {
|
||||
color: #64748b;
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.detail {
|
||||
color: #334155;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
padding: 0.15rem 0.55rem;
|
||||
@@ -225,17 +674,101 @@ th {
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.pager {
|
||||
.ops {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.85rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.4rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.filters-grid {
|
||||
grid-template-columns: 1fr;
|
||||
tr.link {
|
||||
cursor: pointer;
|
||||
}
|
||||
tr.link:hover td {
|
||||
background: #f8fffd;
|
||||
}
|
||||
.view {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: #0f766e;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.confirm {
|
||||
margin: 0;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.filters-grid :deep(.ui-field),
|
||||
.filters-grid :deep(.ui-field:nth-child(2)) {
|
||||
flex: 1 1 100%;
|
||||
max-width: none;
|
||||
}
|
||||
.filters-actions,
|
||||
.filters-actions :deep(button) {
|
||||
width: 100%;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow: visible;
|
||||
}
|
||||
table {
|
||||
min-width: 0;
|
||||
}
|
||||
table thead {
|
||||
display: none;
|
||||
}
|
||||
table,
|
||||
tbody,
|
||||
tr,
|
||||
td {
|
||||
display: block;
|
||||
width: auto;
|
||||
}
|
||||
tbody tr {
|
||||
border: 1px solid #e7eef2;
|
||||
border-radius: 14px;
|
||||
margin-bottom: 0.65rem;
|
||||
padding: 0.35rem 0.7rem 0.55rem;
|
||||
background: #fff;
|
||||
}
|
||||
td {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
border-bottom: 0;
|
||||
padding: 0.35rem 0;
|
||||
text-align: start;
|
||||
}
|
||||
td::before {
|
||||
content: attr(data-label);
|
||||
flex: 0 0 auto;
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
td.ops {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.view {
|
||||
display: none;
|
||||
}
|
||||
tr.link {
|
||||
cursor: pointer;
|
||||
}
|
||||
.when {
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
width: auto;
|
||||
}
|
||||
.clip {
|
||||
white-space: normal;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -470,7 +470,7 @@ watch(
|
||||
<RouterLink class="icon-btn back-btn" :to="{ name: 'home' }" aria-label="بازگشت" @click.stop>
|
||||
<UiIcon name="chevron" :size="18" />
|
||||
</RouterLink>
|
||||
<span class="nav-brand">mento</span>
|
||||
<span class="nav-brand">{{ brand.name }}</span>
|
||||
<ProfileMoreMenu v-model="moreOpen" :items="moreItems" @select="onMoreSelect" />
|
||||
</header>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user