feat(web): add spaces, grouped channels and file chat

This commit is contained in:
justme committed 2026-10-13 01:39:23 +08:00
1 parent c7d8ef15bc
commit f6cfb12716
111 files changed
+4371 -506

No files matched your search

+1 -1
View File
@@ -7,7 +7,7 @@ import styles from './ActionBar.module.css';
interface Props {
/** dock:手机底部动作栏;toolbar:电脑列表上方的工具条 */
layout: 'dock' | 'toolbar';
/** 添加文件、文本:频道发到频道里,分享放进草稿 */
/** 添加文件、文本:空间发到空间里,分享放进草稿 */
onFiles: (files: File[]) => void;
onText: (text: string) => void;
onCompose: () => void;
@@ -0,0 +1,49 @@
import { useEffect, useState, type FormEvent } from 'react';
import { useWorkspace } from '@/store/workspace';
import { channelAccessApi, type ChannelMember } from '@/core-bridge/channelAccess';
import type { AccountInvitation } from '@/core-bridge/account';
import { Button } from '@/ui/Button';
import styles from './ChannelSettingsSheet.module.css';
export function ChannelInvitations({ owner }: { owner: boolean }) {
const channelId = useWorkspace((s) => s.channel?.id);
const invite = useWorkspace((s) => s.inviteUser);
const list = useWorkspace((s) => s.listInvitations);
const revoke = useWorkspace((s) => s.revokeInvitation);
const [members, setMembers] = useState<ChannelMember[]>([]);
const [invitations, setInvitations] = useState<AccountInvitation[]>([]);
const [username, setUsername] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
async function refresh() {
if (channelId) setMembers(await channelAccessApi.members(channelId));
if (owner) setInvitations(await list());
}
useEffect(() => { void refresh().catch(() => setError('成员清单读取失败')); }, [channelId, owner, list]);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); setBusy(true); setError('');
try { await invite(username); setUsername(''); await refresh(); }
catch (cause) { setError(cause instanceof Error ? cause.message : '邀请失败'); }
finally { setBusy(false); }
}
async function remove(username: string) {
setBusy(true); setError('');
try { await revoke(username); await refresh(); }
catch (cause) { setError(cause instanceof Error ? cause.message : '移除失败'); }
finally { setBusy(false); }
}
return <section className={styles.form} aria-label="空间成员与邀请"><strong>空间成员</strong>
{members.map((member) => <div className={styles.member} key={member.username}>
<span>{member.username} · {member.role === 'owner' ? '空间主' : '成员'}</span>
{owner && member.role !== 'owner' ? <Button variant="ghost" disabled={busy} onClick={() => { void remove(member.username); }}>移除成员</Button> : null}
</div>)}
<p className={styles.note}>未登录访客以独立设备身份加入,可在设备面板移除。</p>
{owner ? <><strong>邀请用户</strong><form className={styles.member} onSubmit={(event) => { void submit(event); }}>
<input aria-label="被邀请用户的用户名" maxLength={32} value={username} onChange={(event) => setUsername(event.target.value)} placeholder="已注册用户的用户名" required />
<Button type="submit" disabled={busy || !username.trim()}>发送邀请</Button>
</form>
{invitations.map((row) => <div className={styles.member} key={row.username}><span>{row.username} · 已邀请</span><Button variant="ghost" disabled={busy} onClick={() => { void remove(row.username); }}>撤销邀请</Button></div>)}
<p className={styles.note}>移除会取消加入授权并断开该账号的设备。已读取的内容无法收回。</p></> : null}
{error ? <p className={styles.error} role="alert">{error}</p> : null}
</section>;
}
@@ -0,0 +1,70 @@
.member {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.member input {
min-width: 0;
flex: 1;
padding: var(--space-2);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
}
.form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.field {
display: flex;
flex-direction: column;
gap: var(--space-2);
color: var(--text-dim);
font-size: 13px;
}
.field input,
.field select {
width: 100%;
min-height: 42px;
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;
}
.check {
display: flex;
align-items: center;
gap: var(--space-2);
min-height: 36px;
}
.check input {
accent-color: var(--accent);
}
.field input:disabled,
.field select:disabled {
opacity: 0.7;
}
.note {
margin: 0;
color: var(--text-dim);
font-size: 12px;
}
.error {
margin: 0;
color: var(--danger);
font-size: 13px;
}
@@ -0,0 +1,108 @@
import { useState } from 'react';
import type { ChannelInfo, ChannelSettingsPatch } from '@/core-bridge/types';
import { Button } from '@/ui/Button';
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];
interface Props {
channel: ChannelInfo;
onClose: () => void;
onSave: (patch: ChannelSettingsPatch) => Promise<void>;
}
export function ChannelSettingsSheet({ channel, onClose, onSave }: Props) {
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 [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);
async function save() {
if (!owner || saving) return;
setSaving(true);
setFailed(false);
try {
await onSave({ name: name.trim(), seats, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy, allowGuests,
...(clearPassword ? { joinPassword: '' } : joinPassword ? { joinPassword } : {}) });
onClose();
} catch {
setFailed(true);
} finally {
setSaving(false);
}
}
return (
<Sheet title="空间设置" onClose={onClose}>
<div className={styles.form}>
<label className={styles.field}>
<span>空间名称</span>
<input type="text" maxLength={48} value={name} disabled={!owner} placeholder="例如:工作设备" onChange={(event) => setName(event.target.value)} />
<span className={styles.note}>空间名称和设置对加入者及服务端可见。</span>
</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))} />
</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>)}
</select>
</label>
<label className={styles.field}>
<span>受限文件下载次数</span>
<select value={fileMaxDownloads} disabled={!owner} onChange={(event) => setFileMaxDownloads(Number(event.target.value))}>
{downloadOptions.map((value) => <option key={value} value={value}>{value === 0 ? '不限' : `${value} 次`}</option>)}
</select>
</label>
<label className={styles.check}>
<input type="checkbox" checked={membersCanUpload} disabled={!owner} onChange={(event) => setMembersCanUpload(event.target.checked)} />
<span>允许成员上传</span>
</label>
<label className={styles.field}><span>加入方式</span>
<select value={joinPolicy} disabled={!owner} onChange={(event) => setJoinPolicy(event.target.value as 'open' | 'invite')}>
<option value="open">持链接加入</option><option value="invite">仅受邀账号加入</option>
</select>
</label>
<label className={styles.check}><input type="checkbox" checked={allowGuests} disabled={!owner || joinPolicy === 'invite'} onChange={(event) => setAllowGuests(event.target.checked)} /><span>允许未登录的陌生人加入</span></label>
<label className={styles.field}><span>{channel.passwordRequired ? '更换空间加入密码' : '空间加入密码(可选)'}</span>
<input type="password" autoComplete="new-password" minLength={8} maxLength={256} value={joinPassword} disabled={!owner || clearPassword} placeholder="至少 8 个字符,留空不修改" onChange={(event) => setJoinPassword(event.target.value)} />
<span className={styles.note}>空间主及受邀用户无需加入密码。修改规则不移除已加入成员。</span>
</label>
{owner && channel.passwordRequired ? <label className={styles.check}><input type="checkbox" checked={clearPassword} onChange={(event) => setClearPassword(event.target.checked)} /><span>移除空间加入密码</span></label> : null}
<ChannelInvitations owner={owner} />
{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>
</>
) : <p className={styles.note}>只有所有者可以修改空间设置。</p>}
</div>
</Sheet>
);
}
@@ -0,0 +1,22 @@
import { useChannels } from '@/store/channels';
import { useAccount } from '@/store/account';
import { channelPath, navigate } from '@/app/navigation';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
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}
onClick={() => navigate(channelPath(channel.id))}><Icon name={channel.mode === 'enc' ? 'lock' : 'file'} size={14} /><span>{channel.name || channel.id.slice(0, 8)}</span></button>)}</nav>
</section>;
}
+47
View File
@@ -0,0 +1,47 @@
import { useState } from 'react';
import { useWorkspace } from '@/store/workspace';
import { useAccount } from '@/store/account';
import { useDownload } from '@/features/item/download';
import { iconOf } from '@/features/item/summary';
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
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 }) {
const item = useWorkspace((s) => s.byId[id]);
const owner = useWorkspace((s) => s.channel?.role === 'owner');
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 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} 的头像`} />
: <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>
{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)}>
<span className={styles.fileIcon}><Icon name={iconOf(item)} size={28} /></span>
<span className={styles.fileInfo}><strong>{item.name}</strong><span>{formatSize(item.size)} · {item.mime}</span></span>
</button>
<div className={styles.fileActions}><Button variant="ghost" icon="eye" disabled={!ready} onClick={() => onOpenFile(id)}>预览</Button>
<Button variant="ghost" icon="download" disabled={!ready || downloading} onClick={() => { void save(); }}>{downloading ? '下载中…' : '下载'}</Button>
</div>
</div>}
{item.state.kind === 'receiving' ? <div className={styles.delivery} role="status">{formatTransferProgress(item.state)}<progress max={1} value={item.state.progress} /></div>
: item.state.kind === 'incomplete' ? <span className={styles.delivery}>发送未完成,等待续传。</span> : null}
</div>
</article>;
}
@@ -0,0 +1,203 @@
.pane {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
border-top: 1px solid var(--line);
background: var(--surface);
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
min-height: var(--bar-h);
padding: 0 var(--space-3);
border-bottom: 1px solid var(--line);
font-size: 12px;
}
.head span,
.empty,
.message time {
color: var(--text-dim);
font-size: 11px;
}
.messages {
flex: 1;
min-height: 0;
padding: var(--space-4);
overflow-y: auto;
overscroll-behavior: contain;
}
.empty {
margin: var(--space-2) 0;
}
.message {
display: flex;
align-items: flex-start;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.mine {
flex-direction: row-reverse;
}
.avatar {
width: 36px;
height: 36px;
flex-shrink: 0;
border-radius: var(--radius-avatar);
object-fit: cover;
}
.avatarFallback {
display: grid;
place-items: center;
background: var(--surface-2);
color: var(--text-dim);
font-size: 14px;
}
.bubble {
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);
}
.message header {
display: flex;
align-items: baseline;
gap: var(--space-2);
}
.senderName {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
.message header time {
margin-inline-end: auto;
}
.messageText {
margin: var(--space-1) 0 0;
overflow-wrap: anywhere;
white-space: pre-wrap;
font-size: 13px;
}
.fileMessage {
width: 280px;
max-width: 100%;
}
.fileCard {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
padding: var(--space-3) 0;
border: 0;
background: transparent;
color: var(--text);
text-align: left;
cursor: pointer;
}
.fileIcon {
flex-shrink: 0;
}
.fileInfo {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 0;
}
.fileInfo strong {
overflow-wrap: anywhere;
font-size: 13px;
}
.fileInfo > span,
.delivery {
color: var(--text-dim);
overflow-wrap: anywhere;
font-size: 11px;
}
.fileActions {
display: flex;
gap: var(--space-2);
border-top: 1px solid var(--line);
}
.delivery {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.delivery progress {
width: 100%;
height: var(--space-1);
accent-color: var(--busy);
}
.integrity {
color: var(--danger);
font-size: 12px;
}
.composer {
display: flex;
align-items: flex-end;
gap: var(--space-2);
padding: var(--space-2);
border-top: 1px solid var(--line);
}
.composer textarea {
flex: 1;
min-width: 0;
min-height: 42px;
max-height: 112px;
padding: var(--space-2);
resize: vertical;
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 13px;
}
.error {
margin: 0;
padding: 0 var(--space-3) var(--space-2);
color: var(--danger);
font-size: 12px;
}
@media (width <= 899px) {
.pane {
flex: 1;
min-height: 0;
}
}
+48
View File
@@ -0,0 +1,48 @@
import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
import { Button } from '@/ui/Button';
import { ChatMessage } from './ChatMessage';
import styles from './ChatPane.module.css';
interface Props { threadName: string; ids: string[]; enabled: boolean; onSend: (text: string) => Promise<void>; onDelete: (id: string) => void; onOpenFile: (id: string) => void; onFiles?: (files: File[]) => void }
export function ChatPane({ threadName, ids, enabled, onSend, onDelete, onOpenFile, onFiles }: Props) {
const [text, setText] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const messages = useRef<HTMLDivElement>(null);
const fileInput = useRef<HTMLInputElement>(null);
const chronological = ids.slice().reverse();
useEffect(() => { messages.current?.scrollTo({ top: messages.current.scrollHeight }); }, [chronological.length]);
async function submit() {
const value = text.trim();
if (!value || busy || !enabled) return;
setBusy(true);
setError('');
try { await onSend(value); setText(''); }
catch (cause) { setError(cause instanceof Error ? cause.message : '发送失败'); }
finally { setBusy(false); }
}
function keyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); void submit(); }
}
return (
<section className={styles.pane} aria-label={`${threadName} 聊天`}>
<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} />)}
</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 = ''; }} />
<Button variant="ghost" icon="clip" label="上传文件到当前频道" disabled={!enabled} onClick={() => fileInput.current?.click()} /></> : null}
<textarea value={text} disabled={!enabled || busy} rows={2} placeholder={enabled ? '输入消息,Enter 发送,Shift+Enter 换行' : '先创建或加入一个频道'} onChange={(event) => setText(event.target.value)} onKeyDown={keyDown} />
<Button variant="primary" icon="send" label="发送消息" disabled={!enabled || busy || !text.trim()} onClick={() => void submit()} />
</form>
{error ? <p className={styles.error} role="alert">{error}</p> : null}
</section>
);
}
+62 -27
View File
@@ -24,40 +24,87 @@
.device {
display: flex;
align-items: center;
gap: var(--space-3);
min-height: 40px;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-2) 0;
border-bottom: 1px solid var(--line);
}
.deviceRow,
.deviceIdentity,
.deviceActions,
.deviceEditor,
.deviceMetaRow {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
}
.deviceIdentity {
flex: 1;
}
.deviceMetaRow {
min-height: 32px;
padding-left: var(--space-6);
}
.name {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
}
.online,
.offline {
.current,
.deviceMeta {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: 12px;
}
.online {
.current {
color: var(--ok);
}
.online::before {
content: "";
width: 8px;
height: 8px;
border-radius: var(--radius-lg);
background: var(--ok);
animation: breathe 2.4s ease-in-out infinite;
.deviceMeta {
color: var(--text-dim);
white-space: nowrap;
}
.offline {
color: var(--text-dim);
.deviceEditor {
padding-left: var(--space-6);
margin-top: var(--space-1);
}
.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;
}
.link {
@@ -97,15 +144,3 @@
color: var(--text-dim);
font-size: 12px;
}
@keyframes breathe {
50% {
opacity: 0.4;
}
}
@media (prefers-reduced-motion: reduce) {
.online::before {
animation: none;
}
}
+87 -10
View File
@@ -11,24 +11,93 @@ export function DevicePanel({ qrOpen = false }: { qrOpen?: boolean }) {
const devices = useWorkspace((s) => s.devices);
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 [showOwner, setShowOwner] = useState(false);
const [showQr, setShowQr] = useState(qrOpen);
const online = devices.filter((d) => d.online).length;
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 accountAddress = channel ? `${location.origin}/c/${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);
}
}
return (
<aside className={styles.panel}>
<section>
<h2 className={styles.h}>设备 <span className={styles.dim}>{online}/{channel?.seats} 在线</span></h2>
<h2 className={styles.h}>设备 <span className={styles.dim}>{seatsUsed}/{channel?.seats} 已加入</span></h2>
<ul className={styles.list}>
{devices.map((d) => (
<li key={d.id} className={styles.device}>
<Icon name={d.kind} size={18} />
<span className={styles.name}>
{d.name}
</span>
<span className={d.online ? styles.online : styles.offline}>
{d.online ? '在线' : d.lastSeen ? formatAgo(d.lastSeen) : '离线'}
</span>
<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>
))}
</ul>
@@ -36,6 +105,13 @@ export function DevicePanel({ qrOpen = false }: { qrOpen?: boolean }) {
<section>
<h2 className={styles.h}>分享</h2>
<div className={styles.link}>
<div className={styles.label}>账号空间地址</div>
<code className={styles.code}>{accountAddress}</code>
<CopyButton text={accountAddress} />
<p className={styles.note}>此地址不包含密钥。在空间设置中邀请账号后,成员登录即可自动领取密钥包。</p>
</div>
{channel?.joinPolicy === 'open' ? <>
<div className={styles.link}>
<div className={styles.label}>成员链接</div>
<code className={styles.code}>{channel?.memberLink}</code>
@@ -58,8 +134,9 @@ export function DevicePanel({ qrOpen = false }: { qrOpen?: boolean }) {
</div>
) : null}
<p className={styles.note}>
持有链接即可访问,并能读取此频道的全部历史内容。不隐藏 IP 与访问时间。
持有链接并符合空间加入规则后,可读取空间历史内容。不隐藏 IP 与访问时间。
</p>
</> : null}
</section>
{import.meta.env.DEV ? (
@@ -0,0 +1,35 @@
import type { DragEvent, MouseEvent } from 'react';
import { useWorkspace } from '@/store/workspace';
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
import { Icon } from '@/ui/Icon';
import { Button } from '@/ui/Button';
import { iconOf } from '@/features/item/summary';
import styles from './FileExplorer.module.css';
interface Props {
id: string; selected: boolean; checked: boolean; enabled: boolean;
onSelect: (id: string, event: MouseEvent) => void; onOpen: (id: string) => void; onToggle: (id: string) => void;
onMenu: (id: string, x: number, y: number) => void;
onHover: (id: string, rect: DOMRect) => void; onLeave: () => void;
onDrag: (id: string, event: DragEvent) => void; onDragEnd: () => void;
}
export function ExplorerFileRow(props: Props) {
const { id } = props;
const item = useWorkspace((s) => s.byId[id]);
if (!item || item.kind !== 'file') return null;
const available = item.state.kind !== 'receiving' && item.state.kind !== 'tampered';
return <tr className={props.checked ? styles.selected : undefined} aria-selected={props.checked}
onClick={(event) => props.onSelect(id, event)} onDoubleClick={() => { if (available) props.onOpen(id); }}
onContextMenu={(event) => { event.preventDefault(); event.stopPropagation(); props.onMenu(id, event.clientX, event.clientY); }}
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}
{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>
</tr>;
}
@@ -0,0 +1,22 @@
import type { DragEvent } from 'react';
import type { FolderInfo } from '@/core-bridge/types';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import styles from './FileExplorer.module.css';
interface Props {
folder: FolderInfo; selected: boolean; dropping: boolean; enabled: boolean;
onSelect: () => void; onOpen: () => void; onRename: () => void; onMenu: (x: number, y: number) => void;
drop: { onDragOver: (e: DragEvent) => void; onDragLeave: (e: DragEvent) => void; onDrop: (e: DragEvent) => void };
}
export function ExplorerFolderRow(props: Props) {
return <tr className={`${props.selected ? styles.selected : ''} ${props.dropping ? styles.dropTarget : ''}`} {...props.drop} data-drop-folder={props.folder.id}
onClick={props.onSelect} onDoubleClick={props.onOpen} onContextMenu={(event) => { event.preventDefault(); event.stopPropagation(); props.onMenu(event.clientX, event.clientY); }}>
<td /><td><button className={styles.name} type="button" onKeyDown={(event) => {
if (event.key === 'Enter') { event.preventDefault(); event.stopPropagation(); props.onOpen(); }
if (event.key === 'F2' && props.enabled) { event.preventDefault(); props.onRename(); }
}}><Icon name="folder" size={18} /><span>{props.folder.name}</span></button></td>
<td className={styles.numeric}>文件夹</td><td className={styles.date}>—</td>
<td><Button variant="ghost" icon="more" label={`文件夹操作 ${props.folder.name}`} onClick={(event) => { event.stopPropagation(); const rect = event.currentTarget.getBoundingClientRect(); props.onMenu(rect.right, rect.bottom); }} /></td>
</tr>;
}
@@ -0,0 +1,36 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useWorkspace } from '@/store/workspace';
import { useBlobUrl } from '@/features/item/useBlobUrl';
import { formatSize, formatTime } from '@/lib/format';
import { fitFloatingPoint } from '@/lib/explorer';
import { Icon } from '@/ui/Icon';
import { iconOf } from '@/features/item/summary';
import styles from './FileExplorer.module.css';
export function ExplorerHover({ id, point }: { id: string; point: { x: number; y: number } }) {
const item = useWorkspace((s) => s.byId[id]);
const encrypted = useWorkspace((s) => s.channel?.mode === 'enc');
if (!item || item.kind !== 'file') return null;
const position = fitFloatingPoint(point.x, point.y, 280, 300, innerWidth, innerHeight);
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>
{preview ? <HoverContent id={id} image={item.preview === 'image'} /> : <p>{item.gated ? '受限文件:悬停不读取内容,点击预览后再解密。' : item.state.kind === 'tampered' ? '内容校验失败,已拒绝预览。' : '双击文件打开完整预览。'}</p>}
</aside>, document.body);
}
function HoverContent({ id, image }: { id: string; image: boolean }) {
const blob = useBlobUrl(id);
const [text, setText] = useState('');
useEffect(() => {
if (image || blob.status !== 'ready') return;
let active = true;
void blob.blob.slice(0, 4096).text().then((value) => { if (active) setText(value); });
return () => { active = false; };
}, [blob, image]);
if (blob.status === 'loading') return <p>正在生成预览…</p>;
if (blob.status === 'error') return <p>预览不可用,可双击文件查看。</p>;
return image ? <img className={styles.hoverImage} src={blob.url} alt="文件缩略预览" /> : <pre className={styles.hoverText}>{text}</pre>;
}
@@ -0,0 +1,23 @@
import { useState } from 'react';
import type { FolderInfo } from '@/core-bridge/types';
import { Sheet } from '@/ui/Sheet';
import { Button } from '@/ui/Button';
import styles from './FileExplorer.module.css';
export function ExplorerMoveDialog({ folders, count, currentFolderId, onMove, onClose }: {
folders: FolderInfo[]; count: number; currentFolderId?: string; onMove: (id?: string) => Promise<void>; onClose: () => void;
}) {
const [target, setTarget] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function move() {
setBusy(true); setError('');
try { await onMove(target || undefined); onClose(); }
catch (cause) { setError(cause instanceof Error ? cause.message : '移动失败'); setBusy(false); }
}
return <Sheet title={`移动 ${count} 个文件`} onClose={onClose} actions={<Button variant="primary" disabled={busy || (target || undefined) === currentFolderId} onClick={() => { void move(); }}>{busy ? '移动中…' : '移动'}</Button>}>
<label className={styles.moveLabel}>目标文件夹<select aria-label="目标文件夹" value={target} onChange={(event) => setTarget(event.target.value)}><option value="">根目录</option>
{folders.map((folder) => <option key={folder.id} value={folder.id}>{folder.name}</option>)}
</select></label>{error ? <p className={styles.error} role="alert">{error}</p> : null}
</Sheet>;
}
@@ -0,0 +1,254 @@
.explorer {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
min-height: 0;
background: var(--surface);
}
.heading,
.toolbar,
.breadcrumb,
.batch,
.status,
.edit {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--line);
}
.heading {
min-height: var(--bar-h);
justify-content: space-between;
font-size: 13px;
}
.heading span,
.status,
.empty span {
color: var(--text-dim);
font-size: 12px;
}
.toolbar,
.batch {
flex-wrap: wrap;
}
.breadcrumb {
min-height: 40px;
overflow-x: auto;
font-size: 12px;
}
.breadcrumb span,
.breadcrumb button,
.name {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.breadcrumb button,
.table button {
padding: var(--space-1);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
font: inherit;
cursor: pointer;
}
.scroll {
flex: 1;
min-height: 0;
overflow: auto;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
table-layout: fixed;
}
.table th,
.table td {
padding: var(--space-2) var(--space-1);
border-bottom: 1px solid var(--line);
text-align: left;
}
.table th {
position: sticky;
top: 0;
z-index: 1;
background: var(--surface-2);
font-weight: 400;
}
.table th:first-child {
width: 28px;
}
.table th:nth-child(3) {
width: 76px;
}
.table th:nth-child(4) {
width: 64px;
}
.table th:last-child {
width: 38px;
}
.table tbody tr[draggable="true"] {
cursor: grab;
}
.dropTarget {
outline: 2px solid var(--busy);
outline-offset: -2px;
background: var(--busy-soft);
}
.hover {
position: fixed;
z-index: 70;
width: 280px;
max-width: calc(100vw - 16px);
padding: var(--space-3);
border: 1px solid var(--line-strong);
border-radius: var(--radius-md);
background: var(--surface);
color: var(--text);
box-shadow: var(--shadow-pop);
pointer-events: none;
box-sizing: border-box;
}
.hoverTitle {
display: flex;
align-items: flex-start;
gap: var(--space-2);
overflow-wrap: anywhere;
font-size: 13px;
}
.hover p {
margin: var(--space-2) 0;
color: var(--text-dim);
font-size: 12px;
overflow-wrap: anywhere;
}
.hoverImage {
display: block;
width: 100%;
max-height: 150px;
object-fit: contain;
}
.hoverText {
max-height: 150px;
margin: 0;
overflow: hidden;
white-space: pre-wrap;
font-size: 11px;
}
.moveLabel {
display: flex;
flex-direction: column;
gap: var(--space-2);
color: var(--text);
font-size: 13px;
}
.moveLabel select {
padding: var(--space-2);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--text);
font: inherit;
}
.name {
width: 100%;
min-width: 0;
text-align: left;
}
.name svg {
flex-shrink: 0;
}
.name span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table tbody tr:hover,
.selected {
background: var(--accent-soft);
}
.numeric {
font-family: var(--font-mono);
}
.empty {
padding: var(--space-6);
color: var(--text-dim);
text-align: center;
}
.status {
border-top: 1px solid var(--line);
border-bottom: 0;
}
.status span {
margin-inline-start: auto;
}
.edit input,
.batch select {
min-width: 0;
min-height: 34px;
padding: var(--space-1) var(--space-2);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--text);
font: inherit;
}
.edit input {
flex: 1;
}
.progress {
display: block;
padding: 0 var(--space-1);
color: var(--busy);
font-size: 11px;
}
.error {
color: var(--danger);
font-size: 12px;
}
@media (width <= 1100px) {
.date {
display: none;
}
}
+107
View File
@@ -0,0 +1,107 @@
import { useRef, useState, type FormEvent } from 'react';
import type { FolderInfo } from '@/core-bridge/types';
import { useWorkspace } from '@/store/workspace';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { ExplorerFileRow } from './ExplorerFileRow';
import { ExplorerFolderRow } from './ExplorerFolderRow';
import { ExplorerHover } from './ExplorerHover';
import { ExplorerMoveDialog } from './ExplorerMoveDialog';
import { useExplorerInteractions } from './useExplorerInteractions';
import { ContextMenu } from '@/ui/ContextMenu';
import styles from './FileExplorer.module.css';
interface Props {
scope: string;
threadId?: string;
threadName: string;
folders: FolderInfo[];
folderId?: string;
ids: string[];
selectedId?: string;
checked: ReadonlySet<string>;
enabled: boolean;
onFolder: (id?: string) => void;
onSelect: (id?: string) => void;
onOpen: (id: string) => void;
onToggle: (id: string) => void;
onSelection: (ids: ReadonlySet<string>) => void;
onFiles: (files: File[], folderId?: string) => void;
onCreateFolder: (name: string) => Promise<void>;
onRenameFolder: (id: string, name: string) => Promise<void>;
onDownloadItems: (ids: string[]) => Promise<void>;
onDeleteItems: (ids: string[]) => Promise<void>;
onMoveItems: (ids: string[], folderId?: string) => Promise<void>;
}
export function FileExplorer(props: Props) {
const input = useRef<HTMLInputElement>(null);
const [sort, setSort] = useState<'name' | 'size' | 'date'>('name');
const [edit, setEdit] = useState<string>();
const [name, setName] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [selectedFolder, setSelectedFolder] = useState<string>();
const byId = useWorkspace.getState().byId;
const ids = props.ids.slice().sort((a, b) => {
const first = byId[a]; const second = byId[b];
if (first?.kind !== 'file' || second?.kind !== 'file') return 0;
return sort === 'size' ? second.size - first.size : sort === 'date' ? second.ts - first.ts : first.name.localeCompare(second.name, 'zh-CN');
});
const path: FolderInfo[] = [];
const seen = new Set<string>();
let current = props.folders.find((folder) => folder.id === props.folderId);
while (current && !seen.has(current.id)) {
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'));
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 });
function navigateFolder(id?: string) { interaction.dismiss(); setSelectedFolder(undefined); props.onFolder(id); }
async function submit(event: FormEvent) {
event.preventDefault(); if (!edit || busy || !name.trim()) return;
setBusy(true); setError('');
try { if (edit === 'create') await props.onCreateFolder(name); else await props.onRenameFolder(edit, name); setEdit(undefined); }
catch (cause) { setError(cause instanceof Error ? cause.message : '目录操作失败'); }
finally { setBusy(false); }
}
return <section className={styles.explorer} aria-label="频道文件管理器" tabIndex={0} onKeyDown={interaction.keyDown}>
<header className={styles.heading}><strong>频道文件</strong><span>{props.threadName}</span></header>
<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="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)} />
<button type="button" data-drop-folder="root" className={interaction.dropFolder === '__root' ? styles.dropTarget : ''} {...interaction.dropHandlers()} onClick={() => navigateFolder()}><Icon name="folder" size={16} />根目录</button>
{path.map((folder) => <span key={folder.id}><Icon name="chevronRight" size={12} /><button type="button" className={interaction.dropFolder === folder.id ? styles.dropTarget : ''} {...interaction.dropHandlers(folder.id)} onClick={() => navigateFolder(folder.id)}>{folder.name}</button></span>)}
</nav>
{edit ? <form className={styles.edit} onSubmit={(event) => { void submit(event); }}><input autoFocus maxLength={80} aria-label="文件夹名称" value={name} onChange={(event) => setName(event.target.value)} />
<Button type="submit" disabled={busy || !name.trim()}>{edit === 'create' ? '创建' : '保存'}</Button><Button variant="ghost" onClick={() => setEdit(undefined)}>取消</Button></form> : null}
<div className={`${styles.scroll} ${interaction.dropFolder === (props.folderId ?? '__root') ? styles.dropTarget : ''}`} {...interaction.dropHandlers(props.folderId)} onContextMenu={interaction.blankMenu}
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}
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); }} />)}
{ids.map((id) => <ExplorerFileRow key={id} id={id} selected={id === props.selectedId} checked={props.checked.has(id)} enabled={props.enabled}
onSelect={(id, event) => { setSelectedFolder(undefined); interaction.select(id, event); }} onOpen={props.onOpen} onToggle={props.onToggle} onMenu={(id, x, y) => { setSelectedFolder(undefined); interaction.showFileMenu(id, x, y); }}
onHover={interaction.hoverFile} onLeave={interaction.leave} onDrag={interaction.drag} onDragEnd={interaction.dismiss} />)}</tbody>
</table>{ids.length === 0 && children.length === 0 ? <div className={styles.empty}><Icon name="folder" size={28} /><p>此文件夹还没有文件</p><span>上传文件或将文件拖到窗口中。</span></div> : null}</div>
{props.checked.size > 0 ? <div className={styles.batch}><span>已选 {props.checked.size} 项</span><Button variant="ghost" icon="download" onClick={() => { void props.onDownloadItems([...props.checked]); }}>下载所选</Button>
{props.enabled ? <select aria-label="移动所选文件到文件夹" value="__choose" onChange={(event) => {
const target = event.target.value; if (target !== '__choose') void props.onMoveItems([...props.checked], target === '__root' ? undefined : target).catch((cause: unknown) => setError(cause instanceof Error ? cause.message : '移动失败'));
}}><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>
{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)}
onMove={(folderId) => props.onMoveItems(interaction.moveIds!, folderId)} /> : null}
</section>;
}
@@ -0,0 +1,24 @@
import { useRef } from 'react';
import styles from './Workspace.module.css';
export function FilePaneResize({ width, onWidth }: { width?: number; onWidth: (width: number) => void }) {
const drag = useRef<{ right: number; maximum: number } | undefined>(undefined);
function bounds(element: HTMLElement) {
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 <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))}
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); }}
onPointerCancel={() => { drag.current = undefined; }}
onKeyDown={(event) => {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault(); const limit = bounds(event.currentTarget);
const current = event.currentTarget.parentElement?.querySelector<HTMLElement>('[data-file-pane]')?.offsetWidth ?? 320;
onWidth(event.key === 'Home' ? 320 : event.key === 'End' ? limit.maximum : Math.min(limit.maximum, Math.max(320, current + (event.key === 'ArrowLeft' ? 24 : -24))));
}} />;
}
+1 -1
View File
@@ -5,7 +5,7 @@ import styles from './Notepad.module.css';
const SAVE_DELAY_MS = 400;
/** 共享便签:频道唯一的快照槽,最后写入者胜(protocol §9)。默认折叠,不占首屏 */
/** 共享便签:空间唯一的快照槽,最后写入者胜(protocol §9)。默认折叠,不占首屏 */
export function Notepad({ defaultOpen = false }: { defaultOpen?: boolean }) {
const snapshot = useWorkspace((s) => s.snapshot);
const setSnapshot = useWorkspace((s) => s.setSnapshot);
+3 -5
View File
@@ -25,11 +25,9 @@
font-size: 12px;
}
.dot {
width: 8px;
height: 8px;
border-radius: var(--radius-lg);
background: var(--ok);
.countLabel {
font-family: var(--font-sans);
font-size: 10px;
}
.open {
+4 -5
View File
@@ -11,8 +11,7 @@ interface Props {
/** 电脑右侧的设备与分享:低频,默认收成窄栏,需要时展开 */
export function SideRail({ open, qrOpen, onToggle }: Props) {
const online = useWorkspace((s) => s.devices.filter((d) => d.online).length);
const seats = useWorkspace((s) => s.channel?.seats);
const channel = useWorkspace((s) => s.channel);
if (open) {
return (
@@ -29,9 +28,9 @@ 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="在线设备">
<span className={styles.dot} />
{online}/{seats}
<span className={styles.count} title="已加入席位 / 总席位">
{channel?.seatsUsed ?? '—'}/{channel?.seats ?? '—'}
<span className={styles.countLabel}>席位</span>
</span>
<Button variant="ghost" icon="share" label="分享" onClick={onToggle} />
</aside>
@@ -0,0 +1,252 @@
.panel {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3);
border-bottom: 1px solid var(--line);
}
.channels {
display: flex;
flex-direction: column;
gap: var(--space-1);
max-height: 28vh;
overflow-y: auto;
}
.group {
min-width: 0;
padding-top: var(--space-2);
border-top: 1px solid var(--line);
}
.groupHeading,
.groupActions,
.groupToggle {
display: flex;
align-items: center;
gap: var(--space-1);
}
.groupToggle {
flex: 1;
min-width: 0;
min-height: 34px;
padding: var(--space-1) 0;
border: 0;
background: transparent;
color: var(--text-dim);
text-align: left;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.groupToggle span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.groupToggle svg,
.groupActions {
flex-shrink: 0;
}
.groupToggle small {
margin-inline-start: auto;
}
.groupLabel {
color: var(--text-dim);
font-size: 12px;
}
.channelEditor {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-2) 0;
border-top: 1px solid var(--line);
}
.channelEditor label {
display: flex;
flex-direction: column;
gap: var(--space-1);
color: var(--text-dim);
font-size: 12px;
}
.channelEditor input,
.channelEditor select {
min-width: 0;
min-height: 34px;
padding: var(--space-1) var(--space-2);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--text);
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,
.folderRow {
display: flex;
align-items: center;
gap: var(--space-1);
}
.heading,
.folderHeading {
justify-content: space-between;
font-size: 13px;
}
.folderHeading {
padding-top: var(--space-2);
border-top: 1px solid var(--line);
}
.threads,
.folders {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin: 0;
padding: 0;
list-style: none;
}
.threadRow,
.folderRow {
min-width: 0;
}
.folderRow {
flex-wrap: wrap;
}
.thread,
.folder {
display: flex;
flex: 1;
align-items: center;
gap: var(--space-2);
min-width: 0;
min-height: 34px;
padding: 0 var(--space-2);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
text-align: left;
cursor: pointer;
}
.thread span,
.folder span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.thread small {
margin-inline-start: auto;
color: var(--text-dim);
font-size: 10px;
white-space: nowrap;
}
.folder {
padding-inline-start: var(--space-2);
}
.thread:hover,
.folder:hover,
.selected {
background: var(--accent-soft);
}
.empty,
.hint,
.error {
margin: 0;
color: var(--text-dim);
font-size: 12px;
line-height: 1.5;
}
.error {
color: var(--danger);
}
.edit {
display: flex;
align-items: center;
gap: var(--space-1);
}
.edit input {
flex: 1;
min-width: 0;
min-height: 34px;
padding: 0 var(--space-2);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--text);
font: inherit;
}
.folders ul {
flex: 1 0 100%;
margin: 0;
padding-inline-start: var(--space-4);
list-style: none;
}
@media (width <= 899px) {
.panel {
padding: var(--space-2) var(--space-3);
}
.threads {
flex-direction: row;
overflow-x: auto;
}
.thread {
flex: 0 0 auto;
max-width: 220px;
}
.folders {
max-height: 120px;
overflow-y: auto;
}
}
@@ -0,0 +1,92 @@
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 styles from './ThreadSidebar.module.css';
interface Props {
threads: ThreadInfo[];
groups: ChannelGroupInfo[];
selectedThreadId?: string;
enabled: boolean;
onSelectThread: (id: string) => void;
onCreateThread: (name: string, groupId?: string) => Promise<string>;
onRenameThread: (id: string, name: string) => Promise<void>;
onCreateGroup: (name: string) => Promise<string>;
onRenameGroup: (id: string, name: string) => Promise<void>;
onGroupThread: (id: string, groupId?: string) => Promise<void>;
}
type Editor = { kind: 'channel' | 'group'; id?: string; groupId?: string };
export function ThreadSidebar(props: Props) {
const [edit, setEdit] = useState<Editor>();
const [name, setName] = useState('');
const [groupId, setGroupId] = useState('');
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 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) {
event.preventDefault(); if (!edit || !name.trim() || busy || !props.enabled) return;
setBusy(true); setError('');
try {
if (edit.kind === 'group') {
if (edit.id) await props.onRenameGroup(edit.id, name); else await props.onCreateGroup(name);
} else if (edit.id) {
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);
} 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);
} catch (cause) { setError(cause instanceof Error ? cause.message : '操作失败'); }
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)} />
{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 })} />
</span> : null}</div>
{!collapsed.has(group.id) ? <ChannelRows threads={threads} selectedId={props.selectedThreadId} enabled={props.enabled} busy={busy} 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}
{!props.threads.length && !props.groups.length ? <p className={styles.empty}>创建一个频道后开始聊天和上传文件。</p> : null}
{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;
}) {
return <ul className={styles.threads}>{threads.map((thread) => <li className={styles.threadRow} 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}
</li>)}</ul>;
}
+11 -20
View File
@@ -42,12 +42,18 @@
.name {
min-width: 0;
overflow: hidden;
font-family: var(--font-mono);
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.idSuffix {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 11px;
white-space: nowrap;
}
.badge,
.badgeWarn {
display: inline-flex;
@@ -86,24 +92,9 @@
cursor: default;
}
.dots {
display: inline-flex;
gap: var(--space-1);
}
.on,
.off {
width: 8px;
height: 8px;
border-radius: var(--radius-lg);
}
.on {
background: var(--ok);
}
.off {
background: var(--line-strong);
.countLabel {
color: var(--text-dim);
font-size: 11px;
}
.active {
@@ -127,7 +118,7 @@
color: var(--danger);
}
/* 窄屏(如 iPhone SE、小屏 Android):只留在线点,收起数字与徽标文字 */
/* 窄屏(如 iPhone SE、小屏 Android):收起席位数字与徽标文字 */
@media (width < 360px) {
.count {
display: none;
+10 -12
View File
@@ -10,24 +10,26 @@ interface Props {
onBack: () => void;
/** 手机上点开设备与分享面板;桌面端面板在右侧栏,不传 */
onOpenPanel?: () => void;
onOpenSettings: () => void;
searchOpen: boolean;
/** 有筛选条件在生效 */
filtering: boolean;
onToggleSearch: () => void;
}
export function TopBar({ onBack, onOpenPanel, searchOpen, filtering, onToggleSearch }: Props) {
export function TopBar({ onBack, onOpenPanel, onOpenSettings, searchOpen, filtering, onToggleSearch }: Props) {
const channel = useWorkspace((s) => s.channel);
const connection = useWorkspace((s) => s.connection);
const devices = useWorkspace((s) => s.devices);
const online = devices.filter((d) => d.online).length;
const seatCount = `${channel?.seatsUsed ?? '—'}/${channel?.seats ?? '—'}`;
const raw = channel?.mode === 'raw';
return (
<header className={`${styles.bar} ${raw ? styles.raw : ''}`}>
<Button variant="ghost" icon="chevronLeft" label="返回频道列表" onClick={onBack} />
<Button variant="ghost" icon="chevronLeft" label="返回空间列表" onClick={onBack} />
<div className={styles.id}>
<span className={styles.name}>{channel?.id.slice(0, 8)}…</span>
<span className={styles.name}>{channel?.name || `${channel?.id.slice(0, 8)}…`}</span>
{channel?.name ? <span className={styles.idSuffix}>{channel.id.slice(0, 8)}…</span> : null}
{channel?.creatorAccount ? <span className={styles.idSuffix}>创建者 {channel.creatorAccount}</span> : null}
{raw ? (
<span className={styles.badgeWarn}>明文 · 服务端可见</span>
) : (
@@ -37,13 +39,8 @@ export function TopBar({ onBack, onOpenPanel, searchOpen, filtering, onToggleSea
)}
</div>
<button type="button" className={styles.presence} onClick={onOpenPanel} disabled={!onOpenPanel}>
<span className={styles.dots} aria-hidden="true">
{devices.map((d) => (
<span key={d.id} className={d.online ? styles.on : styles.off} />
))}
</span>
<span className={styles.count}>
{online}/{channel?.seats ?? devices.length}
<span className={styles.count} title="已加入席位 / 总席位" aria-label={`已加入 ${channel?.seatsUsed ?? '未知'} 个设备,共 ${channel?.seats ?? '未知'} 个席位`}>
{seatCount} <span className={styles.countLabel}>席位</span>
</span>
</button>
<span className={`${styles.conn} ${styles[connection]}`} role="status">
@@ -58,6 +55,7 @@ export function TopBar({ onBack, onOpenPanel, searchOpen, filtering, onToggleSea
onClick={onToggleSearch}
/>
<ThemeMenu />
<Button variant="ghost" icon="settings" label="空间设置" onClick={onOpenSettings} />
{onOpenPanel ? <Button variant="ghost" icon="share" label="设备与分享" onClick={onOpenPanel} /> : null}
</header>
);
@@ -0,0 +1,41 @@
.form {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.check {
display: flex;
align-items: center;
gap: var(--space-2);
min-height: 40px;
}
.check input {
width: 18px;
height: 18px;
accent-color: var(--accent);
}
.field {
display: flex;
flex-direction: column;
gap: var(--space-2);
font-size: 13px;
}
.field select {
min-height: 40px;
padding: 0 var(--space-3);
border: 1px solid var(--line-strong);
border-radius: var(--radius-md);
background: var(--surface);
color: var(--text);
font-size: 15px;
}
.note {
margin: 0;
color: var(--text-dim);
font-size: 13px;
}
@@ -0,0 +1,50 @@
import { useState } from 'react';
import type { FileUploadOptions } from '@/core-bridge/types';
import { Button } from '@/ui/Button';
import { Sheet } from '@/ui/Sheet';
import styles from './UploadOptionsSheet.module.css';
const LIMITS = [0, 1, 3, 5, 10, 20];
interface Props {
encrypted?: boolean;
files: File[];
defaultMaxDownloads: number;
onClose: () => void;
onUpload: (options: FileUploadOptions) => void;
}
export function UploadOptionsSheet({ files, defaultMaxDownloads, onClose, onUpload, encrypted = true }: Props) {
const [directPreview, setDirectPreview] = useState(true);
const [maxDownloads, setMaxDownloads] = useState(defaultMaxDownloads);
return (
<Sheet
title={`上传 ${files.length} 个文件`}
onClose={onClose}
actions={<Button variant="primary" onClick={() => onUpload({ directPreview, maxDownloads })}>继续上传</Button>}
>
<div className={styles.form}>
<label className={styles.check}>
<input type="checkbox" checked={directPreview} onChange={(e) => setDirectPreview(e.target.checked)} />
<span>单帧文件加入空间后可直接预览</span>
</label>
{directPreview ? (
<p className={styles.note}>图片、文本等单帧文件会随空间同步,加入者可直接预览且不计下载次数。大文件必须分帧,仍会受空间下载上限限制。</p>
) : (
<>
<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) => (
<option key={n} value={n}>{n === 0 ? '不限' : `${n} 次`}</option>
))}
</select>
</label>
<p className={styles.note}>预览和下载内容会消耗次数。{encrypted ? '文件名与类型通过加密目录同步,不消耗次数。' : '此空间未启用端到端加密,服务端能看到文件名与类型。'}</p>
</>
)}
</div>
</Sheet>
);
}
+66 -24
View File
@@ -1,58 +1,94 @@
/* 电脑:左列表 | 内联预览 | 右侧窄栏 */
.desktop {
--nav-width: 232px;
display: grid;
grid-template-columns: 380px minmax(0, 1fr) auto;
grid-template-columns: var(--nav-width) minmax(260px, 1fr) 6px clamp(320px, var(--files-width, 36%), calc(100% - var(--nav-width) - 266px));
grid-template-rows: auto minmax(0, 1fr);
height: 100vh;
height: 100dvh;
}
.desktop > header {
grid-column: 1 / 3;
grid-column: 1 / -1;
border-bottom: 1px solid var(--line);
}
.list {
.navigation,
.center,
.files,
.mobileContent {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
border-right: 1px solid var(--line);
}
.scroll {
flex: 1;
.navigation {
overflow-y: auto;
border-right: 1px solid var(--line);
background: var(--bg);
}
.batch {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-4);
border-bottom: 1px solid var(--line);
background: linear-gradient(90deg, var(--busy-soft), transparent);
font-size: 13px;
.files {
border-left: 1px solid var(--line);
}
.resize {
background: var(--line);
cursor: col-resize;
touch-action: none;
}
.resize:hover,
.resize:focus-visible {
background: var(--busy);
}
/* 手机:整列滚动,顶栏与底部动作栏 sticky,内容从它们下方滑过 */
.mobile {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
padding-inline: env(safe-area-inset-left) env(safe-area-inset-right);
}
.column {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
overflow-y: auto;
overscroll-behavior-y: contain;
.mobile .navigation {
max-height: 30dvh;
flex-shrink: 0;
border-right: 0;
}
.grow {
.mobileContent {
flex: 1;
overflow: hidden;
}
.tabs {
display: flex;
flex-shrink: 0;
border-bottom: 1px solid var(--line);
}
.tabs button {
flex: 1;
min-height: 44px;
border: 0;
background: var(--surface);
color: var(--text-dim);
font: inherit;
cursor: pointer;
}
.tabs button[aria-pressed="true"] {
border-bottom: 2px solid var(--accent);
color: var(--text);
}
.legacy {
padding: var(--space-2) var(--space-3);
border-top: 1px solid var(--line);
color: var(--text-dim);
font-size: 12px;
}
.drop {
@@ -80,3 +116,9 @@
color: var(--danger);
border: 1px solid var(--danger);
}
@media (width >= 900px) and (width <= 1100px) {
.desktop {
--nav-width: 200px;
}
}
+106 -182
View File
@@ -1,208 +1,132 @@
import { lazy, Suspense, useCallback, useEffect, useRef, useState, type DragEvent } from 'react';
import { lazy, Suspense, useCallback, useState, type CSSProperties, type DragEvent } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { channelPath, navigate, sharePath } from '@/app/navigation';
import { useDownload } from '@/features/item/download';
import { isFiltering, matchesFilter, NO_FILTER } from '@/lib/filter';
import type { ClipboardPayload } from '@/lib/clipboard';
import { matchesFilter, isFiltering, NO_FILTER } from '@/lib/filter';
import { useGlobalPaste } from '@/lib/useGlobalPaste';
import { useMediaQuery } from '@/lib/useMediaQuery';
import { useChannels } from '@/store/channels';
import { useWorkspace } from '@/store/workspace';
import { Button } from '@/ui/Button';
import type { FileUploadOptions } from '@/core-bridge/types';
import type { ClipboardPayload } from '@/lib/clipboard';
import { Sheet } from '@/ui/Sheet';
import { ActionBar } from './ActionBar';
import { ComposeSheet } from './ComposeSheet';
import { DevicePanel } from './DevicePanel';
import { FilterBar } from './FilterBar';
import { ItemList } from './ItemList';
import { LatestCard } from './LatestCard';
import { Notepad } from './Notepad';
import { SideRail } from './SideRail';
import { Button } from '@/ui/Button';
import { ChannelSwitcher } from './ChannelSwitcher';
import { ThreadSidebar } from './ThreadSidebar';
import { ChatPane } from './ChatPane';
import { FileExplorer } from './FileExplorer';
import { FilePaneResize } from './FilePaneResize';
import { TopBar } from './TopBar';
import { UndoBar } from './UndoBar';
import { FilterBar } from './FilterBar';
import { Notepad } from './Notepad';
import { DevicePanel } from './DevicePanel';
import { ChannelSettingsSheet } from './ChannelSettingsSheet';
import { UploadOptionsSheet } from './UploadOptionsSheet';
import styles from './Workspace.module.css';
const PreviewSheet = lazy(() => import('@/features/item/PreviewSheet'));
const PreviewPane = lazy(() => import('@/features/item/PreviewPane'));
const ScanSheet = lazy(() => import('@/features/scan/ScanSheet').then((m) => ({ default: m.ScanSheet })));
const UNDO_MS = 5000;
export function Workspace() {
export function Workspace({ onCreateChannel }: { onCreateChannel: () => void }) {
const desktop = useMediaQuery('(width >= 900px) and (height >= 500px)');
const sendFiles = useWorkspace((s) => s.sendFiles);
const channel = useWorkspace((s) => s.channel);
const threads = useWorkspace((s) => s.threads);
const groups = useWorkspace((s) => s.groups);
const createGroup = useWorkspace((s) => s.createGroup);
const renameGroup = useWorkspace((s) => s.renameGroup);
const groupThread = useWorkspace((s) => s.groupThread);
const folders = useWorkspace((s) => s.folders);
const sendText = useWorkspace((s) => s.sendText);
const sendFiles = useWorkspace((s) => s.sendFiles);
const createThread = useWorkspace((s) => s.createThread);
const renameThread = useWorkspace((s) => s.renameThread);
const createFolder = useWorkspace((s) => s.createFolder);
const renameFolder = useWorkspace((s) => s.renameFolder);
const moveItems = useWorkspace((s) => s.moveItems);
const remove = useWorkspace((s) => s.remove);
const updateSettings = useWorkspace((s) => s.updateChannelSettings);
const error = useWorkspace((s) => s.error);
const download = useDownload();
// 刚创建的频道:直接展示分享面板和二维码,让对方马上能扫
const [justCreated] = useState(() => useChannels.getState().justCreatedId !== null);
const [panelOpen, setPanelOpen] = useState(justCreated); // 手机:设备与分享抽屉
const [railOpen, setRailOpen] = useState(justCreated); // 电脑:右侧栏展开
const [searchOpen, setSearchOpen] = useState(false);
const [filter, setFilter] = useState(NO_FILTER);
const [threadId, setThreadId] = useState<string>();
const [folderId, setFolderId] = useState<string>();
const [selectedId, setSelectedId] = useState<string>();
const [sheetId, setSheetId] = useState<string>();
const [previewId, setPreviewId] = useState<string>();
const [checked, setChecked] = useState<ReadonlySet<string>>(new Set());
const [composeOpen, setComposeOpen] = useState(false);
const [filter, setFilter] = useState(NO_FILTER);
const [searchOpen, setSearchOpen] = useState(false);
const [panelOpen, setPanelOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [scanOpen, setScanOpen] = useState(false);
const [dragging, setDragging] = useState(false);
const [pending, setPending] = useState<string>();
const pendingRef = useRef<{ id: string; timer: number }>(undefined);
const [mobileView, setMobileView] = useState<'chat' | 'files'>('chat');
const [fileWidth, setFileWidth] = useState<number>();
const [upload, setUpload] = useState<{ files: File[]; threadId: string; folderId?: string }>();
const activeThread = threads.find((thread) => thread.id === threadId) ?? threads.find((thread) => thread.name === '常规') ?? threads[threads.length - 1];
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 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);
})));
const messages = useWorkspace(useShallow((s) => s.order.filter((id) => {
const item = s.byId[id]; return Boolean(item && item.threadId === activeThreadId && !(item.kind === 'file' && item.metadataHidden));
})));
const restricted = useWorkspace(useShallow((s) => s.order.filter((id) => {
const item = s.byId[id]; return item?.kind === 'file' && item.metadataHidden;
})));
const sendPayload = useCallback(
(p: ClipboardPayload) => {
if (p.files.length > 0) void sendFiles(p.files);
else if (p.text) void sendText(p.text);
},
[sendFiles, sendText],
);
useGlobalPaste(sendPayload);
// 从首页「快速分享」带过来的内容,进入频道后发送
useEffect(() => {
useChannels.getState().clearJustCreated();
}, [sendPayload]);
const ids = useWorkspace(
useShallow((s) =>
s.order.filter((id) => {
const item = s.byId[id];
return id !== pending && item !== undefined && matchesFilter(item, filter);
}),
),
);
const filtering = isFiltering(filter);
const latestId = !desktop && !filtering ? ids[0] : undefined;
const listIds = latestId ? ids.slice(1) : ids;
const activeId = selectedId && ids.includes(selectedId) ? selectedId : ids[0];
const closeSheet = useCallback(() => setSheetId(undefined), []);
const closePanel = useCallback(() => setPanelOpen(false), []);
const closeCompose = useCallback(() => setComposeOpen(false), []);
const closeScan = useCallback(() => setScanOpen(false), []);
function requestDelete(id: string) {
if (pendingRef.current) {
clearTimeout(pendingRef.current.timer);
void remove(pendingRef.current.id);
}
const timer = window.setTimeout(() => {
void remove(id);
pendingRef.current = undefined;
setPending(undefined);
}, UNDO_MS);
pendingRef.current = { id, timer };
setPending(id);
const requestFiles = useCallback((incoming: File[], destination?: { folderId?: string }) => {
const target = destination ? destination.folderId : activeFolderId;
if (incoming.length && activeThreadId && enabled) setUpload({ files: incoming, threadId: activeThreadId, ...(target ? { folderId: target } : {}) });
}, [activeThreadId, activeFolderId, enabled]);
const paste = useCallback((payload: ClipboardPayload) => {
if (payload.files.length) requestFiles(payload.files);
else if (payload.text && activeThreadId && enabled) void sendText(payload.text, activeThreadId);
}, [requestFiles, activeThreadId, sendText, enabled]);
useGlobalPaste(paste);
function selectThread(id: string) { setThreadId(id); setFolderId(undefined); setSelectedId(undefined); setChecked(new Set()); }
function selectFolder(id?: string) { setFolderId(id); setSelectedId(undefined); setChecked(new Set()); }
function toggle(id: string) { setChecked((previous) => { const next = new Set(previous); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }
function confirmUpload(options: FileUploadOptions) {
const target = upload; setUpload(undefined);
if (target) void sendFiles(target.files, options, target.threadId, target.folderId);
}
function undoDelete() {
if (!pendingRef.current) return;
clearTimeout(pendingRef.current.timer);
pendingRef.current = undefined;
setPending(undefined);
async function downloadItems(ids: string[]) { for (const id of ids) { const item = useWorkspace.getState().byId[id]; if (item) await download(item); } }
async function deleteFiles(ids: string[]) {
if (!window.confirm(ids.length === 1 ? '删除这项内容?' : `删除所选的 ${ids.length} 个文件?`)) return;
for (const id of ids) await remove(id);
setChecked(new Set()); setSelectedId(undefined); setPreviewId(undefined);
}
function toggleSearch() {
if (searchOpen) setFilter((f) => ({ ...f, query: '' }));
setSearchOpen((v) => !v);
}
function toggleChecked(id: string) {
setChecked((prev) => {
const next = new Set(prev);
if (!next.delete(id)) next.add(id);
return next;
});
}
function downloadChecked() {
const { byId } = useWorkspace.getState();
for (const id of checked) {
const item = byId[id];
if (item) void download(item);
}
setChecked(new Set());
}
function onDragOver(e: DragEvent) {
if (!e.dataTransfer.types.includes('Files')) return;
e.preventDefault();
setDragging(true);
}
function onDrop(e: DragEvent) {
e.preventDefault();
setDragging(false);
const files = [...e.dataTransfer.files];
if (files.length) void sendFiles(files);
}
const emptyText = filtering ? '没有符合条件的内容。' : '还没有内容。粘贴、拖入文件,或在其他设备上打开此频道。';
const topBar = (
<TopBar
onBack={() => navigate('/')}
onOpenPanel={desktop ? undefined : () => setPanelOpen(true)}
searchOpen={searchOpen}
filtering={filtering}
onToggleSearch={toggleSearch}
/>
);
return (
<div className={desktop ? styles.desktop : styles.mobile} onDragOver={onDragOver} onDragLeave={() => setDragging(false)} onDrop={onDrop}>
{desktop ? (
<>
{topBar}
<div className={styles.list}>
<ActionBar layout="toolbar" onFiles={(f) => void sendFiles(f)} onText={(t) => void sendText(t)} onCompose={() => setComposeOpen(true)} onScan={() => setScanOpen(true)} />
<Notepad />
<FilterBar filter={filter} searchOpen={searchOpen} onChange={setFilter} />
{checked.size > 0 ? (
<div className={styles.batch}>
<span>已选 {checked.size} 项</span>
<span>
<Button variant="primary" icon="download" onClick={downloadChecked}>下载</Button>
<Button variant="ghost" onClick={() => setChecked(new Set())}>取消</Button>
</span>
</div>
) : null}
<div className={styles.scroll}>
<ItemList ids={ids} selectedId={activeId} checked={checked} empty={emptyText} onSelect={setSelectedId} onToggle={toggleChecked} />
</div>
</div>
<Suspense fallback={null}>
<PreviewPane id={activeId} onDelete={requestDelete} />
</Suspense>
<SideRail open={railOpen} qrOpen={justCreated} onToggle={() => setRailOpen((v) => !v)} />
</>
) : (
<div className={styles.column}>
{topBar}
{latestId ? <LatestCard id={latestId} onOpen={setSheetId} /> : null}
<Notepad />
<FilterBar filter={filter} searchOpen={searchOpen} onChange={setFilter} />
<div className={styles.grow}>
<ItemList ids={listIds} empty={emptyText} onSelect={setSheetId} />
</div>
<ActionBar layout="dock" onFiles={(f) => void sendFiles(f)} onText={(t) => void sendText(t)} onCompose={() => setComposeOpen(true)} onScan={() => setScanOpen(true)} />
</div>
)}
{error ? <div className={styles.error} role="alert">{error}</div> : null}
{!desktop && panelOpen ? (
<Sheet title="设备与分享" onClose={closePanel}>
<DevicePanel qrOpen={justCreated} />
</Sheet>
) : null}
<Suspense fallback={null}>
{!desktop && sheetId ? <PreviewSheet id={sheetId} onClose={closeSheet} onDelete={requestDelete} /> : null}
{scanOpen ? <ScanSheet onClose={closeScan} onChannel={(c) => navigate(channelPath(c.id))} onShare={(sh) => navigate(sharePath(sh.id))} /> : null}
</Suspense>
{composeOpen ? <ComposeSheet onClose={closeCompose} onSubmit={(t) => void sendText(t)} /> : null}
{pending ? <UndoBar onUndo={undoDelete} /> : null}
{dragging ? <div className={styles.drop}>松开以发送文件</div> : null}
</div>
);
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}
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}
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}
</>;
return <div className={desktop ? styles.desktop : styles.mobile} style={fileWidth ? { '--files-width': `${fileWidth}px` } as CSSProperties : undefined}
onDropCapture={() => setDragging(false)} onDragEndCapture={() => setDragging(false)}
onDragOver={(event) => { if (event.dataTransfer.types.includes('Files')) { event.preventDefault(); setDragging(true); } }} onDragLeave={() => setDragging(false)} onDrop={drop}>
<TopBar onBack={() => navigate('/')} onOpenPanel={() => setPanelOpen(true)} onOpenSettings={() => setSettingsOpen(true)} searchOpen={searchOpen} filtering={isFiltering(filter)} onToggleSearch={() => { setSearchOpen((value) => !value); if (!desktop) setMobileView('files'); }} />
{desktop ? <><aside className={styles.navigation} data-space-nav>{navigation}</aside><main className={styles.center}>{chat}</main><FilePaneResize width={fileWidth} onWidth={setFileWidth} /><aside className={styles.files} data-file-pane>{explorer}</aside></>
: <><aside className={styles.navigation}>{navigation}</aside><nav className={styles.tabs} aria-label="频道视图"><button type="button" aria-pressed={mobileView === 'chat'} onClick={() => setMobileView('chat')}>聊天</button><button type="button" aria-pressed={mobileView === 'files'} onClick={() => setMobileView('files')}>文件</button></nav><main className={styles.mobileContent}>{mobileView === 'chat' ? chat : explorer}</main></>}
{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}
<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}
</div>;
}
@@ -0,0 +1,95 @@
import { useCallback, useEffect, useRef, useState, type DragEvent, type KeyboardEvent, type MouseEvent } from 'react';
import { useWorkspace } from '@/store/workspace';
import { FILE_DRAG_TYPE, readFileDrag, selectFiles } from '@/lib/explorer';
import type { MenuAction } from '@/ui/ContextMenu';
interface Options {
scope: string; threadId?: string; ids: string[]; checked: ReadonlySet<string>; selectedId?: string; folderId?: string; enabled: 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;
onRenameFolder: (id: string) => void; onCreateFolder: () => void; onUpload: () => void;
}
type Menu = { x: number; y: number; kind: 'file' | 'folder' | 'blank'; id?: string; name?: string; targets: string[] };
export function useExplorerInteractions(options: Options) {
const [menu, setMenu] = useState<Menu>();
const [hover, setHover] = useState<{ id: string; point: { x: number; y: number } }>();
const [moveIds, setMoveIds] = useState<string[]>();
const [dropFolder, setDropFolder] = useState<string>();
const [error, setError] = useState('');
const anchor = useRef<string | undefined>(undefined);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const dismiss = useCallback(() => { clearTimeout(timer.current); setMenu(undefined); setHover(undefined); setDropFolder(undefined); }, []);
const leave = useCallback(() => { clearTimeout(timer.current); setHover(undefined); }, []);
useEffect(() => () => clearTimeout(timer.current), []);
function run(task: Promise<void>) { setError(''); void task.catch((cause: unknown) => setError(cause instanceof Error ? cause.message : '操作失败')); }
function select(id: string, event: MouseEvent) {
dismiss(); options.onSelect(id);
options.onSelection(selectFiles(options.ids, options.checked, id, anchor.current, { additive: event.ctrlKey || event.metaKey, range: event.shiftKey }));
if (!event.shiftKey) anchor.current = id;
}
function showFileMenu(id: string, x: number, y: number) {
leave(); options.onSelect(id);
const targets = options.checked.has(id) ? options.ids.filter((key) => options.checked.has(key)) : [id];
if (!options.checked.has(id)) options.onSelection(new Set([id]));
setMenu({ x, y, kind: 'file', id, targets });
}
function showFolderMenu(id: string, name: string, x: number, y: number) { leave(); setMenu({ x, y, kind: 'folder', id, name, targets: [] }); }
function blankMenu(event: MouseEvent) {
if ((event.target as HTMLElement).closest('input,select,textarea')) return;
event.preventDefault(); leave(); setMenu({ x: event.clientX, y: event.clientY, kind: 'blank', targets: [] });
}
function hoverFile(id: string, rect: DOMRect) {
if (menu || !matchMedia('(hover: hover)').matches) return;
clearTimeout(timer.current);
timer.current = setTimeout(() => setHover({ id, point: { x: rect.left - 288, y: rect.top } }), 450);
}
function drag(id: string, event: DragEvent) {
dismiss(); const ids = options.checked.has(id) ? options.ids.filter((key) => options.checked.has(key)) : [id];
event.dataTransfer.setData(FILE_DRAG_TYPE, JSON.stringify({ scope: options.scope, ids })); event.dataTransfer.effectAllowed = 'move';
if (!options.checked.has(id)) { options.onSelection(new Set([id])); options.onSelect(id); }
}
function dropHandlers(folder?: string) {
const marker = folder ?? '__root';
return {
onDragOver(event: DragEvent) {
if (!options.enabled || ![...event.dataTransfer.types].some((type) => type === FILE_DRAG_TYPE || type === 'Files')) return;
event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = event.dataTransfer.types.includes(FILE_DRAG_TYPE) ? 'move' : 'copy'; setDropFolder(marker); leave();
},
onDragLeave(event: DragEvent) { if (!(event.currentTarget as HTMLElement).contains(event.relatedTarget as Node)) setDropFolder(undefined); },
onDrop(event: DragEvent) {
if (!options.enabled) return;
event.preventDefault(); event.stopPropagation(); setDropFolder(undefined);
if (event.dataTransfer.types.includes(FILE_DRAG_TYPE)) {
const available = new Set(Object.values(useWorkspace.getState().byId).filter((item) => item.kind === 'file' && item.threadId === options.threadId).map((item) => item.id));
const ids = readFileDrag(event.dataTransfer.getData(FILE_DRAG_TYPE), options.scope, available);
if (!ids) { setError('只能移动当前空间、当前频道中的文件。'); return; }
run(options.onMove(ids, folder));
} else if (event.dataTransfer.files.length) options.onFiles([...event.dataTransfer.files], folder);
},
};
}
function keyDown(event: KeyboardEvent) {
if ((event.target as HTMLElement).closest('input:not([type=checkbox]),select,textarea,[role=menu],[role=dialog]')) return;
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { event.preventDefault(); options.onSelection(new Set(options.ids)); }
else if (event.key === 'Escape') { dismiss(); options.onSelection(new Set()); options.onSelect(undefined); }
else if (event.key === 'Delete' && options.enabled && options.checked.size) { event.preventDefault(); run(options.onDelete([...options.checked])); }
else if (event.key === 'Enter' && options.selectedId && !(event.target as HTMLElement).closest('button,input')) { event.preventDefault(); options.onOpen(options.selectedId); }
}
const actions: MenuAction[] = [];
if (menu?.kind === 'file' && menu.id) {
const item = useWorkspace.getState().byId[menu.id];
const unavailable = menu.targets.some((id) => { const item = useWorkspace.getState().byId[id]; return !item || item.state.kind === 'receiving' || item.state.kind === 'tampered'; });
actions.push({ label: '预览', icon: 'eye', disabled: unavailable || menu.targets.length !== 1, action: () => options.onOpen(menu.id!) },
{ label: menu.targets.length > 1 ? '下载所选文件' : '下载', icon: 'download', disabled: unavailable, action: () => run(options.onDownload(menu.targets)) },
{ label: '复制名称', icon: 'copy', disabled: !item || menu.targets.length !== 1, action: () => { if (item?.kind === 'file') run(Promise.resolve().then(() => navigator.clipboard.writeText(item.name))); } },
{ label: '移动到文件夹…', icon: 'folder', disabled: !options.enabled || unavailable, action: () => setMoveIds(menu.targets) },
{ 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!) });
} 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: '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 };
}