feat(web): add account workspaces and signed content flows

This commit is contained in:
justme committed 2026-10-14 04:06:03 +08:00
1 parent 7b8d61d1ba
commit b5cf3d5958
117 files changed
+2789 -1674

No files matched your search

@@ -5,12 +5,9 @@ import { Sheet } from '@/ui/Sheet';
import styles from './ChannelSettingsSheet.module.css';
import { ChannelInvitations } from './ChannelInvitations';
const TTL_OPTIONS = [
{ label: '1 小时', value: 3600 },
{ label: '24 小时', value: 86400 },
{ label: '7 天', value: 604800 },
];
const DOWNLOAD_OPTIONS = [0, 1, 3, 5, 10, 20];
import { useServerSettings } from '@/store/server';
import type { ServerInfoBody } from '@/core-bridge/generated/api';
import { ttlLabel, withCurrent } from '@/lib/settingOptions';
interface Props {
channel: ChannelInfo;
@@ -18,23 +15,28 @@ interface Props {
onSave: (patch: ChannelSettingsPatch) => Promise<void>;
}
export function ChannelSettingsSheet({ channel, onClose, onSave }: Props) {
export function ChannelSettingsSheet(props: Props) {
const { info, error, load } = useServerSettings();
if (!info || props.channel.ttlSeconds === undefined || props.channel.membersCanUpload === undefined || props.channel.maxDownloads === undefined || props.channel.joinPolicy === undefined || props.channel.allowGuests === undefined)
return <Sheet title="空间设置" onClose={props.onClose}><p role={error ? 'alert' : 'status'}>{error ?? '正在读取空间设置…'}</p>
{error ? <Button onClick={() => { void load().catch(() => {}); }}>重试</Button> : null}</Sheet>;
return <SettingsForm {...props} info={info} />;
}
function SettingsForm({ channel, onClose, onSave, info }: Props & { info: ServerInfoBody }) {
const owner = channel.role === 'owner';
const [name, setName] = useState(channel.name ?? '');
const [seats, setSeats] = useState(channel.seats);
const [membersCanUpload, setMembersCanUpload] = useState(channel.membersCanUpload ?? channel.canUpload ?? true);
const [ttlSeconds, setTtlSeconds] = useState(channel.ttlSeconds ?? 86400);
const [fileMaxDownloads, setFileMaxDownloads] = useState(channel.maxDownloads ?? 10);
const [joinPolicy, setJoinPolicy] = useState<'open' | 'invite'>(channel.joinPolicy ?? 'open');
const [allowGuests, setAllowGuests] = useState(channel.allowGuests ?? true);
const [membersCanUpload, setMembersCanUpload] = useState(channel.membersCanUpload!);
const [ttlSeconds, setTtlSeconds] = useState(channel.ttlSeconds!);
const [fileMaxDownloads, setFileMaxDownloads] = useState(channel.maxDownloads!);
const [joinPolicy, setJoinPolicy] = useState<'open' | 'invite'>(channel.joinPolicy!);
const [allowGuests, setAllowGuests] = useState(channel.allowGuests!);
const [joinPassword, setJoinPassword] = useState('');
const [clearPassword, setClearPassword] = useState(false);
const [saving, setSaving] = useState(false);
const [failed, setFailed] = useState(false);
const ttlOptions = TTL_OPTIONS.some((option) => option.value === ttlSeconds)
? TTL_OPTIONS
: [{ label: `当前设置 (${ttlSeconds} 秒)`, value: ttlSeconds }, ...TTL_OPTIONS];
const downloadOptions = [...new Set([...DOWNLOAD_OPTIONS, fileMaxDownloads])].sort((a, b) => a - b);
const ttlOptions = withCurrent(info.choices.ttl_seconds, ttlSeconds);
const downloadOptions = withCurrent(info.choices.file_max_downloads, fileMaxDownloads);
async function save() {
if (!owner || saving) return;
@@ -61,14 +63,14 @@ export function ChannelSettingsSheet({ channel, onClose, onSave }: Props) {
</label>
<label className={styles.field}>
<span>设备席位</span>
<input type="number" min={1} max={64} step={1} value={seats} disabled={!owner} onChange={(event) => setSeats(Number(event.target.value))} />
<span>成员人数上限</span>
<input type="number" min={1} max={info.limits.max_seats} step={1} value={seats} disabled={!owner} onChange={(event) => setSeats(Number(event.target.value))} />
</label>
<label className={styles.field}>
<span>内容保留时间</span>
<select value={ttlSeconds} disabled={!owner} onChange={(event) => setTtlSeconds(Number(event.target.value))}>
{ttlOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
{ttlOptions.map(value => <option key={value} value={value}>{ttlLabel(value)}</option>)}
</select>
</label>
@@ -99,7 +101,7 @@ export function ChannelSettingsSheet({ channel, onClose, onSave }: Props) {
{owner ? (
<>
{failed ? <p className={styles.error} role="alert">保存失败,请检查名称和设置后重试。</p> : null}
<Button variant="primary" disabled={saving || seats < 1 || seats > 64 || Boolean(joinPassword && joinPassword.length < 8)} onClick={() => void save()}>{saving ? '保存中…' : '保存设置'}</Button>
<Button variant="primary" disabled={saving || seats < 1 || seats > info.limits.max_seats || ttlSeconds < info.limits.min_ttl_seconds || ttlSeconds > info.limits.max_ttl_seconds || Boolean(joinPassword && joinPassword.length < 8)} onClick={() => void save()}>{saving ? '保存中…' : '保存设置'}</Button>
</>
) : <p className={styles.note}>只有所有者可以修改空间设置。</p>}
</div>
@@ -8,12 +8,7 @@ import styles from './ThreadSidebar.module.css';
export function ChannelSwitcher({ id, onCreate }: { id?: string; onCreate: () => void }) {
const channels = useChannels((s) => s.list);
const username = useAccount((s) => s.username);
const personal = useAccount((s) => s.personal);
return <section className={styles.panel} aria-label="我的空间导航">
<button className={styles.identity} type="button" onClick={() => navigate('/')} aria-label="账号与设备设置">
{personal?.avatar ? <img src={personal.avatar} className={styles.avatar} alt="" /> : <Icon name="desktop" size={20} />}
<span>{personal?.displayName || username || '访客'}</span>
</button>
<div className={styles.heading}><strong>空间</strong><Button variant="ghost" icon="edit" label="新建空间" disabled={!username} onClick={onCreate} /></div>
<nav className={styles.channels} aria-label="空间列表">{channels.map((channel) => <button key={channel.id} type="button"
className={`${styles.thread} ${channel.id === id ? styles.selected : ''}`} aria-current={channel.id === id ? 'page' : undefined}
+10 -7
View File
@@ -8,28 +8,31 @@ import { Icon } from '@/ui/Icon';
import { Button } from '@/ui/Button';
import styles from './ChatPane.module.css';
export function ChatMessage({ id, onOpenFile, onDelete }: { id: string; onOpenFile: (id: string) => void; onDelete: (id: string) => void }) {
export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: { id: string; compact?: boolean; onOpenFile: (id: string) => void; onDelete: (id: string) => void }) {
const item = useWorkspace((s) => s.byId[id]);
const owner = useWorkspace((s) => s.channel?.role === 'owner');
const ownDevice = useWorkspace((s) => s.devices.find((device) => device.self)?.name);
const username = useAccount((s) => s.username);
const download = useDownload();
const [downloading, setDownloading] = useState(false);
if (!item) return null;
const sender = item.author;
const name = sender?.displayName || sender?.username || item.deviceId || '未知用户';
const self = sender?.username !== undefined && sender.username === username;
const self = sender?.username !== undefined ? sender.username === username : Boolean(ownDevice && item.deviceId === ownDevice);
const ready = item.state.kind === 'complete';
async function save() {
if (!item || downloading) return;
setDownloading(true); try { await download(item); } finally { setDownloading(false); }
}
return <article className={`${styles.message} ${self ? styles.mine : ''}`} aria-label={item.kind === 'file' ? `${name} 上传了 ${item.name}` : `${name} 的消息`}>
{sender?.avatar ? <img className={styles.avatar} src={sender.avatar} alt={`${name} 的头像`} />
return <article className={`${styles.message} ${self ? styles.mine : ''} ${compact ? styles.compact : ''}`} title={compact ? `${name} · ${formatTime(item.ts)}` : undefined}
aria-label={item.kind === 'file' ? `${name} 上传了 ${item.name}` : `${name} 的消息`}>
{compact ? <span className={styles.avatarSpacer} aria-hidden="true" /> : sender?.avatar ? <img className={styles.avatar} src={sender.avatar} alt={`${name} 的头像`} />
: <span className={`${styles.avatar} ${styles.avatarFallback}`} role="img" aria-label={`${name} 的默认头像`}>{Array.from(name)[0] ?? '?'}</span>}
<div className={styles.bubble}>
<header><strong className={styles.senderName} title={sender ? `${sender.username} · ${item.deviceId}` : item.deviceId}>{name}</strong><time>{formatTime(item.ts)}</time>
{owner || self ? <Button variant="ghost" icon="trash" label={item.kind === 'file' ? `删除文件消息 ${item.name}` : '删除消息'} onClick={() => onDelete(id)} /> : null}
</header>
{!compact ? <header><strong className={styles.senderName} title={sender ? `${sender.username} · ${item.deviceId}` : item.deviceId}>{name}</strong><time>{formatTime(item.ts)}</time>
{owner || self ? <Button className={styles.deleteAction} variant="ghost" icon="trash" label={item.kind === 'file' ? `删除文件消息 ${item.name}` : '删除消息'} onClick={() => onDelete(id)} /> : null}
</header> : null}
{compact && (owner || self) ? <Button className={`${styles.deleteAction} ${styles.compactDelete}`} variant="ghost" icon="trash" label={item.kind === 'file' ? `删除文件消息 ${item.name}` : '删除消息'} onClick={() => onDelete(id)} /> : null}
{item.state.kind === 'tampered' ? <p className={styles.integrity}>内容校验失败,已拒绝显示。</p> : item.kind === 'text' ? <p className={styles.messageText}>{item.text}</p>
: <div className={styles.fileMessage}>
<button type="button" className={styles.fileCard} aria-label={`预览文件 ${item.name}`} disabled={!ready} onClick={() => onOpenFile(id)}>
+62 -16
View File
@@ -27,7 +27,7 @@
.messages {
flex: 1;
min-height: 0;
padding: var(--space-4);
padding: var(--space-3) var(--space-4);
overflow-y: auto;
overscroll-behavior: contain;
}
@@ -39,22 +39,34 @@
.message {
display: flex;
align-items: flex-start;
gap: var(--space-2);
margin-bottom: var(--space-4);
gap: var(--space-3);
width: 100%;
margin-bottom: var(--space-3);
}
.mine {
flex-direction: row-reverse;
}
.compact {
margin-top: calc(var(--space-2) * -1);
margin-bottom: var(--space-1);
}
.avatar {
width: 36px;
height: 36px;
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: var(--radius-avatar);
object-fit: cover;
}
.avatarSpacer {
width: 40px;
height: 1px;
flex: 0 0 40px;
}
.avatarFallback {
display: grid;
place-items: center;
@@ -64,16 +76,10 @@
}
.bubble {
position: relative;
min-width: 0;
max-width: min(85%, 560px);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--surface-2);
}
.mine .bubble {
background: var(--accent-soft);
max-width: min(82%, 760px);
padding: 0 0 var(--space-1);
}
.message header {
@@ -87,18 +93,58 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--accent);
font-size: 13px;
}
.message header time {
margin-inline-end: auto;
}
.deleteAction {
width: 26px;
min-width: 26px;
height: 26px;
min-height: 26px;
padding: 0;
opacity: 0;
transition: opacity 0.12s ease-out;
}
.message:hover .deleteAction,
.message:focus-within .deleteAction {
opacity: 1;
}
.compactDelete {
position: absolute;
inset-block-start: 0;
inset-inline-end: calc(var(--space-2) * -1);
}
.messageText {
margin: var(--space-1) 0 0;
overflow-wrap: anywhere;
white-space: pre-wrap;
font-size: 13px;
font-size: 14px;
}
.dateDivider {
display: flex;
align-items: center;
gap: var(--space-3);
margin: var(--space-4) 0 var(--space-3);
color: var(--text-dim);
font-size: 11px;
font-weight: 600;
}
.dateDivider::before,
.dateDivider::after {
height: 1px;
flex: 1;
background: var(--line);
content: '';
}
.fileMessage {
+22 -2
View File
@@ -1,5 +1,8 @@
import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
import { Fragment, useEffect, useRef, useState, type KeyboardEvent } from 'react';
import { Button } from '@/ui/Button';
import { useWorkspace } from '@/store/workspace';
import type { Item } from '@/core-bridge/types';
import { formatDate } from '@/lib/format';
import { ChatMessage } from './ChatMessage';
import styles from './ChatPane.module.css';
@@ -11,6 +14,7 @@ export function ChatPane({ threadName, ids, enabled, onSend, onDelete, onOpenFil
const [error, setError] = useState('');
const messages = useRef<HTMLDivElement>(null);
const fileInput = useRef<HTMLInputElement>(null);
const byId = useWorkspace((s) => s.byId);
const chronological = ids.slice().reverse();
useEffect(() => { messages.current?.scrollTo({ top: messages.current.scrollHeight }); }, [chronological.length]);
@@ -34,7 +38,23 @@ export function ChatPane({ threadName, ids, enabled, onSend, onDelete, onOpenFil
<div className={styles.head}><strong>{threadName}</strong><span>频道聊天</span></div>
<div className={styles.messages} ref={messages} aria-live="polite">
{chronological.length === 0 ? <p className={styles.empty}>在这里发送消息或文件,成员可以看到你的名称和头像。</p> : null}
{chronological.map((id) => <ChatMessage key={id} id={id} onOpenFile={onOpenFile} onDelete={onDelete} />)}
{(() => {
let previous: Item | undefined;
return chronological.map((id) => {
const item = byId[id];
if (!item) return null;
const dateKey = new Date(item.ts).toDateString();
const previousDate = previous ? new Date(previous.ts).toDateString() : undefined;
const showDate = dateKey !== previousDate;
const sameSender = previous && (previous.author?.username ?? previous.deviceId) === (item.author?.username ?? item.deviceId);
const grouped = Boolean(sameSender && !showDate && item.ts - previous!.ts < 5 * 60_000);
previous = item;
return <Fragment key={id}>
{showDate ? <div className={styles.dateDivider} role="separator"><span>{formatDate(item.ts)}</span></div> : null}
<ChatMessage id={id} compact={grouped} onOpenFile={onOpenFile} onDelete={onDelete} />
</Fragment>;
});
})()}
</div>
<form className={styles.composer} onSubmit={(event) => { event.preventDefault(); void submit(); }}>
{onFiles ? <><input ref={fileInput} type="file" multiple hidden onChange={(event) => { if (event.target.files?.length) onFiles([...event.target.files]); event.target.value = ''; }} />
+24 -64
View File
@@ -16,38 +16,31 @@
font-weight: 400;
}
.memberHeading {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.memberHeading .h {
margin-bottom: 0;
}
.list {
margin: 0;
padding: 0;
list-style: none;
}
.device {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-2) 0;
border-bottom: 1px solid var(--line);
}
.deviceRow,
.deviceIdentity,
.deviceActions,
.deviceEditor,
.deviceMetaRow {
.member {
display: flex;
align-items: center;
gap: var(--space-2);
gap: var(--space-3);
min-width: 0;
}
.deviceIdentity {
flex: 1;
}
.deviceMetaRow {
min-height: 32px;
padding-left: var(--space-6);
min-height: 48px;
padding: var(--space-2) 0;
border-bottom: 1px solid var(--line);
}
.name {
@@ -56,55 +49,22 @@
overflow-wrap: anywhere;
}
.current,
.deviceMeta {
.memberRole,
.memberCurrent,
.memberTime {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: 12px;
}
.current {
color: var(--ok);
}
.deviceMeta {
color: var(--text-dim);
white-space: nowrap;
}
.deviceEditor {
padding-left: var(--space-6);
margin-top: var(--space-1);
.memberCurrent {
color: var(--ok);
}
.deviceActions {
flex-wrap: wrap;
justify-content: flex-end;
margin-left: auto;
}
.deviceActions > button {
min-height: 36px;
padding-inline: var(--space-2);
}
.deviceInput {
flex: 1;
min-width: 0;
min-height: 40px;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--surface-2);
color: var(--text);
font: inherit;
}
.deviceError {
padding-left: var(--space-6);
color: var(--danger);
font-size: 12px;
.memberRole,
.memberTime {
color: var(--text-dim);
}
.link {
+28 -94
View File
@@ -1,106 +1,50 @@
import { useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { formatAgo } from '@/lib/format';
import { useWorkspace } from '@/store/workspace';
import { useAccount } from '@/store/account';
import { channelAccessApi, type ChannelMember } from '@/core-bridge/channelAccess';
import { Button } from '@/ui/Button';
import { CopyButton } from '@/ui/CopyButton';
import { Icon } from '@/ui/Icon';
import { QrCode } from '@/ui/QrCode';
import styles from './DevicePanel.module.css';
export function DevicePanel({ qrOpen = false }: { qrOpen?: boolean }) {
const devices = useWorkspace((s) => s.devices);
const deviceCount = useWorkspace((s) => s.devices.length);
const channel = useWorkspace((s) => s.channel);
const simulate = useWorkspace((s) => s.simulateConnection);
const renameDevice = useWorkspace((s) => s.renameDevice);
const releaseDevice = useWorkspace((s) => s.releaseDevice);
const username = useAccount((s) => s.username);
const [members, setMembers] = useState<ChannelMember[]>([]);
const [membersError, setMembersError] = useState('');
const [showOwner, setShowOwner] = useState(false);
const [showQr, setShowQr] = useState(qrOpen);
const [editingId, setEditingId] = useState<string>();
const [nameDraft, setNameDraft] = useState('');
const [confirmKickId, setConfirmKickId] = useState<string>();
const [busyId, setBusyId] = useState<string>();
const [deviceError, setDeviceError] = useState<{ id: string; message: string }>();
const owner = channel?.role === 'owner';
const seatsUsed = channel?.seatsUsed ?? '—';
const membersUsed = channel?.membersUsed ?? '—';
const accountAddress = channel ? `${location.origin}/c/${channel.id}` : '';
const refreshMembers = useCallback(async () => {
if (!channel?.id) return;
setMembersError('');
try { setMembers(await channelAccessApi.members(channel.id)); }
catch { setMembersError('成员清单读取失败'); }
}, [channel?.id]);
async function saveDeviceName(id: string) {
if (busyId) return;
setBusyId(id);
setDeviceError(undefined);
try {
await renameDevice(id, nameDraft);
setEditingId(undefined);
} catch {
setDeviceError({ id, message: '设备名称保存失败,请重试。' });
} finally {
setBusyId(undefined);
}
}
async function kickDevice(id: string) {
if (busyId) return;
setBusyId(id);
setDeviceError(undefined);
try {
await releaseDevice(id);
setConfirmKickId(undefined);
} catch {
setDeviceError({ id, message: '移除设备失败,请重试。' });
} finally {
setBusyId(undefined);
}
}
useEffect(() => {
void refreshMembers();
}, [refreshMembers, deviceCount]);
return (
<aside className={styles.panel}>
<section>
<h2 className={styles.h}>设备 <span className={styles.dim}>{seatsUsed}/{channel?.seats} 已加入</span></h2>
<div className={styles.memberHeading}>
<h2 className={styles.h}>成员 <span className={styles.dim}>{membersUsed}/{channel?.seats} 人</span></h2>
<Button variant="ghost" disabled={!channel} onClick={() => { void refreshMembers(); }}>刷新</Button>
</div>
<ul className={styles.list}>
{devices.map((d) => (
<li key={d.id} className={styles.device}>
<div className={styles.deviceRow}>
<div className={styles.deviceIdentity}>
<Icon name={d.kind} size={18} />
<span className={styles.name}>{d.name}</span>
</div>
{(owner || d.self) && editingId !== d.id ? (
<Button variant="ghost" icon="edit" label={`重命名 ${d.name}`} onClick={() => { setEditingId(d.id); setNameDraft(d.name); setDeviceError(undefined); }} />
) : null}
{owner && !d.self ? (
confirmKickId !== d.id ? <Button variant="ghost" icon="trash" label={`移除 ${d.name}`} onClick={() => { setConfirmKickId(d.id); setDeviceError(undefined); }} /> : null
) : null}
</div>
<div className={styles.deviceMetaRow}>
<span className={d.self ? styles.current : styles.deviceMeta}>
{d.self ? '当前设备' : d.joinedAt ? `加入于${formatAgo(d.joinedAt)}` : '已加入'}
</span>
{owner && confirmKickId === d.id ? (
<span className={styles.deviceActions}>
<Button variant="ghost" disabled={busyId === d.id} onClick={() => void kickDevice(d.id)}>{busyId === d.id ? '移除中' : '确认移除'}</Button>
<Button variant="ghost" disabled={busyId === d.id} onClick={() => setConfirmKickId(undefined)}>取消</Button>
</span>
) : null}
</div>
{editingId === d.id ? (
<div className={styles.deviceEditor}>
<input
className={styles.deviceInput}
type="text"
maxLength={48}
value={nameDraft}
onChange={(event) => setNameDraft(event.target.value)}
onKeyDown={(event) => { if (event.key === 'Enter') void saveDeviceName(d.id); }}
aria-label={`设备名称 ${d.name}`}
/>
<Button variant="ghost" disabled={busyId === d.id || !nameDraft.trim()} onClick={() => void saveDeviceName(d.id)}>{busyId === d.id ? '保存中' : '保存'}</Button>
<Button variant="ghost" onClick={() => setEditingId(undefined)}>取消</Button>
</div>
) : null}
{deviceError?.id === d.id ? <span className={styles.deviceError} role="alert">{deviceError.message}</span> : null}
</li>
))}
{members.map((member) => <li key={member.username} className={styles.member}>
<span className={styles.name}>{member.username}</span>
<span className={member.username === username ? styles.memberCurrent : styles.memberRole}>{member.username === username ? '你' : member.role === 'owner' ? '空间主' : '成员'}</span>
<time className={styles.memberTime}>{formatAgo(member.joined_at)}</time>
</li>)}
</ul>
{!members.length && !membersError ? <p className={styles.note}>暂无账号成员。</p> : null}
{membersError ? <p className={styles.note} role="alert">{membersError}</p> : null}
</section>
<section>
@@ -139,16 +83,6 @@ export function DevicePanel({ qrOpen = false }: { qrOpen?: boolean }) {
</> : null}
</section>
{import.meta.env.DEV ? (
<section>
<h2 className={styles.h}>演示</h2>
<div className={styles.row}>
<Button variant="ghost" onClick={() => simulate('online')}>在线</Button>
<Button variant="ghost" onClick={() => simulate('reconnecting')}>重连中</Button>
<Button variant="ghost" onClick={() => simulate('offline')}>离线</Button>
</div>
</section>
) : null}
</aside>
);
}
+3 -2
View File
@@ -21,6 +21,7 @@ interface Props {
selectedId?: string;
checked: ReadonlySet<string>;
enabled: boolean;
structureEnabled: boolean;
onFolder: (id?: string) => void;
onSelect: (id?: string) => void;
onOpen: (id: string) => void;
@@ -71,7 +72,7 @@ export function FileExplorer(props: Props) {
<div className={styles.toolbar}>
<input type="file" multiple hidden ref={input} onChange={(event) => { if (event.target.files?.length) props.onFiles([...event.target.files], props.folderId); event.target.value = ''; }} />
<Button icon="share" disabled={!props.enabled} onClick={() => input.current?.click()}>上传文件</Button>
<Button variant="ghost" icon="folder" disabled={!props.enabled || busy} onClick={() => beginFolder()}>新建文件夹</Button>
<Button variant="ghost" icon="folder" disabled={!props.structureEnabled || busy} onClick={() => beginFolder()}>新建文件夹</Button>
<Button variant="ghost" icon="eye" disabled={!props.selectedId} onClick={() => { if (props.selectedId) props.onOpen(props.selectedId); }}>预览</Button>
</div>
<nav className={styles.breadcrumb} aria-label="文件夹路径"><Button variant="ghost" icon="chevronLeft" label="返回上级文件夹" disabled={!props.folderId} onClick={() => navigateFolder(path[path.length - 1]?.parentId)} />
@@ -84,7 +85,7 @@ export function FileExplorer(props: Props) {
onClick={(event) => { if (!(event.target as HTMLElement).closest('tr,button,input')) { props.onSelection(new Set()); props.onSelect(undefined); setSelectedFolder(undefined); event.currentTarget.focus(); } }} tabIndex={-1}>
<table className={styles.table}><thead><tr><th aria-label="选择" /><th><button type="button" onClick={() => setSort('name')}>名称{sort === 'name' ? ' ↑' : ''}</button></th>
<th><button type="button" onClick={() => setSort('size')}>大小{sort === 'size' ? ' ↓' : ''}</button></th><th className={styles.date}><button type="button" onClick={() => setSort('date')}>时间{sort === 'date' ? ' ↓' : ''}</button></th><th aria-label="操作" /></tr></thead>
<tbody>{children.map((folder) => <ExplorerFolderRow key={folder.id} folder={folder} selected={folder.id === selectedFolder} enabled={props.enabled}
<tbody>{children.map((folder) => <ExplorerFolderRow key={folder.id} folder={folder} selected={folder.id === selectedFolder} enabled={props.structureEnabled}
dropping={interaction.dropFolder === folder.id} drop={interaction.dropHandlers(folder.id)} onOpen={() => navigateFolder(folder.id)}
onSelect={() => { interaction.dismiss(); setSelectedFolder(folder.id); props.onSelection(new Set()); props.onSelect(undefined); }} onRename={() => beginFolder(folder.id)}
onMenu={(x, y) => { setSelectedFolder(folder.id); props.onSelection(new Set()); props.onSelect(undefined); interaction.showFolderMenu(folder.id, folder.name, x, y); }} />)}
+2 -2
View File
@@ -7,10 +7,10 @@ export function FilePaneResize({ width, onWidth }: { width?: number; onWidth: (w
const parent = element.parentElement!;
const rect = parent.getBoundingClientRect();
const navigation = parent.querySelector<HTMLElement>('[data-space-nav]');
return { right: rect.right, maximum: Math.max(320, rect.width - (navigation?.offsetWidth ?? 232) - 266) };
return { right: rect.right, maximum: Math.max(320, rect.width - (navigation?.offsetWidth ?? 300) - 266) };
}
return <div className={styles.resize} role="separator" aria-label="调整文件区宽度" aria-orientation="vertical" tabIndex={0}
aria-valuemin={320} aria-valuemax={Math.max(320, innerWidth - (innerWidth <= 1100 ? 200 : 232) - 266)} aria-valuenow={width ?? Math.max(320, Math.round(innerWidth * 0.36))}
aria-valuemin={320} aria-valuemax={Math.max(320, innerWidth - (innerWidth <= 1100 ? 240 : 300) - 266)} aria-valuenow={width ?? Math.max(320, Math.round(innerWidth * 0.36))}
onPointerDown={(event) => { event.preventDefault(); drag.current = bounds(event.currentTarget); event.currentTarget.setPointerCapture(event.pointerId); }}
onPointerMove={(event) => { if (drag.current) onWidth(Math.min(drag.current.maximum, Math.max(320, drag.current.right - event.clientX))); }}
onPointerUp={(event) => { drag.current = undefined; if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }}
+5 -5
View File
@@ -17,7 +17,7 @@ export function SideRail({ open, qrOpen, onToggle }: Props) {
return (
<aside className={styles.open}>
<div className={styles.head}>
<span className={styles.heading}>设备与分享</span>
<span className={styles.heading}>成员与分享</span>
<Button variant="ghost" icon="chevronRight" label="收起" onClick={onToggle} />
</div>
<DevicePanel qrOpen={qrOpen} />
@@ -27,10 +27,10 @@ export function SideRail({ open, qrOpen, onToggle }: Props) {
return (
<aside className={styles.rail}>
<Button variant="ghost" icon="chevronLeft" label="展开设备与分享" onClick={onToggle} />
<span className={styles.count} title="已加入席位 / 总席位">
{channel?.seatsUsed ?? '—'}/{channel?.seats ?? '—'}
<span className={styles.countLabel}>席位</span>
<Button variant="ghost" icon="chevronLeft" label="展开成员与分享" onClick={onToggle} />
<span className={styles.count} title="已加入成员 / 最大成员人数">
{channel?.membersUsed ?? '—'}/{channel?.seats ?? '—'}
<span className={styles.countLabel}>人</span>
</span>
<Button variant="ghost" icon="share" label="分享" onClick={onToggle} />
</aside>
+106 -36
View File
@@ -1,23 +1,24 @@
.panel {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3);
gap: var(--space-3);
padding: var(--space-4) var(--space-3);
border-bottom: 1px solid var(--line);
}
.channels {
display: flex;
flex-direction: column;
gap: var(--space-1);
gap: var(--space-2);
max-height: 28vh;
padding-inline: var(--space-1);
overflow-y: auto;
}
.group {
min-width: 0;
margin-inline-start: var(--space-2);
padding-top: var(--space-2);
border-top: 1px solid var(--line);
}
.groupHeading,
@@ -31,14 +32,16 @@
.groupToggle {
flex: 1;
min-width: 0;
min-height: 34px;
padding: var(--space-1) 0;
min-height: 36px;
padding: 0 var(--space-2);
border-radius: var(--radius-md);
border: 0;
background: transparent;
color: var(--text-dim);
text-align: left;
font: inherit;
font-size: 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
@@ -49,18 +52,40 @@
white-space: nowrap;
}
.groupToggle:hover {
background: var(--accent-soft);
color: var(--text);
}
.groupToggle svg,
.groupActions {
flex-shrink: 0;
}
.groupToggle small {
margin-inline-start: auto;
.categoryActions {
display: flex;
flex-shrink: 0;
align-items: center;
gap: var(--space-1);
opacity: 1;
}
.groupLabel {
color: var(--text-dim);
font-size: 12px;
.groupMore {
opacity: 0;
transition: opacity 0.12s ease-out;
}
.groupHeading:hover .groupMore,
.groupHeading:focus-within .groupMore {
opacity: 1;
}
.categoryActions button {
width: 26px;
min-width: 26px;
height: 26px;
min-height: 26px;
padding: 0;
}
.channelEditor {
@@ -71,6 +96,16 @@
border-top: 1px solid var(--line);
}
.modalEditor {
padding: 0;
border: 0;
}
.modalActions {
justify-content: flex-end;
margin-top: var(--space-2);
}
.channelEditor label {
display: flex;
flex-direction: column;
@@ -91,27 +126,6 @@
font: inherit;
}
.identity {
display: flex;
align-items: center;
gap: var(--space-2);
min-height: var(--bar-h);
padding: 0;
border: 0;
background: transparent;
color: var(--text);
font: inherit;
text-align: left;
cursor: pointer;
}
.avatar {
width: var(--bar-h);
height: var(--bar-h);
border-radius: var(--radius-md);
object-fit: cover;
}
.heading,
.folderHeading,
.threadRow,
@@ -124,7 +138,12 @@
.heading,
.folderHeading {
justify-content: space-between;
font-size: 13px;
font-size: 14px;
}
.heading {
min-height: 36px;
padding-inline: var(--space-2);
}
.folderHeading {
@@ -142,9 +161,45 @@
list-style: none;
}
.threads {
padding-inline-start: var(--space-4);
}
.nestedThreads {
margin-inline-start: 0;
padding-inline-start: var(--space-4);
border-inline-start: 1px solid var(--line);
}
.threadRow,
.folderRow {
min-width: 0;
border-radius: var(--radius-sm);
}
.threadRow:hover,
.activeRow {
background: transparent;
}
.rowActions {
width: 26px;
min-width: 26px;
height: 26px;
min-height: 26px;
margin-inline-end: var(--space-1);
padding: 0;
opacity: 0;
transition: opacity 0.12s ease-out;
}
.activeRow .rowActions {
opacity: 1;
}
.threadRow:hover .rowActions,
.threadRow:focus-within .rowActions {
opacity: 1;
}
.folderRow {
@@ -158,8 +213,8 @@
align-items: center;
gap: var(--space-2);
min-width: 0;
min-height: 34px;
padding: 0 var(--space-2);
min-height: 40px;
padding: 0 var(--space-3);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
@@ -192,6 +247,12 @@
background: var(--accent-soft);
}
.thread.selected {
background: var(--busy-soft);
color: var(--busy);
font-weight: 600;
}
.empty,
.hint,
.error {
@@ -201,6 +262,10 @@
line-height: 1.5;
}
.group > .empty {
padding: var(--space-1) 0 var(--space-1) var(--space-6);
}
.error {
color: var(--danger);
}
@@ -249,4 +314,9 @@
max-height: 120px;
overflow-y: auto;
}
.rowActions,
.groupMore {
opacity: 1;
}
}
+51 -26
View File
@@ -2,6 +2,7 @@ import { useState, type FormEvent } from 'react';
import type { ChannelGroupInfo, ThreadInfo } from '@/core-bridge/types';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { Sheet } from '@/ui/Sheet';
import styles from './ThreadSidebar.module.css';
interface Props {
@@ -9,6 +10,8 @@ interface Props {
groups: ChannelGroupInfo[];
selectedThreadId?: string;
enabled: boolean;
owner: boolean;
onHistory: (id: string, chatHistory: boolean, filesHistory: boolean) => Promise<void>;
onSelectThread: (id: string) => void;
onCreateThread: (name: string, groupId?: string) => Promise<string>;
onRenameThread: (id: string, name: string) => Promise<void>;
@@ -17,17 +20,23 @@ interface Props {
onGroupThread: (id: string, groupId?: string) => Promise<void>;
}
type Editor = { kind: 'channel' | 'group'; id?: string; groupId?: string };
const UNGROUPED = '__ungrouped__';
export function ThreadSidebar(props: Props) {
const [edit, setEdit] = useState<Editor>();
const [name, setName] = useState('');
const [groupId, setGroupId] = useState('');
const [chatHistory, setChatHistory] = useState(true);
const [filesHistory, setFilesHistory] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set());
const knownGroups = new Set(props.groups.map((group) => group.id));
const ungrouped = props.threads.filter((thread) => !thread.groupId || !knownGroups.has(thread.groupId));
function begin(target: Editor, initial = '') { setEdit(target); setName(initial); setGroupId(target.groupId ?? ''); setError(''); }
function begin(target: Editor, initial = '') {
setEdit(target); setName(initial); setGroupId(target.groupId ?? ''); setError('');
const channel = props.threads.find(t => t.id === target.id); setChatHistory(channel?.chatHistory ?? true); setFilesHistory(channel?.filesHistory ?? true);
}
function toggle(id: string) { setCollapsed((previous) => { const next = new Set(previous); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }
function select(id: string) { props.onSelectThread(id); setEdit(undefined); }
async function submit(event: FormEvent) {
@@ -40,6 +49,7 @@ export function ThreadSidebar(props: Props) {
const original = props.threads.find((thread) => thread.id === edit.id);
if (original?.name !== name.trim()) await props.onRenameThread(edit.id, name);
if ((original?.groupId ?? '') !== groupId) await props.onGroupThread(edit.id, groupId || undefined);
if (props.owner && (original?.chatHistory !== chatHistory || original?.filesHistory !== filesHistory)) await props.onHistory(edit.id, chatHistory, filesHistory);
} else props.onSelectThread(await props.onCreateThread(name, groupId || undefined));
if (groupId) setCollapsed((previous) => { const next = new Set(previous); next.delete(groupId); return next; });
setEdit(undefined);
@@ -47,46 +57,61 @@ export function ThreadSidebar(props: Props) {
finally { setBusy(false); }
}
return <section className={styles.panel} aria-label="空间频道导航">
<div className={styles.heading}><strong>频道</strong><span className={styles.groupActions}>
<Button variant="ghost" icon="folder" label="新建频道分组" disabled={!props.enabled || busy} onClick={() => begin({ kind: 'group' })} />
<Button variant="ghost" icon="edit" label="新建频道" disabled={!props.enabled || busy} onClick={() => begin({ kind: 'channel' })} />
</span></div>
{ungrouped.length ? <div className={styles.groupLabel}>未分组</div> : null}
<ChannelRows threads={ungrouped} selectedId={props.selectedThreadId} enabled={props.enabled} busy={busy} onSelect={select}
onEdit={(thread) => begin({ kind: 'channel', id: thread.id, groupId: thread.groupId }, thread.name)} />
<div className={styles.heading}><strong>频道</strong><Button variant="ghost" icon="folder" label="新建频道分组" disabled={!props.enabled || busy} onClick={() => begin({ kind: 'group' })} /></div>
{(ungrouped.length > 0 || props.groups.length === 0) ? <section className={styles.group} aria-label="未分组频道">
<div className={styles.groupHeading}>
<button className={styles.groupToggle} type="button" aria-label="未分组频道" aria-expanded={!collapsed.has(UNGROUPED)} onClick={() => toggle(UNGROUPED)}>
<Icon name={collapsed.has(UNGROUPED) ? 'chevronRight' : 'chevronDown'} size={14} /><span>未分组</span>
</button>
{props.enabled ? <span className={styles.categoryActions}>
<Button variant="ghost" icon="plus" label="在未分组新建频道" disabled={busy} onClick={() => begin({ kind: 'channel' })} />
</span> : null}
</div>
{!collapsed.has(UNGROUPED) ? <ChannelRows threads={ungrouped} selectedId={props.selectedThreadId} enabled={props.enabled} busy={busy} onSelect={select}
onEdit={(thread) => begin({ kind: 'channel', id: thread.id, groupId: thread.groupId }, thread.name)} /> : null}
</section> : null}
{props.groups.map((group) => {
const threads = props.threads.filter((thread) => thread.groupId === group.id);
return <section className={styles.group} aria-label={`频道分组 ${group.name}`} key={group.id}>
<div className={styles.groupHeading}><button className={styles.groupToggle} type="button" aria-label={`分组 ${group.name}`} aria-expanded={!collapsed.has(group.id)} onClick={() => toggle(group.id)}>
<Icon name={collapsed.has(group.id) ? 'chevronRight' : 'chevronDown'} size={14} /><span>{group.name}</span><small>{threads.length}</small>
</button>{props.enabled ? <span className={styles.groupActions}>
<Button variant="ghost" icon="edit" label={`重命名分组 ${group.name}`} disabled={busy} onClick={() => begin({ kind: 'group', id: group.id }, group.name)} />
<Button variant="ghost" icon="file" label={`在 ${group.name} 新建频道`} disabled={busy} onClick={() => begin({ kind: 'channel', groupId: group.id })} />
<Icon name={collapsed.has(group.id) ? 'chevronRight' : 'chevronDown'} size={14} /><span>{group.name}</span>
</button>{props.enabled ? <span className={styles.categoryActions}>
<Button variant="ghost" icon="plus" label={`在 ${group.name} 新建频道`} disabled={busy} onClick={() => begin({ kind: 'channel', groupId: group.id })} />
<Button className={styles.groupMore} variant="ghost" icon="more" label={`分组设置 ${group.name}`} disabled={busy} onClick={() => begin({ kind: 'group', id: group.id }, group.name)} />
</span> : null}</div>
{!collapsed.has(group.id) ? <ChannelRows threads={threads} selectedId={props.selectedThreadId} enabled={props.enabled} busy={busy} onSelect={select}
{!collapsed.has(group.id) ? <ChannelRows threads={threads} selectedId={props.selectedThreadId} enabled={props.enabled} busy={busy} nested onSelect={select}
onEdit={(thread) => begin({ kind: 'channel', id: thread.id, groupId: thread.groupId }, thread.name)} /> : null}
{!collapsed.has(group.id) && !threads.length ? <p className={styles.empty}>此分组还没有频道。</p> : null}
</section>;
})}
{edit ? <form className={styles.channelEditor} onSubmit={(event) => { void submit(event); }}>
<label>{edit.kind === 'group' ? '分组名称' : '频道名称'}<input autoFocus disabled={busy} aria-label={edit.kind === 'group' ? '分组名称' : '频道名称'} maxLength={80} value={name} onChange={(event) => setName(event.target.value)} /></label>
{edit.kind === 'channel' ? <label>所属分组<select aria-label="所属分组" disabled={busy} value={groupId} onChange={(event) => setGroupId(event.target.value)}>
<option value="">未分组</option>{props.groups.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}
</select></label> : null}
<div className={styles.groupActions}><Button type="submit" variant="primary" disabled={busy || !name.trim()}>{edit.id ? '保存' : '创建'}</Button>
<Button variant="ghost" onClick={() => setEdit(undefined)}>取消</Button></div>
</form> : null}
{edit ? <Sheet title={`${edit.id ? '设置' : '新建'}${edit.kind === 'group' ? '频道分组' : '频道'}`} onClose={() => { if (!busy) setEdit(undefined); }}>
<form className={`${styles.channelEditor} ${styles.modalEditor}`} onSubmit={(event) => { void submit(event); }}>
<label>{edit.kind === 'group' ? '分组名称' : '频道名称'}<input autoFocus disabled={busy} aria-label={edit.kind === 'group' ? '分组名称' : '频道名称'} maxLength={80} value={name} placeholder={edit.kind === 'channel' ? '例如:项目讨论' : '例如:项目组'} onChange={(event) => setName(event.target.value)} /></label>
{edit.kind === 'channel' ? <label>所属分组<select aria-label="所属分组" disabled={busy} value={groupId} onChange={(event) => setGroupId(event.target.value)}>
<option value="">未分组</option>{props.groups.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}
</select></label> : null}
{edit.kind === 'channel' && edit.id && props.owner ? <>
<label><input type="checkbox" checked={chatHistory} onChange={event => setChatHistory(event.target.checked)} />新成员可读历史聊天</label>
<label><input type="checkbox" checked={filesHistory} onChange={event => setFilesHistory(event.target.checked)} />新成员可读历史文件</label>
</> : null}
{error ? <p className={styles.error} role="alert">{error}</p> : null}
<div className={`${styles.groupActions} ${styles.modalActions}`}>
<Button variant="ghost" disabled={busy} onClick={() => setEdit(undefined)}>取消</Button>
<Button type="submit" variant="primary" disabled={busy || !name.trim()}>{busy ? '保存中…' : edit.id ? '保存' : edit.kind === 'channel' ? '创建频道' : '创建分组'}</Button>
</div>
</form>
</Sheet> : null}
{!props.threads.length && !props.groups.length ? <p className={styles.empty}>创建一个频道后开始聊天和上传文件。</p> : null}
{error ? <p className={styles.error} role="alert">{error}</p> : null}
{!edit && error ? <p className={styles.error} role="alert">{error}</p> : null}
</section>;
}
function ChannelRows({ threads, selectedId, enabled, busy, onSelect, onEdit }: {
threads: ThreadInfo[]; selectedId?: string; enabled: boolean; busy: boolean; onSelect: (id: string) => void; onEdit: (thread: ThreadInfo) => void;
function ChannelRows({ threads, selectedId, enabled, busy, nested = false, onSelect, onEdit }: {
threads: ThreadInfo[]; selectedId?: string; enabled: boolean; busy: boolean; nested?: boolean; onSelect: (id: string) => void; onEdit: (thread: ThreadInfo) => void;
}) {
return <ul className={styles.threads}>{threads.map((thread) => <li className={styles.threadRow} key={thread.id}>
return <ul className={`${styles.threads} ${nested ? styles.nestedThreads : ''}`}>{threads.map((thread) => <li className={`${styles.threadRow} ${thread.id === selectedId ? styles.activeRow : ''}`} key={thread.id}>
<button className={`${styles.thread} ${thread.id === selectedId ? styles.selected : ''}`} type="button" disabled={busy} aria-current={thread.id === selectedId ? 'page' : undefined}
onClick={() => onSelect(thread.id)}><span aria-hidden="true">#</span><span>{thread.name}</span></button>
{enabled && thread.id === selectedId ? <Button variant="ghost" icon="more" disabled={busy} label={`频道设置 ${thread.name}`} onClick={() => onEdit(thread)} /> : null}
{enabled ? <Button className={styles.rowActions} variant="ghost" icon="more" disabled={busy} label={`频道设置 ${thread.name}`} onClick={() => onEdit(thread)} /> : null}
</li>)}</ul>;
}
+1 -1
View File
@@ -118,7 +118,7 @@
color: var(--danger);
}
/* 窄屏(如 iPhone SE、小屏 Android):收起席位数字与徽标文字 */
/* 窄屏(如 iPhone SE、小屏 Android):收起成员人数与徽标文字 */
@media (width < 360px) {
.count {
display: none;
+5 -5
View File
@@ -8,7 +8,7 @@ const CONNECTION_TEXT = { online: '在线', reconnecting: '重连中', offline:
interface Props {
onBack: () => void;
/** 手机上点开设备与分享面板;桌面端面板在右侧栏,不传 */
/** 手机上点开成员与分享面板;桌面端面板在右侧栏,不传 */
onOpenPanel?: () => void;
onOpenSettings: () => void;
searchOpen: boolean;
@@ -20,7 +20,7 @@ interface Props {
export function TopBar({ onBack, onOpenPanel, onOpenSettings, searchOpen, filtering, onToggleSearch }: Props) {
const channel = useWorkspace((s) => s.channel);
const connection = useWorkspace((s) => s.connection);
const seatCount = `${channel?.seatsUsed ?? '—'}/${channel?.seats ?? '—'}`;
const memberCount = `${channel?.membersUsed ?? '—'}/${channel?.seats ?? '—'}`;
const raw = channel?.mode === 'raw';
return (
@@ -39,8 +39,8 @@ export function TopBar({ onBack, onOpenPanel, onOpenSettings, searchOpen, filter
)}
</div>
<button type="button" className={styles.presence} onClick={onOpenPanel} disabled={!onOpenPanel}>
<span className={styles.count} title="已加入席位 / 总席位" aria-label={`已加入 ${channel?.seatsUsed ?? '未知'} 个设备,共 ${channel?.seats ?? '未知'} 个席位`}>
{seatCount} <span className={styles.countLabel}>席位</span>
<span className={styles.count} title="已加入成员 / 最大成员人数" aria-label={`已加入 ${channel?.membersUsed ?? '未知'} 人,最多 ${channel?.seats ?? '未知'} 人`}>
{memberCount} <span className={styles.countLabel}>人</span>
</span>
</button>
<span className={`${styles.conn} ${styles[connection]}`} role="status">
@@ -56,7 +56,7 @@ export function TopBar({ onBack, onOpenPanel, onOpenSettings, searchOpen, filter
/>
<ThemeMenu />
<Button variant="ghost" icon="settings" label="空间设置" onClick={onOpenSettings} />
{onOpenPanel ? <Button variant="ghost" icon="share" label="设备与分享" onClick={onOpenPanel} /> : null}
{onOpenPanel ? <Button variant="ghost" icon="share" label="成员与分享" onClick={onOpenPanel} /> : null}
</header>
);
}
@@ -4,7 +4,8 @@ import { Button } from '@/ui/Button';
import { Sheet } from '@/ui/Sheet';
import styles from './UploadOptionsSheet.module.css';
const LIMITS = [0, 1, 3, 5, 10, 20];
import { useServerSettings } from '@/store/server';
import { withCurrent } from '@/lib/settingOptions';
interface Props {
encrypted?: boolean;
@@ -15,6 +16,7 @@ interface Props {
}
export function UploadOptionsSheet({ files, defaultMaxDownloads, onClose, onUpload, encrypted = true }: Props) {
const { info } = useServerSettings();
const [directPreview, setDirectPreview] = useState(true);
const [maxDownloads, setMaxDownloads] = useState(defaultMaxDownloads);
@@ -22,7 +24,7 @@ export function UploadOptionsSheet({ files, defaultMaxDownloads, onClose, onUplo
<Sheet
title={`上传 ${files.length} 个文件`}
onClose={onClose}
actions={<Button variant="primary" onClick={() => onUpload({ directPreview, maxDownloads })}>继续上传</Button>}
actions={<Button variant="primary" disabled={!info} onClick={() => onUpload({ directPreview, maxDownloads })}>继续上传</Button>}
>
<div className={styles.form}>
<label className={styles.check}>
@@ -36,7 +38,7 @@ export function UploadOptionsSheet({ files, defaultMaxDownloads, onClose, onUplo
<label className={styles.field}>
<span>每个文件最多下载次数</span>
<select value={maxDownloads} onChange={(e) => setMaxDownloads(Number(e.target.value))}>
{Array.from(new Set([...LIMITS, defaultMaxDownloads])).sort((a, b) => a - b).map((n) => (
{withCurrent(info?.choices.file_max_downloads ?? [], defaultMaxDownloads).map((n) => (
<option key={n} value={n}>{n === 0 ? '不限' : `${n} 次`}</option>
))}
</select>
@@ -1,5 +1,5 @@
.desktop {
--nav-width: 232px;
--nav-width: 320px;
display: grid;
grid-template-columns: var(--nav-width) minmax(260px, 1fr) 6px clamp(320px, var(--files-width, 36%), calc(100% - var(--nav-width) - 266px));
@@ -29,6 +29,12 @@
background: var(--bg);
}
@media (width >= 900px) {
.navigation {
padding-bottom: 80px;
}
}
.files {
border-left: 1px solid var(--line);
}
@@ -84,7 +90,7 @@
color: var(--text);
}
.legacy {
.pendingCatalog {
padding: var(--space-2) var(--space-3);
border-top: 1px solid var(--line);
color: var(--text-dim);
@@ -119,6 +125,6 @@
@media (width >= 900px) and (width <= 1100px) {
.desktop {
--nav-width: 200px;
--nav-width: 272px;
}
}
+6 -5
View File
@@ -31,6 +31,7 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
const channel = useWorkspace((s) => s.channel);
const threads = useWorkspace((s) => s.threads);
const groups = useWorkspace((s) => s.groups);
const setChannelHistory = useWorkspace(s => s.setChannelHistory);
const createGroup = useWorkspace((s) => s.createGroup);
const renameGroup = useWorkspace((s) => s.renameGroup);
const groupThread = useWorkspace((s) => s.groupThread);
@@ -64,7 +65,7 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
const activeThreadId = activeThread?.id;
const activeFolders = folders.filter((folder) => folder.threadId === activeThreadId);
const activeFolderId = activeFolders.some((folder) => folder.id === folderId) ? folderId : undefined;
const enabled = Boolean(activeThreadId && channel?.canUpload !== false);
const enabled = Boolean(activeThreadId && channel?.canUpload);
const files = useWorkspace(useShallow((s) => s.order.filter((id) => {
const item = s.byId[id]; return item?.kind === 'file' && !item.metadataHidden && item.threadId === activeThreadId
&& (item.folderId ?? undefined) === activeFolderId && matchesFilter(item, filter);
@@ -101,19 +102,19 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
function drop(event: DragEvent) { event.preventDefault(); setDragging(false); requestFiles([...event.dataTransfer.files]); }
function deleteItem(id: string) { void deleteFiles([id]).catch((cause: unknown) => useWorkspace.setState({ error: cause instanceof Error ? cause.message : '删除失败' })); }
const navigation = <><ChannelSwitcher id={channel?.id} onCreate={onCreateChannel} />
<ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} enabled={channel?.canUpload !== false} onSelectThread={selectThread} onCreateThread={createThread} onRenameThread={renameThread}
<ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} enabled={Boolean(channel?.canManageStructure)} owner={channel?.role === 'owner'} onHistory={setChannelHistory} onSelectThread={selectThread} onCreateThread={createThread} onRenameThread={renameThread}
onCreateGroup={createGroup} onRenameGroup={renameGroup} onGroupThread={groupThread} />
{desktop ? <Notepad /> : null}<Button variant="ghost" icon="scan" onClick={() => setScanOpen(true)}>加入空间</Button></>;
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}
selectedId={files.includes(selectedId ?? '') ? selectedId : undefined} checked={checked} enabled={enabled} onFolder={selectFolder} onSelect={setSelectedId} onOpen={setPreviewId} onToggle={toggle} onSelection={setChecked}
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('请先选择频道'))}
onRenameFolder={(id, name) => activeThreadId ? renameFolder(id, activeThreadId, name) : Promise.reject(new Error('请先选择频道'))}
onDownloadItems={downloadItems} onDeleteItems={deleteFiles} onMoveItems={async (ids, target) => { if (activeThreadId) { await moveItems(ids, activeThreadId, target); setChecked(new Set()); setSelectedId(undefined); } }} />
{restricted.length ? <div className={styles.legacy}><span>尚未同步目录的旧文件</span>{restricted.map((id) => <Button variant="ghost" key={id} onClick={() => setPreviewId(id)}>查看受限文件</Button>)}</div> : null}
{restricted.length ? <div className={styles.pendingCatalog}><span>等待同步文件目录</span>{restricted.map((id) => <Button variant="ghost" key={id} onClick={() => setPreviewId(id)}>查看受限文件</Button>)}</div> : null}
</>;
return <div className={desktop ? styles.desktop : styles.mobile} style={fileWidth ? { '--files-width': `${fileWidth}px` } as CSSProperties : undefined}
onDropCapture={() => setDragging(false)} onDragEndCapture={() => setDragging(false)}
@@ -124,7 +125,7 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
{error ? <div className={styles.error} role="alert">{error}</div> : null}
{panelOpen ? <Sheet title="设备与账号邀请" onClose={() => setPanelOpen(false)}><DevicePanel /></Sheet> : null}
{settingsOpen && channel ? <ChannelSettingsSheet channel={channel} onClose={() => setSettingsOpen(false)} onSave={updateSettings} /> : null}
{upload ? <UploadOptionsSheet files={upload.files} encrypted={channel?.mode === 'enc'} defaultMaxDownloads={channel?.maxDownloads ?? 10} onClose={() => setUpload(undefined)} onUpload={confirmUpload} /> : null}
{upload && channel?.maxDownloads !== undefined ? <UploadOptionsSheet files={upload.files} encrypted={channel?.mode === 'enc'} defaultMaxDownloads={channel.maxDownloads} onClose={() => setUpload(undefined)} onUpload={confirmUpload} /> : null}
<Suspense fallback={null}>{previewId ? <PreviewSheet id={previewId} onClose={() => setPreviewId(undefined)} onDelete={deleteItem} /> : null}
{scanOpen ? <ScanSheet onClose={() => setScanOpen(false)} onChannel={(resource) => navigate(channelPath(resource.id))} onShare={(resource) => navigate(sharePath(resource.id))} /> : null}</Suspense>
{dragging ? <div className={styles.drop}>松开以上传到当前频道</div> : null}
@@ -5,6 +5,7 @@ import type { MenuAction } from '@/ui/ContextMenu';
interface Options {
scope: string; threadId?: string; ids: string[]; checked: ReadonlySet<string>; selectedId?: string; folderId?: string; enabled: boolean;
structureEnabled: boolean;
onSelect: (id?: string) => void; onSelection: (ids: ReadonlySet<string>) => void; onOpen: (id: string) => void;
onDownload: (ids: string[]) => Promise<void>; onDelete: (ids: string[]) => Promise<void>; onMove: (ids: string[], folder?: string) => Promise<void>;
onFiles: (files: File[], folder?: string) => void; onFolder: (id?: string) => void;
@@ -87,9 +88,9 @@ export function useExplorerInteractions(options: Options) {
{ label: '删除', icon: 'trash', disabled: !options.enabled || unavailable, action: () => run(options.onDelete(menu.targets)) });
} else if (menu?.kind === 'folder' && menu.id) {
actions.push({ label: '打开文件夹', icon: 'folder', action: () => { dismiss(); options.onFolder(menu.id); } },
{ label: '重命名', icon: 'edit', disabled: !options.enabled, action: () => options.onRenameFolder(menu.id!) });
{ label: '重命名', icon: 'edit', disabled: !options.structureEnabled, action: () => options.onRenameFolder(menu.id!) });
} else if (menu) actions.push({ label: '上传文件', icon: 'share', disabled: !options.enabled, action: options.onUpload },
{ label: '新建文件夹', icon: 'folder', disabled: !options.enabled, action: options.onCreateFolder },
{ label: '新建文件夹', icon: 'folder', disabled: !options.structureEnabled, action: options.onCreateFolder },
{ label: '全选文件', icon: 'check', disabled: !options.ids.length, action: () => options.onSelection(new Set(options.ids)) });
return { menu, actions, hover, moveIds, setMoveIds, dropFolder, error, dismiss, leave, select, showFileMenu, showFolderMenu, blankMenu, hoverFile, drag, dropHandlers, keyDown };
}