feat(billing): cap each owner's stored file volume
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user