mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-07 01:09:54 +08:00
feat(core): add workspace quota panel for team workspace (#9085)
close AF-1917 AF-1685 AF-1730
This commit is contained in:
+2
-2
@@ -103,7 +103,7 @@ const getSignUpText = (
|
|||||||
case SubscriptionPlan.Free:
|
case SubscriptionPlan.Free:
|
||||||
return t['com.affine.payment.sign-up-free']();
|
return t['com.affine.payment.sign-up-free']();
|
||||||
case SubscriptionPlan.Team:
|
case SubscriptionPlan.Team:
|
||||||
return t['com.affine.payment.start-free-trial']();
|
return t['com.affine.payment.upgrade']();
|
||||||
default:
|
default:
|
||||||
return t['com.affine.payment.buy-pro']();
|
return t['com.affine.payment.buy-pro']();
|
||||||
}
|
}
|
||||||
@@ -263,7 +263,7 @@ const UpgradeToTeam = () => {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
data-event-args-url={url}
|
data-event-args-url={url}
|
||||||
>
|
>
|
||||||
{t['com.affine.payment.start-free-trial']()}
|
{t['com.affine.payment.upgrade']()}
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|||||||
+13
@@ -4,9 +4,11 @@ import { Button } from '@affine/component/ui/button';
|
|||||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||||
import { useSystemOnline } from '@affine/core/components/hooks/use-system-online';
|
import { useSystemOnline } from '@affine/core/components/hooks/use-system-online';
|
||||||
import { DesktopApiService } from '@affine/core/modules/desktop-api';
|
import { DesktopApiService } from '@affine/core/modules/desktop-api';
|
||||||
|
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import track from '@affine/track';
|
import track from '@affine/track';
|
||||||
import {
|
import {
|
||||||
|
useLiveData,
|
||||||
useService,
|
useService,
|
||||||
type Workspace,
|
type Workspace,
|
||||||
type WorkspaceMetadata,
|
type WorkspaceMetadata,
|
||||||
@@ -23,6 +25,13 @@ export const DesktopExportPanel = ({
|
|||||||
workspace,
|
workspace,
|
||||||
}: ExportPanelProps) => {
|
}: ExportPanelProps) => {
|
||||||
const workspaceId = workspaceMetadata.id;
|
const workspaceId = workspaceMetadata.id;
|
||||||
|
const workspacePermissionService = useService(
|
||||||
|
WorkspacePermissionService
|
||||||
|
).permission;
|
||||||
|
const isTeam = useLiveData(workspacePermissionService.isTeam$);
|
||||||
|
const isOwner = useLiveData(workspacePermissionService.isOwner$);
|
||||||
|
const isAdmin = useLiveData(workspacePermissionService.isAdmin$);
|
||||||
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const isOnline = useSystemOnline();
|
const isOnline = useSystemOnline();
|
||||||
@@ -55,6 +64,10 @@ export const DesktopExportPanel = ({
|
|||||||
}
|
}
|
||||||
}, [desktopApi, isOnline, saving, t, workspace, workspaceId]);
|
}, [desktopApi, isOnline, saving, t, workspace, workspaceId]);
|
||||||
|
|
||||||
|
if (isTeam && !isOwner && !isAdmin) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingRow name={t['Export']()} desc={t['Export Description']()}>
|
<SettingRow name={t['Export']()} desc={t['Export Description']()}>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
+2
@@ -20,6 +20,7 @@ import { MembersPanel } from './members';
|
|||||||
import { ProfilePanel } from './profile';
|
import { ProfilePanel } from './profile';
|
||||||
import { SharingPanel } from './sharing';
|
import { SharingPanel } from './sharing';
|
||||||
import type { WorkspaceSettingDetailProps } from './types';
|
import type { WorkspaceSettingDetailProps } from './types';
|
||||||
|
import { WorkspaceQuotaPanel } from './workspace-quota';
|
||||||
|
|
||||||
export const WorkspaceSettingDetail = ({
|
export const WorkspaceSettingDetail = ({
|
||||||
workspaceMetadata,
|
workspaceMetadata,
|
||||||
@@ -69,6 +70,7 @@ export const WorkspaceSettingDetail = ({
|
|||||||
</SettingWrapper>
|
</SettingWrapper>
|
||||||
<SettingWrapper title={t['com.affine.brand.affineCloud']()}>
|
<SettingWrapper title={t['com.affine.brand.affineCloud']()}>
|
||||||
<EnableCloudPanel onCloseSetting={onCloseSetting} />
|
<EnableCloudPanel onCloseSetting={onCloseSetting} />
|
||||||
|
<WorkspaceQuotaPanel />
|
||||||
<MembersPanel onChangeSettingState={onChangeSettingState} />
|
<MembersPanel onChangeSettingState={onChangeSettingState} />
|
||||||
</SettingWrapper>
|
</SettingWrapper>
|
||||||
<AiSetting />
|
<AiSetting />
|
||||||
|
|||||||
+9
-2
@@ -146,7 +146,7 @@ export const CloudWorkspaceMembersPanel = ({
|
|||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
{t['com.affine.payment.member.description2']()}
|
{t['com.affine.payment.member.description2']()}
|
||||||
{hasPaymentFeature ? (
|
{hasPaymentFeature && isOwner ? (
|
||||||
<div
|
<div
|
||||||
className={styles.goUpgradeWrapper}
|
className={styles.goUpgradeWrapper}
|
||||||
onClick={handleUpgradeConfirm}
|
onClick={handleUpgradeConfirm}
|
||||||
@@ -158,7 +158,14 @@ export const CloudWorkspaceMembersPanel = ({
|
|||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}, [handleUpgradeConfirm, hasPaymentFeature, isTeam, t, workspaceQuota]);
|
}, [
|
||||||
|
handleUpgradeConfirm,
|
||||||
|
hasPaymentFeature,
|
||||||
|
isOwner,
|
||||||
|
isTeam,
|
||||||
|
t,
|
||||||
|
workspaceQuota,
|
||||||
|
]);
|
||||||
|
|
||||||
const title = useMemo(() => {
|
const title = useMemo(() => {
|
||||||
if (isTeam) {
|
if (isTeam) {
|
||||||
|
|||||||
+29
-1
@@ -1,6 +1,6 @@
|
|||||||
import { cssVar } from '@toeverything/theme';
|
import { cssVar } from '@toeverything/theme';
|
||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
import { style } from '@vanilla-extract/css';
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
export const profileWrapper = style({
|
export const profileWrapper = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'flex-end',
|
alignItems: 'flex-end',
|
||||||
@@ -35,3 +35,31 @@ export const workspaceLabel = style({
|
|||||||
lineHeight: '20px',
|
lineHeight: '20px',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const storageProgressContainer = style({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
});
|
||||||
|
export const storageProgressWrapper = style({
|
||||||
|
flexGrow: 1,
|
||||||
|
marginRight: '20px',
|
||||||
|
});
|
||||||
|
globalStyle(`${storageProgressWrapper} .storage-progress-desc`, {
|
||||||
|
fontSize: cssVar('fontXs'),
|
||||||
|
color: cssVarV2('text/secondary'),
|
||||||
|
height: '20px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 2,
|
||||||
|
});
|
||||||
|
globalStyle(`${storageProgressWrapper} .storage-progress-bar-wrapper`, {
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
export const storageProgressBar = style({
|
||||||
|
height: '100%',
|
||||||
|
});
|
||||||
|
|||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
import { ErrorMessage, Skeleton } from '@affine/component';
|
||||||
|
import { SettingRow } from '@affine/component/setting-components';
|
||||||
|
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
|
||||||
|
import { WorkspaceQuotaService } from '@affine/core/modules/quota';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import * as styles from './style.css';
|
||||||
|
|
||||||
|
export const WorkspaceQuotaPanel = () => {
|
||||||
|
const t = useI18n();
|
||||||
|
return (
|
||||||
|
<SettingRow
|
||||||
|
name={t['com.affine.workspace.storage']()}
|
||||||
|
desc=""
|
||||||
|
spreadCol={false}
|
||||||
|
>
|
||||||
|
<StorageProgress />
|
||||||
|
</SettingRow>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StorageProgress = () => {
|
||||||
|
const t = useI18n();
|
||||||
|
const workspacePermissionService = useService(
|
||||||
|
WorkspacePermissionService
|
||||||
|
).permission;
|
||||||
|
const workspaceQuotaService = useService(WorkspaceQuotaService).quota;
|
||||||
|
const isTeam = useLiveData(workspacePermissionService.isTeam$);
|
||||||
|
const isLoading = useLiveData(workspaceQuotaService.isLoading$);
|
||||||
|
const usedFormatted = useLiveData(workspaceQuotaService.usedFormatted$);
|
||||||
|
const maxFormatted = useLiveData(workspaceQuotaService.maxFormatted$);
|
||||||
|
const percent = useLiveData(workspaceQuotaService.percent$);
|
||||||
|
const color = useLiveData(workspaceQuotaService.color$);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// revalidate quota to get the latest status
|
||||||
|
workspaceQuotaService.revalidate();
|
||||||
|
}, [workspaceQuotaService]);
|
||||||
|
|
||||||
|
const loadError = useLiveData(workspaceQuotaService.error$);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
if (loadError) {
|
||||||
|
return <ErrorMessage>Load error</ErrorMessage>;
|
||||||
|
}
|
||||||
|
return <Skeleton height={42} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isTeam) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.storageProgressContainer}>
|
||||||
|
<div className={styles.storageProgressWrapper}>
|
||||||
|
<div className="storage-progress-desc">
|
||||||
|
<span>{t['com.affine.storage.used.hint']()}</span>
|
||||||
|
<span>
|
||||||
|
{usedFormatted}/{maxFormatted}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="storage-progress-bar-wrapper">
|
||||||
|
<div
|
||||||
|
className={styles.storageProgressBar}
|
||||||
|
style={{
|
||||||
|
width: `${percent}%`,
|
||||||
|
backgroundColor: color ?? cssVarV2('toast/iconState/regular'),
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,13 +6,15 @@ import {
|
|||||||
catchErrorInto,
|
catchErrorInto,
|
||||||
effect,
|
effect,
|
||||||
Entity,
|
Entity,
|
||||||
|
exhaustMapSwitchUntilChanged,
|
||||||
fromPromise,
|
fromPromise,
|
||||||
LiveData,
|
LiveData,
|
||||||
mapInto,
|
|
||||||
onComplete,
|
onComplete,
|
||||||
onStart,
|
onStart,
|
||||||
} from '@toeverything/infra';
|
} from '@toeverything/infra';
|
||||||
import { exhaustMap } from 'rxjs';
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
|
import bytes from 'bytes';
|
||||||
|
import { EMPTY, map, mergeMap } from 'rxjs';
|
||||||
|
|
||||||
import { isBackendError, isNetworkError } from '../../cloud';
|
import { isBackendError, isNetworkError } from '../../cloud';
|
||||||
import type { WorkspaceQuotaStore } from '../stores/quota';
|
import type { WorkspaceQuotaStore } from '../stores/quota';
|
||||||
@@ -26,6 +28,38 @@ export class WorkspaceQuota extends Entity {
|
|||||||
isLoading$ = new LiveData(false);
|
isLoading$ = new LiveData(false);
|
||||||
error$ = new LiveData<any>(null);
|
error$ = new LiveData<any>(null);
|
||||||
|
|
||||||
|
/** Used storage in bytes */
|
||||||
|
used$ = new LiveData<number | null>(null);
|
||||||
|
/** Formatted used storage */
|
||||||
|
usedFormatted$ = this.used$.map(used =>
|
||||||
|
used !== null ? bytes.format(used) : null
|
||||||
|
);
|
||||||
|
/** Maximum storage limit in bytes */
|
||||||
|
max$ = this.quota$.map(quota => (quota ? quota.storageQuota : null));
|
||||||
|
/** Maximum storage limit formatted */
|
||||||
|
maxFormatted$ = this.max$.map(max => (max ? bytes.format(max) : null));
|
||||||
|
|
||||||
|
/** Percentage of storage used */
|
||||||
|
percent$ = LiveData.computed(get => {
|
||||||
|
const max = get(this.max$);
|
||||||
|
const used = get(this.used$);
|
||||||
|
if (max === null || used === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.min(
|
||||||
|
100,
|
||||||
|
Math.max(0.5, Number(((used / max) * 100).toFixed(4)))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
color$ = this.percent$.map(percent =>
|
||||||
|
percent !== null
|
||||||
|
? percent > 80
|
||||||
|
? cssVarV2('status/error')
|
||||||
|
: cssVarV2('toast/iconState/regular')
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly workspaceService: WorkspaceService,
|
private readonly workspaceService: WorkspaceService,
|
||||||
private readonly store: WorkspaceQuotaStore
|
private readonly store: WorkspaceQuotaStore
|
||||||
@@ -34,28 +68,59 @@ export class WorkspaceQuota extends Entity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
revalidate = effect(
|
revalidate = effect(
|
||||||
exhaustMap(() => {
|
map(() => ({
|
||||||
return fromPromise(signal =>
|
workspaceId: this.workspaceService.workspace.id,
|
||||||
this.store.fetchWorkspaceQuota(
|
})),
|
||||||
this.workspaceService.workspace.id,
|
exhaustMapSwitchUntilChanged(
|
||||||
signal
|
(a, b) => a.workspaceId === b.workspaceId,
|
||||||
)
|
({ workspaceId }) => {
|
||||||
).pipe(
|
return fromPromise(async signal => {
|
||||||
backoffRetry({
|
if (!workspaceId) {
|
||||||
when: isNetworkError,
|
return; // no quota if no workspace
|
||||||
count: Infinity,
|
}
|
||||||
}),
|
const data = await this.store.fetchWorkspaceQuota(
|
||||||
backoffRetry({
|
this.workspaceService.workspace.id,
|
||||||
when: isBackendError,
|
signal
|
||||||
count: 3,
|
);
|
||||||
}),
|
return { quota: data, used: data.usedSize };
|
||||||
mapInto(this.quota$),
|
}).pipe(
|
||||||
catchErrorInto(this.error$, error => {
|
backoffRetry({
|
||||||
logger.error('Failed to fetch isOwner', error);
|
when: isNetworkError,
|
||||||
}),
|
count: Infinity,
|
||||||
onStart(() => this.isLoading$.setValue(true)),
|
}),
|
||||||
onComplete(() => this.isLoading$.setValue(false))
|
backoffRetry({
|
||||||
);
|
when: isBackendError,
|
||||||
})
|
count: 3,
|
||||||
|
}),
|
||||||
|
mergeMap(data => {
|
||||||
|
if (data) {
|
||||||
|
const { quota, used } = data;
|
||||||
|
this.quota$.next(quota);
|
||||||
|
this.used$.next(used);
|
||||||
|
} else {
|
||||||
|
this.quota$.next(null);
|
||||||
|
this.used$.next(null);
|
||||||
|
}
|
||||||
|
return EMPTY;
|
||||||
|
}),
|
||||||
|
catchErrorInto(this.error$, error => {
|
||||||
|
logger.error('Failed to fetch workspace quota', error);
|
||||||
|
}),
|
||||||
|
onStart(() => this.isLoading$.setValue(true)),
|
||||||
|
onComplete(() => this.isLoading$.setValue(false))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.quota$.next(null);
|
||||||
|
this.used$.next(null);
|
||||||
|
this.error$.next(null);
|
||||||
|
this.isLoading$.next(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
override dispose(): void {
|
||||||
|
this.revalidate.unsubscribe();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1590,5 +1590,6 @@
|
|||||||
"com.affine.upgrade-to-team-page.create-and-upgrade-confirm.title": "Name Your Workspace",
|
"com.affine.upgrade-to-team-page.create-and-upgrade-confirm.title": "Name Your Workspace",
|
||||||
"com.affine.upgrade-to-team-page.create-and-upgrade-confirm.description": "A workspace is your virtual space to capture, create and plan as just one person or together as a team.",
|
"com.affine.upgrade-to-team-page.create-and-upgrade-confirm.description": "A workspace is your virtual space to capture, create and plan as just one person or together as a team.",
|
||||||
"com.affine.upgrade-to-team-page.create-and-upgrade-confirm.placeholder": "Set a workspace name",
|
"com.affine.upgrade-to-team-page.create-and-upgrade-confirm.placeholder": "Set a workspace name",
|
||||||
"com.affine.upgrade-to-team-page.create-and-upgrade-confirm.confirm": "Continue to Pricing"
|
"com.affine.upgrade-to-team-page.create-and-upgrade-confirm.confirm": "Continue to Pricing",
|
||||||
|
"com.affine.workspace.storage": "Workspace storage"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user