feat(billing): cap each owner's stored file volume

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-02 15:44:36 +03:30
co-authored by Cursor
parent e6e90aabf3
commit 28938e8f77
20 changed files with 647 additions and 22 deletions
+159 -2
View File
@@ -1,13 +1,16 @@
<script setup lang="ts">
import type { BillingAccountDto, BillingPlanCode } from '@mento/shared';
import { onMounted, ref } from 'vue';
import axios from 'axios';
import type { BillingAccountDto, BillingPlanCode, StorageUsageDto } from '@mento/shared';
import {
BILLING_PLAN_CODES,
BILLING_PLAN_LABELS,
isPermanentBillingPlan,
} from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
import { platformApi } from '@/lib/platform-api';
defineProps<{
const props = defineProps<{
billing?: BillingAccountDto | null;
busyId: string;
userId: string;
@@ -16,6 +19,90 @@ defineProps<{
const emit = defineEmits<{
apply: [planCode: BillingPlanCode];
}>();
const usage = ref<StorageUsageDto | null>(null);
const quotaGb = ref('');
const storageBusy = ref(false);
const storageError = ref('');
function formatBytes(bytes: number): string {
const n = Math.max(0, bytes);
const units: Array<[number, string]> = [
[1024 ** 3, 'گیگابایت'],
[1024 ** 2, 'مگابایت'],
[1024, 'کیلوبایت'],
];
for (const [size, label] of units) {
if (n >= size) {
const value = n / size;
const text = value >= 10 ? value.toFixed(0) : value.toFixed(1);
return `${toFaDigits(text)} ${label}`;
}
}
return `${toFaDigits(Math.round(n))} بایت`;
}
function fillQuota(next: StorageUsageDto) {
usage.value = next;
quotaGb.value =
next.quotaBytes == null ? '' : String(Math.round((next.quotaBytes / 1024 ** 3) * 10) / 10);
}
function errorText(err: unknown, fallback: string) {
if (axios.isAxiosError(err)) {
const raw = (err.response?.data as { message?: string | string[] } | undefined)?.message;
if (Array.isArray(raw)) return raw.join(' ');
if (raw) return raw;
}
return fallback;
}
async function loadStorage(refresh = false) {
storageError.value = '';
try {
const { data } = await platformApi.get<StorageUsageDto>(
`/platform/users/${props.userId}/storage`,
{ params: refresh ? { refresh: '1' } : undefined },
);
fillQuota(data);
} catch (err) {
storageError.value = errorText(err, 'حجم فایل خوانده نشد');
}
}
async function saveQuota(quota: number | null) {
storageBusy.value = true;
storageError.value = '';
try {
const { data } = await platformApi.patch<StorageUsageDto>(
`/platform/users/${props.userId}/billing/storage`,
{ quotaGb: quota },
);
fillQuota(data);
} catch (err) {
storageError.value = errorText(err, 'سقف حجم ذخیره نشد');
} finally {
storageBusy.value = false;
}
}
function saveTypedQuota() {
const raw = quotaGb.value.trim().replace(/,/g, '');
if (!raw) {
storageError.value = 'سقف را به گیگابایت وارد کنید، یا «بدون سقف» را بزنید';
return;
}
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) {
storageError.value = 'سقف حجم نامعتبر است';
return;
}
void saveQuota(value);
}
onMounted(() => {
if (props.billing) void loadStorage();
});
</script>
<template>
@@ -40,6 +127,43 @@ const emit = defineEmits<{
{{ BILLING_PLAN_LABELS[code] }}
</button>
</div>
<div v-if="billing" class="storage">
<strong>حجم فایل</strong>
<p v-if="usage" class="muted">
مصرف {{ formatBytes(usage.usedBytes) }}
<template v-if="usage.quotaBytes == null"> · بدون سقف</template>
<template v-else>
از {{ formatBytes(usage.quotaBytes) }}
· {{ toFaDigits(usage.percent ?? 0) }}٪
</template>
</p>
<p v-else-if="!storageError" class="muted">در حال خواندن مصرف…</p>
<ul v-if="usage?.byOrganization?.length" class="orgs">
<li v-for="org in usage.byOrganization" :key="org.id">
{{ org.name }} · {{ formatBytes(org.usedBytes) }}
</li>
</ul>
<p class="muted">
عکس فعالیت، پیوست یادداشت، لوگو، کاور فرم، تصویر پروفایل، پیوست مالی و فایل گفتگو حساب می‌شود.
متن مشتری، فعالیت و فاکتور حساب نمی‌شود. فایل‌های قدیمی گفتگو که قبلاً روی سرور خارجی رفته‌اند داخل این عدد نیستند.
</p>
<label class="quota">
سقف حجم (گیگابایت)
<input v-model="quotaGb" type="number" min="0" step="0.5" inputmode="decimal" />
</label>
<div class="plan-row">
<button class="btn" type="button" :disabled="storageBusy" @click="saveTypedQuota">
ذخیره سقف
</button>
<button class="btn btn-ghost" type="button" :disabled="storageBusy" @click="saveQuota(null)">
بدون سقف
</button>
<button class="btn btn-ghost" type="button" :disabled="storageBusy" @click="loadStorage(true)">
محاسبه دوباره
</button>
</div>
<p v-if="storageError" class="error">{{ storageError }}</p>
</div>
</div>
</template>
@@ -65,4 +189,37 @@ const emit = defineEmits<{
flex-wrap: wrap;
gap: 0.4rem;
}
.storage {
display: grid;
gap: 0.45rem;
padding-top: 0.35rem;
border-top: 1px solid var(--line);
}
.orgs {
margin: 0;
padding: 0 1.1rem 0 0;
color: var(--muted);
font-size: 0.84rem;
}
.quota {
display: grid;
gap: 0.3rem;
font-size: 0.84rem;
}
.quota input {
width: 8rem;
padding: 0.4rem 0.55rem;
border: 1px solid var(--line);
border-radius: 8px;
}
.error {
margin: 0;
color: #b91c1c;
font-size: 0.84rem;
}
</style>