Merge pull request 'feat(files): add per-channel storage quota [CI]' (#2) from feat/channel-storage-quota into master
This commit was merged in pull request #2.
This commit is contained in:
commit
0fd2a269d9
19 files changed
+172
-15
No files matched your search
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"x-backend-contract-sha256": "654ec761e3481b423296b402497a97f4092ce167e7a2ca51a47d25f0fca4466e",
|
||||
"x-backend-contract-sha256": "ba787115471d5d33fb0c33c95af014b5cd0d535455bd4bea037cfdcc4f048828",
|
||||
"$defs": {
|
||||
"AccountChannelBody": {
|
||||
"type": "object",
|
||||
@@ -1431,6 +1431,12 @@
|
||||
"max_visitors": {
|
||||
"type": "integer"
|
||||
},
|
||||
"max_workspace_bytes": {
|
||||
"type": "integer"
|
||||
},
|
||||
"max_workspace_channel_bytes": {
|
||||
"type": "integer"
|
||||
},
|
||||
"min_ttl_seconds": {
|
||||
"type": "integer"
|
||||
}
|
||||
@@ -1443,6 +1449,8 @@
|
||||
"max_seats",
|
||||
"max_ttl_seconds",
|
||||
"max_visitors",
|
||||
"max_workspace_bytes",
|
||||
"max_workspace_channel_bytes",
|
||||
"min_ttl_seconds"
|
||||
]
|
||||
},
|
||||
@@ -1748,6 +1756,9 @@
|
||||
"createdBy": {
|
||||
"type": "string"
|
||||
},
|
||||
"fileBytes": {
|
||||
"type": "integer"
|
||||
},
|
||||
"filesEpoch": {
|
||||
"type": "integer"
|
||||
},
|
||||
@@ -1776,6 +1787,7 @@
|
||||
"chatHistory",
|
||||
"createdAt",
|
||||
"createdBy",
|
||||
"fileBytes",
|
||||
"filesEpoch",
|
||||
"filesHistory",
|
||||
"id",
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ WebSocket 无法设置头,令牌通过 `Sec-WebSocket-Protocol` 传递:`wd.v1, w
|
||||
签名根密钥保存在加密保险库;设备签名私钥以不可导出的 CryptoKey 保存在本机账户数据库,不随保险库同步。已登记签名设备写入 Workspace 对象时,FINAL 帧和删除请求必须带签名事件;尚未把签名要求扩展到频道结构和成员/权限变更。
|
||||
|
||||
### `GET /api/v1/info`
|
||||
返回服务端能力:协议版本、限制、验证码配置(`captcha.provider`、`captcha.enabled`、`captcha.api_endpoint`,即 Cap widget 要连接的地址)、传输能力以及创建默认值/选项。`defaults.channel_mode`、`defaults.channel` 和 `choices` 是前端创建表单的初始值与可选项;服务端独立校验提交的设置。验证码的 secret 不会出现在这里。`transports.http3` 只是部署层(反向代理或 Kestrel 配置)声明已启用 HTTP/3,服务端进程本身不内置 QUIC。
|
||||
返回服务端能力:协议版本、限制、验证码配置(`captcha.provider`、`captcha.enabled`、`captcha.api_endpoint`,即 Cap widget 要连接的地址)、传输能力以及创建默认值/选项。`limits.max_workspace_bytes` 是单个空间全部资源的上限,`limits.max_workspace_channel_bytes` 是单个频道 Files 域的文件容量上限;频道结构的 `fileBytes` 为当前已用容量。前端只展示服务端返回的用量和限制。`defaults.channel_mode`、`defaults.channel` 和 `choices` 是前端创建表单的初始值与可选项;服务端独立校验提交的设置。验证码的 secret 不会出现在这里。`transports.http3` 只是部署层(反向代理或 Kestrel 配置)声明已启用 HTTP/3,服务端进程本身不内置 QUIC。
|
||||
|
||||
### `POST /api/v1/channels`
|
||||
创建者必须登录。创建请求必须提供 `settings.name`,服务端拒绝缺失、空白或纯空格名称并返回 `400 space_name_required`,存储时去除首尾空格;名称长度最多 48 个字符。只有一套频道/成员/线程模型,`mode=enc|raw` 仅决定客户端内容是否端到端加密,不改变业务权限。创建者自动写入 owner 成员记录并占一个成员名额,同时登记当前设备终端;该账号的其他设备不增加名额。服务端启用验证码时还需要 `X-WD-Cap`。
|
||||
|
||||
@@ -224,6 +224,8 @@ meta_len (u16) ‖ meta_json (UTF-8) ‖ content_bytes ...
|
||||
| 每频道字节数 | 1 GiB |
|
||||
| 每频道条目数 | `max_items` |
|
||||
|
||||
工作区对象帧使用独立容量限制:单个工作区默认最多 1 TiB,单个 `WorkspaceChannel` 的 Files 域默认最多 10 GiB;Files 域用量以非 CONTROL 帧字节计,删除条目后释放用量。其他未绑定工作区对象的旧式频道仍遵循上表 1 GiB 限制。实际配置值由 `/info` 返回,客户端不得自行推导或执行配额。
|
||||
|
||||
## 14. 接收方校验算法
|
||||
|
||||
对 enc 频道的每个条目:
|
||||
|
||||
@@ -59,6 +59,8 @@ public sealed class LimitsBody
|
||||
[JsonPropertyName("max_payload_bytes")] public required int MaxPayloadBytes { get; init; }
|
||||
[JsonPropertyName("max_frames_per_item")] public required int MaxFramesPerItem { get; init; }
|
||||
[JsonPropertyName("max_channel_bytes")] public required long MaxChannelBytes { get; init; }
|
||||
[JsonPropertyName("max_workspace_bytes")] public required long MaxWorkspaceBytes { get; init; }
|
||||
[JsonPropertyName("max_workspace_channel_bytes")] public required long MaxWorkspaceChannelBytes { get; init; }
|
||||
[JsonPropertyName("max_seats")] public required int MaxSeats { get; init; }
|
||||
[JsonPropertyName("max_ttl_seconds")] public required int MaxTtlSeconds { get; init; }
|
||||
[JsonPropertyName("min_ttl_seconds")] public required int MinTtlSeconds { get; init; }
|
||||
|
||||
@@ -42,6 +42,8 @@ public static partial class Endpoints
|
||||
MaxPayloadBytes = limits.MaxPayloadBytes,
|
||||
MaxFramesPerItem = limits.MaxFramesPerItem,
|
||||
MaxChannelBytes = limits.MaxChannelBytes,
|
||||
MaxWorkspaceBytes = limits.MaxWorkspaceBytes,
|
||||
MaxWorkspaceChannelBytes = limits.MaxWorkspaceChannelBytes,
|
||||
MaxSeats = limits.MaxSeats,
|
||||
MaxTtlSeconds = limits.MaxTtlSeconds,
|
||||
MinTtlSeconds = limits.MinTtlSeconds,
|
||||
|
||||
@@ -245,14 +245,16 @@ public sealed partial class ChannelService(
|
||||
if (!outcome.Result.Created) DeleteBlobIfUnreferenced(blobKey);
|
||||
foreach (string prefix in outcome.BlobPrefixesToDelete) blobs.DeletePrefix(prefix);
|
||||
events.Advanced(channelId, outcome.LastSeq);
|
||||
if (outcome.NotifySettings) events.SettingsChanged(channelId);
|
||||
return outcome.Result;
|
||||
}
|
||||
|
||||
private sealed class AppendOutcome(AppendResult result, long lastSeq, List<string> prefixes)
|
||||
private sealed class AppendOutcome(AppendResult result, long lastSeq, List<string> prefixes, bool notifySettings = false)
|
||||
{
|
||||
public AppendResult Result { get; } = result;
|
||||
public long LastSeq { get; } = lastSeq;
|
||||
public List<string> BlobPrefixesToDelete { get; } = prefixes;
|
||||
public bool NotifySettings { get; } = notifySettings;
|
||||
}
|
||||
|
||||
private AppendOutcome AppendInTx(
|
||||
@@ -272,6 +274,10 @@ public sealed partial class ChannelService(
|
||||
var item = tx.GetItem(channelId, env.ItemId);
|
||||
var crypto = tx.GetWorkspaceCrypto(channelId);
|
||||
var objectEnvelope = crypto.Objects.Find(o => o.ItemId == env.ItemId.ToBase64Url());
|
||||
var workspaceState = objectEnvelope is null ? null : tx.GetWorkspace(channelId);
|
||||
var workspaceChannel = objectEnvelope is null ? null : workspaceState?.Channels.Find(c => c.Id == objectEnvelope.ChannelId);
|
||||
if (objectEnvelope is not null && workspaceChannel is null) throw new WebDropException(ErrorCode.BadRequest);
|
||||
bool countsTowardFileQuota = objectEnvelope?.Domain == CryptoDomain.Files && !env.IsControl;
|
||||
if (signedEvent is not null && (objectEnvelope is null || !env.IsFinal)) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (objectEnvelope is not null && env.IsFinal)
|
||||
{
|
||||
@@ -336,7 +342,11 @@ public sealed partial class ChannelService(
|
||||
if (item.MaxIndex > env.FrameIndex) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (item.FinalIndex is { } f && f != env.FrameIndex) throw new WebDropException(ErrorCode.BadRequest);
|
||||
}
|
||||
if (channel.Bytes + body.Length > limits.MaxChannelBytes) throw new WebDropException(ErrorCode.TooLarge);
|
||||
if (objectEnvelope is null && channel.Bytes + body.Length > limits.MaxChannelBytes
|
||||
|| objectEnvelope is not null && channel.Bytes + body.Length > limits.MaxWorkspaceBytes)
|
||||
throw new WebDropException(ErrorCode.TooLarge);
|
||||
if (countsTowardFileQuota && workspaceChannel!.FileBytes + body.Length > limits.MaxWorkspaceChannelBytes)
|
||||
throw new WebDropException(ErrorCode.TooLarge);
|
||||
|
||||
long seq = tx.NextSeq(channelId);
|
||||
tx.InsertFrame(new StoredFrame(channelId, env.ItemId, env.FrameIndex, seq, body.Length, digest,
|
||||
@@ -391,7 +401,16 @@ public sealed partial class ChannelService(
|
||||
if (pruned > channel.CompactedThrough) tx.SetCompactedThrough(channelId, pruned);
|
||||
}
|
||||
|
||||
return new AppendOutcome(new AppendResult { Seq = seq, Created = true }, lastSeq, prefixes);
|
||||
if (countsTowardFileQuota)
|
||||
{
|
||||
var latestWorkspace = tx.GetWorkspace(channelId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
var latestChannel = latestWorkspace.Channels.Find(c => c.Id == objectEnvelope!.ChannelId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
latestChannel.FileBytes += body.Length;
|
||||
tx.PutWorkspace(channelId, latestWorkspace);
|
||||
}
|
||||
|
||||
return new AppendOutcome(new AppendResult { Seq = seq, Created = true }, lastSeq, prefixes,
|
||||
countsTowardFileQuota && env.IsFinal);
|
||||
}
|
||||
|
||||
private static AppendResult Resolve(StoredFrame existing, byte[] digest) =>
|
||||
@@ -587,10 +606,23 @@ public sealed partial class ChannelService(
|
||||
});
|
||||
foreach (string prefix in prefixes) blobs.DeletePrefix(prefix);
|
||||
events.Advanced(id, seq);
|
||||
events.SettingsChanged(id);
|
||||
}
|
||||
|
||||
private long DeleteItemCore(IResourceTx tx, Id16 channelId, Item item, TombstoneReason reason, List<string> prefixes)
|
||||
{
|
||||
var crypto = tx.GetWorkspaceCrypto(channelId);
|
||||
var objectEnvelope = crypto.Objects.Find(o => o.ItemId == item.ItemId.ToBase64Url());
|
||||
if (objectEnvelope?.Domain == CryptoDomain.Files && (item.Flags & FrameFlags.Control) == 0)
|
||||
{
|
||||
var workspace = tx.GetWorkspace(channelId);
|
||||
var workspaceChannel = workspace?.Channels.Find(c => c.Id == objectEnvelope.ChannelId);
|
||||
if (workspace is not null && workspaceChannel is not null)
|
||||
{
|
||||
workspaceChannel.FileBytes = Math.Max(0, workspaceChannel.FileBytes - item.Bytes);
|
||||
tx.PutWorkspace(channelId, workspace);
|
||||
}
|
||||
}
|
||||
tx.DeleteItemRows(channelId, item.ItemId);
|
||||
tx.AddChannelBytes(channelId, -item.Bytes);
|
||||
long seq = tx.NextSeq(channelId);
|
||||
|
||||
@@ -27,6 +27,11 @@ public sealed partial class ChannelService
|
||||
var grants = crypto.Access.Where(a => a.Username == principal.AccountName).ToList();
|
||||
var ids = grants.Where(a => a.ChatRead || a.FilesRead).Select(a => a.ChannelId).ToHashSet();
|
||||
state.Channels.RemoveAll(c => !ids.Contains(c.Id));
|
||||
foreach (var workspaceChannel in state.Channels)
|
||||
{
|
||||
var grant = grants.Find(a => a.ChannelId == workspaceChannel.Id);
|
||||
if (grant is null || !grant.FilesRead && !grant.FilesUpload) workspaceChannel.FileBytes = 0;
|
||||
}
|
||||
state.Folders.RemoveAll(f => !ids.Contains(f.ThreadId));
|
||||
state.Locations.RemoveAll(l => !Id16.TryParse(l.ItemId, out var itemId) || !CanReadObject(tx, space, principal, itemId));
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ public sealed class ServerLimits
|
||||
public int MaxPayloadBytes { get; set; } = (1 << 20) + 16;
|
||||
public int MaxFramesPerItem { get; set; } = 65_536;
|
||||
public long MaxChannelBytes { get; set; } = 1L << 30;
|
||||
public long MaxWorkspaceBytes { get; set; } = 1L << 40;
|
||||
public long MaxWorkspaceChannelBytes { get; set; } = 10L << 30;
|
||||
/// <summary>最大账号成员数。属性名和 info 字段保持旧线格式兼容。</summary>
|
||||
public int MaxSeats { get; set; } = 64;
|
||||
public int MaxVisitors { get; set; } = 10_000;
|
||||
|
||||
@@ -8,6 +8,7 @@ public sealed class WorkspaceChannel
|
||||
[JsonPropertyName("filesEpoch")] public int FilesEpoch { get; set; }
|
||||
[JsonPropertyName("chatHistory")] public bool ChatHistory { get; set; } = true;
|
||||
[JsonPropertyName("filesHistory")] public bool FilesHistory { get; set; } = true;
|
||||
[JsonPropertyName("fileBytes")] public long FileBytes { get; set; }
|
||||
[JsonPropertyName("id")] public required string Id { get; init; }
|
||||
[JsonPropertyName("name")] public required string Name { get; set; }
|
||||
[JsonPropertyName("createdAt")] public required long CreatedAt { get; init; }
|
||||
|
||||
@@ -163,6 +163,28 @@ public class ChannelServiceTests : IDisposable
|
||||
Assert.Equal("Team space", Svc.GetChannel(channel.Id, channel.Owner(_ownerEp)).Settings.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FileGroupQuotaIsPerWorkspaceChannelAndFreedOnDelete()
|
||||
{
|
||||
_env.Limits.MaxWorkspaceChannelBytes = 200;
|
||||
var space = _env.Create(_ownerEp, ChannelMode.Raw);
|
||||
var owner = space.Owner(_ownerEp);
|
||||
var channelId = Svc.GetWorkspace(space.Id, owner).DefaultChannelId!;
|
||||
var first = TestEnv.NewId(); var second = TestEnv.NewId();
|
||||
Svc.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = first.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
|
||||
Svc.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = second.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
|
||||
|
||||
byte[] firstFrame = TestEnv.Frame(first, 0, FrameFlags.Final, 120);
|
||||
await Svc.AppendFrameAsync(space.Id, owner, firstFrame, null);
|
||||
Assert.Equal(firstFrame.Length, Svc.GetWorkspace(space.Id, owner).Channels.Single().FileBytes);
|
||||
await AssertCode(ErrorCode.TooLarge, () => Svc.AppendFrameAsync(space.Id, owner, TestEnv.Frame(second, 0, FrameFlags.Final, 60), null));
|
||||
|
||||
Svc.DeleteItem(space.Id, owner, first);
|
||||
Assert.Equal(0, Svc.GetWorkspace(space.Id, owner).Channels.Single().FileBytes);
|
||||
await Svc.AppendFrameAsync(space.Id, owner, TestEnv.Frame(second, 0, FrameFlags.Final, 60), null);
|
||||
Assert.Equal(86, Svc.GetWorkspace(space.Id, owner).Channels.Single().FileBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ItemIdsAndFramesAreScopedToTheirChannel()
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Generated from backend DTOs; do not edit.
|
||||
// backend-contract-sha256: 654ec761e3481b423296b402497a97f4092ce167e7a2ca51a47d25f0fca4466e
|
||||
// backend-contract-sha256: ba787115471d5d33fb0c33c95af014b5cd0d535455bd4bea037cfdcc4f048828
|
||||
|
||||
export interface AccountChannelBody {
|
||||
channel_id: string;
|
||||
@@ -299,6 +299,8 @@ export interface LimitsBody {
|
||||
max_seats: number;
|
||||
max_ttl_seconds: number;
|
||||
max_visitors: number;
|
||||
max_workspace_bytes: number;
|
||||
max_workspace_channel_bytes: number;
|
||||
min_ttl_seconds: number;
|
||||
}
|
||||
export interface SettingChoicesBody {
|
||||
@@ -365,6 +367,7 @@ export interface WorkspaceChannel {
|
||||
chatHistory: boolean;
|
||||
createdAt: number;
|
||||
createdBy: string;
|
||||
fileBytes: number;
|
||||
filesEpoch: number;
|
||||
filesHistory: boolean;
|
||||
groupId?: string | null;
|
||||
|
||||
@@ -55,6 +55,7 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann
|
||||
const signedEvent = await objectEventProjection(resource, session, event.item.itemId);
|
||||
if (signedEvent?.kind === 'chat.edit' && signedEvent.targetItemId)
|
||||
publishWorkspace(resource.id, { t: 'removed', id: signedEvent.targetItemId });
|
||||
if (signedEvent?.kind.startsWith('file.')) void loadWorkspace(resource.id).catch(() => {});
|
||||
const control = readResourceRecord(event.item);
|
||||
if (control !== false) {
|
||||
if (control && control.t === 'file-catalog') {
|
||||
@@ -108,6 +109,7 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann
|
||||
fileCatalogs.delete(`${resource.id}:${event.itemId}`);
|
||||
blobs.delete(`${resource.id}:${event.itemId}`);
|
||||
publishWorkspace(resource.id, { t: 'removed', id: event.itemId });
|
||||
void loadWorkspace(resource.id).catch(() => {});
|
||||
break;
|
||||
case 'rejected':
|
||||
showRejected(resource.id, event);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { active, getSession } from './sessions';
|
||||
import { apiFetch, type ApiError } from './http';
|
||||
import { publishWorkspace } from './presentation';
|
||||
import { channelMetadata, processChannelBytes, syncChannel } from './channelEvents';
|
||||
import { loadWorkspace } from './workspaceStructure';
|
||||
import { flushOutbox } from './transfer';
|
||||
import { socketStream, sseStream, delay, type StreamConsumer } from './transports';
|
||||
import { errorMessage } from '../errors';
|
||||
@@ -31,7 +32,11 @@ export async function connectChannel(id: string): Promise<void> {
|
||||
const stream: StreamConsumer = {
|
||||
cursor: 0,
|
||||
async record(bytes) { stream.cursor = Math.max(stream.cursor, await processChannelBytes(resource, session, bytes)); },
|
||||
async control(value) { if (value.t === 'settings' || value.t === 'seats') await channelMetadata(resource, session); },
|
||||
async control(value) {
|
||||
if (value.t === 'settings') {
|
||||
await Promise.all([channelMetadata(resource, session), loadWorkspace(id)]);
|
||||
} else if (value.t === 'seats') await channelMetadata(resource, session);
|
||||
},
|
||||
online() { if (!signal.aborted) publishWorkspace(id, { t: 'connection', state: 'online' }); },
|
||||
};
|
||||
let initialized = false;
|
||||
|
||||
@@ -24,10 +24,11 @@ export function ExplorerFileRow(props: Props) {
|
||||
onMouseEnter={(event) => props.onHover(id, event.currentTarget.getBoundingClientRect())} onMouseLeave={props.onLeave}
|
||||
draggable={props.enabled && available} onDragStart={(event) => props.onDrag(id, event)} onDragEnd={props.onDragEnd}>
|
||||
<td><input type="checkbox" aria-label={`选择文件 ${item.name}`} checked={props.checked} onChange={() => props.onToggle(id)} onClick={(event) => event.stopPropagation()} /></td>
|
||||
<td><button className={styles.name} type="button" onKeyDown={(event) => { if (event.key === 'Enter') { event.stopPropagation(); if (available) props.onOpen(id); }
|
||||
if (event.key === 'ContextMenu' || event.shiftKey && event.key === 'F10') { event.preventDefault(); const rect = event.currentTarget.getBoundingClientRect(); props.onMenu(id, rect.left, rect.bottom); } }}>
|
||||
<Icon name={item.state.kind === 'tampered' ? 'warn' : iconOf(item)} size={18} /><span>{item.name}</span>
|
||||
</button>{item.state.kind === 'receiving' ? <span className={styles.progress}>{formatTransferProgress(item.state)}</span> : null}
|
||||
<td><button className={styles.name} type="button" onKeyDown={(event) => { if (event.key === 'Enter') { event.stopPropagation(); if (available) props.onOpen(id); }
|
||||
if (event.key === 'ContextMenu' || event.shiftKey && event.key === 'F10') { event.preventDefault(); const rect = event.currentTarget.getBoundingClientRect(); props.onMenu(id, rect.left, rect.bottom); } }}>
|
||||
<Icon name={item.state.kind === 'tampered' ? 'warn' : iconOf(item)} size={18} /><span>{item.name}</span>
|
||||
</button>{item.author ? <span className={styles.uploader}>上传者:{item.author.displayName || item.author.username}</span> : null}
|
||||
{item.state.kind === 'receiving' ? <span className={styles.progress}>{formatTransferProgress(item.state)}</span> : null}
|
||||
{item.state.kind === 'tampered' ? <span className={styles.error}>内容校验失败</span> : null}</td>
|
||||
<td className={styles.numeric}>{formatSize(item.size)}</td><td className={styles.date}>{formatTime(item.ts)}</td>
|
||||
<td><Button variant="ghost" icon="more" label={`文件操作 ${item.name}`} onClick={(event) => { event.stopPropagation(); const rect = event.currentTarget.getBoundingClientRect(); props.onMenu(id, rect.right, rect.bottom); }} /></td>
|
||||
|
||||
@@ -16,7 +16,7 @@ export function ExplorerHover({ id, point }: { id: string; point: { x: number; y
|
||||
const preview = encrypted && !item.gated && item.state.kind === 'complete' && (item.preview === 'image' || item.preview === 'text');
|
||||
return createPortal(<aside className={styles.hover} role="tooltip" aria-label="悬浮文件预览" style={{ left: position.x, top: position.y }}>
|
||||
<div className={styles.hoverTitle}><Icon name={iconOf(item)} size={20} /><strong>{item.name}</strong></div>
|
||||
<p>{formatSize(item.size)} · {item.mime}</p><p>{formatTime(item.ts)} · {item.deviceId}</p>
|
||||
<p>{formatSize(item.size)} · {item.mime}</p><p>{formatTime(item.ts)} · 上传者 {item.author?.displayName ?? item.author?.username ?? item.deviceId} · {item.deviceId}</p>
|
||||
{preview ? <HoverContent id={id} image={item.preview === 'image'} /> : <p>{item.gated ? '受限文件:悬停不读取内容,点击预览后再解密。' : item.state.kind === 'tampered' ? '内容校验失败,已拒绝预览。' : '双击文件打开完整预览。'}</p>}
|
||||
</aside>, document.body);
|
||||
}
|
||||
|
||||
@@ -189,6 +189,16 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.uploader {
|
||||
display: block;
|
||||
margin-inline-start: 28px;
|
||||
overflow: hidden;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.name span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -219,6 +229,44 @@
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.quota {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.quotaLabel,
|
||||
.quotaRemaining {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.quotaTrack {
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.quotaTrack span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.quotaTrack .quotaFull {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.quotaRemaining {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.edit input,
|
||||
.batch select {
|
||||
min-width: 0;
|
||||
|
||||
@@ -10,11 +10,14 @@ import { ExplorerMoveDialog } from './ExplorerMoveDialog';
|
||||
import { useExplorerInteractions } from './useExplorerInteractions';
|
||||
import { ContextMenu } from '@/ui/ContextMenu';
|
||||
import styles from './FileExplorer.module.css';
|
||||
import { formatSize } from '@/lib/format';
|
||||
|
||||
interface Props {
|
||||
scope: string;
|
||||
threadId?: string;
|
||||
threadName: string;
|
||||
fileBytes: number;
|
||||
maxFileBytes?: number;
|
||||
folders: FolderInfo[];
|
||||
folderId?: string;
|
||||
ids: string[];
|
||||
@@ -56,6 +59,8 @@ export function FileExplorer(props: Props) {
|
||||
seen.add(current.id); path.unshift(current); current = props.folders.find((folder) => folder.id === current?.parentId);
|
||||
}
|
||||
const children = props.folders.filter((folder) => folder.parentId === props.folderId).sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
|
||||
const maxFileBytes = props.maxFileBytes;
|
||||
const usedRatio = maxFileBytes && maxFileBytes > 0 ? Math.min(1, props.fileBytes / maxFileBytes) : 0;
|
||||
function beginFolder(id?: string) { const folder = props.folders.find((row) => row.id === id); setEdit(id ?? 'create'); setName(folder?.name ?? ''); setError(''); }
|
||||
const interaction = useExplorerInteractions({ ...props, ids, onDownload: props.onDownloadItems, onDelete: props.onDeleteItems, onMove: props.onMoveItems,
|
||||
onRenameFolder: (id) => beginFolder(id), onCreateFolder: () => beginFolder(), onUpload: () => input.current?.click(), onFolder: navigateFolder });
|
||||
@@ -99,7 +104,16 @@ export function FileExplorer(props: Props) {
|
||||
}}><option value="__choose">移动到…</option><option value="__root">根目录</option>{props.folders.map((folder) => <option value={folder.id} key={folder.id}>{folder.name}</option>)}</select> : null}
|
||||
</div> : null}
|
||||
{error || interaction.error ? <p className={styles.error} role="alert">{error || interaction.error}</p> : null}
|
||||
<footer className={styles.status}>{children.length} 个文件夹 · {ids.length} 个文件<span>双击打开 · 右键操作 · 拖动移动</span></footer>
|
||||
<footer className={styles.status}>{children.length} 个文件夹 · {ids.length} 个文件<span>双击打开 · 右键操作 · 拖动移动</span></footer>
|
||||
<div className={styles.quota} role="group" aria-label="当前频道文件空间">
|
||||
<div className={styles.quotaLabel}><span>频道文件空间</span><span>{formatSize(props.fileBytes)} / {maxFileBytes ? formatSize(maxFileBytes) : '读取中'}</span></div>
|
||||
<div className={styles.quotaTrack} role="progressbar" aria-label="文件空间使用量" aria-valuemin={0}
|
||||
aria-valuemax={maxFileBytes ?? 0} aria-valuenow={Math.min(props.fileBytes, maxFileBytes ?? 0)}>
|
||||
<span className={props.fileBytes >= (maxFileBytes ?? Number.POSITIVE_INFINITY) ? styles.quotaFull : undefined}
|
||||
style={{ width: `${usedRatio * 100}%` }} />
|
||||
</div>
|
||||
{maxFileBytes ? <span className={styles.quotaRemaining}>剩余 {formatSize(Math.max(0, maxFileBytes - props.fileBytes))}</span> : null}
|
||||
</div>
|
||||
{interaction.menu ? <ContextMenu point={interaction.menu} actions={interaction.actions} onClose={interaction.dismiss} /> : null}
|
||||
{interaction.hover && ids.includes(interaction.hover.id) && !interaction.menu ? <ExplorerHover key={interaction.hover.id} {...interaction.hover} /> : null}
|
||||
{interaction.moveIds ? <ExplorerMoveDialog folders={props.folders} count={interaction.moveIds.length} currentFolderId={props.folderId} onClose={() => interaction.setMoveIds(undefined)}
|
||||
|
||||
@@ -22,12 +22,14 @@ import { DevicePanel } from './DevicePanel';
|
||||
import { ChannelSettingsSheet } from './ChannelSettingsSheet';
|
||||
import { UploadOptionsSheet } from './UploadOptionsSheet';
|
||||
import styles from './Workspace.module.css';
|
||||
import { useServerSettings } from '@/store/server';
|
||||
|
||||
const PreviewSheet = lazy(() => import('@/features/item/PreviewSheet'));
|
||||
const ScanSheet = lazy(() => import('@/features/scan/ScanSheet').then((m) => ({ default: m.ScanSheet })));
|
||||
|
||||
export function Workspace({ onCreateChannel }: { onCreateChannel: () => void }) {
|
||||
const desktop = useMediaQuery('(width >= 900px) and (height >= 500px)');
|
||||
const { info: serverInfo } = useServerSettings();
|
||||
const channel = useWorkspace((s) => s.channel);
|
||||
const threads = useWorkspace((s) => s.threads);
|
||||
const groups = useWorkspace((s) => s.groups);
|
||||
@@ -108,7 +110,9 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
|
||||
const chat = <ChatPane threadName={activeThread?.name ?? '选择频道'} ids={messages} enabled={enabled} onSend={(text) => sendText(text, activeThreadId)} onDelete={deleteItem} onOpenFile={setPreviewId} onFiles={requestFiles} />;
|
||||
const explorer = <>
|
||||
<FilterBar filter={filter} searchOpen={searchOpen} onChange={setFilter} />
|
||||
<FileExplorer key={activeThreadId} scope={`${channel?.id}:${activeThreadId}`} threadId={activeThreadId} threadName={activeThread?.name ?? '选择频道'} folders={activeFolders} folderId={activeFolderId} ids={files}
|
||||
<FileExplorer key={activeThreadId} scope={`${channel?.id}:${activeThreadId}`} threadId={activeThreadId} threadName={activeThread?.name ?? '选择频道'}
|
||||
fileBytes={activeThread?.fileBytes ?? 0} maxFileBytes={serverInfo?.limits.max_workspace_channel_bytes}
|
||||
folders={activeFolders} folderId={activeFolderId} ids={files}
|
||||
selectedId={files.includes(selectedId ?? '') ? selectedId : undefined} checked={checked} enabled={enabled} structureEnabled={Boolean(activeThreadId && channel?.canManageStructure)} onFolder={selectFolder} onSelect={setSelectedId} onOpen={setPreviewId} onToggle={toggle} onSelection={setChecked}
|
||||
onFiles={(incoming, target) => requestFiles(incoming, { folderId: target })}
|
||||
onCreateFolder={(name) => activeThreadId ? createFolder(activeThreadId, name, activeFolderId) : Promise.reject(new Error('请先选择频道'))}
|
||||
|
||||
@@ -9,7 +9,7 @@ const text = (id: string, ts: number, body = id): TextItem => ({
|
||||
describe('reduce', () => {
|
||||
it('renders server structure and preserves server author and location across late decrypted content', () => {
|
||||
const stateEvent = { t: 'structure' as const, state: { revision: 1,
|
||||
channels: [{ id: 'channel-a', name: '常规', groupId: 'group-a', createdAt: 1, createdBy: 'alice', chatEpoch: 1, filesEpoch: 1, chatHistory: true, filesHistory: true }],
|
||||
channels: [{ id: 'channel-a', name: '常规', groupId: 'group-a', createdAt: 1, createdBy: 'alice', chatEpoch: 1, filesEpoch: 1, chatHistory: true, filesHistory: true, fileBytes: 0 }],
|
||||
groups: [{ id: 'group-a', name: '项目', createdAt: 1 }],
|
||||
folders: [{ id: 'folder-a', name: '目录', threadId: 'channel-a' }],
|
||||
locations: [{ itemId: 'a', threadId: 'channel-a', folderId: 'folder-a', authorAccount: 'alice' }] },
|
||||
|
||||
Reference in new issue
Block a user