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
+57
View File
@@ -46,6 +46,31 @@ const showOwnRecordsBanner = computed(
);
const todayLabel = computed(() => formatJalaliShort(new Date()));
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))} بایت`;
}
const storageBanner = computed(() => {
const storage = auth.user?.storage;
if (!storage || storage.level === 'ok' || storage.level === 'unlimited' || storage.quotaBytes == null) {
return null;
}
return storage;
});
const SIDEBAR_KEY = 'mento_sidebar_collapsed';
function readSidebarCollapsed() {
@@ -914,6 +939,21 @@ onUnmounted(() => {
</div>
</div>
<p
v-if="storageBanner"
class="storage-banner"
:class="storageBanner.level === 'full' ? 'full' : 'warn'"
>
<template v-if="storageBanner.level === 'full'">
سقف حجم فایل پر شده است ({{ formatBytes(storageBanner.usedBytes) }} از
{{ formatBytes(storageBanner.quotaBytes ?? 0) }}). تا شارژ حجم، آپلود جدید ممکن نیست.
</template>
<template v-else>
حجم فایل به {{ toFaDigits(storageBanner.percent ?? 80) }}٪ رسیده
({{ formatBytes(storageBanner.usedBytes) }} از {{ formatBytes(storageBanner.quotaBytes ?? 0) }}).
</template>
</p>
<main class="content">
<RouterView />
<GamificationToaster v-if="moduleAllowed('gamification')" />
@@ -1409,6 +1449,23 @@ onUnmounted(() => {
font-size: 0.75rem;
}
.storage-banner {
margin: 0 0 0.75rem;
padding: 0.7rem 0.9rem;
border-radius: 12px;
font-size: 0.88rem;
}
.storage-banner.warn {
background: #fff7ed;
color: #9a3412;
}
.storage-banner.full {
background: #fef2f2;
color: #991b1b;
}
.sidebar-foot {
margin-top: auto;
display: grid;
+19 -1
View File
@@ -1,6 +1,8 @@
import axios from 'axios';
import { computed, ref, shallowRef } from 'vue';
import type { ConversationRoomDto, ConversationSessionDto } from '@mento/shared';
import type { ConversationRoomDto, ConversationSessionDto, StorageUsageDto } from '@mento/shared';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
export type ChatDelivery = 'sending' | 'sent' | 'read';
@@ -1344,8 +1346,14 @@ export async function refreshUnreadTotal() {
}
export function useOrgChat() {
const auth = useAuthStore();
const loadingMessages = ref(false);
function rememberStorage(usage: StorageUsageDto) {
if (!auth.user) return;
auth.setUser({ ...auth.user, storage: usage });
}
async function ensureConnected() {
await connectSocket();
}
@@ -1696,10 +1704,15 @@ export function useOrgChat() {
}
}
setProgress(12);
await api.post('/conversations/files/reserve', { bytes: fileForUpload.size });
const uploadedPath = await uploadChatFile(session.uploadUrl, fileForUpload, (p) => {
setProgress(12 + Math.round(p * 0.86));
});
setProgress(99);
const committed = await api.post<StorageUsageDto>('/conversations/files/commit', {
bytes: fileForUpload.size,
});
rememberStorage(committed.data);
await sendMediaPath(roomId, uploadedPath, type, {
reply,
fileName: outboundFileName,
@@ -1715,6 +1728,11 @@ export function useOrgChat() {
// ignore
}
}
if (axios.isAxiosError(e)) {
const raw = (e.response?.data as { message?: string | string[] } | undefined)?.message;
const text = Array.isArray(raw) ? raw.join(' ') : raw;
if (text) throw new Error(text);
}
throw e;
}
}
+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>